Unix 时间戳的那些坑,时区和溢出

2025-12-10计算换算
分享到

Unix 时间戳是每个开发者都打过交道的概念。但很多人对它的理解停在表面——知道它从 1970 年开始算秒数,但真遇到时区转换、精度问题或者 2038 年那个著名的溢出,就容易翻车。

这篇文章从定义开始,把时间戳相关的坑一个一个拆开。包括我自己踩过的。

一次时区双倍偏移的线上事故

几年前我接手过一个跨国协作工具的后端服务。东南亚团队用这个工具创建任务,欧洲团队查看。代码里有时区转换逻辑:从数据库读出时间,加上时区偏移,返回给前端。看起来很正常。

线上跑了一段时间,用户开始反馈:创建时间和显示时间对不上。东南亚用户下午两点创建的任务,到了欧洲用户那边变成了晚上八点。差了六个小时。

我开始以为前端渲染有问题,查了两天没找到头绪。后来仔细审计了整个数据流——发现问题出在存储环节。

上一任开发往数据库写时间之前,先做了一次时区转换:把"北京时间 14:00"转成了"伦敦时间 06:00",然后把"06:00"当成 UTC 时间算了时间戳存进去。读出来的时候,代码又按欧洲用户的时区做了一次转换。Double offset。

两次偏移互相叠加,数据彻底乱了。

这个问题的根源不是代码写错了逻辑——是对"时间戳不携带时区"这个基本事实认识不足。

Unix 时间戳到底是什么

它的定义很简单:从 1970 年 1 月 1 日 00:00:00 UTC(Unix Epoch)到目标时刻经过的秒数

几个关键点必须理解。

它是绝对时间。 不管你在北京还是纽约,同一时刻的时间戳值完全一样。你正在读这句话的时候,和我写这句话的时候,如果发生在同一秒,时间戳就是同一个数字。这和时间无关——它就是时间本身的一个数值表示。

它不包含时区。 时间戳就是一个整数。说"时间戳有时区"就像说"数字 42 是人民币还是美元"一样没意义。

Epoch 是锚点。 1970 年 1 月 1 日零时 UTC 这个时刻,时间戳就是 0。以此为原点,往前是负数,往后是正数。

举个例子。北京时间 2025 年 12 月 10 日 22:00:00,实际上对应的是 UTC 时间 2025-12-10 14:00:00。伦敦同一时刻也是这个时间。它们的时间戳都是 1765087200。

你现在就可以用 船长工具箱的 Unix 时间戳转换工具 验证这个数字,把 1765087200 输进去,看看是不是这个时间。

Unix 时间戳结构:Epoch 位于时间轴中心,两侧分别为负数和正数时间戳

为什么时间戳不携带时区

这个问题我被问过很多次。很多人觉得"时间戳基于 UTC,所以它有时区"。这是个常见的误解。

更准确的说法是:Epoch 的参考系是 UTC,但时间戳本身是一个标量,不包含任何时区信息。

类比一下温度:摄氏度的定义依赖水的冰点和沸点,但这不意味着"30 度"这个数值本身包含了水的信息。它只是一个数字。

时间戳也一样。它依赖 UTC 来定义 Epoch 的锚点。但存到数据库、传到 API、写进文件时,它仅仅是一个整数。

这个理解很重要。一旦接受了"时间戳是标量"这个设定,很多设计决策就清晰了:

  • 数据库里应该存时间戳,还是存带时区的日期字符串?
  • API 传参用时间戳还是 ISO 8601?
  • 前端展示时由谁来做时区转换?

答案都是:存储和传输用时间戳(或 UTC 时间)。展示时才涉及时区。

时区转换的正确流程

基于上面的理解,转换流程应该是这样的:

原始本地时间 → 换算到 UTC → 计算时间戳 (存储 / 传输)
时间戳 (读取) → 换算到 UTC → 转换到目标时区 (显示)

中间的时间戳是一个纯粹的中介,它自己不携带任何时区信息。

很多优秀的库和语言特性正是按这个思路设计的。Java 8 的 Instant 类代表时间线上的一个点,不带时区。ZonedDateTime 才是带时区的。这种分离是有意为之,不是巧合。

跨时区协作时,你需要一个可靠的时区转换工具。我经常用 船长工具箱的时区转换工具 来验证我的时区逻辑,支持多个时区同时对比。

时区转换的正确流程:时间戳作为纯净的中介,不携带时区信息

2038 年问题 (Y2K38)

这是 Unix 时间戳最著名的坑。

早期 Unix 系统用 32 位有符号整数 存储时间戳。32 位有符号整数的最大值是 2³¹ - 1 = 2147483647。这个秒数对应的人类时间是 2038 年 1 月 19 日 03:14:07 UTC

再多一秒就是 2147483648。32 位有符号整数存不下,溢出变成 -2147483648,对应 1901 年 12 月 13 日。时钟直接跳到 100 年前。

