那个时间不存在——时区代码里六个会真的出事的坑

那个时间不存在------时区代码里六个会真的出事的坑

所有实验都是 Python 3.11 + IANA tzdata 实测,代码可以直接复制运行。

1. 有些本地时间根本不存在

2026 年 3 月 8 日,美国东部时区凌晨 2 点直接跳到 3 点。02:30 这个时刻在纽约不存在。

但你可以毫无阻碍地构造它:

python

ini 复制代码
t = datetime(2026, 3, 8, 2, 30, tzinfo=ZoneInfo("America/New_York"))
yaml 复制代码
Python 给出:     2026-03-08 02:30:00-05:00
转成 UTC:        2026-03-08 07:30:00+00:00
再转回纽约:       2026-03-08 03:30:00-04:00   ← 变成 03:30 了
round-trip 相等?  False

没有异常,没有警告,静默地变成了另一个时间。

用户在表单里填了个 02:30,你存进数据库,读出来变成 03:30。整个链路没有一行代码报错。

2. 有些本地时间存在两次

同年 11 月 1 日,凌晨 2 点回拨到 1 点。01:30 在纽约出现了两次,中间隔着一小时。

Python 用 fold 属性区分:

ini 复制代码
fold=0 (第一次, EDT): 2026-11-01 01:30:00-04:00  -> UTC 05:30
fold=1 (第二次, EST): 2026-11-01 01:30:00-05:00  -> UTC 06:30
两者实际相差: 1:00:00

a == b ?  True      ← 两个不同时刻, Python 判定相等

最后一行才是真正的陷阱。

这不是 bug,是 PEP 495 为了向后兼容做的取舍:同一时区内的比较忽略 fold 。所以两个相差一小时的真实时刻,== 返回 True,排序时顺序也是乱的。

想象一下在这段时间里做「按时间排序的操作日志」或者「判断 token 是否过期」。

3. Asia/Shanghai 不等于 UTC+8

很多人把时区当成一个固定数字。实测一下上海的历史偏移:

yaml 复制代码
1900-01-01   偏移 8:05:43   LMT    ← 用的还是本地太阳时
1901-01-01   偏移 8:00:00   CST
1940-06-15   偏移 9:00:00   CDT    ← 夏令时
1940-12-01   偏移 8:00:00   CST
1946-06-15   偏移 9:00:00   CDT
1986-06-15   偏移 9:00:00   CDT    ← 1986-1991 全国实行夏令时
1991-06-15   偏移 9:00:00   CDT
1992-06-15   偏移 8:00:00   CST
2026-09-29   偏移 8:00:00   CST

中国在 1986 到 1991 年实行过六年夏令时。 这段时间的历史数据,如果你按固定 UTC+8 去解析,全部错一小时。

更重要的是那条规律:时区是一张随时间变化的规则表,不是一个数字。

所以 timezone(timedelta(hours=8)) 和 ZoneInfo("Asia/Shanghai") 不是一回事。前者是一个固定偏移,后者是一整套历史规则。处理历史日期、或者任何非中国时区时,用前者一定会错。

4. dt + timedelta(hours=24) 不是加 24 小时

这个最反直觉:

python

ini 复制代码
start = datetime(2026,3,7,12,0, tzinfo=NY)      # 跳钟前一天中午
naive = start + timedelta(hours=24)
css 复制代码
写法 A:  dt + timedelta(hours=24)
   结果 2026-03-08 12:00:00-04:00
   实际经过了 23:00:00        ← 不是 24 小时!

写法 B:  (dt.astimezone(utc) + timedelta(hours=24)).astimezone(tz)
   结果 2026-03-08 13:00:00-04:00
   实际经过了 1 day, 0:00:00   ← 这才是 24 小时

两种写法差了 1 小时

Python 文档写得很清楚:对 aware datetime 做加减法,是按「本地钟面」算的,就像它是 naive 的一样,加完之后才重新计算偏移。

所以「24 小时后过期」这种逻辑,一年里有两天会算错一小时。要做绝对时间运算,必须先转 UTC。

Go 的 time.Time.Add 是绝对时间语义(加纳秒),AddDate 是日历语义------同一个库里两套规则,也是同源的坑。

