Node.js 定时任务跑了两次:我用 Redis 分布式锁和心跳续期把统计任务收成单例
一次每日统计任务在多实例下重复执行,导致数据翻倍。我用 Redis 分布式锁、心跳续期和幂等写入把定时任务收成单例,并补上异常释放兜底。

作者
ZHOUYI
上周看小程序后台数据,发现每日统计比实际多了一倍。第一反应以为是埋点重复上报,查了一圈日志才发现:Node.js 服务部署了两个实例,两个实例都挂了同一个定时任务。到点以后,两边各自执行一遍统计,写入 MongoDB 时互相叠加,数据直接翻倍。
先加锁,把执行权收住
最直接的方案是加分布式锁。任务开始时用 Redis 的 SET 命令抢锁:只有拿到锁的实例才继续执行。锁值不要写死,我会用主机名加进程 ID 生成 token,这样释放锁时可以校验“这把锁是不是我加的”,避免误删其他实例刚拿到的锁。不同客户端封装略有差异,以官方文档为准。
- 任务开始先用 SET key token NX PX ttl 抢锁。
- 抢锁失败直接返回,让拿到锁的实例执行。
- 任务执行期间定期续期,避免慢任务中途丢锁。
- 结束时用 Lua 脚本校验 token 后再删除。
心跳续期:别让慢任务被误抢
一开始我把过期时间设成 30 秒,结果统计任务偶尔会跑 40 秒。任务还没结束,锁先过期了,另一个实例马上接管,等于又回到重复执行。后来我加了心跳续期:按过期时间的三分之一去续期。只要任务还活着,就把 TTL 延长;任务结束或进程挂了,续期停止,锁最终会自然过期。
const token = `${os.hostname()}:${process.pid}`;
const ok = await redis.set(lockKey, token, 'PX', ttl, 'NX');
if (!ok) return;
const renewScript = `if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('pexpire', KEYS[1], ARGV[2]) else return 0 end`;
const releaseScript = `if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end`;
const renew = setInterval(() => {
redis.eval(renewScript, 1, lockKey, token, ttl).catch(() => {});
}, ttl / 3);
try { await runDailyStats(date); }
finally { clearInterval(renew); await redis.eval(releaseScript, 1, lockKey, token); }异常释放和幂等兜底
锁只能降低并发,不能保证业务绝对正确。比如进程崩溃时,续期停止,锁会按 TTL 自动过期,下一个实例可以接管,这是好事;但如果上一个实例已经写了一部分数据,就可能留下脏数据。所以我把统计落库改成幂等:按日期和任务类型作为唯一条件,先查当天记录,再决定插入或覆盖,避免使用自增累加。这样即使锁失效、任务重跑,最终结果也只是同一份数据被覆盖,不会翻倍。
“分布式定时任务不能只靠 cron。锁负责互斥,续期负责慢任务,幂等负责兜底,三层都补齐,才敢安心睡觉。”