有天早上我被电话叫醒,说昨晚的对账结果全是错的。原因是:凌晨 2 点的对账任务那天跑了两次。
为什么跑两次?因为那天是夏令时切换日,某个时区的凌晨 2 点出现了两遍。
这是我职业生涯里最哭笑不得的一个 bug,也是我开始认真对待定时任务的起点。在那之前我一直觉得定时任务最简单——不就是到点执行一段代码吗。
陷阱一:时间本身就不可靠
还有几种类似的:
- 夏令时前跳:某些时区凌晨 2 点直接跳到 3 点,2:30 当天不存在,配在这个点的任务不会执行。
- 服务器时钟漂移:NTP 出问题时机器可能慢几分钟。我遇到过一台慢了 90 秒,它认为「还没到执行时间」,实际早过了。
- 容器时区不一致:镜像默认 UTC,宿主机是本地时区。任务配在「凌晨 2 点」,实际跑在早上 10 点——正好业务高峰。我们踩过,还挺严重。
我现在的做法是:服务器和容器统一用 UTC,调度表达式也按 UTC 写,只在展示给人看时转换时区。
陷阱二:上一次还没跑完,下一次又启动了
任务配的是每 5 分钟一次,平时跑 30 秒。某天数据量暴涨跑了 20 分钟——于是 4 个实例同时跑,争抢同一批数据,把数据库锁死了。
多数调度框架默认不做这个保护,需要显式开启并发控制;自己写的话就加分布式锁:
if not redis.set(lock_key, worker_id, nx=True, ex=1800):
log.info('上一轮尚未结束,跳过本次')
return这里有个细节:锁的过期时间要大于任务最长可能耗时,否则任务还在跑锁就没了,下一轮照样并发。但设太长,进程崩溃后要等很久。我的折中是稍长的过期时间加运行期续期。
陷阱三:集群里每台机器都跑了一遍
把 crontab 写在部署脚本里,服务扩容到 5 个实例,于是每个任务同时跑 5 遍。这错误特别常见——单机时代它完全正常,扩容那天才暴露。
解法:用支持分布式调度的框架,或者用上面那个锁,或者把定时任务剥离出去单独部署、副本数固定为 1。我们选第三种,因为它的资源画像和在线服务差别很大——吃内存、跑得久、对延迟不敏感。
陷阱四:时间窗口用了 now 而不是「上次成功时间」
这是逻辑上最微妙的一个。假设任务每小时跑一次,处理过去一小时的数据:
start = now - 1 hour
end = now看着没问题。但某一次执行失败了呢?那一小时的数据就永久丢失了,因为下一次只处理它自己的那一小时。
正确做法是维护一个「水位线」——记录上次成功处理到哪个时间点,每次从那里继续:
start = 读取水位线()
end = now - 5分钟 # 留一点缓冲,避免处理到还在写入的数据
处理(start, end)
更新水位线(end)这样失败一次,下次自动补上。
注意那个 5 分钟缓冲。直接用 now 会漏掉「时间戳已生成但事务还没提交」的记录。这个坑我踩过,表现是对账偶尔少几条,毫无规律。
陷阱五:只在失败时告警
我们有个任务默默停了 11 天没人发现。调度器本身出了问题,任务根本没被触发——没执行就没有失败,没有失败就没有告警。
没有消息不等于好消息。 现在每个任务成功后上报一次心跳,监控侧检查「超过预期周期的 2 倍还没心跳」就告警。这叫死人开关,比失败告警重要得多。
陷阱六:忘了幂等
任务会重试、会被手动重跑,就必须能安全跑第二遍。「给昨天下单的用户发券」不做幂等,重跑一次就是双倍成本。我们给每次发放生成业务唯一键,数据库加唯一索引,重复插入直接跳过。
一个检查清单
我现在新建一个定时任务,会过一遍这几个问题:
- 它在集群里会执行几次?
- 上一次没跑完,这一次会怎样?
- 失败了,数据会不会永久丢失?
- 重跑一次,会不会产生副作用?
- 它一直不跑,多久能被发现?
- 它的日志和输出去了哪里?
- 数据量涨十倍,它还跑得完吗?
七个问题,两分钟就能过一遍。但我敢说能全部答上来的定时任务不超过一半。