资讯动态

定时任务在多节点跑了两遍:租约、幂等键与版本号边界

发布时间:2026/9/30 17:36:54 来源:尧图企业网站定制
确定性架构图租约、幂等键、唯一约束与重启恢复摘要同一个对账任务部署到两个节点后同一天出现了两条流水而两边的日志都显示成功。调度器没有坏每个节点确实都按时到点了。本文按当前官方中文文档、责任 Skill 与实现源码拆开租约、幂等键、唯一约束和重启恢复四条边界说明锁为什么只能减少并发而不能决定副作用次数。✦① 两个节点都说自己按时执行了一个每日对账任务上线后报表里同一天出现了两条流水。翻日志更让人困惑两个节点都在同一分钟触发了任务两边都返回成功没有任何一条错误。调度层并没有漏掉谁恰恰相反它把同一次计划时间交给了每个可用节点。Microi吾码AI 的任务调度建立在 Quartz 集群之上多个 API 或 Worker 节点共享同一套持久化任务库节点各自持有自动生成的调度标识。集群机制保证一个触发器被原子地交给一个节点但它只处理“触发器被领取”不处理“业务语义被执行了几次”。先分清两件事“这次没有报错”是调度事实“这件事一共发生了几次”是业务事实。两者之间隔着租约、幂等键、唯一约束和状态机四道门任何一道被跳过重复就会落到账上。✦② 锁减少的是并发不是次数最直觉的方案是加一把分布式锁谁抢到谁执行。这确实能大幅降低同时执行的概率但它回答的问题是“此刻有几个持有者”而不是“这件事总共该发生几次”。当前实现的分布式锁按租约工作。持有者写入一个实例令牌取得一个单调递增的版本号锁值形如“版本号:实例令牌”。续期与释放各有一段脚本先比对令牌只有令牌还属于自己才能延长时间或主动删除。这是“仅持有者可释放”的落地方式也让锁在持有者进程消失后不会永久占位。关键恰恰在过期。节点被暂停、网络抖动或进程被强制结束时业务可能还没写完租约却已经到期。锁的语义要求它此时自动释放于是第二个节点合法地拿到锁从头执行同一份逻辑。等旧持有者恢复如果它继续把内存里那份结果写进数据库这次写入就已经绕过了保护。解释图四类边界的处理方式不同不能用同一次重试解决没抢到锁正常让位不产生副作用也不需要补偿。抢到锁但租约中途过期必须假设自己已失去写入资格而不是继续提交。写入已发出但结果未知先按业务标识回查不换一个请求号再次写入。消息被重复投递消费端按事件标识幂等落库而不是依赖只投一次。把这四件事压成同一种“失败了就重试”后面就无法判断哪一次真的产生了副作用。锁的价值在于收敛并发窗口它不提供“仅一次”的承诺。✦③ 一次触发要穿过五段链路把一次定时触发摊开可以看到重复能从好几个位置进入共享任务库的触发器领取、调度层向接口引擎传参、业务侧的租约与幂等抢占、真实副作用写入、以及执行记录与检查点落库。前两段由调度内核负责后三段必须由业务自己负责。解释图依据当前官方文档与实现源码静态核对这条链路上最容易出错的地方是第三段。很多任务把“拿到锁”当成了“这件事已经被认领”于是锁一到期第二条执行路径就能顺利走完整条链路。相反如果第三段先做一次带唯一约束的抢占第二个执行者会在写入之前就被拒绝根本走不到副作用。责任边界调度层保证触发器只被领取一次并按节点隔离租户分组它不会替业务去重。业务侧要同时准备租约、幂等键和可恢复状态这三样缺一不可。✦④ 幂等键必须由调度层给不能在业务里现生成幂等键要满足一个朴素条件同一次业务语义重跑一百遍它都得是同一个值不同的业务语义它必须是不同的值。所以它不能是随机数也不能是“当前时间”。调度层在触发任务时传入执行标识和计划时间业务侧直接用它作为抢占条件。下面的片段是最小骨架实际项目应由专用执行表的唯一索引来承担抢占而不是“先查询、再新增”。解释图四个条件同时成立业务副作用才只落一次var runId String(V8.Param.JobRunId || ); if (!runId) return { Code: 0, Msg: 缺少执行标识 }; var claim V8.FormEngine.AddFormData(job_execution, { JobKey: daily_order_summary, IdempotencyKey: runId, Status: Running }); if (claim.Code ! 1) return { Code: 1, Data: { Skipped: true } };“先查询、再新增”在单节点低并发下看起来没问题但它把去重放在了两条语句之间恰好给了第二个节点穿过窗口的机会。唯一索引把这件事交给数据库的原子性代价只是需要提前声明索引并在目标库真实回读。执行标识来自调度层业务侧只读不改避免重试时被重新生成。抢占记录和业务写入要落在同一次事务或同一个幂等单元里。每个业务副作用还要有自己的幂等键不能只靠任务级抢占。✦⑤ 让数据库做裁判条件更新与版本号租约过期之后旧持有者仍然可能带着一份过期结果回来写入。资金、库存、积分和流水这类业务单纯“更新这一行”是不够的需要让写入本身带上条件只有当数据库中记录的版本号仍小于本次持有的版本号时更新才生效。var updated V8.FormEngine.UptFormDataByWhere(account_balance, { _Where: [ [Id, , accountId], [FencingToken, , currentFence] ], Balance: nextBalance, FencingToken: currentFence }); if (updated.Code ! 1 || !updated.DataCount) { return { Code: 0, Msg: 本次持有的版本已过期写入被拒绝 }; }版本号的意义在于它是业务可比较的事实而不是一个只存在于锁内部的字符串。把租约里的版本号写进业务行任何来晚的持有者都会在条件判断上失败即使它的锁在 Redis 里曾经真实存在过。条件更新的前提条件更新要求版本号单调递增并随业务行持久化。只在内存里比较、或者把版本号存成会被覆盖的普通字段都退化成了一次裸更新。✦⑥ 重启与滚动升级恢复扫描不是第二次执行发布期间新旧版本会短暂并存节点也可能在写入之后、响应之前被重启。重启后扫描未完成任务是必须的但这次扫描必须能区分“还没做”和“已经做完只是状态没来得及更新”。长任务的通用做法是分片提交每片在短事务内落一批数据并带上检查点只要还有后续工作就返回继续标志由平台持久化检查点后重新入队。return { Code: 1, Data: { BackgroundTask: { HasMore: true, Checkpoint: { LastId: lastId }, Current: committedCount, Total: totalCount, NextDelaySeconds: 1, Msg: 本批已提交等待下一批 } } };恢复扫描因此要按“最后成功提交的位置”继续而不是按“这一批看起来没处理完”重新开始。检查点落在共享存储里节点异常后由其它节点从同一位置接续旧节点即使复活并尝试写入也会被版本号和业务唯一约束挡在门外。停机顺序先停止接收新工作再在有限宽限期内排空或持久化已接收的工作。重启后自动扫描未完成任务把“未完成”与“已完成”严格分开。✦⑦ 启动初始化同样会被每个节点跑一遍重复执行不只发生在业务任务上。启动时的建索引、补字段、种子数据和缓存预热同样会被每个节点各跑一次。这类逻辑如果写成“先判断不存在、再创建”多节点同时启动时就会排队撞车。建索引与补字段要幂等存在时安静跳过并发执行不报错。种子数据要有稳定标识重复写入得到同一行而不是多行。缓存预热允许被多个节点同时做但结果必须一致且可重复。升级检查点只在成功后推进失败或方向未知时停在原处。共同的判断标准一个好用的自检问题是把这段初始化连续跑三次、并且让两个节点同时跑结果是否与只跑一次完全相同如果答案不确定它就不该出现在启动路径上。✦⑧ 两节点验收与这份结论的边界验收不该只看“任务有没有跑起来”。最低要求是同时启动两个节点连接同一组数据库和缓存覆盖同一定时任务同时到点、同一请求或消息重复投递、锁持有者中途退出、缓存短暂故障、写入完成但响应前重启以及滚动升级期间新旧版本共存。源码证据图依据当前工作区源码与官方文档静态核对行号与哈希留在本地验证记录业务副作用只发生一次重复触发不会产生第二条流水。锁持有者退出后任务可被其它节点接管不出现永久死锁。未完成的任务在重启后能继续已完成的任务不会被重做。多节点同时启动不报错也不产生重复的初始化数据。需要说清楚的是这些结论的来源。上文关于调度、租约、幂等键和条件更新的说法来自当前官方中文文档、对应责任 Skill 与当前实现源码的静态核对以及一组本地确定性检查它没有声称已在某个多节点生产集群上完成压测也没有把示意代码当成真实联调。要把它们变成你环境里的结论仍需在你自己的两节点环境中重跑一遍上面这份清单。本文说明文字由 AI 辅助创作并依据当前文档与源码核对。文中用于短视频平台的竖版图卡使用 AI 生成的抽象底图文字与技术信息由程序确定性叠加示意代码不含真实业务凭据与配置。文中已标注的概念图由AI生成源码与实测证据均来自当前工作区。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