定时任务总在半夜失败:时区、锁与重试的三个坑
先把“半夜”定义清楚
排查夜间任务失败时,第一个容易踩的坑不是代码,而是时间口径。应用日志打的是本地时间,宿主机 cron 按 UTC 调度,数据库里又存着不带时区的时间戳。三方一对,就会得出“任务在凌晨 3 点无故消失”的结论。更稳妥的做法是:日志统一输出带偏移的 ISO8601 时间,例如 2026-10-28T02:00:00+08:00;调度表达式旁边写明时区;数据库连接串显式设置 timezone,不要依赖服务器默认值。
我们曾经遇到一个每日对账任务,在测试环境一直正常,上线后每天少跑一次。最后发现容器镜像是 UTC,而 date 命令的输出被当成了业务日期。把“业务日期”和“机器时间”分开之后,问题才真正收敛。
坑一:cron 的时区与夏令时
0 2 * * * 看起来是每天凌晨两点,但它依赖守护进程所在时区。容器化之后,基础镜像通常没有安装 tzdata,TZ=Asia/Shanghai 也可能被忽略。可以在启动脚本里加一句 date +%Z%z 作为自检,输出不是 CST+0800 就直接失败退出,避免任务在错误的时间静默执行。
另一个细节是夏令时。即使业务只在国内,海外节点也可能受 DST 影响,出现某天 2 点不存在、任务被跳过的情况。对账、结算这类不能漏跑的任务,建议不要卡在 2 点到 3 点之间,或者改成“每小时检查一次水位,缺了就补”的幂等模型。
坑二:锁的粒度比锁本身更重要
很多团队给定时任务加了一把全局锁,结果一个慢任务把整条流水线堵死。锁应该按“资源”而不是按“进程”来设计:同一张表、同一个租户、同一个文件目录各一把锁,键里带上业务日期。这样昨天的补偿任务和今天的正常任务可以并行,又不会重复写同一批数据。
锁还要有超时和持有者信息。只写一个 locked=1 的标记,进程被杀之后没人知道该不该清理。更实用的做法是把 pid、开始时间、任务名写进锁文件,等待方超时后打印持有者再退出,而不是无限重试。我们现在的约定是:拿锁最多等 10 分钟,拿到后先写心跳,超过 30 分钟没有心跳视为僵尸锁,由下一次调度接管。
坑三:重试没有上限等于放大故障
夜间失败往往伴随下游抖动。如果每个任务都重试三次,而任务本身又会调用下游三次,故障期间的压力会被放大九倍。重试必须满足三个条件:错误可重试、次数有上限、间隔有退避。像参数错误、鉴权失败这类确定性错误,重试只会浪费窗口,应该直接失败并告警。
还要区分“提交类”和“查询类”。查询可以放心重试;提交、支付、发布这类有副作用的操作,必须带幂等键,否则重试就是重复提交。一个简单的判断方法是:这次调用失败后,先回读一次状态,确认对方到底有没有成功,再决定要不要重发。
一份可以照抄的检查清单
- 调度表达式旁标注时区,启动时打印
date +%Z%z; - 日志、数据库、接口三处时间口径一致,禁止裸时间戳;
- 锁按资源拆分,记录持有者与心跳,等待有上限;
- 重试只覆盖可重试错误,指数退避,提交类先回读再决定;
- 每次执行落一份带字节数的产物,失败时能直接定位到阶段。
夜间任务的问题很少是“某一个 bug”,更多是时间、并发和失败处理三件事没有说清楚。把这三件事写成可检查的约束,比事后盯着日志猜要省力得多。
- 点赞
- 收藏
- 关注作者
评论(0)