2237 字
约 7 分钟
16
S07-A26 · 多实例下的定时任务:为什么"每天一次"跑了三次

一个"每日对账"任务用 @Scheduled(cron = "0 0 2 * * *"),单机时正常。上了三副本后,财务收到三份对账邮件——因为三个实例的定时器都到了 2 点,这个"三次"的机制很朴素:每台机器的"本地时钟"都到 2 点,于是"各自的定时器"各跑一次——**而它们"互相不知道",也没有"谁该让位"的约定.
于是"同一个任务被执行三次".

@Scheduled 是"进程内定时器":它不知道"世界上有几个自己"。于是"多实例"这件事,必须由"应用之外"或"外部协调"来解决.

1. 三种"重复执行"的成因

成因 说明
多实例(每台都跑) 最常见
滚动发布(重叠窗口) 老新同时跑
手动触发 + 定时撞上 偶发

第二项常被忽略:"发布时"旧实例还在"跑最后一轮"第二项的"隐蔽"在于"只影响一天":"那天报表重复,之后又正常"——**于是"排查的人"很难把"一次发布"与"一次重复"联系起来.
——**于是"那天"会跑两次,而"只有那天".

2. "四种去重"的方案

方案 代价
只让"一个实例"跑(选主) 要"选举机制"
分布式锁(Redis/ZK) 要"锁的可靠性"
外部调度(XXL-Job 等) 要"引入组件"
幂等(重复执行无害) 要"业务能幂等"

第四项的"根本性"在于"它不依赖'协调是否可靠'":"锁会过期、调度器会重试、网络会分区"——而"幂等"让"这些意外"都"无害".
第四项是"最根本"的:
"重复执行不产生副作用"**——**而它要求"任务本身"设计成"可重入".

"Redis 锁"的最小实现是"一条命令 + 一次校验":

// 三步"Redis 锁"(加、续、放)
1 加锁:SET lock:reconcile <uuid> NX EX 300          (uuid 标识"我的锁")
2 续期:EXPIRE lock:reconcile 300(仅当 GET 等于我的 uuid)
3 释放:DEL(仅当 GET 等于我的 uuid)——用 Lua 保证"比对+删除"原子
// 判据:释放"先比对值",否则会删掉别人的锁

3. "分布式锁"的三种实现

实现 要点
Redis SET NX EX 加锁 + 过期 + "续期"
ZK 临时顺序节点 会话结束自动释放
DB 唯一键(任务表) 简单(写入冲突即"已跑")

第三项是"最小依赖":"任务表 + 唯一键(日期)"第三项的"优点"是"没有新组件":"你的库本来就在"——**于是"没有 Redis、没有 ZK"的小团队,也能"五分钟上线".
——**于是"第二个实例插入失败",自然"不执行".

-- 三行"任务表"去重(最省心)
CREATE TABLE job_run (job VARCHAR(64), run_date DATE, PRIMARY KEY (job, run_date));
-- 执行前:INSERT IGNORE INTO job_run VALUES ('reconcile', CURDATE());
-- 影响行数 = 1 → 拿到执行权;= 0 → 今天已有人跑

"续期"对"长任务"是必需的,而它的"频率"要"小于过期时间":

// 三种"续期"策略
1 过期 300s、每 60s 续一次(安全余量大)
2 过期 300s、每 290s 续一次(省,但"抖动"就丢锁)
3 不续期,把"任务切成 5 分钟一片"(最稳,但改任务)
// 判据:续期间隔 ≤ 过期时间的 1/3

4. "锁"的三个必配

必配 为什么
过期时间 防"持锁进程崩了不释放"
续期(长任务) 防"任务比过期时间长"
释放校验(值比对) 防"释放别人的锁"

第三项的"场景"很具体:"任务跑了 10 分钟、锁 5 分钟过期、另一台拿到锁、第一台跑完后删锁"——于是"第二台"的锁"被第一台删掉",第三台"又拿到锁",重复开始.
第三项最容易被漏:
"删锁"时不校验"锁是不是自己的"**——**于是"任务超时后,A 把 B 的锁删了".

"唯一键幂等"要注意"事务边界",否则"插入成功但业务失败"会"永久跳过":

