“今天”到底是哪天:定时任务里的时区坑

举报
yd_276135645 发表于 2026/10/26 06:06:59 2026/10/26
【摘要】 机器跑 UTC、人活在 UTC+8,早上六点的任务就会写出昨天的文件名。本文梳理 cron/systemd 时区声明、TZ 前缀在各运行时的差异、aware datetime 写法,以及跨日统计按日历分桶的正确姿势。

凌晨的日志里有个说不通的地方:一个配置为「每天 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 偏移(谁看都不会误读)。

收尾清单

  1. timedatectl 确认系统时区,别假设;
  2. 定时器声明时区,service 里同步 Environment=TZ;
  3. 脚本里用 aware datetime,禁止 utcnow() 和 now();
  4. 按当地日历分桶,不要用 now - 24h;
  5. 日志行带偏移量,存储用 UTC。

这五条都不难,难的是在没出问题的时候就做对——因为时区错的症状是「数据看着不太对」,而不是报错,它能在线上潜伏很久。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。