跳转到主内容
指南

生产环境中的 Unix 时间戳:秒与毫秒、2038 问题和时区 Bug

· 12 分钟阅读

每个生产系统迟早都会记录、存储或传输一个 Unix 时间戳。而每个生产系统也迟早会发布一个与之相关的 Bug——日期渲染成 1970 年 1 月、事件被排到了 49 年前、API 对接中一方要毫秒另一方发的是秒。这些 Bug 无聊、常见,但只要你掌握了支配时间戳运作的少数几条规则,它们完全可以避免。

本指南覆盖实用的核心:通过位数识别时间戳单位、2038 问题及其今天的真实状况、实际会发生的 Bug 模式,以及在你常用的语言中获取时间戳的正确方式。

Unix 时间戳是什么(以及不是什么)

Unix 时间戳计算的是自 1970-01-01 00:00:00 UTC——即”Unix 纪元”——以来经过的秒数。有两条性质对后面的一切都至关重要:

  1. 它没有时区。 时间戳 1785235837 指向地球表面同时刻的唯一瞬间。只有当你把时间戳展示给人看、或从本地挂钟时间组件构造时间戳时,时区才会参与进来。如果本文你只能记住一句话,那就记住这句:时间戳是 UTC 时刻;时区只是渲染层面的事。
  2. 它忽略闰秒。 Unix 时间假设每天都有恰好 86,400 秒。插入闰秒时,POSIX 时间实际上会重复或抹平它(行为因系统而异;Google 的”leap smear”把它摊到一整天里)。对大多数应用代码这无关紧要;但对跨越闰秒的区间计算,这意味着你”恰好 N 秒”的假设可能差一秒。

还要注意,纪元的选择意味着负时间戳是合法的-1 就是 1969-12-31 23:59:59 UTC。把负时间戳当错误处理的代码,遇到 1970 年之前的日期就会崩——这是处理历史数据(出生日期、档案记录)的系统真实会踩的 Bug 模式。

秒、毫秒、微秒:位数启发法

来自不同生态的时间戳单位各不相同,而认错了单位是最常见的时间戳 Bug,没有之一。快速的现场判断方法是数位数:

单位示例(2026 年中)位数何时达到这个宽度
178523583710自 2001-09-09 起为 10 位;2286 年起为 11 位
毫秒178523583700013自 2001-09-09 起为 13 位
微秒17852358370000001616 位
纳秒1785235837000000000192262 年超出 int64 范围

当下时代(2001–2286)的经验法则:

  • 10 位 → 秒。 永远可以放心假设。
  • 13 位 → 毫秒。 JavaScript 的 Date.now()、Java 的 System.currentTimeMillis(),以及大多数日志/监控系统的输出。
  • 16 位 → 微秒。 Python 的 time.time() 换算后、某些数据库、某些链路追踪系统。
  • 19 位 → 纳秒。 Go 的 time.Now().UnixNano(),这个是个陷阱:19 位数在 2262 年后会超过有符号 64 位最大值(9,223,372,036,854,775,807),但今天 19 位的数还放得下 int64——只是勉强。如果你存储纳秒时间戳,别做假设有裕量的算术。

调试时的快速验证:把数字粘进 时间戳转换器,看看日期是否合理。如果按”秒”解释得到 1970 年,而按”毫秒”解释得到一个合理的近期日期,你两秒钟就确定了单位。

对应的 Bug 模式是:把毫秒当秒(或反过来)。没必要地把秒级时间戳乘以 1000,1785235837 就变成了 1785235837000 秒——一个约 56,000 年后的日期,大多数渲染器会把它截断或绕回成垃圾。把毫秒时间戳除以 1000 而它本来就是秒,你会得到 1970-01-21——这就是为什么那么多出问题的系统显示 1970 年 1 月的日期。在生产中看到 1970,先怀疑单位搞错了。

2038 问题

经典 Unix 的 time_t 是一个有符号 32 位整数,按秒计数。它的最大值 2,147,483,647 对应 2038-01-19 03:14:07 UTC。再过一秒,有符号 32 位计数器会绕回 −2,147,483,648,渲染为 1901-12-13 20:45:52 UTC。这就是”Y2038”或”Epochalypse”问题——结构上与 Y2K 完全相同,但根源是一个具体的 C 类型,而不是两位年份字段。

