禁止并发,为什么仍挡不住重复副作用?

调度器控制同一 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 串行
→ 降低并发重叠
≠ 外部系统没有接收成功
≠ 数据库副作用只写一次
≠ 旧持有者永远不会恢复

一次调度实例标识适合追踪;跨节点业务去重更适合绑定计划时间或业务日期。
✦ ④ 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生成并经确定性程序叠加准确中文。源码结论与测试证据来自当前工作区。
更多推荐




所有评论(0)