Skip to content

定时任务

写一个类、实现一个方法,它就能被后台界面按 cron 调起来。调度器随 AddSmartAdmin() 跑在 API 进程里,不装包、不配置、不多起进程。

csharp
public class DailyReportJob : IAdminJob
{
    public async Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
    {
        context.Log?.Invoke("日报生成完毕");
    }
}

注册一行,与内核内置处理器同路:

csharp
builder.Services.TryAddEnumerable(ServiceDescriptor.Scoped<IAdminJob, DailyReportJob>());

剩下的在界面里做:新建任务、载荷选「编译类」,处理器下拉里就有它。

处理器的三种形态

编译类之外还有两种,都不用写代码,填表就能建:

载荷参数从哪来什么时候用
编译类属性包 + 你自己注入的服务要碰数据库、要复用业务服务
HTTP属性包的 url / method / headers / body触发别的服务的接口,或者健康巡检
SQL属性包的 sql一次性数据订正,默认关闭

属性包是参数的唯一入口。 它是一张字符串字典,存在任务行上,执行时经 context.Properties 交给处理器。属性包配上二十行的 IJob,比框架替你猜参数好用。

SQL 任务的开关是 SmartAdmin:Jobs:Sql:Enabled,默认 false。打开它等于承认一件事:能编辑任务的人就有了 DBA 权限

cron 是 6 段,秒在最前面

秒 分 时 日 月 周
0  30 3  *  *  ?     每天 03:30
*/5 *  *  *  *  ?     每 5 秒
0  0  0  L  *  ?     每月最后一天零点
0  0  9  ?  *  5L    每月最后一个周五 09:00

* , - / ? 全支持,日段还有 L(月末)、L-3(月末前三天)、15W(离 15 号最近的工作日)、LW(月末最后一个工作日),周段还有 5L(最后一个周五)、5#3(第三个周五)。写 5 段会自动补秒位。

日和周不能同时受限,一侧填了具体值另一侧就得写 ?,否则返回 47003。Quartz 是这个语义,理由是「每月 15 号」和「每周一」同时成立时到底该按哪个跑,历史上从来没有一致答案。

前端的 CronEditor 逐段给出「每 / 区间 / 步长 / 指定」四种填法,底下实时预览未来五次执行时刻。它调的是 POST /api/v1/sys/job/preview-cron,任何登录用户都能用,不必单独授权。

时刻可以填秒,但秒级监控要付学费

间隔任务的下限是 5 秒,填 4 会被 47004 拒。cron 的秒段允许写 *,预览区会给一句警告但不拦,后果是一个每秒执行的任务一天就是 8.6 万行执行记录。

记录保留天数在配置中心,键是 sys.job.logRetentionDays,默认 30 天,内核自带的清理任务每天 03:30 分批删过期行。它自己就是一条定时任务,你能在界面上看到它、暂停它、改它的时刻。

单条记录里 context.Log?.Invoke(...) 的输出总量另有一道上限,SmartAdmin:Jobs:MaxMessageChars,单位是字符,默认约 26 万,≤0 不限。超限不会静默丢弃:保留开头与结尾,只截中间,断点处留一句标注原长度的标记——多步任务的结尾通常是各步结论,比中间大段重复的逐行明细更值得留下。

三种存活形态,代码零改动

「不能随着后端停止而停止」这句需求要拆成三层看:

任务不因重启而丢。 触发配置和下次执行时刻都在库里,进程重启后接着算。停机期间错过的时刻按任务上的错过策略处理:默认 Skip 不补跑、直接推进到未来;选 FireOnceNow 则补跑一次,错过再多也只补一次。停机三天的日报任务补三份没有意义。

一个副本挂了,另一个接手。 两个 API 副本都跑调度器,靠 sys_job_lock 上的租约选主,只有主节点扫表。主节点失联后,备节点最迟 40 秒接管(租约 30 秒 + 心跳 10 秒)。这一层要服务器数据库,SQLite 撑不住两个进程同时写。