截至 2026 年,它实际还重要的地方:

  • 32 位嵌入式系统和固件。 服役期很长的设备——工业控制器、汽车系统、医疗设备、基础设施——跑着 32 位内核或 32 位 time_t ABI。这是主要的残余风险类别,恰恰因为这些系统难以打补丁又容易被遗忘。
  • 指定了 32 位秒计数器的旧文件格式和协议。 有些通过把字段重新解释为无符号来修复(上限推到 2106 年);另一些就纯粹是遗留问题。
  • 带纪元限制的数据库类型。 著名的例子:MySQL 的 TIMESTAMP 类型历史上范围只到 2038-01-19 03:14:07 UTC,因为它把秒存成无符号 32 位整数。(DATETIME 的范围到 9999 年,不受影响。)如果你的 schema 用了 TIMESTAMP,要知道这个限制。

已经被修复的:

  • 64 位 Linux 和现代 64 位系统使用 64 位 time_t,够用约 2,920 亿年。
  • Linux 内核的 time64 工作(在内核 5.6 完成,2020 年)和 glibc 的 64 位时间支持(glibc 2.34 的 _TIME_BITS=64)给了 32 位 Linux 用户态一条迁移路径,不过二进制文件必须用新 ABI 重新编译——32 位系统上旧的编译产物依然有漏洞。
  • JavaScript、Python、Go、Java 都使用 64 位(或 float64)时间表示,在任何与人类相关的时间尺度内都不会溢出。JavaScript 的 float64 毫秒可以往前后各走约 285,000 年。

实用建议:如果你在 64 位平台上用主流语言写新代码,2038 不是你的问题。如果你维护嵌入式固件、32 位目标上的遗留 C 代码,或者满是 INT 纪元列和 MySQL TIMESTAMP 字段的 schema,现在就审计——2020 年代安装的系统到 2038 年还会在运行。

实际会发生的 Bug 模式

1. 单位混淆(秒 vs 毫秒)

上面讲过了:最常见也最容易发现(日期出现在 1970 年或 58000 年)。用约定来修复:变量和字段名带上单位——created_at_msexpires_s——并在 API 边界做校验。在这个世纪,一个超出 10^910^10 范围的”秒”字段时间戳是可疑的。

2. 本地时间误作 UTC

“时间戳没有时区”这条规则的反面。经典表现:一台在上海的服务器和一台在弗吉尼亚的服务器对”同一个”截止时间算出了不同结果,因为一方是用本地挂钟时间组件构造的时间戳。任何走 本地日期/时间字段 → 时间戳 路径的代码,都必须显式指定这些字段的时区。JavaScript 里的 new Date(2026, 6, 28) 指的是浏览器本地时区的午夜,对每个用户来说都是不同的时刻。

3. 夏令时边缘情况

夏令时会制造两种损坏的挂钟时间:

  • 不存在的时间。 在美国时区,2026-03-08 02:30 这个本地时间不存在——时钟从 02:00 直接跳到 03:00。一个设为”每天 02:30 运行”的 cron 式调度器,那天要么跳过、要么跑两次、要么行为取决于平台。
  • 有歧义的时间。 2026-11-01,01:30 这个本地时间在美国时区会发生两次,因为时钟回拨。存储”01:30”而不带偏移量或时区,就无法区分你指的是哪一次。

稳健的修复:周期任务用 UTC 调度,时刻存成时间戳,只在展示时转换成本地时间。如果必须存未来的本地挂钟时间(比如”用户上午 9 点的会议”),就存本地时间加上 IANA 时区名(America/New_York),在渲染时才解析为时间戳——因为政府会修改夏令时规则,今天为一个 2028 年本地会议算出的时间戳,在法律变更后可能就是错的。

4. JavaScript 从零开始的月份

new Date(2026, 6, 28)7 月 28 日,不是 6 月 28 日——月份是 0–11,而日期是 1–31。这个不一致制造了一代”差一个月”的 Bug。缓解办法:指 UTC 时用 Date.UTC(...),求清晰就用 ISO 字符串(new Date("2026-07-28T00:00:00Z")),或者用库(Temporal 在广泛落地后会用从 1 开始的月份把这个问题真正修好)。

