理解 Unix 时间戳:开发者指南
什么是 Unix 时间戳、它们如何工作、闰秒、2038 年问题以及实际换算。
为什么开发者应该理解 Unix 时间戳
Unix 时间戳是一个整数,表示自 1970 年 1 月 1 日 UTC 00:00:00(Unix 纪元)以来经过的秒数。它们是计算中时间的通用语言:数据库索引它们,API 返回它们,文件系统存储它们,NTP 和 HTTP 等协议使用它们。每个开发者最终都必须在 Unix 时间戳和人类可读日期之间进行转换 —— 并调试不同系统以不同方式表示时间时出现的问题。
本指南涵盖 Unix 时间戳是什么、它们如何在不同语言中工作、时区和闰秒的复杂性,以及即将到来的 2038 年问题。
Unix 时间戳如何工作
Unix 时间戳只是一个整数秒计数。没有时区,没有夏令时,没有日历算术。给时间戳加 86,400 总是将日期在 UTC 中精确推进一天。
``` 0 = 1970-01-01 00:00:00 UTC (纪元) 1,000 = 1970-01-01 00:16:40 UTC 1,000,000 = 1970-01-12 13:46:40 UTC 1,000,000,000 = 2001-09-09 01:46:40 UTC ("十亿秒") 1,500,000,000 = 2017-07-14 02:40:00 UTC 2,000,000,000 = 2033-05-18 03:33:20 UTC 2,147,483,647 = 2038-01-19 03:14:07 UTC (32 位有符号最大值) ```
最大可表示值取决于整数类型:
- 32 位有符号:2,147,483,647 (2^31 − 1) → 2038-01-19 03:14:07 UTC
- 32 位无符号:4,294,967,295 (2^32 − 1) → 2106-02-07 06:28:15 UTC
- 64 位有符号:9,223,372,036,854,775,807 → 292,277,026,596 年
这就是2038 年问题存在的原因:任何 32 位有符号时间戳都会在 2038-01-19 溢出。64 位时间戳可以避免这个问题达数千亿年。
Unix 时间 vs UTC:闰秒问题
严格来说,Unix 时间忽略闰秒。它把每天算作正好 86,400 秒,即使国际时间机构向 UTC 添加了闰秒。在插入了 27 个闰秒之后(截至 2024 年),Unix 时间现在比"真正的"UTC 提前约 27 秒。
实际上,这很少有影响。TAI(国际原子时)保持完美的秒,UTC 通过添加闰秒保持在 UT1(地球自转时间)的 0.9 秒以内。Unix 时间简单地使用 TAI 的秒计数减去一个固定偏移。
如果你需要严格的原子时间,请直接使用 TAI。对于其他一切,Unix 时间的微小漂移是无关紧要的。
时区和偏移
Unix 时间戳指的是时间中的单个瞬间。要以人类可读的形式显示它,你需要应用时区偏移。常见偏移:
| 时区 | 缩写 | 距 UTC 的偏移 | |------|------|---------------| | 协调世界时 | UTC | 0 | | 美国东部(冬) | EST | −5 | | 美国东部(夏) | EDT | −4 | | 美国太平洋(冬) | PST | −8 | | 美国太平洋(夏) | PDT | −7 | | 英国(冬) | GMT | 0 | | 英国(夏) | BST | +1 | | 中欧(冬) | CET | +1 | | 中欧(夏) | CEST | +2 | | 日本 | JST | +9 | | 中国 | CST | +8 | | 印度 | IST | +5:30 | | 澳大利亚(悉尼,冬) | AEST | +10 | | 澳大利亚(悉尼,夏) | AEDT | +11 |
注意:印度和中国使用单一的全年偏移(无夏令时)。美国、英国和大多数欧洲国家实行夏令时,这使一切变得复杂。
一个常见的 bug:服务器存储 Unix 时间戳(始终是 UTC),然后在显示时不应用用户的本地偏移,用户看到的时间偏差几个小时。始终以 UTC 存储,始终在显示时转换为本地时间。
2038 年问题详解
2038 年问题(又称 Y2K38)是将发生在 2038-01-19 03:14:07 UTC 的 32 位有符号整数溢出。下一秒溢出到 −2,147,483,648,在大多数系统上被解释为 1901-12-13 20:45:52 UTC。任何依赖 Unix 时间戳作为 32 位有符号整数的东西都会崩溃:
- 将 mtime 存储为 32 位 time_t 的文件系统
- 较旧的数据库(较旧的 MySQL、某些 SQLite 版本)
- 嵌入式系统(路由器、物联网设备、汽车)
- 使用 32 位时间字段的网络协议(NTP、DNS、Kerberos、某些 TLS 握手)
现代系统(64 位 Linux、64 位 macOS、现代 Windows、现代数据库)已经迁移到 64 位。风险在于未更新的遗留和嵌入式代码。
修复方法:将数据类型从 `time_t`(32 位)更改为 `int64_t`(或等效)。一个声明更改,加上重新编译。挑战在于找到 32 位假设存在的每个地方 —— 文件格式、线协议、持久化数据、第三方库。
在代码中使用 Unix 时间戳
Python
Python 的 `datetime` 模块是规范工具。
```python import datetime from zoneinfo import ZoneInfo
# 当前 Unix 时间戳(秒,浮点) import time now = time.time() # 1700000000.123
# 将 Unix 时间戳转换为 datetime(UTC) ts = 1700000000 utc = datetime.datetime.fromtimestamp(ts, tz=datetime.timezone.utc) # 2023-11-14 22:13:20+00:00
# 将 Unix 时间戳转换为 datetime(特定时区) ny = datetime.datetime.fromtimestamp(ts, tz=ZoneInfo("America/New_York")) # 2023-11-14 17:13:20-05:00
# 将 datetime 转换为 Unix 时间戳 d = datetime.datetime(2023, 11, 14, 22, 13, 20, tzinfo=datetime.timezone.utc) ts = int(d.timestamp()) # 1700000000
# 格式化为 ISO 8601 print(utc.isoformat()) # 2023-11-14T22:13:20+00:00 ```
注意:`datetime.fromtimestamp(ts)`(不带 `tz`)使用本地时区,这在服务器环境中很少是你想要的。始终传递显式时区。
JavaScript
JavaScript 使用毫秒而不是秒。`Date.now()` 返回自纪元以来的毫秒数。
```js // 当前 Unix 时间戳(毫秒) const now = Date.now(); // 例如,1700000000000
// 当前 Unix 时间戳(秒) const nowSec = Math.floor(Date.now() / 1000);
// 将秒转换为毫秒并创建 Date const date = new Date(1700000000 * 1000); // 2023-11-14T22:13:20.000Z
// 将毫秒转换为秒 const ts = Math.floor(date.getTime() / 1000);
// 使用 Intl 在特定时区显示 console.log(date.toLocaleString("en-US", { timeZone: "Asia/Tokyo" })); // 11/15/2023, 7:13:20 AM
// ISO 8601 console.log(date.toISOString()); // 2023-11-14T22:13:20.000Z ```
JavaScript 最大的陷阱:每个其他语言都使用秒;JavaScript 使用毫秒。忘记乘以或除以 1000 是无数"差 1000 倍"bug 的根源。
SQL
大多数数据库原生支持 Unix 时间戳或通过函数支持。
```sql -- MySQL SELECT UNIX_TIMESTAMP(NOW()); -- 当前时间戳(秒) SELECT FROM_UNIXTIME(1700000000); -- 2023-11-14 22:13:20 SELECT FROM_UNIXTIME(1700000000, '%Y-%m-%d %H:%i:%s');
-- PostgreSQL SELECT EXTRACT(EPOCH FROM NOW()); -- 当前时间戳(秒,浮点) SELECT TO_TIMESTAMP(1700000000); -- 2023-11-14 22:13:20+00
-- SQLite SELECT strftime('%Y-%m-%dT%H:%M:%fZ', 'unixepoch', 1700000000); -- 2023-11-14T22:13:20.000Z ```
MySQL 的 `UNIX_TIMESTAMP()` 返回秒;PostgreSQL 的 `EXTRACT(EPOCH FROM ...)` 返回带小数精度的秒。SQLite 需要手动 `strftime` 配方。
Bash
`date` 命令在每个 Unix 系统上处理 Unix 时间戳。
```bash # 当前 Unix 时间戳 date +%s # 1700000000
# 将 Unix 时间戳转换为日期字符串 # Linux(GNU date) date -d @1700000000 # Tue Nov 14 22:13:20 PM UTC 2023
# macOS(BSD date) date -r 1700000000 # Tue Nov 14 22:13:20 UTC 2023
# 将日期字符串转换为 Unix 时间戳 # Linux date -d "2023-11-14 22:13:20 UTC" +%s # macOS date -j -f "%Y-%m-%d %H:%M:%S" "2023-11-14 22:13:20" +%s
# 在特定时区显示 TZ="Asia/Tokyo" date -d @1700000000 # Wed Nov 15 07:13:20 JST 2023 ```
快速参考:`date +%s`("当前 Unix 时间")是一个你每周都会用的一行命令。
在数据库中存储时间戳
数据库存储的最佳实践:
- 存储为整数(BIGINT):原生 Unix 时间戳便宜、易于存储、易于索引、易于比较。
- 始终使用 UTC:仅在显示时转换为本地时区。
- 使用 BIGINT,而不是 INT:32 位有符号整数会在 2038 年溢出。始终使用 64 位。
- 考虑毫秒精度:对于高频事件日志(交易、游戏、遥测),1 秒分辨率太粗。存储为 BIGINT 毫秒。
- 使用 TIMESTAMP WITH TIME ZONE:PostgreSQL 的 `timestamptz` 是最健壮的。MySQL 的 `TIMESTAMP` 有 2038 年问题;请改用 `DATETIME`。
- 索引时间戳列:像 `WHERE created_at > ?` 这样的查询非常常见。在时间戳列上的 BTREE 索引使它们快速。
一个常见的模式:
```sql CREATE TABLE events ( id BIGSERIAL PRIMARY KEY, event_type TEXT NOT NULL, payload JSONB NOT NULL, created_at BIGINT NOT NULL, -- Unix ms INDEX idx_events_created (created_at) ); ```
常见陷阱
JavaScript 毫秒 bug
JavaScript 时间戳以毫秒为单位;每个其他主要语言都使用秒。混合使用它们会产生差 1000 倍的时间戳。
```js // 来自 Python 后端 const python_ts = 1700000000; // 秒 const js_date = new Date(python_ts); // 1970-01-20T08:13:20.000Z ← 错误!
// 正确:乘以 1000 const js_date = new Date(python_ts * 1000); ```
时区显示 bug
时间戳指的是时间中的一个时刻,而不是一个日期。如果你的服务器记录"2023-11-14 22:13:20"而没有指明 UTC 还是本地,用户无法知道是哪个。始终:
- 存储为 Unix 时间戳(本质上是 UTC)
- 发送给带有显式时区的客户端
- 在显示时使用用户的实际时区转换为本地时间
```js // 服务器发送 "2023-11-14T22:13:20Z"(Z = UTC) // 客户端在用户的本地时区渲染 const d = new Date("2023-11-14T22:13:20Z"); console.log(d.toLocaleString()); // "11/14/2023, 5:13:20 PM"(在 EST) ```
夏令时边界情况
春季的 24 小时实际上是 23 小时(夏令时跳过一小时);秋季的 24 小时是 25 小时。假设"加 86,400 得到明天"的代码工作正常;而做"加 24 × 60 × 60"或"明天 2:30 AM"的代码可能在夏令时转换时失败。
```python # 在夏令时时区中,这是 23 小时或 25 小时的一天 d = datetime.datetime(2024, 3, 10, 1, 0, 0, tzinfo=ZoneInfo("America/New_York")) # 夏令时开始 print((d + datetime.timedelta(days=1)).isoformat()) # 2024-03-11T01:00:00-05:00 ← 正确 print((d + datetime.timedelta(hours=24)).isoformat()) # 2024-03-11T02:00:00-04:00 ← 夏令时已跳转,这是 25 小时后 ```
对于调度和时刻数学,优先使用正确处理这些边缘情况的库(Luxon、Arrow、date-fns)。
方法一:使用 UtilBoxx 时间戳换算工具(推荐)
对于开发过程中的快速换算,UtilBoxx 时间戳换算工具 是一个私密的、浏览器内的工具,可以在任何时区在 Unix 时间戳和日期之间进行换算,支持秒和毫秒,完全在客户端运行。没有上传、没有注册、没有日志。下次你需要解码 API 响应中不熟悉的时间戳时,把它加入书签。
结论
Unix 时间戳是简单、快速和通用的 —— 但周围的生态系统(时区、闰秒、2038 年问题、毫秒 vs 秒混淆)充满了陷阱。每个开发者最终都会遇到每一个;唯一的防御就是知道它们的存在。
最佳实践:
- 在 UTC 中以 64 位整数存储
- 在显示时使用用户的实际时区转换为本地时间
- 对非平凡的时间数学使用真正的时区库(Luxon、date-fns-tz、zoneinfo)
- 如果你维护遗留代码,立即审计 32 位时间戳
Unix 时间戳是仍在使用的最古老的数据格式之一,它们将比我们所有人都更长寿 —— 但前提是我们正确使用它们。