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 执行”这句话是没说清楚的。在信任某个调度器之前,先把这几个问题问一遍:

  1. 这个排期是按 UTC 定义的,还是按某个具名时区?
  2. 请求的本地时间落在空档里时会发生什么?
  3. 碰上重叠,任务是触发一次还是两次?
  4. 规则来自哪个版本的 tzdb?
  5. 停机期间错过的任务,恢复后会补跑吗?
  6. 万一真的触发了两次,有没有幂等保护?

按 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 结束。美国部分辖区不实行夏令时,而且规则是可能变的。