5. 秒上的浮点精度

Python 的 time.time() 返回浮点数。float64 有 53 位尾数;在当前纪元量级(约 1.79 × 10^9 秒)下,分辨率没问题(约 240 纳秒),所以这在 Python 里很少咬人——但同样的推理确实会咬到那些把微秒或纳秒时间戳装进 float64 的系统:16 位的微秒值需要 54 位才能精确表示,所以 float64 微秒今天就已经不精确了。任何比毫秒更细的东西都用整数。

6. 不带偏移量的字符串解析

JavaScript 里 new Date("2026-07-28") 解析为 UTC 午夜,但 new Date("2026-07-28 00:00:00")(没有 T、没有 Z)解析为本地午夜——基于字符串格式细节的一次静默时区翻转。序列化日期时间时永远带上显式的 Z 或数字偏移量。优先用带偏移量的 ISO 8601:2026-07-28T10:00:00+08:00

各语言获取时间戳的正确方式

JavaScript / TypeScriptDate.now() 返回毫秒;要秒就除以 1000:

const ms = Date.now();                      // 1785235837123
const seconds = Math.floor(Date.now() / 1000); // 1785235837
// 从指定时刻构造时,永远显式指定 UTC:
const ts = Date.UTC(2026, 6, 28, 0, 0, 0) / 1000; // 月份从 0 开始!

Pythontime.time() 返回浮点秒;任何要存储的东西都用带显式 UTC 的 datetime

import time
from datetime import datetime, timezone

seconds = int(time.time())                    # 1785235837
ns = time.time_ns()                           # 纳秒,精确整数
ts = int(datetime.now(timezone.utc).timestamp())

Gotime 包显式给出每种单位:

now := time.Now()
seconds := now.Unix()       // int64 秒
millis  := now.UnixMilli()  // int64 毫秒
nanos   := now.UnixNano()   // int64 纳秒——注意 int64 范围

Java — 避免遗留的 Date/Calendar;用 java.time

long millis  = System.currentTimeMillis();
long seconds = Instant.now().getEpochSecond();
long nanos   = Instant.now().getNano(); // 秒内的纳秒部分,不是纪元纳秒!

Bash — GNU 与 macOS(BSD)的 date 在解析上有差异:

date +%s                        # 当前纪元秒(两者通用)
date -d @1785235837             # GNU:时间戳 → 人类可读日期
date -r 1785235837              # macOS/BSD:时间戳 → 人类可读日期
date -u -d "2026-07-28 00:00:00 UTC" +%s   # GNU:日期 → 时间戳
date -u -j -f "%Y-%m-%d %H:%M:%S" "2026-07-28 00:00:00" +%s  # macOS

注意反复出现的主题:每个正确的 API 要么直接给你纪元值,要么强迫你说出时区。每个危险的 API 都允许你省略它。

交互式地处理时间戳

调试日志或 API 响应里的时间戳时,别在心算。把它粘进我们的 时间戳转换器:它会同时显示 UTC 和你本地时区的时刻,并自动检测秒与毫秒——这正是上面说的歧义检查。它还可以反向转换(人类可读日期 + 时区 → 时间戳),用来构造测试夹具。

总结

  • Unix 时间戳是自 1970-01-01 UTC 以来的秒数(或毫秒/微秒/纳秒)。它没有时区,也没有闰秒。
  • 位数可以在现代识别单位:10 = 秒,13 = 毫秒,16 = 微秒,19 = 纳秒。
  • 2038 年有符号 32 位 time_t 在 2038-01-19 03:14:07 UTC 溢出。64 位系统和主流语言是安全的;嵌入式固件、遗留 32 位二进制和 MySQL TIMESTAMP 列是残余风险。
  • 真实世界的 Bug 模式:单位混淆、本地时间与 UTC 混用、夏令时边缘、JS 从零开始的月份、微秒/纳秒上的 float64 精度损失,以及不带偏移量的字符串解析。
  • 时刻存成整数时间戳、字段名带单位、用 UTC 调度、只在展示时转本地——再把 时间戳转换器 加入书签,应付那些漏网之鱼的调试。