双节点定时任务从调度互斥到业务幂等的技术封面

调度器控制同一 JobKey 的重叠,业务层负责让同一副作用只落一次。

摘要| 定时任务加了集群调度和禁止并发,并不等于业务只会发生一次。真正可靠的链路还需要稳定计划时间、业务幂等键、数据库唯一约束、可恢复状态和 fencing token。本文以当前 Microi吾码AI 的 Quartz 调度、V8 接口引擎、后台任务租约与 6 项聚焦测试为证据,拆开“少重叠”和“只生效一次”的边界。

① 最危险的误会:没同时跑,就等于只执行一次

很多任务在类上加了禁止并发,压测时也只看到一个节点工作,于是把“不会重叠”写进了业务承诺。可系统真正害怕的不是两个线程同时进入,而是扣款已经落库、响应却丢了;节点租约过期后旧进程又恢复;或者同一计划时间在故障恢复时被重新投递。

一句话| 互斥回答“现在谁先跑”;幂等回答“同一业务事实最多生效几次”。两者不能互相替代。

调度互斥与业务幂等的四层边界

Quartz 集群、JobKey 串行、稳定业务键、数据库原子约束是四个不同层次。

② 第一层:Quartz 集群先减少同一触发器的争抢

当前 Microi.Job 使用持久化 JobStore,并在依赖注入里显式启用 Quartz 集群;任务组名还把租户可读片段与稳定摘要组合,避免不同 OsClient 的同名任务互相覆盖。它解决的是多节点对同一调度事实的协调,不是业务表里的最终唯一性。

  • 同一数据库中的 Quartz 节点共同抢占触发器。
  • Job 的 Name + Group 以租户隔离,历史 default_group 只在归属当前租户时兼容。
  • Cron、时区、JobParam 与 ApiEngineKey 进入持久任务数据,修改后原子替换。

边界| 如果业务还会被人工按钮、消息补偿或其它接口触发,Quartz 根本看不到这些入口。

③ 第二层:DisallowConcurrentExecution 只保护同一 JobKey

MicroiApiEngineJob 标注了 DisallowConcurrentExecution。这会让同一 JobKey 的执行串行,并在异常时把 JobExecutionException 交回 Quartz,让 misfire 与失败状态可见。它很重要,但仍只是在调度边界减少重叠。

Quartz 集群 + 同一 JobKey 串行
  → 降低并发重叠
  ≠ 外部系统没有接收成功
  ≠ 数据库副作用只写一次
  ≠ 旧持有者永远不会恢复

FireInstanceId 与稳定计划时间的差异矩阵

一次调度实例标识适合追踪;跨节点业务去重更适合绑定计划时间或业务日期。

④ JobRunId 能追踪一次执行,却不一定是跨节点业务键

调度层把 FireInstanceId 写入 JobRunId,同时传入 ScheduledFireTime 和兼容字段 FireTime。前者描述一次具体执行实例;同一计划时间在另一节点恢复时,实例标识可能不同。若直接拿它当“每日备份”的唯一业务键,两次实例就可能各自成功。

当前数据库备份实现给出了更稳的做法:优先把 ScheduledFireTime/FireTime 归一化为 UTC 计划时刻,只在历史参数缺失时兼容 JobRunId。对应测试用两个不同节点实例标识、两种时区表示,最后得到同一个稳定键。

选择规则| 追踪键描述一次尝试;幂等键描述同一业务事实。日结可用业务日期,消息可用 EventId,计划任务可用 OsClient + JobKey + 归一化计划时间。

⑤ 第三层:用数据库原子约束决定谁真正获得执行权

接口引擎应先把稳定幂等键写入专用执行表,并由唯一索引或条件状态迁移原子抢占。不能先查“有没有”,再新增;两个节点完全可能同时查到没有。锁也不能代替这一步,因为锁过期、网络分区和成功后确认丢失都可能让第二个尝试到来。

var key = V8.OsClient + ':daily-summary:' + V8.Param.ScheduleDate;
// 由表上的唯一索引保证原子 claim;冲突按已执行处理
var claim = V8.FormEngine.AddFormData('job_execution', {
  JobKey: 'daily-summary', IdempotencyKey: key, Status: 'Running'
});
if (!claim || claim.Code !== 1) return { Code: 1, Msg: '已执行或正在执行' };

部署方式| 唯一索引写进应用 Manifest 并通过应用商城升级交付;业务编排留在接口引擎,不在任务脚本里手写 DDL。

⑥ 第四层:租约限制重叠,fencing 拒绝过期旧持有者

后台任务的 ConcurrencyKey 会建立租户隔离的 Redis 租约,带 TTL、唯一 owner、续租与仅持有者释放。源码注释直接写明:正确性仍依赖幂等键和 fencing token,租约只限制 overlap。

资金、库存、积分或发布指针等高价值写入,还要把 fencing token 带进条件更新。新持有者得到更大的 token 后,旧节点即使从卡顿中恢复,也会因 token 较旧而更新 0 行。

租约过期后由新节点从检查点恢复的链路

节点替换不是问题;没有 checkpoint、唯一约束和 fencing 才是问题。

⑦ V8 接口引擎负责状态机,底层只保留最小调度原子

当前任务调度的保存、列表、暂停、恢复和删除由官方 Managed 接口引擎 platform-schedule-job 编排;Quartz 只通过最小 V8.Method 原子访问。业务任务继续使用稳定 ApiEngineKey,在 V8 中实现 claim、分页、Checkpoint、重试分类和补偿。

  • 超过 10 分钟的任务按批次返回 HasMore + Checkpoint,不放大一次 V8 调用。
  • 已知总量只用 Current/Total 计算进度;未知总量不伪造百分比。
  • 外部结果未知时先按业务幂等号查询,不换 RequestId 盲目重发。
  • 成功、失败、取消都写共享状态,重启后从最后已提交批次恢复。

架构取舍| 调度协议和租约是可信原子;状态机与业务副作用在接口引擎中可升级、可审计、可按租户扩展。

⑧ 6/6 聚焦测试通过,但生产验收仍要双节点故障演练

本轮在当前工作区执行 3 项 .NET 聚焦测试与 3 项官方 SaaS 应用包合同测试,共 6/6 通过。覆盖租户分组、原子调度、管理员边界、Managed 接口引擎同源,以及不同节点实例映射到同一计划时间键。

定时任务聚焦测试六项全部通过

测试结果来自当前源码并保留 TRX 与 JUnit 原始文件;没有停止共享服务。

验收边界| 源码与单元合同通过不等于生产容灾完成。上线还要两节点同时到点、响应前重启、租约持有者中止、Redis 短故障、重复投递和滚动升级。最终只看业务副作用是否恰好一次。

所以,可靠任务不是再加一把更大的锁,而是把每一层的问题交给正确机制:Quartz 协调计划,JobKey 降低重叠,稳定业务键定义“同一件事”,唯一约束决定唯一赢家,checkpoint 允许接力,fencing 拒绝旧持有者。本文技术概念卡片底图由AI生成并经确定性程序叠加准确中文。源码结论与测试证据来自当前工作区。

Logo

宁波官方开源宣传和活动阵地,欢迎各位和我们共建开源生态体系!

更多推荐