“今天”到底是哪天:定时任务里的时区坑
凌晨的日志里有个说不通的地方:一个配置为「每天 06:00」执行的任务,写出来的文件名却是前一天的日期,统计窗口也跟着错了一天。查了半天代码没找到 bug,最后发现机器跑在 UTC,而需求里的「今天」指的是北京时间。这类问题的共同点是:代码没错,时区错了,而且错了之后不会报错,只会安静地给你一份偏移了八小时的数据。
一、机器的时间和你嘴里的时间不是一回事
云服务器的默认时区基本是 UTC。date 打出来是 UTC,date +%F 拿到的自然也是 UTC 的日期。于是出现一个很隐蔽的窗口:
- 北京时间 00:00 ~ 08:00 之间,UTC 仍然停在前一天。
- 任何在这个区间运行的「取今天日期」逻辑,拿到的都是昨天。
早上六点的定时任务正好落在这个窗口里。它生成的报告标题写着昨天、按日期查询的数据源查的是昨天、写入的文件名也是昨天。人看一眼觉得「大概是缓存」,其实是 %F 取错了时区。
二、三种修法,别混着用
单条命令最省事,TZ 前缀只影响这一条命令的环境:
TZ=Asia/Shanghai date +%F # 2026-10-26
TZ=Asia/Shanghai date -d yesterday +%F
crontab 里可以整段声明时区,Debian/Ubuntu 的 cron 支持 CRON_TZ,注意它作用于该文件里它之后的所有行:
CRON_TZ=Asia/Shanghai
0 6 * * * /opt/job/daily.sh
不支持 CRON_TZ 的老实现,就在命令前加前缀把整行包住:0 6 * * * TZ=Asia/Shanghai /opt/job/daily.sh。
systemd timer 更干净,OnCalendar 自带时区字段,触发时刻按指定时区算,和系统时区无关:
[Timer]
OnCalendar=*-*-* 06:00:00 Asia/Shanghai
Persistent=true
对应的 service 里再补一句 Environment=TZ=Asia/Shanghai,让脚本内部的时间计算跟触发时区一致。这两处要一起改,只改 timer 不改 Environment,任务会在正确的时间跑出错误的数据。
三、TZ 前缀不是万能的
TZ 之所以能生效,是因为走的是 libc 的 localtime 实现。所以 C、C++、Python、Perl、Shell 的 date 都听话;跨到别的运行时就要小心:
- Go 会读
TZ环境变量,但必须在进程启动前设好; - JVM 只认
-Duser.timezone=Asia/Shanghai,或者启动参数里的TZ; - Node 的
process.env.TZ在进程启动后再改,已经生成的 Date 对象不会跟着变,行为还跟版本有关——不要在运行时改,别赌。
结论很简单:时区在进程启动前定死,不要在代码里 setenv 之后指望它立刻生效。
四、Python 里正确的写法
用 aware datetime,不用 naive:
from datetime import datetime, timezone, timedelta
CST = timezone(timedelta(hours=8))
now = datetime.now(CST) # 2026-10-26 06:00:12+08:00
today = now.strftime("%Y-%m-%d")
start_of_day = datetime(now.year, now.month, now.day, tzinfo=CST)
几条例线:
- 别用
datetime.now(),naive 对象和 aware 对象一比较就TypeError; - 别用
datetime.utcnow(),3.12 起已标记废弃,返回的还是没有时区的对象; - 落库和接口里一律存 UTC 时间戳,展示和按天分桶时才换算成本地时区。存本地时间会遇到「明年时区规则变了,历史数据全歪」这种没法补救的事。
五、跨日统计真正的坑在窗口
把时区改对之后,还有一层:用哪个窗口算「今天」。
最常见的写法是 now - 24h,这在任务固定时间运行时只是近似。任务 06:00 跑,now - 24h 覆盖的是昨天 06:00 到今天 06:00,而不是「今天这一天」。如果任务某天迟到到次日凌晨,窗口还会整体平移。
正确做法是按当地日历分桶,而不是按小时数回溯:
start = datetime(now.year, now.month, now.day, tzinfo=CST)
end = start + timedelta(days=1)
rows = query(ts >= start, ts < end)
顺带一句:如果服务器时区设成了有夏令时的地区,now - timedelta(days=1) 严格来说不等于「昨天同一时刻」,一天可能是 23 或 25 小时。涉及日历运算(加一天、月初、月末)请用 zoneinfo 的日历语义,不要用秒数加减。
六、日志打双时间戳,排障少绕一圈
时区问题的排查成本主要花在「这条日志到底是几点」上。每行都带偏移量写出来就没这个疑问了:
print(f"{datetime.now(CST).isoformat(timespec='seconds')} job start")
# 2026-10-26T06:00:12+08:00 job start
原则可以记成一句话:给机器看的存 UTC,给人看的标时区。文件名用本地日期(人找得到),记录内部存 UTC(机器对得齐),日志行带 ISO8601 偏移(谁看都不会误读)。
收尾清单
timedatectl确认系统时区,别假设;- 定时器声明时区,service 里同步
Environment=TZ; - 脚本里用 aware datetime,禁止
utcnow()和now(); - 按当地日历分桶,不要用
now - 24h; - 日志行带偏移量,存储用 UTC。
这五条都不难,难的是在没出问题的时候就做对——因为时区错的症状是「数据看着不太对」,而不是报错,它能在线上潜伏很久。
- 点赞
- 收藏
- 关注作者
评论(0)