API 停了任务照跑。 进程内的调度器物理上不可能比进程活得久,这半句只能靠第二个进程。照抄 backend/samples/WorkerHost

csharp
var builder = Host.CreateApplicationBuilder(args);
// 必须排在 AddSmartAdminWorker 之前:Worker 与 API 共享缓存是多进程部署的前提(强退、权限缓存、限流计数都在这上面)
builder.Services.AddSmartAdminRedisCache(builder.Configuration);
builder.Services.AddSmartAdminWorker(builder.Configuration);
await builder.Build().RunAsync();

漏了 AddSmartAdminRedisCache 这一行,内核会静默退回进程内缓存,Worker 和 API 各算各的,又回到「多副本没有共享缓存」那一整套问题;顺序反了(写在 AddSmartAdminWorker 之后)则装配期直接抛错。

Worker 的配置有三条纪律:SmartAdmin:Id:WorkerId 必须显式给且与所有其它进程不同(不给直接拒绝启动)、建表与种子关掉(schema 归 API 侧所有)、时区与 API 一致。

同一个时刻,全集群只跑一次

租约只回答「谁来扫表」,它是效率手段。真正保证不重复的是领取:每次触发前对任务行的下次执行时刻做一次原子比较更新。

sql
UPDATE sys_job SET NextRunTime=@next
WHERE Id=@id AND NextRunTime=@expected AND Status=1

影响行数是 1 才允许触发。旧主节点 GC 停顿二十秒后醒来照旧扫表,可那一格时刻已经被新主推进了,@expected 对不上,它领不到。脑裂、时钟漂移、进程停顿,同一个到期时刻数学上至多被领走一次。

多副本形态下这条由容器冒烟测试验收:建一个 5 秒任务、等它跑几轮、断言计划时刻两两互异,然后杀掉主副本,断言任务没停、主节点已易主。

任务失败之后

每条任务各自配失败处理,四件套:

旋钮语义
重试次数 / 间隔同一次触发内重试,各次尝试共享一个触发实例 Id
超时秒数到点取消这次执行,记为超时;超时与取消都不再重试
连败告警阈值连续失败达此值发告警并转入崩溃态,不再调度
站内信 / 邮件站内信定向发给任务创建人和超管,不广播;邮件收件人留空则回退配置中心的 sys.job.alertEmails

崩溃态要人工在界面上重新启用才恢复,这是刻意的:一条已经连败十次的任务,继续每五分钟失败一次只会淹没日志。告警也只在跨过阈值那一次发出。

任务实现必须真异步。 Thread.Sleep.Result.Wait() 会占死线程池线程,八个这样的任务同时在飞就能让整个进程失去响应。在飞上限 MaxConcurrentRuns 是兜底,不是解药。

部署前要确认的三件事

所有参与调度的进程同一时区。 全模块用服务器本地时间,容器默认 UTC 而宿主机常是东八区。仓库的 docker-compose.yml 给了 TZ 变量,自建镜像别忘了。

HTTP 任务的目标地址过围栏。 默认封的是云元数据段(169.254.0.0/16fd00:ec2::/32 及 IPv6 孪生 fe80::/10)和回环(127.0.0.0/8::1/128),内网不封,因为调度器打内网服务本来就是主用途(RFC1918 与 ULA fc00::/7 照旧放行)。要打本机端口,得在 SmartAdmin:Jobs:Http:BlockedCidrs 里显式去掉回环段;要收紧就反过来配 SmartAdmin:Jobs:Http:AllowedHosts 白名单。围栏在保存和执行两处都拦,连接时还会对解析后的 IP 复检一次,拦的是「保存时解析成公网、执行时解析成内网」的把戏。

演示模式会拦下「执行一次」。 任务能发任意 HTTP 请求,演示站放行它等于开洞。这是特性,不要为了演示关掉它。

整模块可以下线:SmartAdmin:Api:DisabledModules 里加 "Job",控制器连路由都不注册。只想让某个副本不参与调度、但仍能查看和编辑任务,配 SmartAdmin:Jobs:SchedulerEnabled=false

基于 Apache License 2.0 开源