集群与管理
这一页接着作业与计划讲两件事:一是运行时管理,用 IJobScheduler 登记、暂停、恢复、触发、取消作业,查运行历史,恢复改过的代码作业;二是多实例部署,引擎怎样轮询与抢占、IJobStore 共享与否的差别、三种领导选举、调度事件、配置与错误码。读完你能给管理界面提供完整的作业操作,并把调度部署到多个实例上而不重复运行。
为什么需要
作业一旦上线,就有人要在运行时动它:临时跑一次、出问题时暂停、改一下计划、看最近几次为什么失败。多实例部署又带来另一组问题:三个副本各跑一遍夜间清理,或者谁都以为别人在跑;跑着作业的节点宕了,那次运行就此蒸发。
框架把运行时管理收进一个批量优先的接口 IJobScheduler;多实例的协调交给两个窄契约:共享存储靠原子抢占保证每条到期触发器只被一个节点拿到,不共享的存储靠领导租约保证只有一个实例按计划运行。引擎的轮询、心跳、抢占、维护都在抽象包里实现一份,换 Provider 不改行为。
运行时管理:IJobScheduler
IJobScheduler(实现是 JobSchedulerEngine)是单例,所有方法都是批量的:以集合为入参,逐项返回结果,单条写法是 JobSchedulerExtensions 里的扩展方法。
| 方法 | 作用 | 主要错误 |
|---|---|---|
ScheduleAsync(definitions) | 新增或整体替换作业与触发器,其他节点下一个周期看到 | SCHEDULING_NOTFOUND_002、SCHEDULING_VALIDATION_001 |
UnscheduleAsync(names) | 删除作业与触发器;进行中的运行照常结束,历史保留 | SCHEDULING_NOTFOUND_001 |
PauseAsync / ResumeAsync(names) | 暂停或恢复作业的全部触发器;暂停期间手动触发仍可用,恢复后按 misfire 策略处理 | SCHEDULING_NOTFOUND_001 |
PauseTriggersAsync / ResumeTriggersAsync(keys) | 按 TriggerKey(job, trigger) 暂停或恢复单个触发器 | SCHEDULING_NOTFOUND_003 |
TriggerAsync(requests) | 在本实例立即运行并等待结果,按顺序逐项;不受暂停和领导权限制 | SCHEDULING_NOTFOUND_001、SCHEDULING_CONFLICT_001、SCHEDULING_UNAVAILABLE_001 |
EnqueueAsync(requests) | 插入一个立即到期的一次性触发器(manual- 前缀),由集群任一节点运行,不等待;返回触发器名 | SCHEDULING_NOTFOUND_001 |
CancelRunsAsync(runIds) | 本实例的运行立即取消令牌,其他节点的在下一个轮询周期取消 | SCHEDULING_NOTFOUND_004 |
StatusAsync(name) / StatusesAsync() | 作业状态:是否暂停、各触发器的状态与下一次时刻、运行中数量、最近一次完成的结果 | — |
RunsAsync(query) / CountRunsAsync(query) | 运行历史与同一组过滤条件下的总数 | SCHEDULING_VALIDATION_002(RunsAsync 分页越界) |
RestoreAsync(names) | 把运行时改过的代码作业恢复为代码定义 | SCHEDULING_NOTFOUND_005 |
JobTriggerRequest(Name, Data) 能从 JobName 隐式转换,手动触发带的数据覆盖作业数据的同名键。TriggerAsync 在本实例同步运行并拿到结果,适合「立即执行」;EnqueueAsync 只写存储,由集群任一节点运行,适合「排队跑一次」。
最小示例
我们为每个租户在运行时建一个同步作业。运行时作业引用的类型要事先用 AddJobType<TJob>() 登记:
builder.Services.AddJobType<TenantSyncJob>();using System.Text.Json.Nodes;
using XiHan.Framework.Primitives;
using XiHan.Framework.Scheduling.Abstractions.Jobs;
using XiHan.Framework.Scheduling.Abstractions.Schedulers;
using XiHan.Framework.Scheduling.Abstractions.Schedules;
public sealed class TenantSyncJob : IJob
{
public ValueTask<Result> RunAsync(JobRun run, InvocationContext context, CancellationToken cancellationToken)
=> ValueTask.FromResult(Result.Success());
}
public sealed class TenantSyncPlanner(IJobScheduler scheduler)
{
public async ValueTask<Result> EnableAsync(string tenantId, CancellationToken cancellationToken)
{
// 作业名不合法会抛 ArgumentException:租户 ID 只能含小写字母、数字、点、下划线与连字符
var definition = new JobDefinition($"tenant-{tenantId}.sync", JobSchedule.Every(TimeSpan.FromMinutes(10)), typeof(TenantSyncJob))
{
Data = new JsonObject { ["tenant"] = tenantId },
Retry = JobRetryPolicy.Fixed(3, TimeSpan.FromSeconds(30)),
};
return await scheduler.ScheduleAsync(definition, cancellationToken);
}
public async ValueTask<Result> DisableAsync(string tenantId, CancellationToken cancellationToken)
=> await scheduler.UnscheduleAsync($"tenant-{tenantId}.sync", cancellationToken);
}调用 EnableAsync("acme") 后,tenant-acme.sync 每十分钟运行一次,失败后每 30 秒重试、最多三次。类型未登记时 ScheduleAsync 返回 SCHEDULING_NOTFOUND_002。多节点部署时每个节点都要登记同一组类型:节点抢到类型未登记的触发器时把它改为 Error 并记录原因。
查状态用 StatusAsync(name),返回 Result<Maybe<JobStatus>>,JobStatus 里有 NextRun、Paused、RunningCount、各触发器的 NextFireAt 与 State、最近一次完成的结果。查历史用 RunsAsync(query):JobRunQuery 按作业、状态集合、计划时刻范围(From 含、To 不含)、触发器名、执行节点、有无错误码过滤,Skip 缺省 0,Limit 缺省 100、最大 1000(JobRunQuery.MaxLimit);结果按计划时刻降序。CountRunsAsync 用同一组过滤项给出总数,不看分页。分页越界不在构造时抛出,RunsAsync 返回 SCHEDULING_VALIDATION_002(HTTP 400):查询常由请求绑定而来,越界是调用方可以改正的输入。
代码作业的运行时修改与指纹
管理界面常要改代码作业的计划。ScheduleAsync 替换一个代码作业时,来源记为 Overridden,并保留代码定义的指纹(类型键、数据、标志、说明、各触发器的名字、计划文本、数据、优先级、misfire 策略与说明的 SHA-256;重试策略与执行时限设置了才计入)。下次启动同步代码作业时:
- 存储里是
Overridden且指纹与当前代码定义相同:代码没改,保留运行时的修改。 - 其余情况:按代码写入,来源回到
Code;覆盖了运行时修改的记一条警告日志。 - 暂停状态一律保留。
RestoreAsync 随时把作业恢复为代码定义,保留暂停状态与同名触发器的进度;作业没有代码定义(来源 Runtime)时返回 SCHEDULING_NOTFOUND_005。删除代码作业只到下次启动为止,启动时会按代码重新写入。JobTypeRegistry.Keys 列出已登记的类型键,供界面新建作业时选择。
集群:引擎与存储
JobSchedulerEngine 由 Provider 特性装配,在应用进入服务状态后开始轮询,每个 PollInterval(缺省 1 秒)执行一个周期:
- 应用不在服务状态(启动中、排空中)时什么都不做,只释放持有的领导租约,另一个在服务的实例下一个周期就接手。
- 首个周期把代码登记的作业写入存储;失败时记日志,下个周期重试。
- 存储共享时写节点心跳。
- 续约领导租约,没有时尝试获取(资源
LeaseResource,存活LeaseTimeToLive)。 - 领导者每
HousekeepingInterval(缺省 60 秒)维护一次:恢复失联节点,删除结束早于HistoryRetention(缺省 7 天)的历史。 - 取消本节点上被请求取消的运行。
- 运行到期的本地触发器与本实例的本地重试。
- 抢占到期触发器并在后台运行:名额 =
min(AcquireBatchSize, MaxConcurrentRuns − 本节点运行中数量)。
IJobStore.IsShared 决定集群唯一的作业怎样保证只跑一次:
| 存储 | IsShared | 只跑一次的机制 |
|---|---|---|
Scheduling.Database | true | 抢占:AcquireDueAsync 原子地把到期触发器改成 Acquired(nodeId),每条只被一个节点拿到;TryBeginRunAsync(exclusive: true) 原子地占用作业的 ActiveRunId,实现全集群串行。节点按周期写心跳,超过 NodeTimeout 即失联 |
Scheduling.InMemory | false | 领导权:只有持有租约的实例按计划运行集群唯一的作业,其他实例把抢到的按计划触发器放回等待;入队、重试这类一次性触发器只有本进程看得到,不受领导权限制 |
触发器的状态:
| 状态 | 含义 |
|---|---|
Waiting | 等待到期 |
Paused | 已暂停 |
Acquired | 已被某节点抢占;间隔计划在运行结束前保持这个状态 |
Blocked | 到期但作业不允许并发、上一次未结束 |
Complete | 没有下一次(一次性已运行、次数用尽、超过 EndAt) |
Error | 无法运行(作业类型未登记),原因在 Error 字段 |
停止时引擎先停轮询,再等待进行中的运行结束;宿主的停止令牌触发时取消这些运行,最后释放领导租约。启动之前与开始停止之后,本实例的 TriggerAsync 返回 SCHEDULING_UNAVAILABLE_001(HTTP 503)、不起新的运行;EnqueueAsync 只写存储,不受影响。
数据库 Provider 经 persistence.relational@1 的仓储读写,每个存储操作是一次独立的管道调用与事务,不加入调用方的工作单元;需要建五张表(作业、触发器、运行、节点、租约),每个节点的 XiHan:Scheduling:NodeId 必须唯一,见 Scheduling.Database。
领导选举
ILeaderElection.TryAcquireAsync(resource, timeToLive) 在资源空闲或租约已过期时授予 LeaderLease,被他人持有时返回 SCHEDULING_CONFLICT_002。LeaderLease 有 Holder、ExpiresAt、IsHeld,RenewAsync 延长一个存活时间(已过期或被接管时同样返回 SCHEDULING_CONFLICT_002),ReleaseAsync 立即放弃。租约不可重入。
| 实现 | 持有者 | 存储 | 不可达时 |
|---|---|---|---|
InMemoryLeaderElection | 每次获取新生成的随机值 | 进程内字典,只在本进程内互斥 | — |
DatabaseLeaderElection | 节点 ID(XiHan:Scheduling:NodeId)加随机令牌 | 与作业同库的一行,并发戳裁决 | SCHEDULING_UNAVAILABLE_002,持有状态不变 |
KubernetesLeaderElection | Pod 名 | coordination.k8s.io/v1 Lease,resourceVersion 裁决并发 | SCHEDULING_UNAVAILABLE_002,下个周期重试 |
应用自己的单例工作也可以用它竞争领导权:
using XiHan.Framework.Scheduling.Abstractions.Jobs;
using XiHan.Framework.Scheduling.Abstractions.Leadership;
public sealed class IndexRebuilder(ILeaderElection election)
{
public async ValueTask<bool> TryRebuildAsync(CancellationToken cancellationToken)
{
var acquired = await election.TryAcquireAsync(new JobName("search.rebuild"), TimeSpan.FromMinutes(5), cancellationToken);
if (!acquired.TryGetValue(out var lease))
{
return false; // 别的实例正在做,或选举存储不可达
}
await using (lease)
{
return true; // 长于存活时间的工作要定期 RenewAsync,续约失败时停下
}
}
}在 Kubernetes 上,示例应用 KubernetesApp 在 Pod 内装配 KubernetesSchedulingFeature,在本地退回 Full 组合自带的进程内选举;JobSchedulerEngine.IsLeader 与 NodeId 能放进端点展示当前谁是领导者:
using XiHan.Framework.Profiles.Full;
using XiHan.Framework.Scheduling.Kubernetes;
// 在 Pod 里(API Server 的环境变量存在)用 Lease 做领导选举;本地运行退回进程内选举
var inCluster = Environment.GetEnvironmentVariable("KUBERNETES_SERVICE_HOST") is not null;
builder.AddXiHan(xihan =>
{
xihan.UseFullProfile();
if (inCluster)
{
xihan.UseFeature<KubernetesSchedulingFeature>();
}
});Lease 名缺省是 xihan-scheduler,用 XiHan:Scheduling:Kubernetes:Lease 修改;Pod 的 ServiceAccount 需要对 coordination.k8s.io 下的 leases 有 get、update、patch、create 权限,xihan pack 能按应用清单生成这份 Role,见 Kubernetes。
事件
装配了消息能力时,引擎经 IMessagePublisher 发布 CloudEvents 信封:Source 为 /scheduling,Subject 为运行 ID(作业级事件为作业名),Data 是 SchedulingEventData 的 JSON(作业、触发器名、运行 ID、触发来源、状态、各时刻、错误码与消息、Attempt、RetryOf、节点)。没有发布者时跳过,发布失败只记日志。
主题(SchedulingTopics) | 时机 |
|---|---|
scheduling.job.scheduled / scheduling.job.unscheduled | 作业登记或替换 / 删除 |
scheduling.job.paused / scheduling.job.resumed | 作业暂停 / 恢复 |
scheduling.run.started | 运行开始 |
scheduling.run.succeeded / scheduling.run.failed / scheduling.run.canceled | 运行结束 |
scheduling.run.lost | 运行所在节点失联 |
订阅方式见发布与订阅。Kafka 关了自动建主题时这 9 个主题要预先建好,见 Messaging.Kafka。
配置
引擎选项在 XiHan:Scheduling(SchedulerOptions),两个 Provider 读同一节;值不合法时启动抛 ArgumentException。
| 键 | 类型 | 缺省 | 说明 |
|---|---|---|---|
PollIntervalSeconds | 正数 | 1 | 轮询周期 |
NodeId | 字符串 | 机器名:进程 ID:8 位随机 | 节点身份:心跳、抢占、租约持有者都用它;多节点时每个节点唯一 |
MaxConcurrentRuns | ≥ 1 | 32 | 本节点同时运行的上限 |
AcquireBatchSize | ≥ 1 | 16 | 一个周期最多抢占的触发器数 |
MisfireThresholdSeconds | ≥ 0 | 60 | 落后超过它算 misfire |
LeaseResource | 作业名文法 | scheduling.jobs | 领导租约的资源名 |
LeaseTimeToLiveSeconds | 正数 | 30 | 领导租约的存活时间 |
NodeTimeoutSeconds | 正数 | 90 | 心跳超过它即失联 |
HousekeepingIntervalSeconds | 正数 | 60 | 领导者维护周期 |
HistoryRetentionDays | ≥ 0 | 7 | 运行历史保留天数,0 为不删 |
各 Provider 自己的键(XiHan:Scheduling:InMemory:MaxRuns、XiHan:Scheduling:Database:HealthProbeIntervalSeconds、XiHan:Scheduling:Kubernetes:*)见对应包页。
错误码
| 错误码 | 含义 |
|---|---|
SCHEDULING_NOTFOUND_001 | 作业未登记 |
SCHEDULING_NOTFOUND_002 | 作业类型未登记 |
SCHEDULING_NOTFOUND_003 | 触发器不存在 |
SCHEDULING_NOTFOUND_004 | 运行不存在或已结束 |
SCHEDULING_NOTFOUND_005 | 作业没有代码定义,不能恢复 |
SCHEDULING_VALIDATION_001 | 作业定义不合法:触发器名重复、日历未登记 |
SCHEDULING_VALIDATION_002 | 运行历史查询的分页越界(HTTP 400) |
SCHEDULING_CONFLICT_001 | 作业正在运行且不允许并发 |
SCHEDULING_CONFLICT_002 | 领导租约被其他持有者占用 |
SCHEDULING_CONFLICT_003 | 运行被取消 |
SCHEDULING_UNAVAILABLE_001 | 本实例的调度器尚未启动或已开始停止,TriggerAsync 不起新的运行 |
SCHEDULING_UNAVAILABLE_002 | 领导选举的存储(数据库、API Server)不可达或超时 |
SCHEDULING_UNAVAILABLE_003 | 作业存储不可达 |
SCHEDULING_UNAVAILABLE_004 | 作业存储容量已满(内存实现的运行历史全部在运行中) |
SCHEDULING_TIMEOUT_001 | 运行超过作业的执行时限 |
SCHEDULING_UNEXPECTED_001 | 作业抛出未处理异常 |
错误文案在消息目录 scheduling 里,十种区域都有译文,见全球化和本地化。
常见问题
三个副本各跑了一遍夜间作业
只装配了 Scheduling.InMemory:每个副本有自己的存储和进程内租约,都当自己是领导者。在 Kubernetes 上加 KubernetesSchedulingFeature;需要运行时登记的作业在副本间可见、节点宕机后恢复运行,换 DatabaseSchedulingFeature。
领导者 Pod 滚动更新时会不会停一阵
不会等租约到期。排空中的实例在每个周期释放租约,另一个在服务的实例下一个周期就接手。只有实例被强杀、没来得及释放时,才要等 LeaseTimeToLive(缺省 30 秒)过期。
TriggerAsync 返回 SCHEDULING_UNAVAILABLE_001
本实例的引擎还没启动或已经开始停止,这两段时间没有轮询照看运行,所以不起新的。排空期间要排队运行的用 EnqueueAsync,它只写存储,由别的节点运行。
运行时改了作业计划,重新发布后被覆盖了
代码定义变了。只有代码定义的指纹与存储里 Overridden 记录保存的指纹相同,运行时修改才保留;代码一改就以代码为准并记警告。要让修改长期有效,把它写回代码;临时调整用 PauseAsync,暂停状态一律保留。
相关包与下一步
- 包参考:Scheduling.Abstractions、Scheduling.InMemory、Scheduling.Database、Scheduling.Kubernetes
- 事件的订阅:发布与订阅;部署到 Kubernetes:Kubernetes
- Provider 必过的套件:一致性套件与门禁
- 规范:RFC-0013《调度与领导选举》
- 下一步:定义与发布,工作流的
Delay活动与定时启动复用这里的 cron 文法