5. 日期加法不满足结合律,加减也不可逆

yaml 复制代码
起点 2026-01-31
  (+1月) 再 (+1月) = (2026-02-28) -> 2026-03-28
  直接  (+2月)     =                2026-03-31    ← 差 3 天

2026-01-31 +1月 = 2026-02-28
2026-02-28 -1月 = 2026-01-28                      ← 回不到 01-31

因为「加一个月」在 2 月必须做截断。一旦截断,信息就丢了,回不去。

这直接影响所有「按月」的业务:订阅续费、账单周期、分期还款。1 月 31 日订阅的用户,续费日期该是几号? 这是产品决策,不是技术细节------但如果没人决策,你的代码已经替产品决定了。

6. 墙钟会跳,单调钟不会

lua 复制代码
time.time()      可被 NTP 校正、被运维手动改、被虚拟机快照恢复 ------ 可以往回跳
time.monotonic() 只增不减,但没有绝对含义

任何测量「经过了多久」的代码,都必须用单调钟。

超时判断、限流窗口、重试退避、性能计时------用墙钟写这些,遇到一次 NTP 回拨就可能永久卡死(while time.time() < deadline 在时钟回拨后会多等很久)。

对应关系:Go 的 time.Since 自动用单调钟(time.Time 内部同时存了两个时钟),Java 用 System.nanoTime() 而不是 currentTimeMillis()。

该怎么存

存「已经发生的事情」→ UTC 时间戳。

日志、创建时间、审计记录。时刻是绝对的,UTC 存了就不会错。

存「未来要发生的事情」→ 本地时间 + 时区名(不是偏移)。

这是最容易做错的一条。「下周二上午 9 点在上海开会」,如果你转成 UTC 存 01:00Z,而某国在这期间修改了时区规则,会议时间就错了。

IANA 时区数据库一年要更新好几次 ------各国改夏令时规则是常态。所以未来事件必须存 ("2026-10-06 09:00", "Asia/Shanghai"),到时候再算。

永远不要存 UTC 偏移代替时区名。 +08:00 丢掉了规则,Asia/Shanghai 保留了规则。

清单

  1. 用户输入的本地时间,校验它是否存在、是否歧义 (fold 判断)
  2. 绝对时间运算先转 UTC,再转回来
  3. 时区用 IANA 名称(Asia/Shanghai),不用偏移,不用 CST(这个缩写同时指美国、中国、古巴三个时区)
  4. 测量时长一律用单调钟
  5. 定期更新运行环境的 tzdata------它不是「装一次就完事」的东西
  6. 测试用例里放一个跨夏令时的日期

最后

时间看起来是最简单的数据类型------一个数字而已。

但它是唯一一个由各国政府定义、随时可以修改、并且会追溯生效的数据类型。

你的代码里没有 bug,是地球在变。

相关推荐
小园子的小菜1 小时前
深度图解 Python 进程、线程与 GIL:彻底吃透并发核心原理
后端
用户7713970207061 小时前
Trae 深度使用指南:从配置到实战,一个 AI 原生 IDE 的完整工作流
后端
创新技术阁1 小时前
FastapiAdmin代码生成详细解释
前端·后端·fastapi
杨利杰YJlio1 小时前
ITSK 万能驱动 26V5:新驱动批量更新
前端·javascript·后端
imDwAaY1 小时前
6篇文章讲清楚Git:Git 团队开发实践:从 Pull Request 到版本发布 (6/6)
git·后端
DevUp1 小时前
老站往哪搬:织梦、帝国、PHPCMS 的出路
后端·php·cms
王中阳Go1 小时前
甲骨文裁3万、DeepSeek却招150人:后端程序员往哪走,我把这批JD拆了一遍
后端
打工仔折腾 AI2 小时前
蓝耘元生代实测:用WorkBuddy与TextIn xParse拆解43页建模论文
人工智能·后端·python·数学建模·langchain·ai agent 实战
隔窗听雨眠2 小时前
FunProxy用Rust构建跨平台全链路测试抓包代理工具
开发语言·后端·rust