凌晨两点,其实不是你的凌晨两点
一、凌晨两点,其实不是你的凌晨两点
最常见的一类事故来自时区。容器基础镜像默认是 UTC,你在 Dockerfile 里没设 TZ,date 输出的就是 UTC 时间,于是 0 2 * * * 这条任务在 UTC 02:00 触发,对应北京时间上午十点,业务高峰期跑全量统计,直接把数据库拖垮。反过来更隐蔽:宿主机是 UTC,业务按北京时间切日,0 0 * * * 实际是北京时间早上八点执行,脚本里算“昨天”的数据,但库里的时间戳已经进入新的一天。
改时区也有坑:cron 守护进程读取的是启动时的时区,改完 /etc/timezone 或 timedatectl set-timezone 必须 systemctl restart cron,否则第二天照旧。另外涉及欧美时区时,夏令时切换那天 02:30 这个时刻根本不存在(spring forward)或者会执行两次(fall back)。稳妥做法是调度用 UTC,日期计算用业务时区,两者在代码里显式区分,不要依赖环境变量碰运气。
二、跨过零点的那一秒,幂等键变了
一个跑 8 分钟的任务在 23:58 启动,日志文件名是 app-$(date +%F).log,跑到 00:03 时后半段日志写进了新一天的文件,排查问题时你只能看到半截堆栈。更严重的是幂等键:任务用 date +%F 生成去重 ID,失败重试时刚好跨天,去重完全失效,同一批订单被重复结算。
实操上只有一条规则:在任务入口处把 RUN_DATE=$(date +%F) 固定一次,之后所有子步骤、日志、幂等键、SQL 参数都用这个变量,流程中间绝不再调 date。查数据库时用半开区间:
- 正确:
WHERE created_at >= '2024-05-01' AND created_at < '2024-05-02',能走索引 - 错误:
WHERE date(created_at) = '2024-05-01',函数包裹列导致全表扫描,半夜 CPU 打满 - 错误:
BETWEEN '2024-05-01' AND '2024-05-02',边界包含,重复统计一整天
三、半夜是所有任务的独木桥
零点前后挤着这些东西:logrotate、cron.daily/anacron、MySQL 全量备份、Redis 的 RDB fork、云厂商磁盘快照窗口、ES 索引滚动、证书续期、监控天级聚合。它们互相不认识,但共享同一块磁盘和同一个数据库连接池。你的任务超时,往往不是代码慢,是 IO 被隔壁备份占满了。
处理办法是错峰加抖动。不要写 0 3 * * *,写 17 3 * * *;systemd timer 直接用 RandomizedDelaySec=300;纯 cron 没有原生抖动,就在脚本开头加 sleep $((RANDOM % 300))。还有一个容易忽略的细节:logrotate 若对正在写入的文件做 mv 轮转,进程仍持有旧 inode,日志写进已删除文件,df 显示磁盘没释放,凌晨告警就这么来的——要么用 copytruncate,要么让应用自己 reopen。
四、锁、PATH 和退出码的三重陷阱
锁文件路径带日期是经典错误:/var/lock/job-$(date +%F).lock,跨零点后两个进程各拿各的锁,并发跑同一份数据。锁必须用固定路径,并且用内核级文件锁而不是“文件存在即加锁”的判存在逻辑:
flock -n /var/lock/job.lock -c 'python3 job.py',进程退出锁自动释放,不会留下僵尸锁- cron 的
PATH只有/usr/bin:/bin,python3装在/usr/local/bin,于是手工跑正常、cron 跑command not found,错误只发到MAILTO,而没人配邮件 - 管道吞退出码:
python3 job.py | tee /var/log/job.log返回的是tee的状态码 0,cron 认为成功。开头加set -o pipefail,或用${PIPESTATUS[0]} - 加
timeout -s TERM 3600,并确保脚本捕获 SIGTERM 做清理,否则强杀会留下半写状态
五、别监控“有没有启动”,要监控“有没有结果”
“任务启动了”和“任务成功了”是两件事。可靠的方案是 dead man's switch:任务跑完才向 https://hc.example.com/ping/<uuid> 打一个 GET,超过预期时间没收到就打点告警。这比监控 cron 进程存活有用一百倍,因为它能覆盖“机器还在但任务根本没触发”的情况。
同时把每次执行写进一张 job_runs 表:job_name、started_at、finished_at、exit_code、rows_affected。经验是 exit_code = 0 但 rows_affected = 0 往往才是真正的故障——上游表空了、分区没建、权限被回收,任务“成功”地处理了零条数据。最后,失败日志单独落到不参与轮转的目录:logrotate 的 rotate 7 意味着连续失败一周后,第一天的现场已经被切走了。
- 点赞
- 收藏
- 关注作者
评论(0)