时间戳、时区,以及那个发生两次的小时
一个时间戳回答的是两个问题之一,而多数日期相关的 bug 都源于把它们搞混。它要么指明一个瞬间,也就是时间轴上所有观察者都认同的一个点;要么指明一次挂钟读数,而在你知道那是谁家的挂钟之前它毫无意义。一笔支付发生在某个瞬间;一次牙医预约则是一次挂钟读数。把其中一个当成另一个来存,日后就会出问题,不多不少差一个小时,在某个星期天的早晨。
Unix 时间是在计数,不是在报时
Unix 时间是自 1970-01-01 00:00:00 UTC 以来流逝的秒数。那个 UTC 说明的是从哪里开始计数;这个值本身不携带任何时区。同一个瞬间在阿姆斯特丹、奥克兰和利马都是同一个数字。
麻烦在于单位,所以要数位数。
| 位数 | 示例 | 单位 |
|---|---|---|
| 10 | 1756080000 | 秒 |
| 13 | 1756080000000 | 毫秒 |
当今的每一个日期,用秒表示都是 10 位,用毫秒表示都是 13 位,而且到 2286 年之前都会如此。两种误读都很响亮:把毫秒当成秒会落到 57626 年,把秒当成毫秒会落到 1970 年 1 月。一个能把解读出来的日期显示出来的时间戳转换器,能让这件事一目了然。
另一个限制来自容器。有符号 32 位的秒数在 2147483647 处走到头,也就是 2038 年 1 月 19 日 03:14:07 UTC,随后绕回负数,被读成 1901 年 12 月。任何要存 30 年有效期的东西,现在算出来就已经越过那条线了。无符号 32 位能到 2106 年,但丢掉了 1970 年之前的所有日期;64 位则没有实际的上限。
ISO 8601 与 RFC 3339
值得记住的形态是 2026-08-25T14:30:00Z:日期、一个 T、时间,然后是一个时区标识符。RFC 3339 是 ISO 8601 面向互联网协议的一个更严格的子集,它要求必须带上那个标识符。ISO 8601 本身还允许周日期、像 20260825T143000Z 这样不带分隔符的形式,以及不带偏移量的时间戳,所以一个承诺“ISO 8601”的 API,通常指的是 RFC 3339 那个子集。
Z 表示偏移量为零。+02:00 表示当地时钟比 UTC 快两个小时,所以要减去它才得到 UTC;把方向弄反是常见的差两小时错误。
| 字符串 | 它指明的是什么 |
|---|---|
2026-08-25T14:30:00Z | 一个瞬间,毫无歧义 |
2026-08-25T16:30:00+02:00 | 同一个瞬间,在一个快两小时的钟面上 |
2026-08-25T14:30:00 | 一次挂钟读数,没有瞬间 |
2026-08-25 | 一个日期,长度为 23、24 或 25 小时 |
既没有 Z 也没有偏移量的字符串是有歧义的,而各家解析器并不一致:有的假定是 UTC,有的假定是系统时区。这就是为什么像生日这种只有日期的值,在格林尼治以西经常渲染成早一天。
偏移量不是时区
+02:00 是关于某个地方某个瞬间的一个事实。Europe/Amsterdam 则是一套规则:冬天 +01:00,夏天 +02:00,更早的年代还是另一副样子。存储偏移量只保住了它被测量的那个瞬间的答案,同时丢掉了推算任何其他时刻的能力。
偏移量也不是唯一的。在一个夏日的午后,+02:00 同时覆盖阿姆斯特丹、拉各斯和约翰内斯堡,所以光凭偏移量既回不到那套规则,也无从得知那面钟今年十二月会显示什么。缩写就更糟了,因为 CST 同时是三个不同时区的名字。请以 Area/Location 的形式存储 IANA 标识符。
不存在的那个小时,和发生两次的那个小时
在整个欧盟,时钟在三月的最后一个星期日拨快,在十月的最后一个星期日拨慢。在阿姆斯特丹,春天 02:00 会变成 03:00,所以从 02:00:00 到 02:59:59 的每一个当地时间那天都不会出现。秋天 03:00 会变成 02:00,所以 02:30 发生了两次,一次在 +02:00,一小时后又一次在 +01:00。
- 周期性闹钟。 一个设在当地 02:30 的任务,在春天那个星期日没有合法的时间。各家调度器做法不同:跳过它、挪到 03:00,或者挪到 01:30。在秋天那个星期日,同一个任务可能触发一次,也可能触发两次,所以用 cron 解析器检查一下接下来几次的触发时间。
- 日志文件。 一小时的不带偏移量的本地时间戳无法排序,而相隔一小时的两个事件写出来的文本一模一样。请用 UTC 记日志,或者在每一行都带上偏移量。
- 时长。 从 2026 年 10 月 24 日 20:00 到第二天 20:00,按阿姆斯特丹当地时间算是 25 小时。“加一天”和“加 24 小时”是两种不同的操作,而以日历天计算的日期差,回答的问题也不同于以小时工作的时长换算器。
规则会变,所以存储方式必须扛得住
IANA 时区数据库 tzdata 保存着这些规则,每年发布若干次,因为这些规则是立法:政府会废除夏令时、采纳夏令时,或者挪动切换日期,有时只提前几周通知。一个被固定住的容器镜像、一个浏览器和一个语言运行时各自带着自己的一份副本,在同一台机器上都可能彼此不一致。把一个未来的本地时间转换成瞬间,是一次预测,只有等它真的到来才尘埃落定。
| 值的种类 | 存什么 | 原因 |
|---|---|---|
| 已经发生的事 | UTC 表示的瞬间 | 过去无法被重新立法 |
| 挂钟意义上的未来预约 | 本地日期时间加上 IANA 时区 id | 意图在于钟面读数 |
| 跨时区固定的未来瞬间 | UTC | 意图在于那个瞬间 |
| 不带时间的日期 | 纯日期,不带时区,也不带午夜时刻 | 附上时区会让它漂移 |
这就是为什么对明年的一场会议来说,“直接存 UTC”是错的。今天把 Europe/Amsterdam 的 2027 年 3 月 30 日 09:00 转换成 UTC,答案是 07:00Z。如果在那之前切换日期挪动了,会议仍然是当地 09:00,但 07:00Z 现在渲染成 08:00:存下来的值冻结了一个预测,却丢掉了意图。请把本地时间加时区作为事实来源;旁边任何 UTC 列都只是缓存。
把日期显示给人看
03/04 光凭这个字符串是无法消歧的。对世界上多数地方它是 4 月 3 日,在美国则是 3 月 4 日;03/04/05 又添了第三个未知数。给人看的输出请把月份写成词(4 Apr 2026),机器之间交换则用 ISO。Discord 的时间戳标记在聊天里绕开了这个问题,它会按阅读者所在的时区和区域设置去渲染:存一个无歧义的值,在边缘处再做格式化。
周数有它自己的规则。ISO 周从星期一开始,第 1 周是包含一月第一个星期四的那一周,等价地说,也就是包含 1 月 4 日的那一周。所以 1 月 1 日可能落在上一个 ISO 年的第 52 或第 53 周里,而 12 月 29 日可能落在下一年的第 1 周里。北美常见的另一种约定则是从包含 1 月 1 日的、以星期日开始的那一周起算,于是同一个日期有了两个周数,因此星期计算器给出的 ISO 周值得拿电子表格核对一遍。
时长、月份与闰年
“一个月之后”不是一个固定的天数;它是 28、29、30 或 31 天。给 1 月 31 日加一个月没有正确答案,各种库通常会把它钳制到二月末,这让这个操作不可逆:加一个月,再减一个月,1 月 31 日就变成了 1 月 28 日。舍入是同一个陷阱的缩小版:把一个钟面时间截断到最近的一刻钟,和把它四舍五入到最近的一刻钟,最多可以相差整整一个间隔。
完整的闰年规则是:能被 4 整除的年份是闰年,但能被 100 整除的不是,除非它同时也能被 400 整除。所以 1900 年不是,2000 年是,2100 年不会是,由此得到平均年长 365.2425 天。只检查能否被 4 整除的代码,对活着的人记得的每一个年份都是正确的,这也是它能一路苟活到 2100 年的原因。2 月 29 日的周年纪念是相关的一件麻烦事:第二年只提供 2 月 28 日和 3 月 1 日,而没有任何规则能在两者之间做选择,所以请有意识地挑一个。