这个问题和当年的 Y2K 问题很像。但 Y2K38 更难修。Y2K 改一下日期显示格式就行——"99"改成"1999"。Y2K38 要改数据类型——从 32 位整数升级到 64 位。这意味着要改内核、系统库、应用程序接口,改造成本大得多。

好消息是大部分现代系统已经迁移:

  • Linux 内核从 5.6(2020 年发布)开始完整支持 64 位时间戳
  • 64 位系统的用户空间早就用 64 位 time_t
  • Go、Rust、Java 等语言默认用 64 位整数存时间戳

嵌入式设备仍然是重灾区。大量物联网设备、工业控制系统、路由器固件还在跑 32 位 Linux 内核。如果你在做 IoT 开发,现在就必须检查代码里的 time_t 类型。

64 位时间戳的范围是 ±9.22 × 10¹⁸,覆盖约 2920 亿年。宇宙年龄才 138 亿年——够用到宇宙毁灭了。

2038 年问题:32 位整数溢出导致时间回退到 1901 年,64 位则完全够用

毫秒精度带来的兼容性陷阱

不同语言对时间戳的精度约定不一致,这个坑几乎每个后端开发者都踩过。

大多数 Unix 系统 API 返回级别的时间戳。但 JavaScript 是个例外——Date.now()Date.getTime() 返回的是毫秒

// JavaScript
const ms = Date.now(); // 1765087200000 (毫秒)
const s = Math.floor(Date.now() / 1000); // 1765087200 (秒)
const d = new Date(1765087200000); // 正确
# Python
import time
ts = time.time()                 # 1765087200.125 (秒,浮点数)
// Go
now := time.Now()
sec := now.Unix()                // 1765087200 (秒)
ms  := now.UnixMilli()           // 1765087200000 (毫秒, Go 1.17+)
// Java
long ms = System.currentTimeMillis();  // 1765087200000 (毫秒)
long s  = ms / 1000;                    // 1765087200 (秒)
// PHP
$ts = time();                    // 1765087200 (秒)
$ms = intval(microtime(true) * 1000);  // 1765087200000 (毫秒)

前端 JS 生成毫秒时间戳传给后端 PHP 服务,后端直接用 date() 处理——这个 bug 我见过不下三次。小数点移三位,时间从 2025 年直接跳到 57994 年。因为 date() 期望的是秒,你给了毫秒。

解决很简单:团队内约定好精度,前后端保持一致

一个实用的做法:

  • 前端和 Node.js 服务层用毫秒(JS 原生单位)
  • Go 服务层用毫秒(UnixMilli()
  • 数据库存毫秒整数(bigint 列)
  • 和第三方 API 交互时,先读文档确认对方用秒还是毫秒

如果项目历史代码已经用了秒,前端就统一除以 1000 再传输。万分别混着用。

闰秒的处理

闰秒是时间戳体系里不太常见但绕不开的问题。

地球自转速度不均匀,天文时间和原子钟时间之间会有偏差。IERS(国际地球自转服务)会不定期插入闰秒来校正。自 1972 年以来已经插了 27 次闰秒,最近一次是 2016 年 12 月 31 日。

闰秒让一天变成 86401 秒。标准的时间流是 23:59:59 → 00:00:00。但有闰秒那天是 23:59:59 → 23:59:60 → 00:00:00。多出一秒。

大多数系统和语言对闰秒的处理是"假装它不存在"。POSIX 标准明确规定:Unix 时间戳忽略闰秒。POSIX 系统假定每天都是 86400 秒。时间戳在闰秒发生时不会"跳秒",而是继续保持线性增长。

这种做法在实际使用中问题不大,但对某些精度要求极高的系统有影响。

Google 的做法是"闰秒涂抹"(leap smear):把闰秒的调整分散到一天内完成,每秒加或减一点点,保证时间戳和 UTC 的误差在毫秒级别。Amazon 和 Microsoft 的云服务也有类似方案。

如果你在做高频交易、天文观测或 GPS 相关系统,建议直接规避 UTC,用 TAI(国际原子时)或 GPS 时间。这两种时间系统没有闰秒。普通业务系统不需要担心这个——一秒的误差对绝大多数应用完全无感。

数据库中的时间戳

不同数据库处理时间戳的方式差别很大,选错了会出现诡异的问题。

MySQL

MySQL 有 TIMESTAMPDATETIME 两种类型。名字很像,行为完全不同。

TIMESTAMP 类型底层存的是 32 位整数,范围从 1970 年到 2038 年。对,它也受 Y2K38 影响。还有个问题:TIMESTAMP 存的时候会转成 UTC 存储,读的时候转回当前会话时区。如果你改了数据库的时区设置,读出来的值会跟着变。

DATETIME 类型存的是原始文本,范围从 1000 年到 9999 年,不受 Y2K38 影响。它不关心时区——存什么就是什么,不改动。

建议:优先用 DATETIME,存 UTC 时间字符串。时区转换交给应用层。

PostgreSQL

PostgreSQL 的 TIMESTAMP WITH TIME ZONEtimestamptz)是我见过的所有数据库里设计最好的。它内部统一用 UTC 存储,不管你插入时带什么时区。显示的时候按客户端时区转换。TIMESTAMP WITHOUT TIME ZONE 则不做任何转换,存原值。

