时区数据是会过期的依赖:61 天错点,和同一台机器上的三个答案
时区数据是会过期的依赖:61 天错点,和同一台机器上的三个答案
11 月 1 日,古巴没有回拨。
这不是推测。IANA 在 2026-10-27 发布的 tzdata 2026f 里,第一条变更说明就是:
Cuba will not fall back 2026-11-01; stays on -04 through 2027-11-07.
一个按"每年 11 月首个周日回拨一小时"活了多年的定时任务,今年起不再需要回拨。运行时若还带着旧数据,11 月 1 日之后它算出的哈瓦那时间会早一小时。今天 11 月 8 日,采用旧数据的系统已经连错 8 天。
多数人不把时区库当"会变化的依赖":它看着像标准库的一部分,装完就不再看第二眼。但它一年的破坏性变更,比多数工具库都多。
一年 6 个版本,5 个改了未来时间
把 2026 年 IANA 的发布记录拉出来,Briefly 字段是这样的:
| 版本 | 发布日 | 改了什么 |
|---|---|---|
| 2026a | 03-01 | 摩尔多瓦自 2022 起采用欧盟切换时刻 |
| 2026b | 04-22 | 不列颠哥伦比亚自 03-09 起永久 −07 |
| 2026c | 07-08 | 阿尔伯塔自 06-18 起永久 −06;摩洛哥自 09-20 起永久 +00 |
| 2026d | 09-11 | 西北地区自 08-21 起永久 −06 |
| 2026e | 09-29 | 曼尼托巴自 10-31 起永久 −05 |
| 2026f | 10-27 | 古巴 11-01 不再回拨 |
六个版本里五个改了未来时间戳。加拿大四个辖区在同一年先后取消夏令时,摩洛哥把 +01 改回 +00,古巴停止回拨——任何一条落在跨境巡检或跨境业务上,都是可观测的时间偏移。
法规生效到数据发布,最长空了 44 天
把"规则生效日"与"承载该规则的版本发布日"并排,会出现一个少有人算过的窗口:
| 变更 | 生效 | 版本 | 发布 | 窗口 |
|---|---|---|---|---|
| BC 永久 −07 | 03-09 | 2026b | 04-22 | +44 天 |
| 阿尔伯塔永久 −06 | 06-18 | 2026c | 07-08 | +20 天 |
| 西北地区永久 −06 | 08-21 | 2026d | 09-11 | +21 天 |
| 摩洛哥永久 +00 | 09-20 | 2026c | 07-08 | −74 天 |
| 曼尼托巴永久 −05 | 10-31 | 2026e | 09-29 | −32 天 |
| 古巴不再回拨 | 11-01 | 2026f | 10-27 | −5 天 |
BC 那条最值得看:法律 3 月 9 日生效,数据 4 月 22 日才有。这 44 天里没有任何一个库能算对温哥华的偏移——不是代码的问题,是所有人手里的数据都还没有这个事实。曼尼托巴和古巴相反,数据提前几十天就到了,但只在版本里,没进你的镜像。升级时区库因此没有统一的时间点。
同一台机器,三个答案
对照实验:取同一时刻 2026-12-15T12:00:00Z,分别问三个运行时"温哥华几点"。
NODE America/Vancouver => 2026-12-15, 04:00 GMT-08:00
DOTNET America/Vancouver => -08:00:00 (太平洋标准时间)
tzdata 2026f => -07:00 (MST)
Node 24.12.0 捆绑 ICU 77.1,process.versions.tz 报 2025b;.NET 走 Windows 系统时区库,也给 −08;只有加载 tzdata 2026f 的那份是 −07。同一台机器、同一秒、同一个时区名,两个成熟运行时给出同一个错误答案。
原因不复杂:Node 的时区数据跟着 ICU 版本走,要等 Node 发版;Windows 的规则跟着系统更新走,要等累积更新。应用层能升的只有 tzdata 这类数据包,升它不会让 Node 或 .NET 跟着变。
后果出现在"算下一次触发点"这个动作上:
const tz = 'America/Vancouver';
const f = new Intl.DateTimeFormat('en-CA', {
timeZone: tz, hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
});
// 2025b 数据下:UTC 16:00 是本地 08:00,17:00 是 09:00
console.log(f.format(new Date('2026-12-01T17:00:00Z'))); // 2026-12-01, 09:00
它把 17:00Z 当成 09:00 存进任务表。而 17:00Z 在真实规则下是 10:00。任务不报错、不告警,每天晚一小时,持续到 12 月 31 日。
61 天:把版本差折算成错点天数
偏移差多少秒不是好指标,业务关心的是"一年有几天跑错点"。用 2025b 与 2026f 两份数据,对 2026 全年逐日比对同一 UTC 时刻被解释出的本地小时:
| 时区 | 分歧天数 | 区间 |
|---|---|---|
| America/Vancouver | 61 | 11-01 ~ 12-31 |
| America/Edmonton | 61 | 11-01 ~ 12-31 |
| America/Winnipeg | 61 | 11-01 ~ 12-31 |
| America/Havana | 61 | 11-01 ~ 12-31 |
| Africa/Casablanca | 103 | 09-20 ~ 12-31 |
| America/Toronto | 0 | — |
| Asia/Shanghai | 0 | — |
598 个时区里,两份数据有 52 个 TZif 文件字节不同,13 个在 2026 年会真的算出不同偏移。上海是 0——中国 1991 年后不再实行夏令时,规则稳定。这类问题因此总在跨境业务上爆。
再看"把 UTC 钉死"这种常见做法。任务是每天本地 09:00,实现成固定 UTC 表达式:
| 时区 | 钉死的 UTC | 命中 9 点 | 错成 8 点 |
|---|---|---|---|
| America/Vancouver | 16:00Z | 299 天 | 66 天 |
| America/Havana | 13:00Z | 299 天 | 66 天 |
| Africa/Casablanca | 08:00Z | 227 天 | 138 天 |
摩洛哥错 138 天,超过全年三分之一。用 UTC 钉死换来"表达式不漂",代价是"漂移被固化进表达式",且没有任何日志会告诉你它错了。
同一个字符串,两个 UTC 时刻
比差一小时更麻烦的是"同一句话有两种解释"。哈瓦那 2026-11-01 00:30 在旧数据下是歧义时刻——回拨那一小时里 00:30 出现两次:
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
h = ZoneInfo('America/Havana') # 旧数据 2025b
a = datetime(2026, 11, 1, 0, 30, tzinfo=h, fold=0).astimezone(timezone.utc)
b = datetime(2026, 11, 1, 0, 30, tzinfo=h, fold=1).astimezone(timezone.utc)
print(a, b, (b - a).total_seconds())
# 2026-11-01 04:30:00+00:00 2026-11-01 05:30:00+00:00 3600.0
同一个本地字符串,fold 取 0 或 1,落到相差 3600 秒的两个 UTC 时刻。换到 2026f,两者都返回 04:30Z,差值 0——歧义消失了。
两层影响:原来那套"回拨当夜双跑、用 fold 去重"的防护成了死代码,不再触发也没人删;更隐蔽的是,若去重键存的是旧数据算出的 05:30Z,这个时刻在新规则下对应本地 01:30,历史记录与现实从此对不上,去重会一直命中一个"不存在"的时刻。哈瓦那 2026 年的跳变次数,旧数据 2 次,新数据 1 次,少的那次正是 11 月 1 日。
空缺时刻不报错,只是悄悄平移
春季方向相反:那个本地时刻不存在。圣地亚哥 2026-09-06 本地 00:00 直接跳到 01:00。
s = ZoneInfo('America/Santiago')
g = datetime(2026, 9, 6, 0, 30, tzinfo=s) # 这个时刻不存在
print(g.astimezone(timezone.utc)) # 2026-09-06 04:30:00+00:00
print(g.astimezone(timezone.utc).astimezone(s)) # 01:30
zoneinfo 不抛异常、不返回 None、不告警,直接把 00:30 当 01:30 处理。写进去的 00:30 往返一次变成 01:30。若任务是"每天 00:30 生成日报",这一天的文件名、分区键、去重键全是 01:30 口径,代码里的常量还写着 hour=0, minute=30。日志里时间齐全、没有异常,只是某天的数据落在了一个奇怪的整点上。
三行代码把版本暴露出来
最省事的做法是让版本自己说话。Python 侧要特别注意 Windows:
import importlib.metadata as md
from zoneinfo import ZoneInfo
try:
print('tzdata', md.version('tzdata'))
except md.PackageNotFoundError:
print('tzdata 未安装,zoneinfo 将无可用数据')
try:
ZoneInfo('Asia/Shanghai')
except Exception as e:
print(type(e).__name__, e)
本机跑这段,第一条输出 tzdata 未安装,第二条是:
ZoneInfoNotFoundError 'No time zone found with key Asia/Shanghai'
TZPATH= ()
Windows 没有系统级 IANA 数据库,zoneinfo.TZPATH 是空元组,任何时区名都失败。这和 Linux 上"系统自带 /usr/share/zoneinfo"完全不同——在 Linux 容器里跑得好的代码,搬到 Windows 会直接抛异常,而不是悄悄算错。所以 tzdata 必须显式写进依赖。
Node 侧一行就够:process.versions.tz。这个值在 ICU 编译时打进,升级依赖包不会改它,只有换 Node 版本才会变。
有了版本号,巡检就能落地。核心不是比对版本字符串,而是比对未来一段时间的触发点:
import datetime as dt
from zoneinfo import ZoneInfo
def audit(tz_name, hour, days=365, start=dt.date(2026, 11, 9)):
z = ZoneInfo(tz_name)
bad = []
for i in range(days):
d = start + dt.timedelta(days=i)
local = dt.datetime(d.year, d.month, d.day, hour, tzinfo=z)
back = local.astimezone(dt.timezone.utc).astimezone(z)
if back.hour != hour:
bad.append((d.isoformat(), back.strftime('%H:%M')))
return bad
print(audit('Europe/Berlin', 2)) # [('2027-03-28', '03:00')]
print(audit('America/Santiago', 0)) # [('2027-09-05', '01:00')]
这个审计有两个作用:发现"本地 09:00 往返后不是 09:00"的空缺时段;当数据版本变化时,同一段代码在同一段日期上的输出会变——纳入回归测试,破坏性变更就会在发布前暴露,而不是在 11 月 1 日凌晨。
踩坑:我第一次的指标口径是错的
第一版实验写的是"用 UTC 钉死 09:00,看 2026 年错几天"。跑出来温哥华和哈瓦那都是 66 天,两版数据结果一模一样。
程序没报错,输出很整齐,但它什么也没测到——我把 UTC 表达式固定成 16:00Z,又拿同一份新数据判定"本地是不是 9 点",版本差异根本没进入计算路径。两列数字相同恰恰是"实验无效"的信号,而不是"结论稳定"。
改成"同一 UTC 时刻在新旧数据下被解释成哪个本地小时",才得到 61 天和 103 天。这个坑值得单独写,因为定时任务与时区问题最容易产出"看起来对、其实没测"的结果:时间字段齐全、偏移是整点、日志能跑通,就是没人对一个已知正确的样例。缺失是静默的,只能靠对账发现,不能靠报错发现。
结论
时区数据是一条持续变更的外部依赖,变更节奏由各国立法机构决定,不由你的发版节奏决定;把定时任务的时间语义建在"某个固定 UTC 偏移"或"某个未钉版本的运行时"上,等于把一次立法变更翻译成一次持续数十天的静默错点。可操作的做法只有三条:钉住 tzdata 版本并让它可查,把触发点算成 UTC 再落库,每次数据更新后跑一次未来一年的触发点比对。
- 点赞
- 收藏
- 关注作者
评论(0)