什么是 Unix 时间戳?
Unix 时间戳(Unix Timestamp)是从 1970-01-01 00:00:00 UTC(称为 epoch)起经过的秒数(或毫秒数)。它是一个与时区无关的绝对时刻——无论你在北京、伦敦还是纽约,同一瞬间的时间戳数值完全相同。
这种「用一个整数表示时刻」的设计,让时间可以像数字一样被存储、比较、做差值,而不必关心人类日历的复杂规则。
三个容易被忽略的细节:
- epoch 是人为选的。1970-01-01 UTC 只是早期 Unix 团队拍定的起点,换任何一天都不影响机制。
- 它是有符号的。1970 年之前的时间戳是负数,比如
1969-12-31 23:59:59 UTC=-1。处理生日、历史档案这类数据时会遇到。 - 它是整数,不是浮点。用浮点存时间戳会引入精度误差,比较和排序都可能出错。
精度:不只有秒和毫秒
实际开发中最常踩的坑是单位混用。四种精度都在生产环境真实存在:
| 精度 | 位数 | 示例 | 常见来源 |
|---|---|---|---|
| 秒 | 10 位 | 1700000000 |
Linux date +%s、MySQL UNIX_TIMESTAMP()、多数后端 API |
| 毫秒 | 13 位 | 1700000000000 |
JavaScript Date.now()、Java System.currentTimeMillis() |
| 微秒 | 16 位 | 1700000000000000 |
Python time.time_ns()//1000、MySQL DATETIME(6) |
| 纳秒 | 19 位 | 1700000000000000000 |
Go time.Now().UnixNano()、Python time.time_ns() |
快速判断表:
| 位数 | 精度 | 对应年份量级 |
|---|---|---|
| 10 | 秒 | 2026 年前后 |
| 13 | 毫秒 | 2026 年前后 |
| 16 | 微秒 | 2026 年前后 |
| 19 | 纳秒 | 2026 年前后 |
关键规则:13 位值 ÷ 1000 ≈ 10 位值。如果前端拿到的 13 位直接当秒存进数据库,时间会错误地跳到几万年后。转换工具应能自动识别精度(看位数即可判断),避免手动算错——时间戳转换工具 就是按位数自动判定并同时给出各时区结果。
⚠️ 位数判断在两种边界会失效:一是 2001-09-09 前后(秒级正好 10 亿,10 位→仍 10 位,无歧义,此规则安全);二是未来纳秒值已达 19 位、秒级仍是 10 位不变。真正危险的是把「微秒 16 位」误判成「毫秒」,差 1000 倍。跨系统传输时在字段名里写死单位(
created_at_ms)比自动推断更可靠。
时区:UTC、GMT 与偏移
时间戳是 UTC 绝对时刻。当我们在页面上看到「2023-11-15 06:13:20」,那是把 UTC 时刻按当前时区渲染后的结果。
const ts = 1700000000 // 秒级
const d = new Date(ts * 1000) // 注意 ×1000 转毫秒
console.log(d.toString()) // 本地时区:Wed Nov 15 2023 06:13:20 GMT+0800
console.log(d.toISOString()) // UTC:2023-11-14T22:13:20.000Z
三个常被混用的概念:
| 概念 | 含义 | 用法 |
|---|---|---|
| UTC | 协调世界时,原子钟基准 | 存储与计算的唯一基准 |
| GMT | 格林尼治平均时,天文基准 | 日常口语中与 UTC 混用,技术文档应用 UTC |
| 偏移(offset) | 与 UTC 的小时差,如 +08:00 |
只描述某一时刻的差值,不是时区 |
偏移 ≠ 时区,这是最隐蔽的错误来源。+08:00 只说明此刻比 UTC 快 8 小时;中国全年是 +08:00,但伦敦冬天 +00:00、夏天 +01:00。要正确表达「用户所在地」,应该用 IANA 时区标识符(Asia/Shanghai、Europe/London),而不是偏移量——IANA 时区数据库(tzdata)里带了历年规则变更的历史,偏移没有。
需要按地区换算时用 时区转换器,它按 IANA 数据库处理,能正确处理夏令时切换。
永远用 UTC 存储与传输,只在展示层按用户时区格式化。这是跨时区系统不踩坑的唯一铁律。
夏令时(DST)陷阱
实行夏令时的地区,一年中有两天会出现「不存在的小时」或「重复的小时」:
| 场景 | 发生时刻 | 现象 | 后果 |
|---|---|---|---|
| 春季跳变 | 本地 02:00 → 03:00 | 02:00–02:59 不存在 | 定时任务在这一小时静默跳过 |
| 秋季回拨 | 本地 03:00 → 02:00 | 02:00–02:59 出现两次 | 同一本地时间对应两个时刻,日志、去重、聚合都会错 |
基于本地日历的计算(如「加 24 小时」「明天同一时刻」)在 DST 切换日会偏差 1 小时。正确做法是:在时间戳(UTC 整数)层面做算术,再格式化展示,绝不在本地墙上时间上直接加减。
需要算两个日期之间隔了多少天、或加上 N 个工作日时,用 日期计算器 比手算安全;要写定时规则则用 Cron 表达式解析 确认实际触发时刻——注意 Cron 走的是服务器本地时区,容器里默认是 UTC。
闰秒
Unix 时间按固定每天 86400 秒线性递增,刻意不感知闰秒。闰秒由操作系统/NTP 在底层做「闰秒补偿」(通常是 smear,把 1 秒摊到前后若干小时),对绝大多数业务透明。只有金融撮合、卫星授时等极端精度场景需要单独处理。
存储与传输怎么选
| 方案 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| 整数时间戳(秒/毫秒) | 跨语言一致、比较快、无时区歧义 | 人不可读、数据库难按日期聚合 | 日志、排序、跨系统传输 |
| ISO 8601 带偏移 | 可读、可字典序排序、自解释 | 字符串较长、索引体积大 | API 响应、配置文件 |
| 数据库原生时间类型 | 可按日期分组、索引友好、有函数支持 | 各库语义不同,需确认是否带时区 | 需要时间维度聚合的业务表 |
| 本地时间字符串 | — | 无时区、不可比较、不可排序 | ❌ 任何场景都不该用 |
各数据库的差异(容易踩)
| 数据库 | 类型 | 是否带时区 | 注意 |
|---|---|---|---|
| PostgreSQL | timestamptz |
✅ 存 UTC,按会话时区展示 | 首选;timestamp 不带时区,别用错 |
| MySQL | DATETIME |
❌ 按字面存 | 适合存「本地墙上时间」;跨时区需自己约定为 UTC |
| MySQL | TIMESTAMP |
✅ 存 UTC,按会话时区展示 | 范围只到 2038-01-19,且有 1970 下限 |
| SQLite | 无原生类型 | ❌ | 通常存整数时间戳或 ISO 8601 文本 |
| MongoDB | Date |
✅ 内部是 UTC 毫秒 | 驱动层自动转换 |
MySQL 的 TIMESTAMP 撞上 2038 上限是真实事故来源——新表建议用 DATETIME 并存 UTC,或直接用 BIGINT。
各语言的正确转换
JavaScript(毫秒 → 可读,注意单位):
const sec = 1700000000
const d = new Date(sec * 1000) // 秒→毫秒
const iso = d.toISOString() // UTC 字符串,末尾 Z
const local = d.toLocaleString('zh-CN') // 本地时区字符串
// 按指定时区渲染(服务端渲染、多时区报表必备)
new Intl.DateTimeFormat('zh-CN', {
timeZone: 'America/New_York',
dateStyle: 'full', timeStyle: 'long',
}).format(d)
Python:
from datetime import datetime, timezone
ts = 1700000000
print(datetime.fromtimestamp(ts, tz=timezone.utc)) # 明确指定 UTC,否则按系统本地时区
print(datetime.fromtimestamp(ts, tz=timezone.utc).isoformat())
Python 的
datetime.fromtimestamp(ts)不传 tz 会按系统本地时区解释——本地开发机和 UTC 服务器结果不同,这是最常见的上线后才发现的时间 bug。
Go:
import "time"
t := time.Unix(1700000000, 0) // 秒级
// 毫秒用 time.UnixMilli(ms),微秒 time.UnixMicro,纳秒 time.Unix(0, ns)
loc, _ := time.LoadLocation("Asia/Shanghai")
fmt.Println(t.In(loc).Format(time.RFC3339))
Java:
Instant i = Instant.ofEpochSecond(1700000000L); // 秒
Instant j = Instant.ofEpochMilli(1700000000000L); // 毫秒
System.out.println(i.atZone(ZoneId.of("Asia/Shanghai")));
// 用 java.time(Java 8+),不要再碰 java.util.Date / Calendar
PHP:
$dt = (new DateTimeImmutable('@1700000000')) // @ 前缀表示时间戳,默认 UTC
->setTimezone(new DateTimeZone('Asia/Shanghai'));
echo $dt->format(DateTimeInterface::RFC3339);
Rust:
use chrono::{DateTime, Utc, TimeZone};
let dt: DateTime<Utc> = Utc.timestamp_opt(1700000000, 0).unwrap();
println!("{}", dt.to_rfc3339());
症状 → 原因 → 修复
| 症状 | 原因 | 修复 |
|---|---|---|
| 时间显示成 1970-01-01 | 传了 0 或 null,或字符串解析失败 | 校验输入;解析失败要显式报错而不是静默取 0 |
| 时间跳到 5 万年后 | 毫秒当秒存(差 1000 倍) | 统一精度;字段名写死单位 |
| 本地正确、服务器差 8 小时 | 服务器时区 UTC,代码按本地时区解析 | 全程 UTC,只在展示层转 |
| 用户看到的日期差一天 | 展示用了 UTC 而用户按本地预期 | 展示层传用户时区 |
| 定时任务少跑一次 | DST 春季跳变,目标时刻不存在 | Cron 用 UTC,或改用「每隔 N 秒」而非「每天 HH:MM」 |
| 订单在同一秒重复 | 时间戳精度不够(秒级撞车) | 改用毫秒,或加单调序号 |
| 2038 之后时间变负数 | 32 位整数溢出 | 换 64 位或 ISO 8601 |
| 跨库迁移后差几小时 | 一边存本地、一边存 UTC | 统一为 UTC 后重算历史数据 |
最佳实践
- 统一用 UTC 存储:数据库字段用
timestamptz/DATETIME(UTC)或整数时间戳,别用「本地时间」列。 - 接口传整数时间戳或 ISO 8601 带偏移:避免传「2023-11-15 06:13:20」这种无时区字符串。
- 展示层按用户时区格式化:用
Intl.DateTimeFormat(前端)或带ZoneId的格式化器(后端),别手写拼接。 - 单位要显式约定:前后端文档写清是秒还是毫秒,字段名带
_ms后缀,转换工具自动识别兜底。 - 别在墙上时间做算术:差值、定时、排序都在时间戳整数层面算。
- 时区用 IANA 标识符存:数据库里存
Asia/Shanghai,不要存+08:00。 - 边界要测:1970 之前(负数)、2038-01-19(32 位溢出)、DST 切换日、闰年 2 月 29 日。
改完代码别忘了实测。把可疑值丢进 时间戳转换工具,它会同时给出 UTC 与各主要时区的渲染结果,一眼就能看出是单位错了还是时区错了。