timestamptz 用 64 位整数存储,微秒精度,范围约 4713 BC 到 294276 AD。没有 2038 年问题。

建议:新项目优先用 PostgreSQL。

SQLite

SQLite 没有原生的时间戳类型。通常用 INTEGER 列存秒数,或者用 TEXT 存 ISO 8601 字符串。存整数时精度由应用决定——秒、毫秒、微秒都可以。SQLite 的 datetime() 函数接受这两种格式,用起来还算方便,但需要你自己保持一致性。

选型总结

数据库推荐类型精度Y2K38 安全
MySQLDATETIME
PostgreSQLTIMESTAMPTZ微秒
SQLiteINTEGER自定自定

时区偏移的常见陷阱

除了之前说的时间戳精度问题,获取时间戳的操作本身也可能引入时区偏移。

// 错误示范
const d = new Date();
const ts = Date.UTC(
  d.getFullYear(),
  d.getMonth(),
  d.getDate(),
  d.getHours(),
  d.getMinutes(),
  d.getSeconds(),
);

这段代码看起来在构建 UTC 时间。但实际上 getHours() 返回的是本地时间的小时数。你把本地时间的小时当成 UTC 的小时传进去了——结果偏差了时区偏移量。

假设你在 UTC+8 时区,本地时间是 14:00,这段代码会算出 UTC 时间的 14:00 的时间戳。但真正的 UTC 时间应该是 06:00。差了八个小时。

类似的问题在 Python 里:

# 不推荐
from datetime import datetime
dt = datetime.now()           # 本地时间,不含时区信息
ts = dt.timestamp()           # 结果正确,但意图不清晰

# 推荐
from datetime import datetime, timezone
dt = datetime.now(timezone.utc)  # 明确是 UTC
ts = dt.timestamp()

datetime.now() 返回"本地时间,无时区信息"。Python 调用 timestamp() 时会假设这个时间是系统时区,大多数情况下结果是对的。但代码的意图不明确,容易在后续维护中出事。

原则很简单:获取时间戳时,确保你操作的是 UTC 时间,而不是本地时间

JavaScript 的 Date 对象

JavaScript 的 Date 对象设计得不算好,但理解它的行为能避免很多坑。

new Date() 创建当前时间的 Date 对象,内部存的是自 Epoch 以来的毫秒数getTime() 返回这个毫秒数。

Date.now() 是获取当前时间毫秒数的快捷方式,等价于 new Date().getTime()

几个容易混淆的方法:

  • getTime() — 返回 UTC 时间戳(毫秒)
  • getFullYear() — 返回本地时间的年份
  • getUTCFullYear() — 返回 UTC 的年份
  • getTimezoneOffset() — 返回本地时间与 UTC 的差值(分钟)

如果你在写跨时区的前端应用,统一用 getUTC* 系列方法,别碰本地的 get* 方法。或者干脆只用时间戳传输和比较,只在最后渲染时转成人类可读的时间。

Epoch 的故事:为什么是 1970 年

很多人问过这个问题:为什么 Unix 时间戳的起点是 1970 年?

答案没那么浪漫。早期的 Unix 系统在 1969 年诞生,开发者在设计时间表示时选择了 1971 年 1 月 1 日作为起点。后来系统改版,时间戳从 60 分之一秒调整为秒为单位,起点也被改到了 1970 年 1 月 1 日。

这只是一个历史偶然。为什么是 1970 年而不是 1960 年或 1980 年?因为当时 Unix 正在开发,需要一个就近的整数时间点。1970 年就定下来了。

后来 POSIX 标准化了 time_t 类型,1970-01-01 00:00:00 UTC 作为 Epoch 就成了行业标准。Windows、Linux、macOS 都遵循这个约定。所以你现在打开任何一台电脑,时间戳都是从 1970 年开始算的。

总结

Unix 时间戳的核心概念不复杂——从 1970 年开始的秒数。但围绕它的细节不少。

几个关键原则记住了,基本不会踩坑:

时间戳是标量。 它不包含时区。存储用 UTC 时间戳或 UTC 时间字符串,显示时才做时区转换。

精度统一。 团队内约定好用秒还是毫秒。前后端保持一致。和第三方 API 对接前先读文档。

用 64 位。 新系统确保用 64 位时间戳。如果还在维护 32 位系统,尽快安排升级。2038 年只剩十几年了。

UTC 优先。 获取时间戳时确保操作的是 UTC 时间。Date.now() 在 JavaScript 里是安全的,但 Date.UTC() 配合本地时间的 getter 方法会翻车。

闰秒不用过度设计。 对 99% 的业务系统来说,忽略闰秒完全没问题。

这些都来自我自己踩过的坑和修复过的线上事故。希望你能从我走过的弯路里抄近道。