Unix 时间戳为什么不对?秒、毫秒与时区
一个显示错误的时间戳,几乎从来不是数字本身出了问题,而是搞错了类别。Unix 时间戳既不是日期,也没有时区——它只是从某个固定时刻起算的一个秒数。一旦你把「这个瞬间」和「它被渲染成的样子」分开,「差了几十年」「差了三小时」「差了一天」就各自塌缩成一个具体、能找到的原因。
你记了一个时间戳,结果它不对。有时是离谱地不对——今早刚创建的一条记录,硬说自己发生在 1970 年 1 月,或者跑到了公元 55840 年。有时是悄悄地不对——时间是对的,但偏了三个小时;或者你想要 15 号,日期却落在了 14 号。于是你开始打补丁:乘上点什么、减掉几个小时、再套一层转换。每个补丁都能修好眼前这一个例子,然后在下一个例子上崩掉——因为你在治标,却没抓住那个能一次性解释所有症状的事实。
这个事实是:Unix 时间戳不是日期,也没有时区。它就是一个整数——从某个固定时刻 1970-01-01T00:00:00Z 起算,已经过去的秒数——而这同一个整数,在地球上任何地方指的都是同一个瞬间。它不知道现在是哪一年、你用什么历法、你在东京还是芝加哥。所有这些含义,都是之后才被加上去的——在你把这个数字渲染成一个人能读的东西的那一刻。几乎每一个「时间戳错了」的 bug,压根就不是数字错了,而是一个类别错误:把「瞬间」和「它的显示方式」混为一谈,或者把一个值喂给了一个期待另一种单位的函数。
这篇文章要把这两层分开,并且一直保持分开。先讲清时间戳到底是什么——一个数字,没有时区。然后走一遍这两层最容易被搅在一起的几个地方:把你甩进 1970 年的秒/毫秒错位、让一个正确的瞬间偏出几个小时的时区混淆、把一个日期整整挪一天的字符串解析规则,以及夏令时前后的历法运算陷阱。看到最后,你会有一份简短的清单,能把几乎任何时间戳 bug 定位到某一个具体原因上。
唯一的核心:瞬间是一个数字,日期是它的一种渲染
一共两层,而几乎每个 bug 都活在这两层之间的缝隙里。
- 瞬间(instant)。宇宙时间线上的一个绝对的点。在计算机里它通常就是一个 Unix 时间戳——一个整数、没有时区、对所有人都是同一个值。
1700000000是一个确定的时刻;它在圣保罗和在首尔,指的是同一个瞬间。 - 渲染(rendering)。一个人能读的日期时间——
"2023-11-14 22:13:20",或者"11 月 14 日 下午 5:13"——而它只有在你同时说明它是用哪个时区表达的之后才有意义。同一个瞬间,在每个时区里渲染出来的挂钟字符串都不一样。
瞬间是你存储、传输、比较的那个「真相」;渲染只是它的一个视图,为某个人、在某个时区、按某种区域格式,临时生成出来。请把「瞬间」和「渲染」这两个词记在心里读完全文,因为下面每一个陷阱,都是同一个错误的某种变体:要么把一个渲染当成了瞬间,要么在两者之间转换时把单位或时区搞错了。
Unix 时间戳到底是什么
把工具层剥掉,一个 Unix 时间戳简单到近乎让人不好意思:它数的是从 1970-01-01T00:00:00Z 起流逝的秒数,这个被随手定下的起点叫 Unix 纪元(epoch)。值为 0 就是 1970 年的那个午夜;1700000000 大约是它之后 53.9 年;1970 年以前的时间,就是负数。
关于它,有两点人们常常悄悄搞错:
- 它并不像一个被渲染出来的日期那样「处于 UTC 里」。这个数字本身不带任何时区。UTC 只在两个地方出现:作为纪元的标签(这个计数从 UTC 的午夜、1970 年开始),以及作为你通常会渲染到的那个中立时区。整数
1700000000既不是一个 UTC 时间,也不是一个本地时间——它是一个瞬间,而「UTC」只不过是把它读出声来最顺口的那种方言。 - 它忽略闰秒。真实的 UTC 自 1972 年以来插入过二十几个闰秒,用来让时钟跟上地球那略微不规则的自转。Unix 时间当它们从没发生过:它把每一天都当成恰好 86,400 秒。这让运算干净又可逆——你可以用简单的除法把时间戳和日期来回转换——代价是这个计数并不是对物理秒数的绝对精确的清点。日常工作里这永远不重要;知道它,只是为了别被「自纪元起的秒数」这句话误导,以为它有天文级的精度。
这种简单正是它全部的魅力所在。比较两个瞬间,就是比较两个整数;给事件排序,就是给数字排序。没有历法、没有时区、没有歧义——直到你在人类日期和它之间做转换,而 bug 恰恰就从这里开始。
头号 bug:秒 vs 毫秒
这是时间戳出错最常见的一种方式,值得单开一节,因为它的失败太戏剧化了。对于「自 1970 起的计数」,存在两套互相打架的惯例:
- Unix / POSIX 惯例:秒。命令行的
date +%s、JWT 里的exp和iatclaim、大多数数据库的纪元函数、大多数 Unix 系统调用——都是秒。 - JavaScript 惯例:毫秒。
Date.now()和new Date().getTime()返回的是自纪元起的毫秒,比秒大一千倍。
把这两者混用,你就会差 1000 倍——而在时间线上,1000 倍就是几十年的差距。JavaScript 里最经典的版本:
// 一个以「秒」为单位的 Unix 时间戳,比如来自某个 API 或 JWT 的 exp claim:
const ts = 1700000000;
new Date(ts); // Tue Jan 20 1970 —— Date 构造器要的是「毫秒」
new Date(ts * 1000); // Tue Nov 14 2023 —— 正确
new Date(1700000000) 不会报错。它乐呵呵地把你的秒当成毫秒,落在纪元之后 17 亿毫秒(大约 20 天)的地方,甩给你一个 1970 年 1 月的日期。反过来,如果你要从 JavaScript 的毫秒得到秒,必须除以 1000 再向下取整:
Math.floor(Date.now() / 1000); // 以「秒」为单位的 Unix 时间戳
在当前这个年代,判断的诀窍是位数:秒时间戳是 10 位(1700000000),毫秒时间戳是 13 位(1700000000000)。如果一个接近 17 亿的值给出了 1970 年的日期,那你就是把秒交给了一个想要毫秒的东西;如果一个接近 1.7 万亿的值给出了几万年后的日期,那你就是反着搞了。当你拿不准手里是哪种单位时,把这个原始数字丢进一个 Unix 时间戳转换器——它会把这个值按两种方式都读一遍,告诉你哪一种落在了一个合理的日期上,于是你靠「看」而不是靠「猜」就能定下单位。
时间戳没有时区——困惑全在格式化上
接下来是更安静的那种失败:日期基本是对的,但偏了几个小时。这里,「去修数字」的冲动恰恰是错的,因为数字没问题。你只是在错误的时区里渲染了一个正确的瞬间。
回想一下,瞬间不带任何时区。当你把它变成文本时,是你——或者你所用语言的默认设置——选定了一个时区,于是同一个瞬间会产生不同的挂钟字符串:
const d = new Date(1700000000 * 1000);
d.toISOString(); // "2023-11-14T22:13:20.000Z" —— 永远是 UTC,末尾那个 Z 就是标记
d.toString(); // "Tue Nov 14 2023 17:13:20 GMT-0500 ..." —— 运行时的本地时区
d.toLocaleString(); // 一个依赖区域和时区的渲染
上面每一行描述的都是同一个瞬间。22:13:20Z 和落后它五小时的 17:13:20 不是两个不同的时间——它们是同一个时刻在两个时区里的名字。所以当一条日志「比它该有的时间早了三小时」,通常的原因并不是时间戳损坏了,而是这个值被格式化成了 UTC,而你以为是本地时间;或者被格式化成了服务器的时区,而你以为是用户的时区。修法是在显示的那一刻控制时区,而不是从存储的值里加减小时——一旦你把一个偏移量固化进这个数字,你就把唯一正确的那个东西也弄坏了。
这同样是存储和传输时间的准则:让瞬间保持时区中立——一个 Unix 时间戳,或者一个带显式偏移的 ISO 8601 字符串,比如 2023-11-14T22:13:20Z——并且只在最边缘、需要给人看的时候才套上一个时区。(整数时间戳还是这样一个 ISO 字符串,这两种记法在数据库里到底该挑哪个,另有一篇专门讲存储的取舍。)一个被写成不带偏移的裸挂钟文本("2023-11-14 17:13:20")的时间,已经丢掉了判断它究竟是哪个瞬间所需的信息。想看一个瞬间在你用户实际所在的各个时区里分别是什么样,时区转换器会把它们并排列出来——这也让「偏了几小时」的 bug 一目了然:时刻是一致的,变的只是标签。
解析陷阱:这个字符串到底指的是哪个时区?
把一个字符串转回瞬间,也有它自己一道锋利的刀刃,而「日期早了一天」这类 bug 里有很大一部分要归咎于它。在 JavaScript 里——而且这个行为是规范规定的,不是某个引擎的怪癖——一个日期字符串被假定处于哪个时区,取决于它是否包含时间部分:
new Date("2026-07-15"); // 纯日期 → 按 UTC 午夜解析
new Date("2026-07-15T00:00:00"); // 日期 + 时间、无偏移 → 按「本地时间」解析
这两行请读两遍,因为它真的反直觉:加上一个时间部分,翻转了被假定的时区。一个纯日期的 ISO 字符串按 UTC 解析;而你一旦追加一个不带偏移的时间,标准就切换成按运行时的本地时区来解析。字符串看起来几乎一样,得到的却是不同的瞬间。
看得见的破坏是日历上的位移。假设一个在洛杉矶(夏季为 UTC−7)的浏览器运行:
new Date("2026-07-15").toLocaleDateString("en-US"); // "7/14/2026"
你解析的是「7 月 15 日」,屏幕上显示的却是 7 月 14 日。没有任何东西坏掉——那个字符串被读成了 2026-07-15T00:00:00Z,在太平洋时间里就是 14 号的下午 5 点——但在用户看来,就像这个应用晚了一天。任何负偏移的时区(整个美洲)都可能产生这个现象,这也是为什么这个 bug 常常在一个身处「UTC+某个正数」的团队手里悄无声息地溜进生产环境。
防御手段只是一个习惯:永远别依赖默认时区——给每一个日期时间字符串都写上显式的偏移。想表达 UTC 就写 2026-07-15T00:00:00Z,想表达某个具体的本地时刻就写 2026-07-15T00:00:00-07:00。有了偏移,每一个解析器对这个瞬间的理解都会一致,而「纯日期 vs 带时间」这个陷阱也就彻底消失了。
差一天的另一种原因:历法运算与夏令时
即便解析是干净的,对日期做运算也有裸整数所没有的陷阱。瞬间是一个数字,所以瞬间之间的差是安全的——两个时间戳相减,你得到的是一个精确的秒数,不需要任何时区。危险在于对被渲染出来的日历做运算。
它会从两个地方咬你:
- 「加 24 小时」并不等于「明天的同一个挂钟时间」。在一个地区因夏令时而向前拨或向后拨的那些天里,本地的一天有 23 或 25 个小时。在春季向前拨的前一天上午 9:00 上加 86,400 秒,你落到的是上午 10:00,而不是 9:00。如果你真正想要的是「明天上午 9 点,不管偏移怎么变」,你就得在本地日历里做这个推理,而不是加一个固定的秒数。
- 有些挂钟时间根本不存在,有些则发生两次。时钟向前拨时,被跳过的那一小时(比如凌晨 2:30)是那天从未出现过的本地时间;向后拨时,重复的那一小时会发生两次,于是这个窗口里的一个裸本地时间,到底指哪个瞬间是有歧义的。
可靠的模式,正是这篇文章一直在回到的那个:在时区中立的瞬间上做比较和求时长,只在为了显示、或为了真正基于日历的问题(「离 1 号还有几天?」)时才碰本地日历,而且这类问题要用一个感知时区的库,而不是手写运算。至于其中最常见、最平淡的那种需求——两个日期之间隔多少天、90 天后是哪天、某样东西有多久了——一个日期计算器会替你数好,你不必去操心中间是不是正好跨了一个夏令时的边界。
拓展:2038 年问题
很多老系统里内建着一道上限之墙。如果一个时间戳被存成一个有符号 32 位整数的秒数,它能装下的最大值是 2^31 − 1 = 2147483647,换算成 Unix 时间就是:
2038-01-19T03:14:07Z
再过一秒,计数器就溢出到 32 位有符号整数的顶端之外,绕回成一个很大的负数——把日期翻回到 1901 年 12 月。这就是 2038 年问题(「Y2038」或「Unix 末日」),是 Y2K 千年虫的直系后代,也正是现代 64 位系统把 time_t 拓宽到 64 位的原因(这把那堵墙推到了大约 2920 亿年之后——稳稳地永远不会到)。它依然潜伏在嵌入式设备、老旧的二进制文件与网络格式、以及带有 32 位纪元列的数据库里——比如 MySQL 的 TIMESTAMP 类型历史上的上限就是 2038 年,而 DATETIME 则不受此限。如果一个很遥远的未来日期悄悄塌缩成了 1901 年,那 32 位溢出就是头号嫌犯。
同样的「有符号」也从另一头切进过去:正因为这个整数是有符号的,1970 年以前的日期是合法的,只是负数(-1000000000 是 1938 年 4 月)。那些错误地假设时间戳是无符号的代码,会把任何纪元之前的日期搅烂——这对生日、历史记录、以及任何回溯到 1970 年代以前的东西,都是实实在在的隐患。
拓展:不是所有东西都从 1970 起算
最后还有一种「差得离谱」的来源,它并不是秒/毫秒的错位:不是每一个把自己叫做「时间戳」的系统,都从 Unix 纪元起算。单位可以不同,起点也可以不同。几个你真的会碰到的:
- 电子表格(Excel、Google Sheets)数的是自大约 1899–1900 年起的天数,而不是自 1970 起的秒数——所以一个从表格里以裸序列号粘出来的日期,尺度和起点都完全不同。
- Windows 的文件时间,数的是自 1601 年起的 100 纳秒为单位的间隔数。
- NTP(网络时间协议)数的是自 1900 年起的秒数。
所以在你信任任何一次转换之前,先把两个坐标都钉死:单位(秒?毫秒?天?100 纳秒的刻度?)和纪元(1970?1900?1601?)。一个差了几十年、又没法用「×1000」解释的值,往往是纪元不匹配,而不是单位不匹配。当你实在说不清手里拿的是什么时,把这个原始数字过一遍 时间戳转换器、看看按 Unix 纪元解释是否落在一个合理的日期上,是最快能把「常见情形」排除或坐实的办法。
时间戳出错时的排查清单
下次某个时间显示错了,别再随手乱乘乱减。按顺序走一遍——它能隔离出几乎每一种情况:
- 它是什么单位?数位数。在这个年代,10 位 ≈ 秒,13 位 ≈ 毫秒。如果一个「当下」的值给出了 1970 年的日期,你就是把秒交给了想要毫秒的东西(乘以 1000);如果它给出了几万年后的日期,反过来做。
- 是瞬间还是渲染?存储的数字没有时区。如果时间偏了整数个小时,那你几乎肯定是在错误的时区里格式化了一个正确的瞬间——在显示时修时区,绝不要往存储的值里加小时。
- 在解析字符串吗?它带偏移吗?纯日期字符串按 UTC 读;不带偏移的日期时间字符串按本地读——这是大多数「差一天」bug 的源头。给每一个日期时间字符串都写上显式的
Z或±hh:mm。 - 在做日期运算吗?两个瞬间相减是安全的;在本地时间上加历法量(「明天」「下个月」)不安全,因为有 23/25 小时的夏令时日子——要么在瞬间上做,要么用感知时区的库,而单纯的数天数就交给工具。
- 差了几十年、而单位又是对的?怀疑是另一套纪元——一个表格序列号、一个基于 1601 的文件时间、一个 NTP 值——它压根就不是 Unix 时间戳。而如果一个很遥远的未来日期塌缩成了 1901 年,那就是 32 位有符号溢出。
这五条底下,是开头那个唯一的「两分」:瞬间是一个时区中立的数字;你屏幕上的日期是它在某个时区里的一次渲染。出问题的几乎从来不是那个数字,而是它进来时被跨错了单位,或者出去时被跨错了时区——而一旦你能说清是这两者中的哪一个发生了,时间戳就不再是玄学,而变成一行就能改好的东西。