分布式定时任务如何避免重复执行

liulian
liulian 正式会员超兽战士
发布于 2026-10-07 16:23 ·0 浏览 ·0 回复

结论:分布式定时任务要彻底避免重复执行,靠单一手段做不到,必须"调度层防重复触发 + 执行层分布式锁 + 业务层幂等"三层叠加,其中最后一道业务幂等是唯一能兜住所有异常(网络超时、GC 停顿、锁过期、重试)的防线。

为什么定时任务在分布式环境一定会重复执行

结论:只要存在"调度成功但执行结果未知"的窗口,重复执行就不可避免,这是分布式系统的基本约束,不是 Bug。

典型场景有三个。一是调度中心向执行器下发任务时网络抖动,执行器实际跑完了但回执丢了,调度中心按失败策略重试,任务跑了两次。二是执行器节点刚拿到锁就发生 Full GC 停顿 20 秒,Redis 上的锁 PX 30000 已经过期,另一个节点抢到锁开始执行。三是同一任务被部署到多台机器,每台机器的本地 cron 都会触发一次。

所以正确的心态不是"消灭重复",而是"让重复执行不产生副作用"。

调度框架自带的集群能力能解决多少问题

结论:Quartz 集群模式、XXL-JOB、ElasticJob 这类框架能解决"同一时刻多个节点同时触发"的问题,但解决不了执行超时后的重试重复。

Quartz 集群的机制是抢 QRTZ_LOCKS 表的行锁(SELECT ... FOR UPDATE),抢到的节点才触发任务,其余节点空转。XXL-JOB 的调度中心是单点集中调度,配合执行器的路由策略(如"第一个""一致性 HASH")决定任务落到哪台执行器。ElasticJob 则用 ZooKeeper 临时节点做选主,只在主节点上跑。

这些机制的共性是:它们保证"同一时刻只有一个节点在跑",但不保证"同一个业务周期只跑成功一次"。执行超时被判定失败后重新调度,框架照样会再发一次。所以框架层只是第一道闸门。

分布式锁怎么写才不会锁了个寂寞

结论:锁的 value 必须唯一、释放必须用 Lua 原子比对删除、锁有效期必须大于任务最长耗时,并且优先按业务维度加锁而不是全局加锁。

最小可用写法(Redis):

SET lock:job:settle:{date} <requestId> NX PX 60000

NX 保证只有第一个请求能设置成功,PX 60000 是 60 秒自动过期防止死锁。释放时不能用 DEL,否则可能删掉别人的锁,要用 Lua:

if redis.call('get', KEYS[1]) == ARGV[1] then
  return redis.call('del', KEYS[1])
else
  return 0
end

如果任务耗时不确定,用 Redisson 的看门狗续期,默认 lockWatchdogTimeout 是 30000 毫秒,每 10 秒自动续一次,直到业务代码主动释放。

两个关键细节:一是锁的粒度要按业务键,比如 lock:job:settle:20240601,而不是 lock:job:settle,这样按天跑的任务天然隔离;二是拿到锁不代表可以执行,还要检查锁的 fencing token(单调递增令牌)是否大于上一次写入业务库的值,防止"锁过期后旧节点还在写数据"。

业务幂等怎么做才算真的兜底

结论:幂等的核心是"用数据库的唯一约束或条件更新把重复请求变成无效请求",而不是在代码里 if (已执行) return; 这种检查后执行,那本身就是竞态。

三种落地方式,按可靠性排序:

第一种,唯一索引兜底。给业务记录表加联合唯一索引:

CREATE UNIQUE INDEX uk_job_biz ON t_settle_record(job_name, biz_date);

执行时用 INSERT ... ON CONFLICT DO NOTHING(PostgreSQL)或 INSERT IGNORE(MySQL),插不进去就说明今天已经跑过,直接退出。数据库的唯一性由存储引擎保证,并发下也不会漏。

第二种,状态机条件更新。把状态从 INIT 改成 RUNNING:

UPDATE t_job SET status='RUNNING' WHERE id=? AND status='INIT';

判断 affected rows == 1 才继续执行,等于 0 说明别的节点已经在跑了。这个写法在 MySQL 行锁下是原子的,比先 SELECT 再 UPDATE 安全得多。

第三种,幂等表 + 业务唯一键。单独建一张 t_idempotent(key_hash, expire_at) 表,key_hash 是 任务名+业务日期+业务主键 的哈希,插入成功才执行业务。适合没有天然唯一键的场景,比如调用第三方支付接口。

实战里最容易踩的三个坑

结论:踩坑集中在"锁范围""重试语义""时钟"三处,每一个都会让前面的防护全部失效。

第一,把锁加在方法上但事务在锁外提交。常见错误是 tryLock 之后开事务、业务写完、锁先释放、事务后提交,中间那几毫秒另一个节点已经读到旧数据。正确顺序是事务提交后再释放锁。

第二,把"至少一次"的重试当成"最多一次"来用。MQ 消费、HTTP 回调、调度重试本质上都是 at-least-once,必须假设消息会重复投递。

第三,忽略时钟漂移。多台机器的 NTP 同步有毫秒级误差,如果锁有效期只有 1 秒而任务平均跑 800 毫秒,抖动一下就击穿了。经验值是锁有效期至少设为任务 P99 耗时的 3 倍,长任务改用看门狗续期。

一句话收尾:调度框架解决"同一时刻谁来跑",分布式锁解决"同一时刻只有一个人跑",业务幂等解决"跑了多次也只生效一次"。三层缺一层,重复执行就一定会从缺口钻进来。

版权声明:本文来自 GJ站长论坛《分布式定时任务如何避免重复执行》
原文链接:https://www.gj0.com/thread-397.html
转载请注明出处并保留本声明;内容仅代表作者观点,与本站立场无关。若本文涉嫌侵权,请联系本站处理。

全部回复 0

还没有回复,来抢沙发~