时区数据是会过期的依赖:61 天错点,和同一台机器上的三个答案

举报
yd_263929376 发表于 2026/11/08 21:11:46 2026/11/08
【摘要】 时区数据是会过期的依赖: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 月首个周日回拨一小时"活了多年的定时...

时区数据是会过期的依赖: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 再落库,每次数据更新后跑一次未来一年的触发点比对。

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

评论(0)

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

全部回复

上滑加载中

设置昵称

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

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

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