这道坎只有一秒宽,却层层叠叠
32 位有符号整数的最大值是 2,147,483,647。把它当作 Unix 秒来解释就是:
2038-01-19T03:14:07Z
再过一秒,数学上的 Unix 值是 2,147,483,648,而这个数装不进 32 位有符号字段。
很多说法称这个值会“回绕”成 -2,147,483,648,也就是 1901-12-13T20:45:52Z。那确实是二进制补码存储下常见的结果,但并不是唯一可能的行为。在 C 里有符号溢出是未定义行为,数据库可能拒绝存超范围的值,序列化器可能把它截断,而 API 可能直接抛错。
所以这个 bug 并不保证会表现为一次时间跳变。它的本质是:在某个边界上丢失了有效的表示能力——而这个边界可能藏在技术栈的任何一层。
| 表示方式 | 最终值或范围 | 撑得过 2038 吗 |
|---|---|---|
| 32 位有符号 Unix 秒 | 2,147,483,647 |
不行 |
| 32 位无符号 Unix 秒 | 4,294,967,295 |
只撑到 2106 |
| 64 位有符号 Unix 秒 | 纪元前后各约 2920 亿年 | 对现实的民用日期来说没问题 |
JavaScript 的 Date |
距纪元前后各 1 亿天 | 在它自己的范围内可以 |
MySQL 8.4 的 TIMESTAMP |
到 2038-01-19 03:14:07 UTC 为止 |
不行 |
MySQL 8.4 的 DATETIME |
可到 9999 年 | 可以,但时区语义不同 |
time_t 是一份 ABI 约定,不是 long 的同义词
POSIX 系统用 time_t 表示日历时间,但它的宽度历来取决于平台 ABI。一个程序完全可能跑在 64 位内核上,却仍然使用 32 位的用户态 ABI,或者通过一个 32 位结构体来传递时间。
现行 POSIX 要求 time_t 至少 64 位。GNU C 库的文档说明,在它支持的平台上 time_t 都是 64 位——只有少数老平台的配置例外,那些配置需要用 _TIME_BITS=64 来选中 64 位接口。
在支持这一点的 glibc 构建里,请在包含系统头文件之前把两个宏都定义好:
#define _FILE_OFFSET_BITS 64
#define _TIME_BITS 64
#include <stdint.h>
#include <time.h>
_Static_assert(sizeof(time_t) >= 8, "time_t must be at least 64 bits");
在这些目标平台上,用 _TIME_BITS=64 时那个文件偏移宏是必须一起加的。另外,如果还有别的二进制组件仍在用旧 ABI,那只重新编译一个库是不够的。
GNU C 库:时间类型 · GNU C 库:_TIME_BITS
到操作系统之外去找那道边界
一个现代的 time_t,并不会把已经被打包进别的类型里的数据变宽。请把时间戳跨越边界的每一处都审一遍:
- C 和 C++ 的结构体,尤其是对外暴露的 ABI 字段
- 存纪元秒或毫秒的 SQL
INT列 - Protocol Buffers、JSON schema、ASN.1 和自定义的线上格式
- 二进制文件头和文件系统元数据
- 各语言的外部函数接口(FFI)
- 缓存、消息队列、埋点事件和数据湖的 schema
- 日志、格式化和排序代码里的整数强制转换
- 那些预计会一直部署到 2038 年之后的厂商固件和设备
搜的时候别只搜日期,也要搜类型声明。一个叫 created_at 的字段,比一个叫 created_at_epoch_seconds_i64 的字段更容易把 32 位整数藏起来。
MySQL 的 TIMESTAMP 仍然卡在 2038
MySQL 8.4 的文档写明 TIMESTAMP 支持到 2038-01-19 03:14:07 UTC。DATETIME 的日历范围要宽得多,但换类型不是机械替换那么简单:
| MySQL 类型 | 范围上的顾虑 | 时区行为 |
|---|---|---|
TIMESTAMP |
到 2038 年为止 | 在会话时区和 UTC 之间来回转换 |
DATETIME |
支持 1000–9999 年 | 直接存日历字段,不做时区转换 |
BIGINT 纪元值 |
宽度取决于你选的单位和符号性 | 单位和转换由应用自己负责 |
只有当 DATETIME 的时区语义确实符合应用需求时,才用它来存未来的民用日期。如果你的需求依赖某个 IANA 时区,那就采用一个感知时区的设计,或者“解析出的瞬间 + 时区”这种组合。
决定怎么迁之前,先把候选列找出来:
SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, DATA_TYPE
FROM information_schema.COLUMNS
WHERE DATA_TYPE IN ('timestamp', 'int', 'integer');
INT 列要看清它到底存了什么才谈得上可疑。而 TIMESTAMP 受制于文档写明的范围——哪怕目前还没有任何一行数据接近那个边界。
JavaScript 绕开了这道坎,但它有别的上限
JavaScript 的 Date 把毫秒存在 Number 里,允许距纪元前后各 ±8.64e15 毫秒,正方向可以到 275760 年。所以它本身并不暴露在 32 位有符号 Unix 秒的边界上。
但它照样可能通过一次类型转换或一份 schema,把 Y2K38 重新引进来:
const boundarySeconds = 2_147_483_647;
const nextSecond = boundarySeconds + 1;
console.log(new Date(nextSecond * 1_000).toISOString());
// "2038-01-19T03:14:08.000Z"
console.log(nextSecond | 0);
// -2147483648
那个位运算符把数字转成了 32 位有符号整数。同样的窄化也可能发生在原生绑定、类型化数组、数据库或自动生成的序列化代码里。
要迁移的是约定,不只是那一列
一次靠谱的 Y2K38 迁移要分好几轮:
- 盘点每一种时间戳表示,把它的纪元、单位、符号性、宽度和时区语义都记下来。
- 加宽存储和 API,改用 64 位类型或者合适的原生日期时间类型。
- 升版本:改字段宽度会改变布局时,二进制协议要相应升版本。
- 回填数据时,别把秒重复乘成毫秒两次。
- 新旧生产方共存的分阶段迁移期,做双读或双写。
- 度量还有多少旧格式的值仍在进来。
- 只有当每一个生产方和每一条存量记录都跨过这道坎之后,才移除兼容代码。
别把有符号秒改成 32 位无符号当作长期方案。那只是买到了到 2106 年的时间,还顺手弄丢了负时间戳,并把同一个架构问题留给下一任维护者。
拿边界附近的值做测试
用一个小小的边界矩阵:
| 用例 | Unix 秒 | 期望的 UTC |
|---|---|---|
| 32 位有符号的最后一秒 | 2147483647 |
2038-01-19T03:14:07Z |
| 第一个需要更大宽度的值 | 2147483648 |
2038-01-19T03:14:08Z |
| 常见的回绕值 | -2147483648 |
1901-12-13T20:45:52Z |
| 32 位无符号的最后一秒 | 4294967295 |
2106-02-07T06:28:15Z |
对每个值,都要测:
- 应用的解析和格式化
- 数据库的插入、查询、索引、备份和恢复
- API 双向的序列化
- 消息队列和事件消费方
- 文件与固件的升级路径
- 在代表性时区里的本地时间显示
优先用注入的 clock 或显式输入,别去动共享系统时钟。“时间旅行”写在测试名字里挺有意思,发生在构建机上就没那么有意思了。
几个相关的翻转问题并不是同一个 bug
| 边界 | 日期 | 根本原因 |
|---|---|---|
| Y2K | 2000-01-01 |
两位数的日历年份 |
| GPS 周数翻转 | 第二次翻转在 2019-04-06 |
老式报文里 10 位的周数字段 |
| NTP 纪元轮边界 | 2036-02-07 |
32 位秒字段加上 era 的解释方式 |
| Y2K38 | 2038-01-19 |
32 位有符号的 Unix 秒 |
| 无符号 Unix 翻转 | 2106-02-07 |
32 位无符号的 Unix 秒 |
一个感知 era 的 NTP 实现能区分不同纪元轮,而一个不含 era 的字段做不到。共同的教训是:把表示方式如实记录下来,别以为时间戳共用了一个常见名字,它就自动面向未来了。
“Y2K38 安全”的务实定义
只有当每一个组件都能表示、传输、存储、比较和格式化 2038-01-19T03:14:07Z 之后的瞬间,且不窄化数值、不改变其含义时,这个系统才算安全。
这是数据约定的一项属性,而不是贴在 CPU 上的一张标签。
延伸阅读
Frequent questions:
- Q: 什么是 2038 年问题?
- A: 它是那些用 32 位有符号值表示 Unix 秒的系统的失效边界。能表示的最后一秒是 2038-01-19T03:14:07Z。下一秒就装不下了,视所处层次不同,可能回绕、失败、被钳制,也可能触发未定义行为。
- Q: 哪个 Unix 时间戳标记着 2038 年这道坎?
- A: 2,147,483,647 是 32 位有符号能表示的最后一个 Unix 秒值,对应 2038-01-19T03:14:07Z。数学上的下一个值 2,147,483,648,已经超出 32 位有符号字段的容量。
- Q: 2038 年问题该怎么修?
- A: 在每一处边界上都改用 64 位的时间表示:time_t 和系统调用、数据库列、序列化字段、文件格式、网络协议。在适用的 glibc 目标上,编译时同时定义 _TIME_BITS=64 和 _FILE_OFFSET_BITS=64。
- Q: Y2K38 和 Y2K 是一回事吗?
- A: 不是。Y2K 的根源是年份存的十进制位数太少;Y2K38 的根源是把流逝的 Unix 秒存进了二进制位数太少的有符号字段。
- Q: 什么是 2106 年问题?
- A: 32 位无符号的 Unix 秒字段会在 2106-02-07T06:28:15Z 达到 4,294,967,295。改用无符号存储能把边界往后推,但代价是丢掉纪元之前的日期,而且并没有解决「定宽设计」这个根本问题。
- Q: 所有 64 位系统都是安全的吗?
- A: 并不是。一个 64 位操作系统照样可能去读一个 32 位的数据库列、协议字段、文件时间戳或应用整数。安全与否取决于完整的数据通路,而不是处理器上贴的那个标签。