阅读提要

一个每天跑一次的任务,某天开始同时跑了五份,把数据库连接池耗尽。重叠执行不是「偶发故障」,是缺少互斥和超时的必然结果。

开始阅读

字号

事故是这样发生的

一个「每小时清理过期缓存」的任务,平时跑 40 秒就结束。某天数据库变慢,单次跑到 25 分钟。

下一个整点,cron 照常触发,第二份开始跑。两份同时操作同一批数据,互相等待锁。第三个小时,三份。

到第六个小时,六份任务把连接池占满,正常请求拿不到连接,接口全线超时。

根因不是「数据库变慢」,而是任务没有互斥机制。cron 只负责按时触发,它不知道上一次有没有跑完。

调度与互斥

第一层:进程内互斥

单机部署时,最简单的办法是加锁文件:

import { open } from 'node:fs/promises'

async function withLock(name, fn) {
  const path = `/tmp/${name}.lock`
  let handle
  try {
    handle = await open(path, 'wx')   // 排他创建,已存在则失败
  } catch {
    return { skipped: true }          // 上一轮还在跑
  }
  try {
    return { result: await fn() }
  } finally {
    await handle.close()
    await unlink(path).catch(() => {})
  }
}

open 用 wx 标志是关键:文件存在就抛错,这一步是原子的。不要用「先 existsSync 再创建」,那是竞态。

锁文件的弱点:进程被 kill -9 时文件残留,下一轮永远跳过。所以要么记录 PID 并检查进程是否存活,要么给锁加超时(文件内容写时间戳,超过阈值视为失效)。

第二层:超时

每个任务必须有硬超时。没有超时的任务,一旦卡住就会永久占着锁。

const timeout = setTimeout(() => {
  throw new Error('job timeout after 25min')
}, 25 * 60_000)

try {
  await run()
} finally {
  clearTimeout(timeout)
}

超时值怎么定:看历史执行时间的 P99,留 2-3 倍余量。设得太紧会误杀正常任务,太松失去意义。

第三层:多机部署用分布式锁

多台机器都跑 cron 时,锁文件不起作用。用 Redis:

const token = crypto.randomUUID()
const ok = await redis.set(`lock:${name}`, token, 'PX', 30 * 60_000, 'NX')
if (!ok) return { skipped: true }

try {
  await run()
} finally {
  // 只删自己持有的锁,避免删掉别人的
  await redis.eval(
    'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end',
    1, `lock:${name}`, token
  )
}

三个细节容易漏:

  1. NX:不存在才设置,这是互斥的基础;
  2. PX:必须有过期时间,否则进程崩溃后锁永不释放;
  3. 删除时校验 token:否则任务超时后锁已过期、被另一个进程拿到,你的 del 会误删别人的锁。

更好的架构:调度与执行分离

如果任务数量多,更稳的做法是「单点调度 + 幂等执行」:

  • 只有一个调度器(或使用数据库行锁选主),负责决定「该跑了」;
  • 执行逻辑写成幂等,重复执行不出错;
  • 执行状态落库(job_runs 表),能看到每次的开始时间、结束时间、结果。

状态表的价值在排查时体现:出问题时能直接看出「上一次跑了多久」「是不是重叠了」,而不是靠猜。

幂等比互斥更重要

互斥解决「不要同时跑」,幂等解决「跑了两次也别出错」。

一个清理任务如果写成 DELETE FROM cache WHERE expired_at < now(),重复执行无害,天然幂等。

如果写成 UPDATE counter SET value = value + 1,重复执行就错了。

设计任务时优先考虑幂等:即使锁失效、调度重复、人工重跑,结果都应该一致。这比把锁做得完美更可靠,因为锁总有失效的可能。

监控什么

三个指标:

  • 执行时长:突然变长是重叠的前兆;
  • 跳过次数:连续跳过说明任务一直没跑完;
  • 最后成功时间:超过两个周期没成功,该告警了。

最后一条最实用。与其监控「任务是否报错」,不如监控「任务多久没成功了」——它覆盖了报错、卡死、调度丢失所有情况。

收尾

定时任务的三件套:互斥、超时、幂等。

缺互斥会重叠,缺超时会永久占用,缺幂等会在重试时写坏数据。三个都缺,出事只是时间问题。

再加一条:把「最后成功时间」做成告警指标。这一条能兜住前面所有机制的疏漏。

时间区与夏令时

服务器时区和业务时区不一致,是定时任务另一类高频事故。一个「每天凌晨 2 点」的任务,在 UTC 服务器上实际是北京时间上午 10 点执行。

统一做法:调度器显式指定时区,不要依赖系统默认。cron 表达式里带上 CRON_TZ=Asia/Shanghai,或者用支持时区的调度库,代码里写明。

有夏令时的地区还要注意「跳过的那一小时」和「重复的那一小时」——前者会让任务当天不执行,后者可能执行两次。幂等设计在这里又一次救了命。

你认为这篇文章怎么样?

还没有表情反馈

分享文章