硬编码 offset 与错标时区的代价
一个时间值在系统里流转时,携带的信息比它看起来的少。一个 epoch 数字只说得出「地球上的哪一瞬」,说不出它当初是一个日历日期、一个约定的墙上时间,还是一次已经发生的事件;一串
2026-08-29 10:00:00 只说得出钟面上的字,说不出这块钟挂在哪里。信息在写入时丢失,在读出时被一个假设补回来,两处假设不一致,页面上就出现一个谁都对不上的数字。本页拆两起真实事故:一起在生日上差了一天,一起在全站时间上差了 8 小时。
1 · 一张差一天的生日
一位 1944 年出生的老人致电客服激活账号,报出的出生日期与系统里显示的差一天。工单查到页面上负责渲染日期的那段代码,写的是「把 UTC 时间加 8 小时得到 SGT」。
新加坡今天确实是 UTC+8,但那是 1982 年 1 月 1 日之后的事。1942 年 2 月 16 日起,日据时期的新加坡与东京同步,改用 UTC+9;直到 1945 年 9 月 12 日零点把钟拨回 UTC+7:30,本地时间随之退回 9 月 11 日 22:30。offset,即某地钟面时间相对 UTC 的时差,从来不是一个可以写进代码的常数,它是一个随日期查表得到的函数,表就是 IANA 维护的 tz database。
老人的生日在库里是「出生地当天午夜」对应的一个绝对时刻。1944 年 6 月 15 日零点在当时的新加坡是 +09:00,换算成 UTC 是 1944-06-14T15:00:00Z。页面给它加 8 小时,得到 1944-06-14T23:00,日期落在 6 月 14 日。
值得留意的是 1941 年 9 月 1 日那一行:+07:20 改为 +07:30,本地时间从 00:00 直接跳到 00:10,这十分钟在新加坡不存在。任何一个「按分钟枚举一天」的实现在这一天都会多算十分钟。
1.1 · 触发条件比预想的窄
最初的判断是「1982 年之前出生的新加坡人都会中招」。实测把它推翻了:1933 年至 1941 年的 +07:20 与 1945 年至 1981 年的 +07:30,硬编码的 +08:00 反而不出错。写死的 offset 比真实 offset 大,午夜被推到 00:40 与 00:30,日期没有动。日期跳一天要求真实 offset
严格大于写死的那一个,所以这张工单只可能来自 1942 年 2 月 16 日至 1945 年 9 月 11 日这 1303 天里出生的人。
1.2 · 每个 UTC+8 时区都有这样的窗口
新加坡只是窗口最短的一个。为本页的引擎写测试时,driftWindows('Asia/Shanghai', '+08:00', 1990, 2000) 原本断言为空数组,跑出来是两段红:1990 和 1991 的夏天,上海走的是中国夏令时 +09:00。这项夏令时行了六年,1986 年至 1991 年每年从四月中到九月中。1990 年 7 月 1
日出生的人,生日同样会被渲染成 6 月 30 日。
香港的窗口累计 8274 天,横跨 1941 年至 1979 年,因为它在这段时间里几乎年年实行夏令时。这类窗口无法靠换一个更好的常数消掉,只能靠不写常数。
2 · 生日不该是一个时刻
真正的缺陷不在偏移量写错了,而在生日被存成了 instant:epochMs = -806230800000 这个数回答的是「地球上的哪一瞬」,而生日回答的是「日历上的哪一天」。后者在任何时区都是同一天,老人在纽约过生日也仍是 6 月 15
日。把日期塞进时刻,等于凭空补上一条「哪个时区的午夜」的信息;取回来时只要还原用的时区与写入时的不同,日期就会跳。
PlainDate。可改日期与落成时刻时用的时区,观察通道 A 在六个观察地读出几个不同的日期。选型的判据是一句可以逐字段问的话:这个值的意义,会不会随观察者所在的位置改变,会不会随将来时区规则的修订改变。
| 字段 | 该用的类型 | wire 格式 |
|---|---|---|
| 生日、纪念日、法定假日 | Temporal.PlainDate |
1944-06-15 |
| 订单创建时间、支付时刻、日志 | Temporal.Instant |
2026-08-29T02:00:00Z |
| 会议开始、航班起飞 | Temporal.ZonedDateTime |
2026-08-29T10:00:00+08:00[Asia/Shanghai] |
| 闹钟、营业时间 | Temporal.PlainTime |
09:00:00 |
| 账单周期、报表月份 | Temporal.PlainYearMonth |
2026-08 |
建议 · 会议与日志的区别值得单独记住。会议定在下周三上午十点,若在那之前当地修订了夏令时规则,会议仍在上午十点,它对应的绝对时刻应当跟着变,所以存 ZonedDateTime。日志记的是已经发生的事,绝对时刻不容改写,任何时区规则的修订都不该动它,所以存
Instant。两者都「带时间」,但对未来规则变更的反应正好相反。
3 · 一个只有数字的字段无法自证
第二起事故的形态不同,机制相同。server 在存储时按 +8 落库,前端把拿到的值当成 UTC 读,全站时间整体偏 8 小时。它在 wire 上通常呈现为三种形态:
DATETIME列直出的裸串2026-08-29 10:00:00,不带 offset,语义写在接口文档里;- 被 +8 平移过的 epoch,即拿 +8 的墙上时间当作 UTC 算出来的数;
- 平移之后又补了一个
Z的 ISO 串2026-08-29T10:00:00.000Z。
第三种最难查,因为它看上去自证语义,每一个客户端都会毫不犹豫地信任那个 Z。
3.1 · Date 对无 offset 字符串的两种读法
裸串交给 new Date 会撞上一条不对称的规则。ECMA-262 的 Date Time String Format 规定:offset 缺失时,只有日期的形式按 UTC 解释,带时间的形式按运行环境的本地时区解释[2]。下表在 TZ=America/New_York 的 node v26 下实测。
| 字符串 | new Date(...).toISOString() |
按哪个时区读 |
|---|---|---|
2026-08-29 |
2026-08-29T00:00:00.000Z |
UTC |
2026-08-29T10:00:00 |
2026-08-29T14:00:00.000Z |
本地 |
2026-08-29 10:00:00 |
2026-08-29T14:00:00.000Z |
本地,且该形式在规范之外 |
2026-08-29T10:00:00Z |
2026-08-29T10:00:00.000Z |
UTC |
同一个日期写不写时间部分,落到两个相差一整个时区的时刻上。带空格的第三种形式规范并未定义,当前主流引擎都按本地读,但这是实现的一致,不是规范的保证。
3.2 · 在边界上把语义补回去
三种形态各自缺失的信息不同,补法也不同,共同点是补偿只发生在一处。
// 形态 1 · 裸墙上时间串: 缺的是「这块钟挂在哪」
const wall = Temporal.PlainDateTime.from('2026-08-29 10:00:00'.replace(' ', 'T'));
const inst = wall.toZonedDateTime('Asia/Shanghai').toInstant();
// 形态 2 · 被 +8 平移过的 epoch: 那个数指向的时刻本身是错的,
// 先当 UTC 读回墙上时间, 再挂回 Asia/Shanghai
const wall2 = Temporal.Instant.fromEpochMilliseconds(1787997600000)
.toZonedDateTimeISO('UTC')
.toPlainDateTime();
const inst2 = wall2.toZonedDateTime('Asia/Shanghai').toInstant();
// 出了这道门, 下游只有 Instant
inst2.toZonedDateTimeISO(Temporal.Now.timeZoneId());
判断线上跑的是哪一种有一个不需要读 server 代码的办法:写一条新记录,立刻拿它与 Date.now() 对表。差值接近 0 说明是正常的 UTC epoch;接近 8 小时整说明被平移过。这个办法对形态 3 同样有效,而肉眼看那串带 Z 的字符串是看不出来的。
警示 · 不要在业务代码里写 ts + 8 * 3600 * 1000 这类补偿。一处补偿会被复制到每一个用到该字段的地方,此后任何一次漏改都是新的 bug;更麻烦的是这个 8 写进业务代码之后就再也说不清它指的是 server 的存储时区还是用户所在时区,下一个人接手时只能靠猜。补偿属于 API
层的解码函数,且每个字段只写一次。
4 · 让 offset 成为可验伪的断言
前两节的事故都有一个共同的前提:错误的假设无法被系统本身发现。库里那个 epoch 不会因为解释错了而报错,它只是安静地渲染出另一个日期。要让这类错误在写入侧就暴露,值本身必须携带足够的冗余。
RFC 9557 定义的扩展 ISO 格式同时记下 offset 与 IANA 时区名:1944-06-15T00:00:00+09:00[Asia/Singapore]。两者互为校验,Temporal.ZonedDateTime.from 解析字符串时 offset 的默认策略即 reject,对不上直接抛 RangeError,不替调用方选一个。
Temporal.ZonedDateTime.from 校验。可改写串里的 offset,观察写错时的 RangeError。冲突真的发生时,offset 选项决定保留哪一半。以 1944-06-15T00:00:00+08:00[Asia/Singapore] 为例:
offset |
结果 | 保留了什么 |
|---|---|---|
reject(默认) |
抛 RangeError |
拒绝在两个矛盾的断言之间做选择 |
use |
1944-06-15T01:00:00+09:00[Asia/Singapore] |
保留 +08:00 所指的绝对时刻 |
prefer |
1944-06-15T00:00:00+09:00[Asia/Singapore] |
保留墙上时间 |
ignore |
1944-06-15T00:00:00+09:00[Asia/Singapore] |
同上,且不看串里的 offset |
use 与 prefer 的结果相差一小时,哪个对取决于当初写入的究竟是一个时刻还是一个墙上时间。只存一个 epoch 的库回答不了这个问题,存了 RFC 9557 串的库至少能把问题问出来。
5 · 参考文献
- Eggert, P., & Olson, A. D. (eds.). Time Zone Database. IANA. 新加坡、上海、香港的历史 offset 见
asia文件。https://www.iana.org/time-zones - ECMA International. (2025). ECMAScript Language Specification, §21.4.1.32 Date Time String Format. https://tc39.es/ecma262/#sec-date-time-string-format
- Sharhalakis, U., & Bormann, C. (2024). RFC 9557: Date and Time on the Internet — Timestamps with Additional Information. IETF. https://www.rfc-editor.org/rfc/rfc9557
- TC39. Temporal Proposal Documentation, ZonedDateTime 的
offset与disambiguation选项。https://tc39.es/proposal-temporal/docs/