发布于
后端里几乎所有时间相关的 bug,都出自四种混淆之一:把秒当成毫秒、把墙钟读数当成时刻、把 UTC 偏移量当成时区、以及存了本地时间却没说是哪个地方的本地。这四件事只要把概念分清就都不难,可一旦进了生产数据就都很贵。本文逐一拆开它们,并给出应该存什么。
时间戳是一个时刻,不是一次钟面读数
Unix 时间戳就是一个整数:自 1970-01-01T00:00:00Z 这个纪元起经过的秒数。它不是日期,不属于任何时区,也不携带任何日历概念。它在一条通用时间轴上标定了唯一一个时刻;地球上任何一台时钟同步正确的机器,在那一刻都会对它的取值达成一致,无论它摆在哪里。
时区只在显示的那一刻才登场。整数 1000000000 在 UTC 下是 2001-09-09T01:46:40Z,同一时刻在纽约是 2001-09-08 的 21:46:40,在上海是 2001-09-09 的 09:46:40。数字本身没有变,变的是它的三种呈现。这就是「时刻」与「墙钟读数」的全部区别,而一个类型为 DATETIME、名叫 created_at 的字段,会悄无声息地把这个区别抹掉。
一旦你把时间戳理解成「一个点」而不是「一段描述」,多数问题的正确处理方式就自然成立了。两个时间戳之差不需要任何时区信息就有意义:相减得到以秒为单位的时长,跨夏令时切换、跨南北半球都正确。比较同样有意义,所以事件排序、缓存过期、令牌有效期都用这种方式表达。
时间戳表达不了的,是未来的本地约定。「办公室九点开门」不是一个时刻,而是一条规则,它在不同日期会解析成不同的时刻;如果在那一天到来之前政府改了夏令时规则,它还会解析成另一个时刻。把它存成 epoch 值,等于把一个你无权做出的假设固化进了数据。
秒、毫秒、微秒、纳秒,以及数位数的经验法
C 标准库、多数 Unix 工具链、JWT 的时间声明、Postgres 的 extract(epoch) 用的都是秒。JavaScript 的 Date.now()、Java 的 System.currentTimeMillis() 以及多数 JSON 接口用的是毫秒。Go 的 time 包顺手就是纳秒,数据库和链路追踪系统则常用微秒。而数值本身没有任何标记,能告诉你手里这个到底是哪一种。
现实中好用的判别方式是数位数,因为各单位的量级差得太远。当下,秒值是 10 位,毫秒值是 13 位。把 10 位数按毫秒解读会落到 1970 年 1 月:1758412800 当作毫秒解析的结果是 1970-01-21T08:26:52.800Z,这正是「我们所有记录都显示 1970 年」这一经典症状。把 13 位数按秒解读则会落到五万多年之后,对应「有效期显示在公元 57000 年」这一同样经典的症状。
这个经验法很方便,但它不是规范。它在纪元附近的时间戳上失效,在表示 1970 年之前日期的负值上失效,在任何可能同时收到两种单位的代码路径上也失效。真正长效的修法是把单位写进字段名:用 expires_at_ms,而不是 expires_at。如果字段已经无法改名,就在系统边界处转换一次,此后不再转换。
下面五行描述的都是 2026-09-21T00:00:00Z。数位数是识别无标注数值最快的办法。
| 单位 | 当前位数 | 2026-09-21T00:00:00Z 的取值 | 典型来源 |
|---|---|---|---|
| 秒 | 10 | 1789948800 | C 的 time_t、crontab 工具链、JWT 的 exp 与 iat、Postgres extract(epoch) |
| 毫秒 | 13 | 1789948800000 | JavaScript Date.now()、Java currentTimeMillis()、多数 JSON 接口 |
| 微秒 | 16 | 1789948800000000 | Postgres 内部存储、OpenTelemetry span、部分日志管道 |
| 纳秒 | 19 | 1789948800000000000 | Go 的 time.UnixNano()、Prometheus 与 Kubernetes 内部 |
| 带小数的秒 | 10 位加小数 | 1789948800.000 | Python time.time()、Ruby Time#to_f |
ISO 8601 和 RFC 3339 不是同一个标准
当时间戳既要机器可解析又要人可阅读时,它会被写成字符串,而这里涉及两个标准。ISO 8601 是表示日期与时间的广义国际标准,涵盖日历日期、序数日期、周日期、时长、重复区间,以及扩展格式(带分隔符)与基本格式(不带分隔符)两种写法。RFC 3339 是专为互联网协议裁剪出的 ISO 8601 子集,而几乎所有接口文档里说的「ISO 格式」,指的其实都是它。
这个差别只在一个方向上成立:每一个 RFC 3339 时间戳都是合法的 ISO 8601,但大量合法的 ISO 8601 并不是 RFC 3339。RFC 3339 要求完整的日期加时间并且必须带显式偏移,所以 2026-09-21T00:00:00Z 合法,2026-09-21T00:00:00 不合法;它禁止基本格式,所以 20260921T000000Z 出局;它允许小写的 t 和 z,而现实中不少解析器会拒收;它还给 -00:00 赋予了 ISO 8601 根本不允许的含义 —— 时刻已知,但观测方的本地偏移未知。
还有一个较新的扩展值得知道。RFC 9557 增加了方括号时区标注,于是 2026-09-21T09:00:00+02:00[Europe/Berlin] 同时携带了解析出的偏移和产生这个偏移的规则。这正是 JavaScript Temporal API 输出的格式,也是第一种被广泛规范化的、能在不丢信息的前提下写下「未来本地约定」的写法。
| 形式 | 同一时刻的示例 | 是否符合 RFC 3339 | 适用场景 |
|---|---|---|---|
| 扩展格式 UTC | 2026-09-21T00:00:00Z | 是 | 接口载荷与日志行的默认选择 |
| 扩展格式带偏移 | 2026-09-21T08:00:00+08:00 | 是 | 保留事件被观测时所处的偏移 |
| 带小数秒 | 2026-09-21T00:00:00.123Z | 是 | 亚秒级排序,小数位数不限 |
| 基本格式 | 20260921T000000Z | 否 | 紧凑文件名与部分遗留协议 |
| 不带偏移 | 2026-09-21T00:00:00 | 否 | 一次本地墙钟读数,单独看有歧义 |
| 序数日期 | 2026-264 | 否 | 科研与物流数据里的年内天数运算 |
| 周日期 | 2026-W39-1 | 否 | 按 ISO 周对齐的报表周期 |
| 偏移未知 | 2026-09-21T00:00:00-00:00 | 是 | 时刻已知、但观测方偏移未知 |
| 带时区标注 | 2026-09-21T02:00:00+02:00[Europe/Berlin] | RFC 9557 扩展 | 需要扛住规则变更的未来本地约定 |
偏移量不是时区
+08:00 是偏移量:相对 UTC 的一个固定差值,只对某一个时刻有效。Asia/Shanghai 是时区:IANA tz 数据库中的一个命名条目,它把过去与未来的每一个时刻都映射到一个偏移,其中也包括中国曾经实行夏令时的那几年。两者不可互换,把其一当作其二,是仅次于秒毫秒混淆的第二常见时间 bug。
对已经发生的时间戳来说,这个区别是隐形的,因为偏移早已确定、不会再变。但对任何未来的时间,它就是决定性的。如果你把柏林每周九点的例会存成 +02:00,那么在十月最后一个周日之前它都是对的;柏林切回 +01:00 之后,此后每一次都会早一小时。如果你存的是 Europe/Berlin 加本地时间 09:00,它就一直是对的 —— 并且当欧盟哪天真的取消季节性调表时,它依然是对的,因为 tz 数据库会更新,而你存的数据不需要动。
偏移量也无法标识地点。在同一时刻,好几个时区共享同一个偏移,却对「什么时候离开这个偏移」意见不一:America/New_York 和 America/Toronto 夏天都是 -04:00、冬天都是 -05:00,而 America/Phoenix 全年停在 -07:00,America/Denver 却会切换。Asia/Kolkata 的 +05:30 和 Australia/Adelaide 的 +09:30 这类半小时、乃至一刻钟的偏移,则进一步提醒你偏移不一定是整小时 —— 那些把偏移存成整数小时的代码会在这里翻车。
把一个时刻存得住
时间值一共有三类,需要三种不同的处理。已发生的事件 —— 日志行、审计记录、一笔支付 —— 是时刻,应当以 UTC 存储:PostgreSQL 用 timestamptz,MySQL 用统一按 UTC 写入的 DATETIME 或 BIGINT,文档型存储用以 Z 结尾的 RFC 3339 字符串。未来的本地约定 —— 会议、截止、定时通知 —— 应当存成「本地日期时间 + IANA 时区名」两列,只在需要时才解析成时刻。完全没有时间的日期 —— 生日、账期、法定假日 —— 应当用 DATE 存储,绝不要给它补上时间分量,因为一旦补上就必须选一个时区,而这个选择没有正确答案。
数据库类型要格外小心。PostgreSQL 的 timestamptz 并不存时区:它在写入时把输入转成 UTC,在读取时按会话时区渲染,这通常正是你想要的。而 timestamp without time zone 原样存下给定的数字,只有当整个系统对时区达成一致时才安全。MySQL 的 TIMESTAMP 会按会话时区在 UTC 之间来回转换,但受限于 32 位范围,无法表示 2038-01-19T03:14:07Z 之后的任何时刻;它的 DATETIME 范围更宽,却完全不做转换。
最后,请把你所用语言的解析规则查清楚,而不是凭直觉假设。在 JavaScript 中,纯日期字符串按 UTC 解析,而不带偏移的日期时间字符串按本地解析 —— 相邻两行代码只差六个字符,结果却差了好几个小时。
new Date("2026-09-21").toISOString()
// "2026-09-21T00:00:00.000Z" 纯日期形式按 UTC 处理
new Date("2026-09-21T00:00:00").toISOString()
// "2026-09-20T16:00:00.000Z" 不带偏移的日期时间按「本地」处理
new Date("2026-09-21T00:00:00Z").toISOString()
// "2026-09-21T00:00:00.000Z" 带显式偏移,没有歧义
// 永远把偏移写出来,这样同一个字符串在任何地方含义相同。
Date.parse("2026-09-21T00:00:00Z") / 1000
// 1789948800闰秒与 2038 问题
Unix 时间假定每一天正好包含 86400 秒。天文时间并不配合,UTC 因此会偶尔插入一个闰秒,而 POSIX 的定义里根本没有它的位置:为 23:59:60 计算出的数值,会和一个已经用过的数值撞车。真实系统的处理方式是重复一秒、把时钟拨一下,或者像 Google 和 AWS 选择的那样,把这多出来的一秒「抹平」到一整天里,让任何时钟都不会倒退。实际后果是:跨闰秒时由两个 epoch 值算出的时长可能差一秒 —— 这对物理学和金融定序有意义,对其他几乎所有场景都没有。
最近一次闰秒插入于 2016 年年末,此后 TAI 领先 UTC 37 秒;2022 年国际计量大会已决议在 2035 年前停止插入闰秒。如果你在写新代码,诚实的态度是:承认闰秒是模型里一个真实的缺口,不要自己动手去「修正」它,并且用单调时钟来测量经过时长,这样任何形式的时钟调整都不会产出负数。
2038 问题则要具体得多。有符号 32 位的 time_t 在 2147483647 秒处达到上限,对应 2038-01-19T03:14:07Z。再过一秒它溢出为 -2147483648,渲染出来是 1901-12-13T20:45:52Z。任何在 32 位有符号字段里越过那个点继续计数的系统都不会报错,而是悄悄给出一个十九世纪的日期 —— 这要糟糕得多。
现代 64 位操作系统已经把 time_t 扩到 64 位,在任何值得讨论的时间尺度内都不受影响。仍然暴露在外的是:32 位嵌入式固件、较老的文件系统 inode 格式、某些固定四字节时间字段的二进制协议,以及 MySQL 的 TIMESTAMP 类型。一份百年期的订阅、一张长有效期的证书、一个排到很远未来的定时任务,今天就会撞上它,而不是等到 2038 年 —— 所以这个 bug 通常是被一条日期特别远的测试用例发现的,而不是被日历发现的。
2147483647 -> 2038-01-19T03:14:07Z 最后一个可表示的秒
2147483648 -> 2038-01-19T03:14:08Z 64 位下没问题,32 位有符号则溢出
-2147483648 -> 1901-12-13T20:45:52Z 溢出后渲染出来的样子
另外几个值得记住的点:
0 -> 1970-01-01T00:00:00Z 纪元本身
1000000000 -> 2001-09-09T01:46:40Z 很好用的校验基准点
8.64e15 毫秒 -> +275760-09-13 JavaScript Date 能容纳的上限一套能止住复发的工作流
本文的大部分内容可以压缩成几条习惯,一旦形成条件反射就几乎不花成本:在系统边界处做转换、内部只保留一种表示、每个字段名都把单位写清楚,这样下一个读代码的人不必去猜。
当你在排查而不是在设计时,时间戳工具可以双向转换,并把同一时刻同时渲染成多个时区下的样子 —— 这是确认「一条日志和一行数据库记录是否指向同一瞬间」最快的办法。如果这个值来自调度器,再用 Cron 工具在同一时区下预览一次排期,整个链路就闭合了。
- 时刻用 UTC 存;未来的本地约定存「本地时间 + IANA 时区名」;纯日期用 DATE 存。
- 把单位写进字段名:在之后的每一次代码评审里,expires_at_ms 都胜过 expires_at。
- 序列化时永远带显式偏移,并优先使用 Z 而不是隐含的本地读数。
- 该放时区名的地方绝不要放 UTC 偏移,也不要假定偏移一定是整小时。
- 用单调时钟测量经过时长,而不是把两次墙钟读数相减。
- 用 2038 年之后和 1970 年之前的日期各测一遍,这两类用例能挖出普通夹具永远碰不到的 bug。
要点回顾
- Unix 时间戳标定的是一个时刻而不是钟面读数,时区只在渲染那一刻才起作用。
- 拿到 epoch 值先数位数:10 位是秒,13 位是毫秒,而根治办法是把单位写进字段名。
- RFC 3339 是 ISO 8601 面向互联网的严格子集,所以永远写完整的日期时间并带上显式偏移,优先用 Z。
- 偏移量只对某一个时刻有效,时区名才是规则,因此未来的本地事件必须配 IANA 时区名而不是固定偏移。
- 有符号 32 位时间止于 2147483647,即 2038-01-19T03:14:07Z,并且会静默溢出到 1901 年而不是报错。