定时任务
写一个类、实现一个方法,它就能被后台界面按 cron 调起来。调度器随 AddSmartAdmin() 跑在 API 进程里,不装包、不配置、不多起进程。
public class DailyReportJob : IAdminJob
{
public async Task ExecuteAsync(JobExecutionContext context, CancellationToken cancellationToken)
{
context.Log?.Invoke("日报生成完毕");
}
}注册一行,与内核内置处理器同路:
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:
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 一致。
同一个时刻,全集群只跑一次
租约只回答「谁来扫表」,它是效率手段。真正保证不重复的是领取:每次触发前对任务行的下次执行时刻做一次原子比较更新。
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/16、fd00: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。