时间戳的零点就是 Unix 纪元
Unix 时间戳 0 精确对应 1970-01-01 00:00:00 UTC。那一刻就是 Unix 纪元,正负时间戳都以它为基准来度量。
| Unix 时间戳 | UTC 日期 | 含义 |
|---|---|---|
-86400 |
1969-12-31 00:00:00 |
纪元前一天 |
-1 |
1969-12-31 23:59:59 |
纪元前一秒 |
0 |
1970-01-01 00:00:00 |
Unix 纪元本身 |
1 |
1970-01-01 00:00:01 |
纪元后一秒 |
86400 |
1970-01-02 00:00:00 |
纪元后一天 |
1000000000 |
2001-09-09 01:46:40 |
十亿秒里程碑 |
2147483647 |
2038-01-19 03:14:07 |
32 位有符号上限 |
时间戳不是“你所在时区的某个日期”,它是一个全球统一的瞬间。你的时区只改变盖在这个瞬间上面的那层标签。
想看当前值每秒跳一下,可以打开实时纪元时钟;想核对本文里的任意数字,用 Epoch 转日期工具。
说人话:什么叫“纪元”
纪元就是一个零点,是某个系统选定的、开始计数的那一天。
Unix 时间选了 1970-01-01 00:00:00 UTC 作为零点。于是时间戳只回答一个简单问题:我们离那一刻有多少秒?
有了这个模型就好理解了:
0是“正好在零点上”。10是“零点之后十秒”。-10是“零点之前十秒”。1700000000是“零点之后 1,700,000,000 秒”。
这也解释了为什么同一个数字在不同地方会显示成不同的钟面时间。0 在 UTC 是午夜,在纽约却显示为 1969 年 12 月 31 日的傍晚。瞬间没变,变的只是本地那层标签。
Unix 时间、POSIX 时间、纪元时间戳
在普通应用代码里,Unix 时间、Unix 时间戳、POSIX 时间、纪元时间戳通常说的都是同一个以 1970 为基准的计数。名字的差别在你写接口约定的时候才真正要紧:
- 产品文档和 API 文档里用 Unix 时间戳,然后把单位写清楚。
- 当标准定义和它那套“每天 86,400 秒”的模型很关键时,用 POSIX 时间。
- 纪元时间戳这个说法,只有在同时讲明纪元和单位之后才用;Windows FILETIME、NTP、GPS 时间和 .NET ticks 都是基于纪元的计数器,但它们不是 Unix 时间。
- 把
time_t当成一个实现层的类型,而不是可移植的字段格式——它的宽度和符号性依平台而定。
createdAtUnixSeconds 或 event_time_ms 这样的名字本身就携带了有用信息。而一个只叫 timestamp 的字段,逼着每一个使用方重新去把纪元、单位和含义都猜一遍。
为什么偏偏是 1970 年 1 月 1 日?
1970 年 1 月 1 日并没有藏着什么天文事件。务实的答案是:Unix 大约在 1969 到 1970 年间在贝尔实验室成型,开发者需要一个干净、离得又近的日期来做系统时间的参考点。
Dennis Ritchie 写的 Unix 发展史里提到,这个系统诞生于 1969 年,1970 到 1971 年间经历了 PDP-7 和 PDP-11 时期,随后成为后来 Unix 接口的基础。在那个背景下,1970-01-01 离项目起点足够近、好记,往前推得也够远,足以覆盖新系统里普通文件和进程的时间戳。
POSIX 后来把这个惯例正式化:The Open Group 的 POSIX 文本把纪元定义为 1970 年 1 月 1 日 UTC 的零时零分零秒,并规定“自纪元以来的秒数”按每个被表示的日子恰好 86,400 秒来计算。
对今天的开发者来说,重要的其实不是这个日期本身有多浪漫,而是:每一个服务端运行时、数据库、浏览器、日志系统、消息队列、API 和分析管道,都认同同一个参考瞬间。
负的 Unix 时间戳
负时间戳不是什么特殊语法,它就是纪元之前的有符号数字而已。
| 时间戳 | UTC 日期 | 常见用途 |
|---|---|---|
-1 |
1969-12-31 23:59:59 |
Unix 时间开始前的最后一秒 |
-86400 |
1969-12-31 00:00:00 |
纪元前一天 |
-31536000 |
1969-01-01 00:00:00 |
纪元前一个平年 |
-2208988800 |
1900-01-01 00:00:00 |
NTP 的纪元换算成 Unix 时间戳 |
-11644473600 |
1601-01-01 00:00:00 |
Windows FILETIME 的纪元换算成 Unix 时间戳 |
现在主流语言的库大多都能表示这些值。JavaScript 里 new Date(-86400000).toISOString() 会得到 1969-12-31T00:00:00.000Z;Python 可以用 datetime.fromtimestamp(-86400, tz=timezone.utc);Java 可以用 Instant.ofEpochSecond(-86400)。
麻烦出在边界上:
- 一些电子表格和商业工具拒绝 1900 年之前的日期。
- 有的数据库函数接受负纪元,有的却会截断或者返回
NULL。 - 一些老代码把时间戳存在无符号整数里,压根表示不了 1970 年之前。
- 部分历史日期对历法本身敏感,尤其是在现代时区规则标准化之前。
如果你的数据里有出生日期、档案、证书、法律记录、老媒体文件,或者任何 1970 年以前的东西,请用 64 位有符号字段来存,并且把单位写进列名,比如 event_time_s 或 event_time_ms。
秒、毫秒、微秒、纳秒
严格意义上的 Unix/POSIX 时间是自纪元起的秒数。而真实系统经常换个单位,纪元却保持不变。
| 2026 年的位数 | 大概率的单位 | 示例 | 读作 UTC |
|---|---|---|---|
| 10 | 秒 | 1700000000 |
2023-11-14 22:13:20 |
| 13 | 毫秒 | 1700000000000 |
2023-11-14 22:13:20 |
| 16 | 微秒 | 1700000000000000 |
2023-11-14 22:13:20 |
| 19 | 纳秒 | 1700000000000000000 |
2023-11-14 22:13:20 |
下面这个 bug,在生产日志里几乎天天能见到:
1700000000按秒解释 =2023-11-14 22:13:20 UTC1700000000按毫秒解释 =1970-01-20 16:13:20 UTC1700000000000按毫秒解释 =2023-11-14 22:13:20 UTC1700000000000按秒解释 = 一个遥远的未来年份,很多库都处理不干净
把单位写进字段名里。created_at 是有歧义的,而 created_at_s、created_at_ms 或 created_at_unix_ms 能让下一个人不用猜。
时区:混乱真正的起点
Unix 时间戳里并不存 UTC、PST、EST、CET 或任何时区标签,它存的是一个瞬间。标签是后来由格式化代码挑的。
拿时间戳 0 来说:
- UTC:
1970-01-01 00:00:00 - America/New_York(标准时期间):
1969-12-31 19:00:00 - Asia/Tokyo:
1970-01-01 09:00:00
这三行说的是同一个瞬间。它们看起来不一样,只是因为显示用的时区换了。
经验法则很简单:瞬间就存成 Unix 时间戳或 UTC 日期时间。只有在展示的时候,或者要把用户填的本地钟面预约转成瞬间的时候,才用到用户的 IANA 时区。
比较的法则同样简单:比较瞬间,别比较格式化之后的字符串。某个本地时区里的 2026-03-08 02:30 可能因为夏令时而根本不存在,可 Unix 时间戳始终是时间轴上一个明确的点。
存储上限与 2038 年问题
真正的限制不在 Unix 时间戳这个格式上,而在存储类型上。
| 存储类型 | 范围 | 实际影响 |
|---|---|---|
| 32 位有符号秒 | -2147483648 到 2147483647 |
1901-12-13 20:45:52 到 2038-01-19 03:14:07 |
| 32 位无符号秒 | 0 到 4294967295 |
1970-01-01 到 2106-02-07 06:28:15,但表示不了 1970 年之前 |
| 64 位有符号秒 | 约正负 2920 亿年 | 对普通应用的时间戳来说绰绰有余 |
JavaScript 的 Date |
距 1970 前后各 1 亿天 | 范围很宽,但基于毫秒,且受数字精度规则约束 |
2038 年问题指的就是 32 位有符号的那个边界。过了 2147483647,32 位有符号的系统可能溢出、拒绝该值、把它截断,或者把存进去的位重新解释成一个负时间戳——看上去像 1901 年的某一天。现代 64 位操作系统和运行时基本已经跨过这道坎,但嵌入式设备、老 C 代码、二进制协议、数据库表结构和文件格式里,风险依然可能潜伏着。
闰秒:最别扭的那部分
POSIX 风格的 Unix 时间把每一个被表示的日子都当作恰好 86,400 秒。而 UTC 作为民用时标,可以插入闰秒来让原子时跟上地球自转。
这个错位就是闰秒在 Unix 系统里显得别扭的原因。民用钟面可能显示 23:59:60,但 POSIX 时间并不会为这个标号生成一个干净的额外整数。不同系统会用跳变、重复、在一个窗口内抹平,或者干脆依赖上游的时间同步来应付这件事。
对于常规的日志、API 时间戳、JWT 过期值、数据库行和界面上的日期,Unix 时间依然是趁手的工具。但如果场景是精密计时、分布式链路追踪、金融、卫星或科学测量,就不该把 Unix 时间戳当成一个完备的时标——那时候你可能需要单调时钟、支持 TAI/GPS 的时间,或者一张明确的闰秒表。
还有一个细节值得说准确:国际上的那个决定,并不是笼统的“2026 年投票废除闰秒”。第 27 届 CGPM 在 2022 年通过了第 4 号决议,决定在 2035 年或之前放宽 UT1-UTC 差值的允许上限,并要求为 2026 年的 CGPM 会议准备实施方案。
你迟早会碰上的其他纪元
Unix 是 Web 软件里的主流纪元,但不是唯一的。别的系统选了别的零点和别的滴答粒度。
| 系统 | 纪元 | 单位 | 转成 Unix 的捷径 |
|---|---|---|---|
| Unix / POSIX | 1970-01-01 00:00:00 UTC |
秒 | 本身就是 Unix 秒 |
| Unix 毫秒 | 1970-01-01 00:00:00 UTC |
毫秒 | 除以 1000 |
| Windows FILETIME | 1601-01-01 00:00:00 UTC |
100 纳秒 tick | filetime / 10000000 - 11644473600 |
.NET DateTime.Ticks |
0001-01-01 00:00:00 |
100 纳秒 tick | (ticks - 621355968000000000) / 10000000 |
| NTP | 1900-01-01 00:00:00 UTC |
秒 | ntp_seconds - 2208988800 |
| Apple Core Data / CFAbsoluteTime | 2001-01-01 00:00:00 UTC |
秒 | core_data_seconds + 978307200 |
| GPS 时间 | 1980-01-06 00:00:00 |
秒,不含 UTC 闰秒 | 用当前的 GPS-UTC 偏移换算 |
| Excel 1900 日期系统 | 1899-12-30 式的序列基准 |
天 | 显式处理 1900 闰年 bug |
套路永远一样:认出纪元,认出单位,套上偏移,再把结果这个瞬间格式化出来。偏移量本身算起来并不难,真正出错的地方在于——想当然地以为所有大数字都是 Unix 毫秒。
微软的 FILETIME 文档把它定义为自 1601 年 1 月 1 日 UTC 起的 100 纳秒间隔数;.NET 文档把 DateTime.Ticks 定义为格里高利历中自 0001 年 1 月 1 日起的 100 纳秒间隔数。这些同样是基于纪元的体系,只不过它们不是 Unix 时间。
一份快速排查清单
时间戳看着不对劲时,先做完下面几项再去怪库:
- 数位数:10、13、16、19 位,通常分别对应秒、毫秒、微秒、纳秒。
- 确认显示用的时区。同一个瞬间,UTC 输出和本地输出可能落在不同的日历日上。
- 确认符号性。无符号存储表示不了 1970 年之前的日期。
- 确认上界。
2147483647就是 32 位有符号那道 2038 年的坎。 - 确认来源系统。FILETIME、.NET ticks、NTP、GPS、Excel、Core Data 用的纪元各不相同。
- 确认这个值是“瞬间”还是“时长”。掐表测时长通常该用单调时钟,而不是墙上时钟的 Unix 时间。
如果结果落在 1970 年 1 月,多半是把秒当成了毫秒。如果落在几千年后的未来,多半是把毫秒当成了秒。如果只差一小时,去查时区或夏令时换算。如果差了好几年,那就是纪元用错了。
这篇文章在整个时间戳系列里的位置
这一篇是概念上的起点:纪元是什么、时间戳 0 为什么重要,以及这个数字身上都附带了哪些假设。
下一步该看哪篇,取决于你手头的任务:
- 把 Unix 数字转成日期:Unix 时间转日期
- 把日期转成 Unix 数字:日期转纪元
- 挑一个合适的转换器:Epoch 转换器指南
- 在代码里取当前时间戳:当前 Unix 时间戳
- 修掉单位错误:毫秒还是秒
- 安全地存时间戳:数据库里的时间戳
- 避开线上事故:常见的时间戳 bug
真正能长期靠得住的心智模型其实很简单:一个 Unix 时间戳标识的是相对 1970-01-01 00:00:00 UTC 的某一个瞬间。单位、时区和显示格式是三份独立的约定,每一份都得在系统边界上明确写出来。
Frequent questions:
- Q: Unix 时间戳的零点是什么?
- A: Unix 时间戳 0 就是 1970-01-01 00:00:00 UTC。它是 Unix 时间起算的那个参考瞬间,也就是纪元(epoch)。
- Q: Unix 时间为什么从 1970 年开始?
- A: Unix 大约在 1969 到 1970 年间诞生于贝尔实验室,1970-01-01 就顺理成章地成了系统时间计数器一个既近又好用的零点。POSIX 后来把这个纪元正式定为 1970 年 1 月 1 日 UTC 的零时零分零秒。
- Q: Unix 时间戳里带时区信息吗?
- A: 不带。这个数字表示的是一个瞬间。时区只影响它被显示成什么样子,比如显示成 UTC、America/New_York、Europe/London 还是 Asia/Tokyo。
- Q: Unix 时间戳可以是负数吗?
- A: 在支持有符号时间戳的系统上可以。-1 是 1969-12-31 23:59:59 UTC,-86400 是 1969-12-31 00:00:00 UTC。
- Q: 纪元时间的单位一定是秒吗?
- A: POSIX 的 Unix 时间是自纪元以来的秒数,但很多接口用的是同一个纪元、不同的单位:毫秒、微秒或纳秒。在 2026 年,当代的 Unix 秒通常是 10 位,毫秒 13 位,微秒 16 位,纳秒 19 位。
- Q: Unix 时间戳最大能到多少?
- A: 取决于存储类型。32 位有符号的 Unix 时间戳到 2147483647 为止,也就是 2038-01-19 03:14:07 UTC。而 64 位有符号的秒值,对普通软件的日期需求来说基本等于没有上限。
- Q: POSIX 时间和 Unix 时间有什么区别?
- A: 在日常开发里它们指的是同一个时间戳。POSIX 时间是标准定义的那个版本:自 Unix 纪元起的秒数,并且把每一个被表示的日子都当作恰好 86400 秒。
- Q: Unix 时间会数闰秒吗?
- A: 不会。POSIX 风格的 Unix 时间不会为 23:59:60 单独生成一个时间戳。各个系统会用跳变、重复、抹平或别的办法在这个简单数字之外去处理闰秒。
- Q: 为什么有些时间戳显示成 1970 年?
- A: 最常见的原因是单位搞混了:一个秒值被按毫秒解释了。举例来说,1700000000 秒是 2023-11-14 22:13:20 UTC,可 1700000000 毫秒却是 1970-01-20 16:13:20 UTC。
- Q: Windows FILETIME 和 Unix 时间是一回事吗?
- A: 不是。Windows FILETIME 计的是自 1601-01-01 UTC 起的 100 纳秒间隔数。要把 FILETIME 转成 Unix 秒,先除以 10000000,再减去 11644473600。