Unix 时间到底在数什么(秒、毫秒、纳秒)
Unix 时间就是一个整数:自纪元 1970-01-01T00:00:00Z 起的秒数。这个日期本身是任意选定的 — 贝尔实验室的早期 Unix 需要一个能以合理分辨率装进 32 位值的零点,1970 年年初是一个方便的、离当时很近的整数。负值完全合法,表示 1970 年之前的时刻;-86400 就是 1969 年的最后一天。由于计数锚定在 UTC,首尔和圣保罗的两台机器在同一瞬间生成的时间戳数值相同:时间戳标识的是时刻,从来不是墙上时钟的读数。
这里的经典缺陷家族是单位混淆,因为各生态对分辨率意见不一。POSIX 的 time()、多数数据库和 JWT 声明用秒;JavaScript 的 Date.now() 和 Java 的 System.currentTimeMillis() 返回毫秒;许多 API 和数据库用微秒;Go 的 UnixNano 和可观测性领域大量使用纳秒。混用它们,日期要么落回 1970 年(把毫秒当秒,几乎全被除掉),要么飞到公元 56000 年以后(把秒当毫秒)。
对当前时代的值,数位数是最快的诊断:10 位是秒(1751414400 按秒算是 2025 年 7 月),13 位是毫秒,16 位是微秒,19 位是纳秒。相关的陷阱是浮点精度:把时间戳当浮点数走 JSON,秒级没问题,纳秒级会被破坏,因为 64 位 double 的整数精度只有 53 位 — 这是高分辨率 API 用字符串传时间戳的原因之一。
Same instant, four resolutions:
1751414400 seconds (10 digits)
1751414400000 milliseconds (13 digits) Date.now()
1751414400000000 microseconds (16 digits)
1751414400000000000 nanoseconds (19 digits) Go UnixNano
// The two classic unit bugs (JavaScript):
new Date(1751414400) // 1970-01-21 — seconds fed as ms
new Date(1751414400 * 1000) // 2025-07-02 — correct闰秒:每个时间戳里藏着的谎言
Unix 时间号称在数 1970 年以来的秒数,但这并非字面上的真话。地球自转不规则,UTC 为了与天文时间对齐会不时插入闰秒(1972 年以来共 27 次)。Unix 时间干脆假装闰秒不存在:按定义每天恰好 86400 秒。插入闰秒时,UTC 时钟读作 23:59:60,而 Unix 计数器无法表达那个瞬间 — 各实现要么重复一秒,要么冻结,这意味着两个不同的真实时刻可能共享同一个时间戳,跨越闰秒测量的间隔会差一秒。
这不是纸上谈兵。2012 年 6 月的闰秒通过内核定时器代码的 livelock 让全行业的 Linux 系统崩溃,连带压垮了航空订票系统;2016 年底的闰秒在 Cloudflare 引发生产事故 — 代码用时间戳相减得到无法处理的负时长。教训可以推广:永远不要假设后取的墙钟时间戳更大。测量时长要用单调时钟(JS 的 performance.now()、POSIX 的 CLOCK_MONOTONIC、Python 的 time.monotonic()),它对闰秒、NTP 跳变和手动改时都免疫。
大型运营商用"闰秒涂抹"绕开问题 — Google 和 AWS 在 24 小时窗口内把每一秒略微拉长,让闰秒消融进微观的减速里。终局则是废除:2022 年国际计量大会决议在 2035 年前停止插入闰秒。在那之前,时间戳仍是出色的时刻标识符,却是把测量时长时略不可靠的尺子。
2038 年问题已经到来
有符号 32 位整数的上限是 2,147,483,647。作为 Unix 时间戳,它对应 2038-01-19T03:14:07Z;一秒之后数值回绕为负,脆弱的系统会突然相信现在是 1901-12-13。还有十二年,听着像明天的问题 — 但任何计算未来日期的代码会提前几十年撞上这道边界。20 年期房贷的还款计划 2018 年就撞上了;15 年期证书和超长 TTL 今天正在撞。早在日期到来之前,系统就已经产出了真实的 2038 缺陷。
解决方案 — 64 位 time_t — 在新平台上早已是标准,64 位秒计数器可以安全使用约 2920 亿年。残留问题活在边缘地带:2038 年仍会在运行的 32 位 ABI 嵌入式与物联网设备、带 32 位时间字段的老文件格式和网络协议,还有数据库。MySQL 的 TIMESTAMP 列类型是著名的幸存者:它存储 32 位 epoch 秒,历史上无法表示 2038-01-19 之后的时间(请改用没有此限制的 DATETIME)。Linux 内核在 5.6 版(2020)为 32 位架构加入 64 位时间系统调用,glibc 以 _TIME_BITS=64 构建选项跟进;ext4 把时间戳扩展到 2446 年,XFS 到 2486 年。
还有两个相邻的溢出值得了解。把同样的 32 位重新解释为无符号来"修复" 2038 的系统,只是把期限推到 2106-02-07 — 是缓期,不是解决。毫秒世界也有自己的悬崖:32 位毫秒计数器(嵌入式运行时钟常用)每 49.7 天回绕一次,这个缺陷模式因 Windows 95 出名,后来还催生了要求波音 787 定期重启发电机控制单元的适航指令。
2^31 - 1 = 2147483647 → 2038-01-19T03:14:07Z (signed 32-bit)
+1 second → -2147483648 = 1901-12-13T20:45:52Z
2^32 - 1 = 4294967295 → 2106-02-07T06:28:15Z (unsigned 32-bit)
2^63 - 1 (64-bit) → ~292 billion years (safe)
-- MySQL: TIMESTAMP is 32-bit, DATETIME is not
CREATE TABLE t (
ts TIMESTAMP, -- max 2038-01-19 03:14:07 UTC
dt DATETIME -- max 9999-12-31 23:59:59
);时区与夏令时:时间戳缺陷的诞生地
Unix 时间戳是绝对时刻;时区是呈现规则。把这两种角色分开,是对日期缺陷最有效的单一防御:存储和传输用 UTC 时间戳,只在展示边缘转换为本地时间。一旦"时间戳"悄悄经过了本地时间解释 — Python 的 naive datetime、被填入服务器本地时间的 DATETIME 列、datetime-local 表单输入 — 同一个值在不同机器上就代表不同的时刻。
夏令时是本地时间即使在单一时区内也危机四伏的原因。春季拨快时,本地时间的一个小时凭空消失(美国东部三月切换日的凌晨 2:30 不存在);秋季拨回时,一个小时出现两次,不知道是哪一遍,"凌晨 1:30" 就是歧义的。调度代码两种情况都会踩:每天 02:30 的任务一年两次要么不触发要么触发两次;朴素的"加 24 小时"算术在切换日漂移,因为那天只有 23 或 25 个小时。
再记两条规则。其一,偏移量不是时区:+09:00 只告诉你如何渲染某一个时刻,只有 Asia/Seoul、America/Sao_Paulo 这样的 IANA 时区名才承载完整的夏令时与政治历史 — 而历史会变,这正是 tzdata 包每年更新数次的原因(巴西 2019 年废除夏令时;埃及 2023 年恢复)。其二,了解你的语言的解析怪癖:在 JavaScript 里,new Date('2026-07-02') 按 UTC 午夜解析,而 new Date('2026-07-02T00:00') 按本地时间解析 — 一个字符的差异,给格林尼治以东或以西的所有用户制造经典的"差一天"缺陷。
// JavaScript parsing quirk (machine in UTC+9):
new Date('2026-07-02') // date-only → parsed as UTC
.toString() // Thu Jul 02 2026 09:00 GMT+0900
new Date('2026-07-02T00:00') // date-time → parsed as LOCAL
.toString() // Thu Jul 02 2026 00:00 GMT+0900
// Same string minus one 'T00:00' = 9 hours apart.
// DST gap: 2:30 AM does not exist on spring-forward day
// America/New_York, 2026-03-08: 01:59:59 → 03:00:00ISO 8601 与 RFC 3339:写给人类和排序器的时间戳
当时间戳需要可读时,答案是 ISO 8601 家族:2026-07-02T09:30:00+09:00。实践中 API 实现的是 RFC 3339 — 一个刻意收窄、面向互联网的 ISO 8601 子集。完整的 ISO 标准允许一些你在 API 里永远见不到的东西 — 2026-W27-4 这样的周日期、2026-183 这样的序数日期、截断形式、不带连字符的基本格式。RFC 3339 把这些全部砍掉:完整的年-月-日、T 分隔符、强制的 UTC 偏移,以及作为 +00:00 简写的 Z。设计 API 时,"带 Z 的 RFC 3339" 是互操作性最高的选择;JavaScript 的 toISOString() 和 Python 的 datetime.isoformat() 都输出合规结果。
这个格式的杀手级特性是字典序等于时间序 — 但仅当所有值使用相同偏移和相同字段宽度时成立。对混合偏移的字符串按字母排序是错的(+09:00 的上午会排在它其实早于的 Z 后缀下午之后),这是把一切归一化为 Z 的又一个理由。正是这种可排序性,让日志文件、S3 对象键和备份名习惯性以 UTC ISO 时间戳开头。
容易咬人的细节:RFC 3339 允许 -00:00(历史上表示"偏移未知",不同于断言 UTC 的 +00:00);小数秒可选且宽度可变,解析器必须同时接受 .5、.500 和 .500000;而只有日期的 2026-07-02 根本不是时间戳 — 如上一节所示,不同解析器会给它配上不同的午夜。跨系统交换时刻时,要么发整数 epoch 值,要么发带显式偏移的完整 RFC 3339 字符串;任何更含糊的形式,都是在邀请两个系统各执一词。