// 三步"安全幂等"(同一事务里)
1 INSERT job_run … (唯一键占位)
2 执行任务本体
3 任一步失败 → 事务回滚 → 占位消失 → 下次可重跑
// 判据:"占位"与"业务"要么一起成、要么一起败

5. "幂等"的三种做法

做法 说明
唯一键(INSERT 冲突即跳过) 最简单
状态机("已处理"不再处理) 清晰
版本号/水位(只处理"新于") 适合"增量同步"

第三项在"同步类任务"里最实用:"只处理 updated_at > last_watermark 的记录"第三项的"配套"是"水位要'持久化'":"存在库里(而不是内存)"——**否则"重启"会把"水位退回",于是"全量再跑一遍".
——**于是"重复执行"只"处理 0 条".

"任务不跑"的排查顺序,应当"先看锁,再看调度":

// 三步"任务不跑"排查
1 查锁:key 是否存在、值是谁、TTL 多少("锁没释放"最常见)
2 查调度:cron 是否跑在"预期时区"(容器默认 UTC 常导致"差 8 小时")
3 查日志:任务"是否进入方法"(进了但"空转"是另一类问题)
// 判据:这三步覆盖"80% 的'没跑'"

6. 排错:五种"定时任务问题"

现象 成因 处理方向
一天跑多次 多实例 锁/幂等
发布当天跑两次 滚动重叠 锁 + 幂等
有时不跑 锁"没释放" 检查过期
长任务"跑一半" 锁过期 续期
重复发邮件 无幂等 唯一键/水位

7. 边界:什么情况"多实例都跑"是对的

场景 为什么 做法
任务"按实例分片"(各跑自己那份) 并行 分片键
任务"只做本机清理"(本地文件/日志) 天然"每台各一份" 各自跑
任务"幂等且便宜"(缓存预热) 重复无成本 各自跑
监控探针(每台自检) 需要"每台的视角" 各自跑

最后一条与"运维"有关的边界:"锁的可观测性"要"到位":"谁持有锁、什么时候过期"要能查——否则"任务不跑"时,只能"重启试试",而"重启"会"清掉现场".
判断口径一句话:
"这个任务重复跑,会不会"产生用户可见的重复副作用""**。会(发邮件、扣款、写对账文件),必须"锁 + 幂等";不会(本机清理、缓存预热、自检),"每台都跑"反而是"设计意图"——而"默认以为只有一个自己",是这类 bug 的唯一成因.

8. 自测

  1. 三种"重复执行"成因里,哪一种"只在发布那天发生"?
  2. "幂等"为什么是"最根本"的方案?它要求任务具备什么?
  3. "任务表 + 唯一键"为什么依赖数据库就够了?"影响行数"说明了什么?
  4. 分布式锁的三个"必配"里,哪一项最容易被漏?漏了会发生什么?
  5. "水位(watermark)"为什么适合"增量同步"?重复执行会怎样?
  6. 判断"能否每台都跑"的那个问题是什么?

补一句"发布流程"的细节:"滚动发布"期间要"暂停定时任务"(如"发布窗口避开 2 点"),或"让任务判断'是否在发布中'"——**否则"发布与定时"撞在一起,就会"重复执行",而"重复"往往"只在那一天". 这一条比"改代码"便宜得多,却常被"没人想到".

而"想到"这件事,恰好是"多实例"时代里"最容易忘"的一类经验。

所以"多实例"这条约束,应当在"写第一个定时任务时"就被想起。

而"想起"它,只需要"问一句'有几个实例'"。

9. 一句话总结

@Scheduled 是"进程内定时器",它不知道有几个自己——于是"多实例"必然"多跑"。稳妥组合是"外部协调(锁/调度器)+ 任务幂等":锁保证"同时只有一个跑",幂等保证"跑了也不出错"。而"只加锁不做幂等"的团队,会在"锁过期/发布重叠"那天收到两份邮件——**因为"锁"是"尽力而为","幂等"才是"保证".

S07-A26 · 多实例下的定时任务:为什么"每天一次"跑了三次
https://www.clxhxhhr.top/posts/6707/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。