·更新于 2026年10月25日· 15 次浏览 Node.jsRedis定时任务分布式锁BUG解决工程实践

Node.js 定时任务跑了两次:我用 Redis 分布式锁和心跳续期把统计任务收成单例

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

Node.js 定时任务跑了两次:我用 Redis 分布式锁和心跳续期把统计任务收成单例 封面
ZHOUYI

作者

ZHOUYI

上周看小程序后台数据,发现每日统计比实际多了一倍。第一反应以为是埋点重复上报,查了一圈日志才发现:Node.js 服务部署了两个实例,两个实例都挂了同一个定时任务。到点以后,两边各自执行一遍统计,写入 MongoDB 时互相叠加,数据直接翻倍。

先加锁,把执行权收住

最直接的方案是加分布式锁。任务开始时用 Redis 的 SET 命令抢锁:只有拿到锁的实例才继续执行。锁值不要写死,我会用主机名加进程 ID 生成 token,这样释放锁时可以校验“这把锁是不是我加的”,避免误删其他实例刚拿到的锁。不同客户端封装略有差异,以官方文档为准。

  1. 任务开始先用 SET key token NX PX ttl 抢锁。
  2. 抢锁失败直接返回,让拿到锁的实例执行。
  3. 任务执行期间定期续期,避免慢任务中途丢锁。
  4. 结束时用 Lua 脚本校验 token 后再删除。

心跳续期:别让慢任务被误抢

一开始我把过期时间设成 30 秒,结果统计任务偶尔会跑 40 秒。任务还没结束,锁先过期了,另一个实例马上接管,等于又回到重复执行。后来我加了心跳续期:按过期时间的三分之一去续期。只要任务还活着,就把 TTL 延长;任务结束或进程挂了,续期停止,锁最终会自然过期。

js
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。锁负责互斥,续期负责慢任务,幂等负责兜底,三层都补齐,才敢安心睡觉。”

Guestbook

留言

若这篇文章也曾停在你心上,留一行字吧,我会看的。

先确认一下邮箱

多这一步,是为了少些机器骚扰,也让我能更踏实地读每一条真人写下的字。验证码 10 分钟内有效;同一邮箱 60 秒内只能发一封。

本文留言

共 0 条已加载 0 条

这里还静悄悄的——愿意做第一个留下脚印的人吗?