凌晨三点的告警不是巧合
凌晨三点的告警不是巧合
很多团队都遇到过这种场景:白天一切正常,定时任务跑得好好的,到了凌晨三点突然开始告警——任务超时、数据没更新、报表为空。值班同事第一反应是“是不是机器挂了”,登上去一看,进程还在,日志里只有一行 context deadline exceeded 或者干脆什么都没有。
定时任务总在半夜失败,十有八九不是代码 bug,而是它运行的那段时间,整个系统环境发生了白天不存在的剧烈变化。下面几个原因,是我在真实项目里反复踩过的。
同一条流水线上,任务互相踩踏
最常见的原因:所有人都在午夜前后排定时任务。
- 凌晨 0:00 数据归档,0:10 报表生成,0:30 全量同步,2:00 备份……
- 这些任务共享同一个数据库或同一台 Redis,白天单个跑都很快,零点一起跑就把连接池打满。
- 结果单任务看起来“忽快忽慢”,实际上是负载被邻居推高了几倍。
解决办法不是加大连接池,而是错峰并增加互斥。把任务从 0:00 挪到 0:05、0:17 这种随机分钟,再用 SELECT ... FOR UPDATE 或 Redis 的 SETNX 加一把分布式锁,确保同一业务键不会被并发执行。锁要带 TTL 并做续期,否则任务一卡死,锁反而成为下一次失败的根因。
数据库的“凌晨状态”和你想象的不一样
白天数据库有热缓存、有稳定连接,凌晨经过一段空闲后,很多事情变了:
- 连接池里的空闲连接被服务端
wait_timeout回收,任务拿到的第一条连接直接报MySQL server has gone away。 - 白天被缓存挡住的慢查询,凌晨冷缓存下第一次执行直接超时。
- 如果数据库有夜间备份或主从延迟,
SELECT读到的可能是尚未同步完成的数据。
实操上:连接池配置 testOnBorrow 或定期 validationQuery;任务里把长事务拆小,避免一个事务持锁十几分钟;对关键读取显式指定主库,别让半夜的查询悄悄落到延迟的从库上。还有一点容易被忽略——定时任务里的 SQL 也要加超时,否则一个慢查询会把整个调度线程卡死,后面排队的任务全部顺延。
任务自己把内存和句柄吃光了
不少半夜失败的任务,其实是“一个晚上跑了很多次”造成的。典型表现:
- 任务每 5 分钟触发一次,上一次还没结束下一次就来了,进程越堆越多。
- 有的实现用
setInterval而不是setTimeout递归,间隔固定、执行不保证,短时间爆发后内存被打爆。 - 脚本里打开的文件、数据库游标、HTTP 连接没关闭,跑几千轮后
ulimit -n触顶,报Too many open files。
凌晨触发频率低,问题不明显;一旦某个日终批量把调度间隔挤密,堆积就出现了。修复方式:给任务加单实例占位锁,上一轮没结束直接跳过并打日志;用 try/finally 或 with 结构保证资源释放;调度层记录“上次开始/结束时间”和连续跳过次数,超过阈值告警,而不是默默堆积到天亮。
时间本身会骗你:时区、夏令时与时钟漂移
如果你的凌晨任务在 2:00~3:00 之间,请立刻检查时区。夏令时切换会让这个小时直接消失或重复一次,很多调度框架在这天要么跳过任务,要么同一个任务跑两遍。服务器用 UTC、业务按本地时间、cron 又按机器时区写,三者叠加就是灾难。
实操建议:
- cron 表达式统一按 UTC 写,业务需要本地时间就在代码里换算。
- 任务里记录实际执行时间戳,而不是只看
HH:mm。 - 对有状态的调度(如 Quartz)确认是否开启了
misfire补跑,补跑没有幂等保护就会重复写数据。
把“半夜”当成一等公民来排查
凌晨失败难查,是因为没人看着它跑 lag。改掉这个习惯:在任务里加结构化日志——开始/结束时间、耗时、堆栈、读了哪个库连了哪台机、当前内存和句柄数;失败时把日志和当时的系统指标一起留存,而不是事后去猜。
再加一条:拿最闲的那台预发环境,把 cron 在白天手动跑一遍,效果完全不同——因为异常往往靠“抢资源”触发,而白天本来就没什么可抢的。既然问题在夜里爆发,就让它在夜里被复现和观察,这才是排查定时任务最务实的方式。
- 点赞
- 收藏
- 关注作者
评论(0)