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 输进去,看看是不是这个时间。
为什么时间戳不携带时区
这个问题我被问过很多次。很多人觉得"时间戳基于 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 亿年——够用到宇宙毁灭了。
毫秒精度带来的兼容性陷阱
不同语言对时间戳的精度约定不一致,这个坑几乎每个后端开发者都踩过。
大多数 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 有 TIMESTAMP 和 DATETIME 两种类型。名字很像,行为完全不同。
TIMESTAMP 类型底层存的是 32 位整数,范围从 1970 年到 2038 年。对,它也受 Y2K38 影响。还有个问题:TIMESTAMP 存的时候会转成 UTC 存储,读的时候转回当前会话时区。如果你改了数据库的时区设置,读出来的值会跟着变。
DATETIME 类型存的是原始文本,范围从 1000 年到 9999 年,不受 Y2K38 影响。它不关心时区——存什么就是什么,不改动。
建议:优先用 DATETIME,存 UTC 时间字符串。时区转换交给应用层。
PostgreSQL
PostgreSQL 的 TIMESTAMP WITH TIME ZONE(timestamptz)是我见过的所有数据库里设计最好的。它内部统一用 UTC 存储,不管你插入时带什么时区。显示的时候按客户端时区转换。TIMESTAMP WITHOUT TIME ZONE 则不做任何转换,存原值。
timestamptz 用 64 位整数存储,微秒精度,范围约 4713 BC 到 294276 AD。没有 2038 年问题。
建议:新项目优先用 PostgreSQL。
SQLite
SQLite 没有原生的时间戳类型。通常用 INTEGER 列存秒数,或者用 TEXT 存 ISO 8601 字符串。存整数时精度由应用决定——秒、毫秒、微秒都可以。SQLite 的 datetime() 函数接受这两种格式,用起来还算方便,但需要你自己保持一致性。
选型总结
| 数据库 | 推荐类型 | 精度 | Y2K38 安全 |
|---|---|---|---|
| MySQL | DATETIME | 秒 | 是 |
| PostgreSQL | TIMESTAMPTZ | 微秒 | 是 |
| SQLite | INTEGER | 自定 | 自定 |
时区偏移的常见陷阱
除了之前说的时间戳精度问题,获取时间戳的操作本身也可能引入时区偏移。
// 错误示范
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% 的业务系统来说,忽略闰秒完全没问题。
这些都来自我自己踩过的坑和修复过的线上事故。希望你能从我走过的弯路里抄近道。