Unix 时间不会跳,跳的是墙上的钟
Unix 时间戳是时间轴上的一个点。夏令时改变的,只是这一刻在某地钟面上被叫作几点。
这个区别,一句话就把整篇文章的主题说完了。
春季切换时,钟可能往前跳,于是留下一段根本不会出现的本地时间;秋季切换时钟往回拨,于是有一段本地时间会重复一遍。而这两件事,在 UTC 那条一直往前的时间轴上都毫无波澜。
| 方向 | 本地钟面示例 | 从本地时间映射到瞬间 |
|---|---|---|
| 春季前拨 | 01:59:59 → 03:00:00 |
空档里的时间找不到对应瞬间 |
| 秋季回拨 | 01:59:59 → 01:00:00 |
重叠里的时间可能对应两个瞬间 |
转换方向决定了风险大小
只要有时区数据库,把一个瞬间转成本地时间是确定的:
瞬间 + IANA 时区 → 唯一的本地日期、时间和偏移
反过来就可能有歧义:
本地日期时间 + IANA 时区 → 零个、一个,或两个瞬间
这正是为什么“存 UTC”这条建议有用但不完整。UTC 很适合描述已经发生过的事。而一个按民用时间定义的未来事件——比如“纽约的门店每个工作日 09:00 开门”——还需要那个时区,因为是它未来的规则决定了届时对应哪个瞬间。
春季的空档制造出永远不会发生的时间
在 America/New_York,现行美国规则会在 3 月第二个星期日把钟从 02:00 拨到 03:00。所以 2026 年 3 月 8 日,下面这个请求没有精确对应:
2026-03-08 02:30:00 America/New_York
库或调度器必须自己决定怎么办。常见策略有:
- 拒绝这个输入,要求换一个有效时间
- 按空档大小整体往后顺延
- 取切换之后第一个有效瞬间
- 沿用某个老 API 传下来的兼容行为
这几种做法本身都不能说错,错在让库替你做决定。挂号预约表单也许就该直接拒掉这个时间,而夜间维护任务提前一点跑完全无所谓。这该由产品约定来定,而不是由某个没人读过的库默认值来定。
秋季的重叠制造出两个都合法的瞬间
2026 年 11 月 1 日,纽约的 01:30 会出现两次:
2026-11-01T01:30:00-04:00 = 2026-11-01T05:30:00Z
2026-11-01T01:30:00-05:00 = 2026-11-01T06:30:00Z
钟面上的文字一模一样,两个瞬间却差了整整一小时。真正区分它们的是那个明确写出来的偏移量。单凭一个 IANA 时区,你只能知道“两种都有可能”;要选定其中一个,得靠消歧策略。
这段重叠常常导致任务重复执行、日志顺序错乱,以及时长计算悄无声息地多出一小时。
偏移量和时区不能互相替代
-05:00 这样的偏移量描述的是与 UTC 的一种关系。America/New_York 这样的 IANA 时区描述的则是一段历史加一整套切换规则。
| 值 | 它能回答什么 | 它回答不了什么 |
|---|---|---|
| Unix 时间戳 | 是哪一个瞬间? | 该套用哪条本地排期规则? |
| UTC 偏移 | 此刻距 UTC 多远? | 以后偏移会变成多少? |
| IANA 时区 | 适用哪个地区的规则? | 没有策略时,重叠该取哪一次? |
| 时区缩写 | 特定语境下给人看的标签 | 一套可移植、无歧义的规则集 |
各国政府一改民用时间规则,IANA 时区数据库就得跟着变。本文写作时 IANA 的最新发行版是 2026c——这个版本号本身就在提醒你:时区数据是一个需要定期更新的依赖。
不同类型的事件,要用不同的方式存
按用户的真实意图来选模型。
已经发生过的事件
存一个瞬间,通常是数据库里的带时区类型,或者一个单位写明白的 Unix 值。只有在审计或展示确实需要时,才另外把原始时区或偏移单独留一份。
支付扣款、日志条目、传感器读数、消息投递都属于这一类。
已知时区里的一次性未来预约
把本地日期时间、IANA 时区,以及选定的偏移或解析出的瞬间都存下来。如果输入正好落在空档或重叠里,把当时用的消歧策略也记下来。另外要想清楚:将来 tzdb 更新了,是要重算这个瞬间,还是保留原始预约不动。
周期性的民用时间事件
存重复规则、本地钟面时间和 IANA 时区,每次触发时用当前的时区规则去生成。如果只存第一个 UTC 瞬间、然后每次加固定的 24 小时,夏令时一变,本地时间就跟着漂了。
需要精确计时的流程
用瞬间和时长来表达。“超时 24 小时”和“明天的同一个本地时间”是两个完全不同的需求:前者跨过夏令时依然是 24 小时,后者实际可能横跨 23 或 25 小时。
用 JavaScript 把一个瞬间格式化到某个时区
Intl.DateTimeFormat 负责把一个已经存在的瞬间拿来显示。它不负责从一个有歧义的本地时间里造出瞬间。
const instant = new Date("2026-11-01T05:30:00Z");
const formatter = new Intl.DateTimeFormat("zh-CN", {
timeZone: "America/New_York",
dateStyle: "medium",
timeStyle: "long",
});
console.log(formatter.format(instant));
如果是支持 Temporal 的新代码,请把消歧策略显式写出来:
const fields = {
timeZone: "America/New_York",
year: 2026,
month: 11,
day: 1,
hour: 1,
minute: 30,
};
const earlier = Temporal.ZonedDateTime.from(fields, {
disambiguation: "earlier",
});
const later = Temporal.ZonedDateTime.from(fields, {
disambiguation: "later",
});
console.log(earlier.epochNanoseconds !== later.epochNanoseconds);
// true
如果“悄悄选中其中任意一次”会违反产品约定,那就用 disambiguation: "reject"。
调度器需要一条写明白的切换策略
在一个会调表的时区里,“每天 02:30 执行”这句话是没说清楚的。在信任某个调度器之前,先把这几个问题问一遍:
- 这个排期是按 UTC 定义的,还是按某个具名时区?
- 请求的本地时间落在空档里时会发生什么?
- 碰上重叠,任务是触发一次还是两次?
- 规则来自哪个版本的 tzdb?
- 停机期间错过的任务,恢复后会补跑吗?
- 万一真的触发了两次,有没有幂等保护?
按 UTC 排期确实能绕开夏令时切换,但代价是:时区偏移一变,实际执行的本地时间就跟着变了。有些基础设施任务无所谓,可对大量面向人的日程来说这就是错的。
要测的是切换点,而不是随便挑个星期二
夏令时的测试集应该盯着边界,而不是只测一个冬天的日期加一个夏天的日期。
- 前拨切换前后紧邻的那两个瞬间
- 一个落在春季空档里的本地时间
- 秋季重叠里的两次出现,都要覆盖
- 一个横跨两次切换的周期性本地事件
- 一段跨越切换、在瞬间时间轴上测量的时长
- 更新时区数据库之后的行为
- 一个不实行夏令时的时区,作为对照组
别在普通单元测试里去改机器的真实时钟。传入明确的瞬间,或者注入一个可控的 clock,这样测试才是确定的,也才能安全地并行跑。
一条靠得住的准则
记录“什么时候”,用瞬间。保留“钟面上该显示成什么”,用 IANA 时区加本地字段。当本地输入可能对应 0 个或 2 个瞬间时,强制要求给出一条策略。
哪怕哪天立法机构又改了切换日期,这套模型依然成立。硬编码的偏移表可不行。
延伸阅读
Frequent questions:
- Q: 夏令时会影响 Unix 时间戳吗?
- A: 不会。Unix 时间戳标识的是 UTC 时间轴上的一个瞬间,跨越夏令时切换时它照常往前走。夏令时改变的是显示这个瞬间时所用的本地钟面和 UTC 偏移。
- Q: 什么叫有歧义的本地时间?
- A: 指一个钟面时间对应了不止一个瞬间。在秋季回拨的重叠区间里,01:30 可能先以夏令时偏移出现一次,再以标准时偏移出现一次。要么用明确的偏移量指定是哪一次,要么定一条写明白的消歧规则。
- Q: 什么叫不存在的本地时间?
- A: 指被偏移切换整段跳过的钟面值。如果某个时区从 02:00 直接跳到 03:00,那么 02:30 这样的时间在该时区里根本没有对应的瞬间。
- Q: 夏令时切换时,定时任务会怎么样?
- A: 取决于调度器。按本地时间定义的任务如果落在春季空档里,可能被跳过也可能被顺延;落在秋季重叠里的任务,可能跑一次也可能跑两次。请实测你的调度器,并明确选定一条策略。
- Q: 代码里该用 EST 还是 America/New_York?
- A: 如果需求是“纽约的民用时间”,那就用 America/New_York——它会套用该地区完整的历史规则和当前规则。EST 只是一个缩写或固定的标准时标签,表达不了纽约完整的切换历史。
- Q: 2026 年美国夏令时什么时候开始和结束?
- A: 按现行联邦规则,在实行夏令时的地区,2026 年从当地时间 3 月 8 日 02:00 开始,到 11 月 1 日 02:00 结束。美国部分辖区不实行夏令时,而且规则是可能变的。