秒、毫秒、2038 与时间戳背后的时区故障
Unix 纪元是 1970-01-01 00:00:00 UTC。这个日期在天文、文化、数字上都没有任何特殊意义。最早的 Unix 设计者只是挑了系统发布那年的元旦,因为这比挑别的更省事。五十五年后,这个随手挑的起点成了数字时间的全球坐标原点。
它到底是什么
Unix 时间戳是从纪元开始算的秒数,忽略闰秒。最后这一句比大多数人以为的更重要——下面会讲。
数字本身没有内置单位,它就是一个整数。这个整数代表秒、毫秒、微秒还是纳秒,是你必须带外知道的约定。没有元数据,没有标记,没有 schema。各语言 / 生态约定不同:
- 秒(今天 10 位数): C 的
time_t、Gotime.Time.Unix()、PHPtime()、Pythontime.time()(返回浮点秒)、绝大多数 UNIX 命令行工具。 - 毫秒(今天 13 位数): JavaScript
Date.now()、JavaSystem.currentTimeMillis()、Kotlin / Android、绝大多数返回数字的 JSON API。 - 微秒(16 位): PostgreSQL
timestamptz的epoch、部分 C++ API、性能剖析输出。 - 纳秒(19 位): Go
time.Time.UnixNano()、新式内核 API。
两秒钟看穿小技巧:10 位时间戳落在 2001~2286 之间;13 位是毫秒;更长就是微秒或纳秒。如果某层把 A 当成 B,你会得到 1000 倍误差——通常表现成"这是公元 50000 年"或者"这是 1970 年"。
怎么读懂一个陌生的时间戳
如果你拿到一个不知出处的整数:
- 数位数。10 → 秒,13 → 毫秒,16 → 微秒,19 → 纳秒。(这条规则在 2286 年前都成立。)
- 转 UTC 看年份。年份明显不对,单位就猜错了。
- 如果是浮点数,整数部分是秒,小数部分是亚秒精度。别四舍五入,保留全精度。
不要相信变量名。你会见到 timestamp_ms 装秒、created_at_seconds 装毫秒、unix_time 两者皆有的情况。
Y2K38
Unix 上 time_t 历史上是有符号 32 bit 整数。最大值 2_147_483_647,对应 2038-01-19 03:14:07 UTC。03:14:08 起,time_t 溢出成负数,回到 1901。
几乎所有桌面与服务器系统都已经迁到 64 bit time_t。没迁的恰恰是没人愿意碰的那些——工业设备里的嵌入式固件、ATM、传感器、POS 终端。Y2K38 不会像 Y2K 那样像末日,但从大约 2035 年起,今天写的软件开始向后看 13 年时,就会开始持续出现一连串"莫名其妙的故障"。
如果你存的时间戳要保存很久,用 64 bit 整数(int64)。数据库列类型,毫秒时间戳字段一律 BIGINT 而不是 INT——INT 的溢出点 ≈ 1970 + (2³¹ − 1)ms,已经在 1995 年就翻车了。
闰秒:那个谎言
Unix 时间假装每一天都正好 86,400 秒。地球不同意,每 18 个月左右两者会差大约 1 秒。国际时间机构定期插入"闰秒"——23:59:60 UTC——让原子时和平均太阳时对齐。
Unix 时间表示不出 23:59:60。约定是:要么跳过这一秒(Unix 时钟暂停),要么重放前一秒(时钟"涂抹")。绝大多数 NTP 同步的服务器走"涂抹"路线:把闰秒摊到 24 小时里,每秒都被几乎察觉不到地拉长一点点。Google 在 2008 年公开了自己的涂抹算法,现在已经是事实标准。
这件事在三个地方真的会咬人:
- 金融系统——闰秒窗口里需要毫秒级排序时。
- 任何存"时间间隔"的地方。 跨过一个闰秒的"10 秒间隔"实际上是 11 秒。
- 日志和指标——会出现整秒边界上的重复时间戳,或者看起来的"空缺"。
IERS 在 2022 年宣布闰秒计划在 2035 年废止。在那之前还会偶尔插入。
时区不是时间戳的一部分
这一点几乎让所有人栽过跟头。Unix 时间戳没有时区。它就是从某个 UTC 时刻开始的秒数。时区只在你把它转换成"墙钟"表示(如 "2026-06-05 14:30")时才出场。
实践含义:
- 存一个不带时区的墙钟字符串
"2026-06-05 14:30"是数据腐败。你不知道它指向哪一个时刻。 - 存 Unix 时间戳不会丢失"什么时候"的信息,只会丢失"那时候用户在哪儿"的信息。如果业务关心"在哪儿",单独存 IANA 时区名(
Asia/Shanghai、America/New_York),不要存偏移量(+08:00)——偏移量不携带 DST 规则。 - ISO 8601 字符串带
Z或带偏移后缀(2026-06-05T06:30:00Z)是无歧义的。不带后缀的,应当拒绝。
DST:那个反复出现的 bug
夏令时让某些墙钟时间根本不存在(春天向前那个空缺),让另一些时间发生两次(秋天向后那个重叠)。
美国东部时区,3 月第二个星期天,2:00 到 3:00 不存在——时钟从 1:59:59 直接跳到 3:00:00。日历里"每周二 2:30"这种条目,那天会怎么解读?(通常会被解读成 3:30,因为大部分日历系统会安静地跳过缺失的那一小时。)
11 月第一个星期天,1:00 到 2:00 会发生两次。那一天的墙钟值 01:30 是有歧义的——它对应两个不同的 Unix 时间戳,相差正好 3600 秒。
DST 处理错的工具会让 cron 任务跑零次(落在空缺里)或两次(落在重叠里)。不要跨 DST 转换地存"墙钟 + 偏移量"。直接存时间戳。
单调时钟 vs 墙钟时间
系统时间可以倒流。时钟可以被手动改。NTP 可以把时钟推前或推后来纠正漂移。笔记本睡了一小时再唤醒,对"现在"的定义就和睡前不一样了。
如果你在测时间间隔——请求延迟、按钮按下到现在的时间、动画帧时间——用单调时钟,不要用墙钟。单调时钟从某个系统自定义的原点(通常是开机)开始计秒,永不倒退。各语言 API 名字不同:C 的 clock_gettime(CLOCK_MONOTONIC)、Node 的 process.hrtime()、Python 的 time.monotonic()、Rust 的 Instant。
如果你用 Date.now() 测间隔,正好碰上 NTP 中途纠错,间隔可能是负数。更糟的是当人为把时钟向前调时,间隔可能是个荒唐大的正数。这类"时钟干了奇怪的事"的 bug 报告,答案永远是"换单调时钟"。
常见坑
- 秒和毫秒搞混。1000 倍误差是世界上最常见的时间戳 bug。
- 存本地时间不带时区。早晚有人在另一台机器上读这份数据。
- 用偏移字符串(
+08:00)做存储。它不携带 DST 规则;6 月 1 号和 12 月 1 号同一座城市的偏移量未必相同。 - 用墙钟时间测时间间隔。换单调时钟。
- 取整。一个时间戳取整到秒再转回去,已经不是原来的值了——丢了最多 999ms 信息。显示前都保持精度。
- 比较来自不同精度源的时间戳而不归一化。
Date.now() > some_seconds_field永远是 true。
实用规则
- 存 UTC,只在显示边界转本地时间。
- 新代码用 64 bit 整数,数据库用
BIGINT。 - 一个系统选一个精度(秒或毫秒)并写进文档,不要混用。
- 测人类感知的间隔用单调时钟;存绝对时刻用墙钟。
- "在哪儿"重要时,时区存 IANA 名而不是偏移量。
- 拒绝不带时区信息的墙钟字符串。
主要参考资料
用于核对本文技术细节的标准与官方文档。
相关文章
继续阅读同一主题领域的实践指南。
Node 生产 Dockerfile 里到底该有什么,不该有什么
网上大多数 Node Dockerfile 都把 node_modules 直接拷进镜像、用 root 运行、最后产出一个 900 MB 的层。本文只讲那几个真正影响构建时间、镜像体积和运行时安全的决定:基础镜像、多阶段构建、依赖层缓存、NODE_ENV 陷阱,以及为什么你的 docker-compose 不该照搬生产。
在用户之前发现缺失的翻译键和插值参数不匹配
缺失的翻译键会把原始键路径直接渲染给用户,插值参数不匹配会渲染出空串或崩溃。这两者在评审中都容易漏,因为开发者的语言包永远有全部键。本文讲如何结构化比较 locale JSON 文件、找出缺失键,并在发布前抓住参数不匹配。
能真正压测 UI 的 mock 数据(而不是只把页面填满)
大多数 mock 数据是同一行复制十遍、只换个 id。它填满页面,却什么都测不到。本文讲如何生成能压测布局边界、长名字、缺失字段、空状态,以及会破坏格式化代码的日期和数字格式的 mock 数据,并通过字段推断让一个 JSON 样本一步变成贴近真实的数据集。