你永远无法理解时间
本文为已获授权的原文转载,原作者:Marine。正文除标注为「编者补充」的小节外,仅做段落、标点、链接与代码格式整理。
一、基本概念
以下命题,哪些为真,哪些为假?
- 一年总有 365 天。
- 一天总有 24 小时。
- 一分钟总有 60 秒。
- 地球上所有人当前的时间都一样。
- 历法时间总是连续、单调向前的。例如,
2012-03-25 01:59:59的下一秒一定是2012-03-25 02:00:00。 - 计算机的系统时间总是准确的。
- 计算机的系统时间总是单调向前的。即,顺序调用系统库获取当前时间的函数,返回的时间总是递增(或非递降)的。
- Unix 时间的最小单位是秒。
- 服务器端和客户端的系统时间总是一致的。
- Unix 时间总是单调向前的。即,永远不会发生
Epoch Timestamp的倒退。 - 计算机可表示的时间没有上限,也没有下限。
计时和历法系统的演化非常像大型软件的开发。
- 通过观察发现规律(日夜循环、季节循环),建立了简单的模型(秒、分、时、日、月、年)并实现了系统(建立了历法)
- 无尽地打补丁。每次打补丁,系统都似乎变得更正确了一点,但是,也变得更加复杂了一点。
- 于是,人类现在幸运地拥有了一个有着巨大历史债务的、非常怪异的、极其难以理解的、假装与现实吻合但又不可能完全吻合的、不统一的计时和历法系统。
本文会从软件工程的视角来观察时间和历法系统。历法的演进和软件工程的核心问题一样,都是下面这两个:
- 「我设计的模型拥有一切优点:优雅、精巧、易于理解。唯一的问题是:与现实世界不吻合。」
- 「我实现的系统拥有一切优点:优雅、精巧、易于理解。唯一的问题是:与客户需求不吻合。」
复杂的不是系统,而是现实和人心。所以在下文的介绍中,会有大量历法系统对经济、政治、文化、人类理解力的妥协,和我们开发时的无奈如出一辙。
对大部分人的生活,这些时间和历法的不合理和怪异并没有什么影响——毕竟大部分人用到的最高深的数学可能是在菜市场买菜。
但是,我们是程序员。我们需要理解时间、计时和历法。我们需要理解历史债务,才能正确地对时间编程。
而且,我们是程序员。程序员必然会写 bug。于是,经过我们不懈的努力,现在的计算机系统中已经有了很多关于时间的 bug、漏洞、技术债务、设计缺陷。我们需要理解这些实现上的问题,才能正确地对时间编程。
Have fun.
本文大部分陷阱可能在日常工作中不会直接遇到,尤其是当无需关心历法时。权作谈资。即使如此,本文仍然希望传达一个观点:
「时间编程非常困难。不要自己实现时间库,使用经过验证的时间库。」
答案:以上命题均为假。
为了指出错误在哪,作为理解时间系统复杂性的第一步,首先需要理解时间的基本概念。
那么……
秒、分、时、日、月、年、季节、星期到底是多久?
注:本文不讨论「时间的本质是什么」。这更多是哲学问题或是物理问题。
哪些时间现象是古人容易发现的?日出日落,春种秋收。古人很早就发现了这两个循环,并且由此衍生出了日和年这两个概念。
此时,日是一个昼夜的时长。年是一个季节周期的时长。
为了方便人类的计时,一天被分成了 24 小时或者 12 时辰。一小时被分为了 60 分。一分钟被分为了 60 秒。
人们又自然地将年分为了四季。星期也是一个七天的轮回。
所以,日和年这两个时间概念是与物理世界相吻合的。其它的概念都是方便人类计时所加的抽象,从现实中。也就是:
一天 = 一昼夜,是锚定时间。而时、分、秒的定义则是衍生自日长,即:
1 day
= 24 hours
= 24 * 60 minutes
= 24 * 60 * 60 seconds
= 86400 seconds
一年 = 一个春夏秋冬季节周期,也是锚定时间。
月实际上是一个历法概念,将一年粗略地分成了 12 份。而每个月的天数是 28,29,30 抑或是 31,更多是由历史文化因素决定的,与天文学现象无关。
季节和星期也是基于年/日定义。
日
随着天文学的发展,我们发现一个日夜这个循环实际上是由于地球自转,而一个季节的循环是由于地球公转。
当我们试图搞清楚这几个概念,麻烦接踵而来。
怎么定义一昼夜是多久?或者说,既然一天是一个循环,那么,这个循环是关于什么的循环呢?应该是以下哪个?
太阳连续两天出现在同一位置的时间:这是(视)太阳日,solar day。
地球上某点对恒星连续两次经过其上中天的时间间隔,即地球自转 360 度:这是恒星日,sidereal day。
86400 秒:这是标准日,standard day。
反正都一样,随便选一个不就好了?
不幸的是,这三个概念代表的时长各不相同。首先,标准日是基于秒的,但是秒却是由日衍生出的概念,这本身是循环定义。因此,我们实际上采取了另一种方式定义秒长——秒长与日长解耦,不再与日长有直接的对应关系。见下文的介绍。
其次,太阳日与恒星日时长不同。由于地球沿太阳的公转,因此,当地球转动 360 度时,实际上同样的位置并不会经过上中天。因此恒星日比太阳日要短。如图:在时间 1,太阳都在正上方;在时间 2,行星转了 360°,但这时太阳并不在正上文(1→2 =恒星日),在时间 3 的位置上太阳才会在正上方。(1→3 =太阳日)。

因此,我们在现在的时间系统里使用的是另一个概念,平太阳日:
平太阳日是经由观察太阳相对于恒星的周日运动,所获得的平均太阳时,经由人为的调整而显示在时钟上的时间。
换句话说,平太阳日是「平均」一天的长度。
Lesson 1:我们现在的「一天」,其实是平太阳日。它是自转周期的平均值。这是一个人为产生的概念,并不存在天文意义的准确对应。
那么,这个「平均太阳日」到底等于不等于一标准日呢?为此,我们需要先定义秒长。
秒
在现代时间定义中,秒是时间的基础单位。
秒的定义是精确的,即一秒所表示的时间永远是相同的。例如,如果命题「一分总有 60 秒」为假,那么只可能是因为分的长度不是固定的(60 * 1 秒),而不可能是秒的长度发生变化。
秒的最新的定义为:
将铯-133 原子不受扰动的基态超精细能级跃迁频率的值固定为 9192631770 赫兹。9192631770 赫兹为 1 秒。
进而衍生出毫秒、微秒、纳秒、飞秒等更小的时间单位,都有准确的定义:
1 秒
= 1e3 毫秒
= 1e6 微秒
= 1e9 纳秒
= 1e15 飞秒
Lesson 2:秒,以及秒以下的时间单位,时长是准确的、不变的。
既然最初一秒是按平太阳日 / 86400 的长度作为参考定义的原子钟时间,反之,一天是 86400 秒吗?
非常不幸,仍然不是。因为地球的自转是在逐渐变慢的,也就是说我们的一天是在变得越来越长。因此,即使在秒最初被定义的时候可能一天正好等于 86400 秒,随着时间的推移,日长会逐渐超过 86400 秒。
秒是一个绝对准确的时间单位,但天必须与现实世界匹配,反而并不准确。那么,我们怎么弥补这个差距呢?
答案是引入闰秒(Leap Second)。
即当标准日与平太阳日的计时相差短一秒时,会在近期的一天插入额外的一秒。即,该天会有 24:00:00,当天有 86401 秒。
当标准日与平太阳日的计时相差长一秒时,会在近期的一天跳过一秒,即,该天会从 23:59:58 一秒后跳到 00:00:00。
这是一个很程序员的处理方式:当上层(天)需要与现实世界对应时,无法作为精准抽象时,我们定义新的精准的抽象的下层(秒),然后将处理下层抽象与上层现实的映射的复杂性交给中间翻译阶段。换言之,不调整数据模型,而是调整数据模型之间的转换关系。
Lesson 3:闰秒用来弥补标准日与平太阳日之间的时间差。
注:后文会描述,闰秒实际上给计时系统带来了无穷无尽的麻烦,因此,第 27 届国际计量大会宣布最迟不晚于 2035 年取消闰秒,采取其它的方式平滑地处理闰秒。
年
年的概念源自于地球的公转。类似于日,年也有几种定义:
恒星年(Sidereal year)是太阳在天球上返回到对恒星而言的相同位置上的时间。直观地解释,当天空的星象连续出现的时间间隔即为恒星年。
回归年(Solar year)是地球在公转轨道上回到同一位置的时间。
恒星年和回归年不会像恒星日和回归日一样因为所在公转轨道位置的变化而产生视差,那么我们是不是可以认为这两个概念是等效的?
不幸的是,仍然不相同,因为岁差(地轴进动,axial precession)现象,即地球的自转轴与公转轨道并不垂直,地球的自转轴在缓慢地自转。因此,视恒星年与回归年仍然是不同的,大概每年会相差 20 分钟。

红色的是自转轴。自转轴会缓慢转动,以~25772 年为周期绕行一周。
现代历法使用回归年作为一年的周期,因为回归年在某种意义上是「真正」的一个周期——四季的变化与回归年是对应的。
但是请注意,由于受月球或其它天体的影响,地球的公转速度也会变化,所以实际上回归年的时长也会逐渐漂移。因此,我们使用的也是平均回归年。
Lesson 4: 我们现在的「一年」,是平均回归年,即地球的平均公转周期。
这个问题解决后,为了人类的理解成本,我们需要「一年有 X 天」这样的直观描述。这个问题又引进了无限的麻烦:一年到底有几天?事实上,在过去几千年,历法的进步都是体现在如何更精确地计算 X。不同的地区都发明了各自的历法,以罗马历法为例:
- 罗慕路斯历 X = 304
- 努马历 X = 355
- 儒略历 X = 365.25
- 格里高里历 X = 365.2422
但是对人类而言,理解一年有 365.2422 天太难。因此,类似于秒与日的换算关系的妥协,人类引入了闰年的概念:
一年有 365 或 366 天。闰年是为了弥补因人为历法规定的年度天数 365 日和平均回归年的大约 365.24219 日的差距而设立的。依我们现用历法,闰日在 2 月 29 号,大概每四年一次。
Lesson 5:闰年多出的一天是为了补偿人为规定的「一年有整数天」和实际年长的差距而补充的一天。
UTC
协调世界时(英语:Coordinated Universal Time,简称 UTC)是最主要的世界时间标准,其以原子时的秒长为基础,在时刻上尽量接近于格林威治标准时间。
上文描述的时间体系是 UTC 时间体系。目前计算机系统中主要使用 UTC 时间。
Lesson 6:使用 UTC 时间
时区
时区,是指地球上的区域使用同一个时间定义。以前,人们通过观察太阳的位置(时角)决定时间,这就使得不同经度的地方的时间有所不同(地方时)。1863 年,人类首次使用时区的概念,通过设立一个区域的标准时间部分地解决了这个问题。比如,我国使用的是东八区,即基于 UTC 时间 + 8 小时即为我国的本地时间。
世界各国位于地球不同位置上,因此不同国家,特别是东西跨度大的国家日出、日落时间必定有所偏差。这些偏差就是所谓的时差。
时区的设定并不是完全按照经度划分。事实上,时区的划分受政治和经济的影响非常大,由此导致了无数的复杂度和程序 bug。
时区引入了另两个复杂的概念,也是程序漏洞的重灾区。有处理国际业务的同学可能会遇到:夏令时与国际日期变更线。
夏令时
夏时制(美国及加拿大英语:daylight time),又称夏令时、日光节约时间(美国及加拿大称为 daylight saving time,简称 DST;英国与其他地区称为 Summer Time),是一种在夏季月份牺牲正常的日出时间,而将时间调快的做法。通常使用夏时制的地区,会在接近春季开始的时候,将时间调快一小时,并在秋季调回正常时间。实际上,夏时制会造成在春季转换当日的睡眠时间减少一小时,而在秋季转换当日则会多出一小时的睡眠时间。
举例:
- 夏令时到来时,会由 3 月 25 日 1:59 直接跳到 3:00,即 3 月 25 日 2:00 - 3:00 这一小时并不存在。
- 夏令时结束时,会在 9 月 25 日 3:00 跳回到 2:00,即 9 月 25 日 2:00 - 3:00 这一小时会发生两次。
所以,夏令时破坏了我们通常的认知:时间是连续并匀速向前的。夏令时是无数程序 bug 的源头。
考虑一个简单的场景:设定一个半小时后的闹钟。应该怎么实现?
选择一:Unix Time + 1800 秒。
这可能导致时钟变动时间不是半个小时。如果这两个小时恰好跨过了夏令时前跳,则时钟时间前进了 1.5 个小时。如果恰好跨过夏令时的回跳,则时钟时间后退了半小时。
而且,如果遇上闰秒,时钟时间可能会前进的是 29 分 59 秒。
选择二:当时的本地时间 + 半小时。即,如果当前是 2:45,则制定为同一天的 3:15。
那么,这里问题是相反的,甚至更严重:如果 3:15 正好落在夏令时前跳的时间内,则 3:15 这个时间并不存在。这时候只能选择报错或落回到一个较合理的存在的时间,如 3:00。而如果 3:15 落在在夏令时回退的时间内,这个本地时间有二义性:是指回跳之前的 3:15 还是回跳之后的 3:15?
这只是最简单的夏令时导致的复杂性的典型例子。夏令时还有更多复杂的情况,见后文。
国际日期变更线
(剧透注意:以下有《八十天环游地球》的核心诡计揭示)
国际日期变更线是一条假想的子午线,在跨越这条线时会发生日期的变更。
国际日期变更线是为了防止绕地球一圈后,像《八十天环游地球》一样,沿自转方向绕地球一圈时,79 天的时间会经历 80 个昼夜。
Unix Time
我们在计算机系统内通常使用的是 Unix 时间,即从 1970 年 1 月 1 日 00:00:00(这个时间也称为 epoch,因此 Unix 时间也被称为 epoch 时间)到现在的秒数,不考虑闰秒。
所谓「不考虑闰秒」是指 Unix Time 认为一天总是 86400 秒,因此,一天的开端总是 86400 * #day 秒。闰秒是通过跳秒实现的,即,Unix Time 会将某些秒出现两次(像是夏令时的处理)。
因此,Unix Time 实际上也不遵守我们通常的认知:时间是单调向前的。
NTP
电脑里有一个由电池驱动的石英钟,用来计算真实的时间。但是,石英钟并不准确,如果没有校对的情况下,石英钟每 30 天能快或慢 15 秒。这个现象被称为时钟飘移(clock shift)。
因此,很多操作系统现在都使用 NTP(Network Time Protocol)来从网络上获取当前的准确时间。这个协议会补偿网络延迟。大部分情况下,NTP 提供的时间误差都会 < 0.1 秒。这些 NTP 都是通过原子钟时间来提供的,达到全球时间同步(听上去是不是像 Spanner?)。
NTP 会定时请求服务器并重新校准时间。当我们调用 System clock 时,返回的是经过 NTP 校准的时间。这可能会导致连续的两次调用返回的时间有较大差距,甚至时间会倒流。
解答篇
Q:「一年总有 365 天」吗?
A:闰年有 366 天。
Q:「一天总有 24 小时」吗?
A:夏令时会导致某些天多于 24 小时或者少于 24 小时。
Q:「一分钟总有 60 秒」吗?
A:闰秒会导致多于 60 秒或者少于 60 秒。
Q:「地球上所有人当前的时间都一样」吗?
A:不同时区的当地时间可能不一样。
Q:「历法时间总是单调向前、连续的。例如,2012-03-25 01:59:59 的下一秒一定是 2012-03-25 02:00:00」吗?
A:夏令时会导致时间倒退或者被跳过。
Q:「计算机的系统时间总是准确的」吗?
A:不一定。
- 石英钟不准确。因此,在 NTP 两次同步之间的计算机时钟时间可能是不准确的。
- NTP 可能未开启。
- 用户可能故意调整计算机的时钟时间,例如,故意调快半小时以增强紧迫感。
Q:「计算机的系统时间总是单调向前的。即,顺序调用系统库获取当前时间的函数,返回的时间总是递增(或非递降)的」吗?
A:考虑时钟飘移跑快后,NTP 同步后发生时钟同步。这种情况下,可能会发生时间的回调。
Q:「Unix 时间的最小单位是秒」吗?
A:Unix Time 最初是按秒定义的,但是通常有毫秒和秒两种形式。在实际的时间系统里,我们会使用更细粒度的时间单位。
Q:「服务器端和客户端的系统时间总是一致的」吗?
A:客户端时间未必准确。同样,服务器端的时间也未必准确。因此两者的时间未必是一致同步的。
Q:「Unix 时间总是单调向前的。即,永远不会发生 Epoch Timestamp 的倒退」吗?
A:Unix Time 处理闰秒的方式是回跳。
Q:「计算机可表示的时间没有上限,也没有下限」吗?
A:不一定。
- 当使用
Unix Time时,它无法存储早于 1970.1.1 之前的时间。 - 当使用
int32存储Unix Timesecond 时,它会在 2038 年溢出,这是新的千年虫问题。 - 程序员还成功地创建了更多 bug,见后文介绍。
上述内容简要介绍了计时和历法的概念,也简要介绍了计算机系统一般怎么表示时间。了解了这些概念后,就可以真正来欣赏历法系统 —— 人类历史上最混乱复杂的遗产之一(另一个可能是语言)—— 的复杂性。
请再次注意:作为程序员,您总是需要处理这些坑的。
Bon Appétit!
二、历法复杂度
进阶问题篇
注:本篇仅列出问题,接下来将分篇开展解答。
以下命题,哪些为真,哪些为假?
有关闰年
- 闰年每 4 年一次。
- 1900 年是闰年。
- 能被 4 整除且不能被 100 整除,或能被 400 整除的年份是闰年。
有关闰秒
- 闰秒每 X 年添加一次,是可以预测的。
Unix Time是从 1970.1.1 00:00:00 UTC 到现在的秒数。- 所有的 NTP 都用回跳的方式处理闰秒。
有关时区
- 这个世界上有 24 个时区。
- 时区都与 UTC 时间偏差整数个小时。
- 或者偏差半小时的整数倍。
- 或者偏差 1/4 小时的整数倍。
- 所有时区与 UTC 的偏差的区间在
UTC-12:00到UTC+12:00。 - 在某一时间偏差值(UTC +HH:MM)只有一个时区。如
UTC+08:00只有一个时区。 - 两个相邻的时区相差不超过 1 个小时。
- 国际日期变更线是一条直线(或者更准确的,测地线)。
- 可以从一个地点所在的经度判断所在时区。
- 可以从一个地点所在的地理位置判断所在时区。
- 可以从一个地点所在的国家判断所在时区。
- 可以从一个地点所在的省/州/地区判断所在时区。
- 可以从一个地点所在的市判断所在时区。
有关夏令时
以下(DST = 夏令时,daylight saving time)
DST的偏移是一个小时(即夏季调快一个小时,冬季回调一个小时)。DST在所有的时区都同时发生变更。DST每年都在同一时间发生变更。DST总是对整个国家生效。DST总是对一个省/州/地区生效。DST总是在上半年开始,下半年结束。DST总是夏令时,即,总是夏季调快,冬季回调。DST的变更总是会提前充足时间预告。
有关一些冷知识
- 一周总是从周一开始。
- 一周总是从周日或周一开始。
- 工作日总是周一到周五。
- 也可能是周一到周六。
- 工作日总是一周的前几天。
- 一天总是从零点开始。
- 节假日总是持续标准整数天。
- 节假日的安排是固定的。
- 「
11-10-09」表示 2011 年 10 月 9 日。 - 一年有 365 或 366 天。
- 同一时间只有一个历法会生效。
- 同一时间,同一个国家只会有一个历法生效。
- 一年有 12 个月。
1927-12-31 23:54:07的上海到1927-12-31 23:54:08的上海之间只有一秒。- 在 21 世纪以来,没有国家变更自己所在的时区。
- 周五之后必有周六。
- 在山顶的计算机和在平原上的计算机的时间是一致的。
- 所有的整数都是理论上可能的年份。
- 在同一时间,地球表面所有的点都属于且只属于某一个时区。
先给出结论:上文文末给出的命题,(在某些语境下)均有反例。
闰年
命题
- 闰年每 4 年一次。
- 1900 年是闰年。
- 能被 4 整除且不能被 100 整除,或能被 400 整除的年份是闰年。
解析
前文提及,一年是一平太阳年,一日是一平太阳日。当前,一年大概有 365.24219 天。
「闰年每 4 年一次」是儒略历(Julian Calendar)的定义,即儒略历认为一年有 365.25 天时。
这会导致每年偏差约 365.25 - 365.24219 = 0.00789 天。因此,约每 128 年,儒略历就会比真实的时间多一天。
目前,我们使用的是格里高里历。格里高里历假定一年有 365.2425 天。
格里高里历的闰年定义是:能被 4 整除且不能被 100 整除,或能被 400 整除的年份。
为方便起见,400 N + 100,400 N + 200,400 N + 300,这三个年份不是闰年。因此,1900 年不是闰年。
每天是 365 天,每 4 年一个闰年,再去除掉每 400 年有 3 年不是闰年,因此一年正好有 365 + 1 / 4 - 3 / 400 = 365.2425 天。
这会导致每年偏差约 365.2425 - 365.24219 = 0.00031 年。因此,约每 3226 年,格里高里历会比真实的时间多一天。
在未来的某一年,一定会有对格里高历里的闰年规则的调整。而且,由于地球的自转和公转周期都不是恒定的,我们无法设置一劳永逸的闰年规则。
当前有一个对闰年的修补提案是:添加一个规则:能被 4000 整除的年份不是闰年。也就是,3600 年是闰年,而 4000 年不是闰年,4004 年开始重新变成闰年。这样会调整历法的周期为:
每天是 365 天,每 4 年一个闰年,再去除掉每 400 年有 3 年不是闰年,再去掉每 4000 年有 1 年不是闰年,因此一年有 365 + 1 / 4 - 3 / 400 - 1 / 4000 = 365.24225 天。
这会导致每年的偏差降低至 365.24225 - 365.24219 = 0.00006 天,也就是大约每 15000 年,格里高里历会比真实的时间多一天。
这个提案没有被正式立法。毕竟公元 4000 年离我们还太远,大家都倾向于相信后人的智慧。
接下来就有趣了:虽然大部分实现都仍然认为 4000 年是闰年,例如 Excel,R,JavaScript,仍有一些软件遵守这个非正式的 4000 不是闰年的规定,例如 SAS。所以,同一个晚于 4000/3/1(但是早于 8000/3/1)的时间戳,在 SAS 里与在 Excel 里会相差一天。
回到 1900 年是不是闰年的话题。Excel 认为 1900 是闰年。这是典型的「火车轨道宽度是由马屁股的宽度决定」:
当 Lotus 1-2-3 首次发布时,该项目假定 1900 年是闰年,尽管它实际上不是闰年。这使得程序更容易处理闰年,并且不会对 Lotus 1-2-3 中几乎所有日期计算造成任何损害。当 Microsoft Multiplan 和 Microsoft Excel 发布时,他们还假定 1900 年是闰年。此假设允许 Microsoft Multiplan 和 Microsoft Excel 使用 Lotus 1-2-3 使用的同一串行日期系统,并提供与 Lotus 1-2-3 的更大兼容性。将 1900 视为闰年也使用户更容易将工作表从一个程序移动到另一个程序。
在现在,修复 1900 是闰年的错误造成的损害比收益大。因此 Excel 决定不做这个变更。
但是,Excel 会正确地处理其它的闰年年份,如 2100 年,2500 年。1900 年是作为一个特例保留下来的。这是一个 feature,不是 bug(对 Microsoft 来说)。很想知道处理这个 feature 的程序员的心路历程。
(腾讯表格和 Google Sheets 都不认为 1900 年是闰年)
Excel 错误地假定 1900 年是闰年 - Office | Microsoft Learn
闰年导致的问题主要在于不应对日期进行朴素算术运算。例如:
date = {year, month, day}
same_day_next_year = {year + 1, month, day} // Danger! The day might not exist.
一个可能合理的类比是把日期当作指针:你可以直接对裸指针进行算术操作,但是很危险。你也可以直接对日期进行算术运算,这几乎同样危险。更安全的方式是将指针/时间的操作封装在安全的 API 后。
虽然闰年听上去像是一个常识,在 21 世纪不可能有程序员会犯错,但现实是,闰年仍然是时间编程的主要事故来源之一。知名的事故有:
- 2012/2/29,Gmail 的所有保存在 2012/2/29 的聊天记录被显示为 1969/12/31。
- 2012/2/29,Azure 服务因为闰年 bug 当机:https://www.eweek.com/cloud/microsoft-azure-leap-year-glitch-key-lessons-learned/
- 不完全的 2016 年闰年 bug 列表:List of 2016 Leap Day Bugs
- 不完全的 2020 年闰年 bug 列表:List of 2020 Leap Day Bugs
- 关于闰年的 CVE 有最少 270 个:https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=leap+year
- 可以预见,2024/2/29 还会再有很多新的闰年 bug。
闰年是如此之易错,以至于有人编排了一个段子:
与其花上几十亿美金建立空间望远镜寻找外星生命,我们不如低成本地观测哪些行星的 公转周期/自转周期 是整数。一个技术能力足够的高等文明,人工调整公转和自转周期,比正确处理闰年简单多了。
解答
Q:「闰年每 4 年一次」吗?
A:不一定。1900 年不是闰年。
Q:「1900 年是闰年」吗?
A:只有在 Excel 里才是闰年。
Q:「能被 4 整除且不能被 100 整除,或能被 400 整除的年份是闰年」吗?
A:这是当前使用的格里高里历的假定。由于公转周期并不是自转周期的整数倍,在未来会对闰年再进行调整,因此长远来看不正确。
闰秒
命题
- 闰秒每 X 年添加一次,是可以预测的。
- 闰秒只会在凌晨(00:00:00)添加。
- 闰秒只会在年末添加。
Unix Time是从 1970.1.1 00:00:00 UTC 到现在的秒数。- 所有的 NTP 都可能用回跳的方式处理闰秒。
解析
闰秒通常会在年末(12/31)或者年中(6/30)添加。闰秒的添加是全球同步的。因此,不应该假设本地时间的闰秒只会在 24:00:00 添加。例如在我国,闰秒通常是在 7:59:60。
IERS(International Earth Rotation and Reference Systems Service)下属的 BIPM 负责决定何时添加闰秒。一般来说,IERS 只保证会至少提前半年宣布。所以,稍远一点的未来的时间戳与时钟时间的转换都是不准确的。
前文提及,地球的自转周期是会变化的。所以,我们无法预测未来,无法准确地预测闰秒应该什么时候被添加。IERS 添加闰秒也没有明显的规律。例如 1972-1989 年添加了 9 次闰秒,而自 2017 年以来,尚未添加过闰秒。这是由于 2020 年以来,地球的自转变快,现在一天已经小于 84000 秒。所以,闰秒在未来可能会被废除。
由于在计算机系统里对闰秒的处理没有明确的细节规定,因此,这里会有大量的不一致,对实际处理造成了很多困难:
时间分配系统不一致
大部分时间分配系统只会在提前 12 小时宣布,甚至提前一分钟宣布,甚至无视闰秒,但仍然宣布时间是同步的。所以,所有的 NTP 所广播的时间,即使考虑了由光速导致的延迟,也可能出现较大的偏差(~1s)。
实现不一致
不同的时间系统对闰秒的处理方式不同。NTPD 对闰秒的处理,由于没有强制规定,有以下几种方式:
- 时间跳变,并通知客户端(OS Kernel)。在这种情况下,在不同的类 Unix 系统中,
Unix Time对闰秒的处理有两种:- 重复
23:59:59一秒,即有23:59:59.000 -> 23:59:59.999 -> 23:59:59.000 -> 23:59:59.999 -> 00:00:00.000 - 添加
23:59:60一秒,即有23:59:59.000 -> 23:59:59.999 -> 23:59:60.000 -> 23:59:60.999 -> 00:00:00.000
- 重复
前者的导致了时间倒流,这破坏了大部分程序依赖的假设:时间是单调向前的,大部分实现并没有。后者导致了「23:60:60」这个本不应该存在的时钟时间,相当多的时间库实现也没有对此做好准备,遑论自研时间库。

- 时间渐变(time slew),即客户端(OS kernel)在一段时间内逐步调整闰秒导致的偏差。

可以在 NTPD 协议中设置转向策略。
- 闰秒抹涂(leap smearing),见下。
闰秒抹涂
闰秒抹涂(Leap Smear)是由 Google 提出的一种新的闰秒处理策略。这个策略描述如下:
将闰秒均匀地抹涂到以闰秒为中心的 24 小时的时间周期里,从 UTC 时间的中午到中午。
例如,2016/12/31 年末要插入闰秒时,NTP 会将闰秒线性地均匀地抹涂到 2016:12:31:12:00:00 到 2017:01:01:12:00:00 的 86400 秒中,即,这段时间 NTP 返回的每一秒都实质上有 86401/86400 秒。
注意不同于时间转向仍然在客户端处理,闰秒抹涂是完全在 NTP 端处理的,客户端对闰秒不会有任何的感知。
Google 的闰秒抹涂处理有几个要点:
- 抹涂的周期不能过短。24 小时偏移 1 秒与大部分计算机使用的石英时的误差差不多,也不会触发 NTP 的偏差阈值(当计算机的时钟与 NTP 的时钟相差过大时,时间协议会报错,因为无法继续正常工作)。
- 闰秒跳转要居中。这样,与未抹涂闰秒的 NTP 相比,最多只会相差大约 0.5 秒,而非 1 秒。
- 使用最简单的线性沫涂,即被沫涂的每一秒得到的沫涂量都是一样的。这样方便计算,方便调试。
现状
AWS Time Sync Service 也采取了闰秒抹涂的处理方式。其它一些厂商也采取了闰秒抹涂的处理方式,但是抹涂周期各不相同。例如,
- UTC-SLS 的抹涂周期是闰秒到来前的 1000 秒,即 23:43:20 到 00:00:00。
- Bloomberg’s smear 的抹涂周期是闰秒到来后的 2000 秒,即 00:00:00 到 00:33:20。
NTP 提出了闰秒抹涂的方案,但是始终未进入标准。所以上述的处理目前都是有效的,而这个试图解决闰秒问题的「标准方案」又成为了新的非标准方案,就像是:

参考资料:
https://developers.google.com/time/smear
解答
Q:「闰秒每 X 年添加一次,是可以预测的」吗?
A:由于地球的自转周期并非常数,甚至一阶导数都非常数,因此周期的变更不好预测。宣布闰秒的官方组织只会提前半年公布闰秒。
Q:「闰秒只会在凌晨(00:00:00)添加」吗?
A:闰秒只会在 UTC 的凌晨添加。由于是全球同步添加,所以在全球的本地时间可能不是凌晨。
Q:「闰秒只会在年末添加」吗?
A:闰秒通常在年中或者年末添加,视 UTC 时间与 TAI 时间的误差而定。
Q:「Unix Time 是从 1970.1.1 00:00:00 UTC 到现在的秒数」吗?
A:Unix Time 时间忽略了闰秒。所以实际上 epoc time 到现在的秒数等于 Unix Time 时间 + 闰秒数(如果当前没有闰秒发生)。
Q:「所有的 NTP 都可能用回跳的方式处理闰秒」吗?
A:NTP 对闰秒主要有三种处理方式:回跳、转向和抹涂。每种方式又有不同的实现。
时区
命题
- 这个世界上有 24 个时区。
- 时区都与 UTC 时间偏差整数个小时。
- 或者偏差半小时的整数倍。
- 或者偏差 1/4 小时的整数倍。
- 所有时区与 UTC 的偏差的区间在
UTC-12:00到UTC+12:00。 - 在某一时间偏差值(UTC +HH:MM)只有一个时区。如
UTC+08:00只有一个时区。 - 两个相邻的时区相差不超过 1 个小时。
- 国际日期变更线是一条直线(或者更准确的,测地线)。
- 可以从一个地点所在的经度判断所在时区。
- 可以从一个地点所在的地理位置判断所在时区。
- 可以从一个地点所在的国家判断所在时区。
- 可以从一个地点所在的省/州/地区判断所在时区。
- 可以从一个地点所在的市判断所在时区。
- 在 21 世纪,一个国家不会变更自己所在的时区。
- 周五之后必有周六。
1927-12-31 23:54:07的上海到1927-12-31 23:54:08的上海之间只有一秒。
解析
首先我们明确一个事实:时区是一个人造概念。时区是某一个群体的人为了方便交流时间,在某一个区域使用同样的时间定义。设置时区的首要目标是方便交流,而不是必须与天文学现象相对应。所以,一个地区使用哪个时区,深受政治、经济、社会等因素的影响。
既然时区是为了方便人类理解,一个自然的选项是将地球按经度子午线划分,每个小时有一个时区,也就是有 24 个时区。鉴于经度一共有 360 度(东经 180 度 + 西经 180 度),那么以格林尼治天文台所在地,即经度 0 为中心,每 15 度为一个时区即可。也就是说,(W 7.5,E 7.5)为零区,(E 7.5,E 22.5)为东一区,以此类推,东八区为(E 112.5,E 127.5)。东十二区和西十二区为同一时区。
这就是理论时区。理论时区的定义、直观意义、地理意义都很简单清晰、易于理解。唯一的问题是,(由于政治、经济、社会等因素的影响)大家并不用理论时区。
实际时区,也称法定时区,是我们平时所指的时区的概念。实际时区通常用与 UTC 的时差来表示,如中国标准时间使用的是东八区,也就是 UTC+8。人类社会所使用的实际时区远超 24 个。
下面简要介绍一些著名边角案例,以期能够定性地展示时区系统的复杂度。完整的时区列表请见https://en.wikipedia.org/wiki/List_of_UTC_offsets。当前现存时区的 UTC 时差有约 38 种,而历史上所有存在过的时区的 UTC 时差有约 57 种。
不同时区与 UTC 的时差可能是相同的。
首先,注意到上文,我们的描述是「现存时区的 UTC 时差有 38 种」,而不是「现存时区有 38 种」。这是因为时区是「使用同一时间的地区」,而不是「同一地区使用的时间」。例如,我国使用的时间和新加坡的时间是相同的,都是 UTC+8,但是我国是 CST = China Standard Time,而 SGT = Singapore Standard Time。两个时区当前没有时差,但并不是同一个时区。
为什么不直接使用东八区呢?因此一个地区的时区是会变化的。以新加坡举例,SGT 直到 1982 年才调整为东八区,为了与马来西来的时区一致。而马来西亚直到 1981 年才统一自己境内的时区为东八区。所以新加坡的时区选择的原则是与马来西亚保持一致。这印证了我们之前说到的点:时区深受政治、经济、社会等因素的影响(请注意,这句话在下文还会重复出现)。
整时时区有 27 个。
等下,一天就 24 个小时,UTC-12,…,UTC,UTC+11,UTC+12(=UTC-12),怎么可能有 27 个?
首先注意到 UTC-12 和 UTC+12 不是同一个时间,两者正好相差一天。例如,当 UTC 时间是 2022/10/08 中午 12 时,UTC-12 是 2022/10/08 凌晨,而 UTC+13 是 2022/10/09 凌晨。既然都有 UTC+12 了,那么有 UTC+13 和 UTC+14 也不难想象。这个现象与国际日期变更线有关。
实际使用的国际日期变更线是一条在东经 180 度子午线附近、基本上只经过太平洋表面的折线。国际日期变更线的选择绕过国家,也就是尽量不让同一个国家在不同的两天。毕竟,旁边的地区时差是 1 个小时还好理解一些,但如果差的是 23 个小时,就很让人困惑了。考虑到这条子午线上绝大部分是海洋,这个调整不是特别困难。但是在国际日期变更线周围的国家,尤其是太平洋岛国,并不严格地根据地理位置是在国际日期变更线的哪一侧来决定自己是在早一天还是晚一天。

实际的国际日期变更线的形状
它们的选择更多地受政治、经济和社会因素影响——在国际日期变更线的国家大部分是太平洋岛国,他们倾向于与和他们外贸政经最紧密的经济体待在同一天,也就是亚太地区的几个主要经济体——澳大利亚,东南亚,还有东亚。所以,有好几个国家虽然在国际日期变更线的东侧,他们仍然选择与西侧的国家待在同一天。这导致了 UTC+12,UTC+13,UTC+14。
一个典型的例子是新西兰在 UTC+13。
时差可能与 UTC 相差 n + 1/2 小时。
世界上有近 1/5 的人生活在一个与 UTC 相差非整时的时区。
印度使用的 Indian Standard Time(IN,IND)与 UTC 的时差为 5:30,即 UTC+5:30。
印度使用 UTC+5:30 超过了一个世纪。虽然这个世界上大部分国家都把自己的时区调整到了整时,印度始终使用半时时区。
时差可能与 UTC 相差 n + 1/4 小时。
尼泊尔使用 UTC+5:45 时差。选择 UTC+5:45 是因为这样可以在正午时使赤仁玛峰正对太阳。
尼泊尔在从印度独立前使用印度时间,即 UTC+5:30。从印度独立后,尼泊尔在 1956 年调整时区到 UTC+5:45。
历史上,时区非常混乱。
在二战结束前,尤其是在一战以前,经纬度的设计尚未在全球得到公认时,各地的时区可谓五花八门。
举几个典型的例子:
UTC+00:20 阿姆斯特丹时间,在 1909 年到 1940 年在荷兰被应用。在 1940 年停止使用的原因是 1940 年被德国入侵,被迫使用柏林时间代替了阿姆斯特丹时间。柏林使用的时区是 CET(Central European Time,UTC+2)和 CEST(Central European Summer Time,UTC+1,夏令时)。从此之后荷兰就一直使用 CET 与 CEST。
UTC-00:44 在 1972 年以前的利比里亚时间。1972 年,利比里亚时间调整为 UTC+0。
UTC-00:25:21 是都柏林时间(Dublin Mean Time),在 1880-1916 年间在爱尔兰被应用。1916 年爱尔兰改用大布列颠时间。(仍然是政治因素影响)。
最后一个例子留给我国。Stackoverflow 上有一个很著名的问题:
java - Why is subtracting these two times(in 1927)giving a strange result? - Stack Overflow
// 为什么下面这段代码给出的结果如此奇怪
// Timezone(`TimeZone.getDefault()`):
// sun.util.calendar.ZoneInfo[id="Asia/Shanghai",]
// Locale(Locale.getDefault()): zh_CN
public static void main(String[] args) throws ParseException {
SimpleDateFormat sf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String str3 = "1927-12-31 23:54:07";
String str4 = "1927-12-31 23:54:08";
Date sDt3 = sf.parse(str3);
Date sDt4 = sf.parse(str4);
long ld3 = sDt3.getTime() /1000;
long ld4 = sDt4.getTime() /1000;
System.out.println(ld4-ld3);}
// Output: 343
// 为什么两秒之间会差 343 秒?
}StackOverflow 的高赞回答是正确的——这是时区调整,不是时间库的 bug。但是答案没有解释原因。
这里的真正原因是在 1928 年初,上海的时区从北平的平太阳时换成了东八区。几个关键的历史事件如下:
- 1912 年之前,清政府钦天监制订国家的标准历法《御定万年书》,按照北京的地方视太阳时计算时间,由朝廷颁布,称为「奉正朔」。
- 1912 年春,甫成立不久的中华民国北洋政府设中央观象台。赶编《元年历书》《二年历书》均沿袭旧法。
- 1913 年初,编纂《三年历书》时改用北京地方平太阳时,即从 1914 年开始生效。
- 民国 17 年(1928 年),中华民国国民政府继承中华民国北京政府,原中央观象台的业务由南京政府中央研究院的天文研究所(现天文及天文物理研究所)和气象研究所(现中国科学院大气物理研究所)分别接收。天文研究所编写的历书基本上沿袭中央观象台的做法,仍将全国划分为 5 个标准时区,只是在有关交气、合朔、太阳出没时刻等处,不再使用北平的地方平时,而改以南京所在的标准时区的区时,即东经 120°标准时。(顺便一提,四一二事变发生在 1927 年,自此之后 KMT 在上海取得了完全治权。时区也能反映一些历史。)
所以,这次时区调整回调了 342 秒,即 1927-12-31 23:54:08 到 1928-01-01 00:00:00 这段时间重复了两次(就像是夏令时或是闰秒)。因此,「1927-12-31 23:54:08」这个时间有二义性,而 SimpleDateFormat 选取了第二次,所以产生了上述的结果。

时区是「区域」,不是「国家领土」。
有些国家,因为跨越的经度很长,所以为了方便当地的人生活,有超过一个时区。
例如,美国有多个时区。我们常说的美国有四个时区,是指:
- UTC−08:00(PT)— Pacific Time zone 太平洋时间/西部时间,即硅谷时间。
- UTC−07:00(MT)— Mountain Time zone 山区时间
- UTC−06:00(CT)— Central Time zone 中部时间
- UTC−05:00(ET)— Eastern Time zone 东部时间,即纽约时间。
但是,美国不止四个时区,这四个时区实际上是美国在主领土上使用的时区。事实上,美国使用了 11 个时区。另 7 个是:
- UTC−12:00(
AoE)— 贝克岛时间 - UTC−11:00(ST)— 萨摩亚时间
- UTC−10:00(HT)— 夏威夷时间
- UTC−09:00(AKT)— 阿拉斯加时间
- UTC−04:00(AST)— 波多黎哥时间
- UTC+10:00(ChT)— 查莫罗时间
- UTC+12:00(
WAKT)— 威克岛时间
类似的,俄罗斯也使用了 11 个时区,澳大利亚使用了 8 个时区。使用时区最多的是法国,因此历史殖民地等原因,一共使用了 13 个时区。
反之,也不能假设所生活的时区近似于所在经度靠近的理论时区。例如,我国使用统一的北京时间,也就是 CST。因此,新疆和西藏等地区通常会在凌晨四点天亮,下午四点天黑,中午十二点太阳已经在下降了。
新疆有非正式的乌鲁木齐时间 UTC+6,但是与北京时间的换算导致了很多困惑。现在新疆的时间在逐渐转换到使用北京时间。
时区仍然在不断调整。
随着人类文明的进步和一体化,时区整体上变得越来越规整。但是,即使在 21 世纪,时区也不是一成不变的。
Time Zone News: Countries That Change Their Clocks 记录了每年地区时间系统的变化。
我们以一个变更所在时区导致极端混乱的、所有异常情况的集大成者作为例子结束:
2011 年 12 月 29 日零点,Somoa 群岛调整了自己的时区,将自己从国际日期变更线的东边变到了西边,即从 UTC-11 改为 UTC+13。这样做的原因是为了和 Somoa 的主要经济伙伴——澳大利亚、新西兰——时区更接近。之前,Somoa 与它们相差一天,这使得一周只有四个工作日方便与它们交易。这样调整后,Somoa 从世界上最晚度过一天的国家一跃成为世界上最早迎来一天的国家。
这个调整方便了本地人,苦了程序员:
- 向前跳了一天,这使得相邻的两秒钟可能会相差一天。
- 周五没了。2011/12/28 是周四,2011/12/30 一觉醒来就是周六了。这导致「一周有七天」这个假设失效。光是夏令时「不存在的一小时」就造成了海量 bug,想象一下处理这种「不存在的一天」会遇到多少问题。
解答
Q:「这个世界上有 24 个时区」吗?
A:有 24 个理论时区。实际时区远超 24 个。
Q:「时区都与 UTC 时间偏差整数个小时」吗?
A:印度与 UTC 偏差 5.5 小时。
Q:「或者偏差半小时的整数倍」吗?
A:尼泊尔与 UTC 偏差 5.75 小时。
Q:「或者偏差 1/4 小时的整数倍」吗?
A:在当前(2022 年),这个是正确的。在历史上,时区的偏差值非常复杂多样。
Q:「所有时区与 UTC 的偏差的区间在 UTC-12:00 到 UTC+12:00」吗?
A:有 UTC+13 和 UTC+14。新西兰在 UTC+13。
Q:「在某一时间偏差值(UTC +HH:MM)只有一个时区。如 UTC+08:00 只有一个时区」吗?
A:CST(中国标准时间)和 SGT(新加坡标准时间)都在 UTC+8。
Q:「两个相邻的时区相差不超过 1 个小时」吗?
A:「相邻」不是一个有效的对实际时区的概念。对于理论时区,国际日期变更线附近的两个相邻时区会相差 23 小时。
Q:「国际日期变更线是一条直线(或者更准确的,测地线)」吗?
A:国际日期变更线要尽量绕过陆地以免穿过国家造成混乱。因此它有好几处扭曲。
Q:「可以从一个地点所在的经度判断所在时区」吗?
A:新疆和相似经度的印度时区差 2.5 小时。
Q:「可以从一个地点所在的地理位置判断所在时区」吗?
A:地理位置不可靠,受行政区域的影响。
Q:「可以从一个地点所在的国家判断所在时区」吗?
A:不一定。
- 很多国家使用超过一个时区。
- 一个典型的例子:(时区受政治、经济、社会因素影响很大。美国各个州是高度自治的。)美国的大部分州都实行夏令时。Arizona 州不实行夏令时。
Q:「可以从一个地点所在的省/州/地区判断所在时区」吗?
A:不一定。
- 美国的时区不是按州划分的,所以会出现同一个州使用不同的时区。
- 续上面的例子:(时区受政治、经济、社会因素影响很大。)Navajo Nation 在 Arizona 州。Navajo Nation 实行
DST。这导致在夏天,两者时区不一致。
Q:「可以从一个地点所在的市判断所在时区」吗?
A:(再来一遍:时区受政治、经济、社会因素影响很大。)以色列和巴勒斯坦人在巴勒斯坦地区混合生活在一起,但是,由于政治上的因素,以色列和巴勒斯坦人虽然在同一个城市,但是他们不使用同一个时区。这使得在银行里,需要先确认所使用的时区才能确定所使用的时间。它们都是 UTC+2 标准时间,UTC+3 的夏令时,但是以色列和巴勒斯坦开始和结束夏令时的时间因此显然的政治上的原因,是分别宣布的。这使得生活在一个地区的人的时间可能会不一致。
Q:「在 21 世纪,一个国家不会变更自己所在的时区」吗?
A:夏令时是通过改时区实现的。除此之外,会有一次性的时区的修改,如上文 Somoa 改变自己的时区。
Q:「周五之后必有周六」吗?
A:上文 Somoa 改变时区导致了这个奇妙的事件。
Q:「1927-12-31 23:54:07 的上海到 1927-12-31 23:54:08 的上海之间只有一秒」吗?
A:见上文的解释。是由于时区调整。
夏令时
命题
以下(DST = 夏令时,daylight saving time)
DST的偏移是一个小时(即夏季调快一个小时,冬季回调一个小时)。DST在所有的时区都同时发生变更。DST每年都在同一时间发生变更。DST总是对整个国家生效。DST总是对一个省/州/地区生效。DST总是在上半年开始,下半年结束。DST总是夏令时,即,总是夏季调快、冬季回调。DST的变更总是会提前充足时间预告。
解析
前文已经简单介绍过夏令时,这里不再赘述。
这里只简单说明几个易错点:
夏令时时间和标准时间是两个不同的时区。
换言之,当一个地区开始夏令时,它实际上并没有调整所在时区的时间,而是从标准时间时区进入了夏令时时间时区。例如:
- 美西使用的是 太平洋时间
PST= Pacific Standard Time =UTC-8 - 当开始夏令时时,美西会换用 太平洋夏令时,即
PDT= Pacific Daylight Time
其它地区同理。
夏令时的实施由各个国家自行决定,不存在统一的标准。
时区深受政治、经济和社会等因素影响。夏令时最初是由于经济原因被引入的——夏天日出较早,所以如果在夏天把时钟调早一个小时,那么大家仍然会在清晨醒来,但是下班凭白多了一个小时的白昼。毕竟,大家都喜欢白天嘛——这是夏令时的支持者的理由。他们显然没有加入程序员关爱协会。
所以,夏令时更多的是一个可选的、地区自行决定的政策。夏令时在欧洲和美洲应用较广,而在亚州使用很少。因此夏令时在全球范围内只有少数人使用。
解答
Q:「DST 的偏移是一个小时(即夏季调快一个小时,冬季回调一个小时)」吗?
A:澳大利亚的 Lord Howe Island 的夏令时只调整半个小时。
Q:「DST 在所有的时区都同时发生变更」吗?
A:每个地区的政府组织会宣布自己的夏令时的进入和结束时间。因此,这个变更并没有明确的规律。
Q:「DST 每年都在同一时间发生变更」吗?
A:同上,由于是各个政府自行决定时间。
Q:「DST 总是对整个国家生效」吗?
A:沿用上文的例子。美国的大部分州都实行夏令时。Arizona 州不实行夏令时。
Q:「DST 总是对一个省/州/地区生效」吗?
A:沿用上文的例子。Navajo Nation 在 Arizona 州。Navajo Nation 实行夏令时。
Q:「DST 总是在上半年开始,下半年结束」吗?
A:考虑南半球。既然是「夏令时」,南半球的季节是反的,因此,南半球的夏令时在下半年到来。这导致悉尼时间和美西时间在一年的不同时间可能会差 17/18/19 个小时。
Q:「DST 总是夏令时,即,总是夏季调快,冬季回调」吗?
A:事实上,DST 翻译成「夏令时」是不准确的。
- 有一些国家使用的是「冬令时」,即在冬天将时间调慢。
- https://en.wikipedia.org/wiki/Winter_time_(clock_lag)
Q:「DST 的变更总是会提前充足时间预告」吗?
A:2020 年,巴勒斯坦在 10 月 19 日宣告 10 月 24 日结束夏令时。只提前了五天。所以,如果五天之内没有更新时间的数据库,计算机上的时间会出现错误。
扩充知识之——
tz database
前文中我们得到的一个关键教训是闰秒的添加、时区的临时变动、夏令时的开始与结束都是不确定的、不可预测的。为了能够正确地处理时间,计算机系统需要及时地获取这些数据。所有时区的变动是通过 tz database 的更新实现的。
https://en.wikipedia.org/wiki/Tz_database
时区信息数据库,又称 TZ database、Zoneinfo database,是一个主要应用于电脑程序以及操作系统的,可协作编辑世界时区信息的数据库。由于该数据库由 David Olson 创立,因而有些地方也将其称作 Olson 数据库。数据库由 Paul Eggert 进行编辑和维护。
它的显著特色是由上面提到的 Paul Eggert 设计的一套通用时区命名规则,例如「America/New_York」和「Europe/Paris」。数据库试图记录自 1970 年(Unix 元年)以来时区和城市的变化,并且还包含一些时间的转换,例如夏令时和闰秒。
绝大部分时间库都引用了 tz database。例如,python 的时间库会定期在网络上试图获取 tz database 的最新版。JDK 将 tz database 放入了 Java 的标准库,java.util.DateTime 等都使用了 tz database。由于这个数据库是随 JDK 发布的,所以 JDK 应该定时更新以保证 tz database 被及时更新。
RFC 6557 Procedures for Maintaining the Time Zone Database 讨论了如何维护 tzdata 数据库。tzdata 都是由 Paul Eggert 十几年如一日地义务劳动维护,整个互联网的时间的可靠性都维系于他一人。
https://www.rfc-editor.org/rfc/rfc6557.html

tz database 是非官方的普遍使用的时区数据库。应该及时更新系统内的 tz database 以保证本地的时区数据是及时的。
冷知识
以下是一些我们的、但是在全球范围内未必一致的认知。在面向用户的应用开发时,它们可能是雷,所以多了解一些总是有益。
Q:「一周总是从周一开始」吗?
A:美国、以色列等国家认为一周从周日开始。
Q:「一周总是从周日或周一开始」吗?
A:中东地区的部分国家认为一周从周六开始。值得祝贺的是,在当前,所有的国家的一周都是从周六、周日或是周一开始。Google Calendar 对此有正确的实现:

Q:「工作日总是周一到周五」吗?
A:有些国家实行单休。也可能是周一到周六。
Q:「工作日总是一周的前几天」吗?
A:文莱是唯一一个休息日不连续的国家。他们的工作日是周一到周四和周六。周五和周日是休息日。
Q:「一天总是从零点开始」吗?
A:犹太教使用希伯来历,规定一天从日落开始。
Q:「节假日总是持续标准整数天」吗?
A:续上,以色列的假日从一天的黄昏开始,到第二天的黄昏结束。
Q:「节假日的安排是固定的」吗?
A:调休。
(深受政治、经济、社会等因素影响)
时间表示
Q:「11-10-09」表示 2011 年 10 月 9 日吗?
A:不同的国家的表示习惯不同。11-10-09 可能表示:
- 2011 年 10 月 9 日。即「
YMD」格式。东亚国家如我国、日本使用这个格式。大约有 16 亿人使用这种格式(主要在我国)。 - 2009 年 11 月 10 日。即「
MDY」格式。较少被使用,但确实存在。 - 2009 年 10 月 11 日。即「
DMY」格式。这是全球大部分国家使用的格式。大约有 29 亿人使用这种格式。
DMY 格式有一个变种即「11 Oct 09」可以减少歧义。但是很不幸,YMD 格式也有类似的变种,即「11 Oct 09」。这两种格式仍然很容易混淆。
日期格式在全球的使用详情请见:
https://en.wikipedia.org/wiki/Date_format_by_country
为了避免歧义,必须使用字符串表示日期时,应该使用 ISO-8601 格式。更多对 ISO 8601 的内容在下一节会详细讲解。
使用 ISO8601 格式表示时钟时间。
其它历法
Q:「一年有 365 或 366 天」吗?
A:只对格里高里历成立。我们的农历一年平均有 354.3672 天。
Q:「同一时间只有一个历法会生效」吗?
A:我国会同时使用公历(格里高里历)与农历。
Q:「同一时间,同一个国家只会有一个历法生效」吗?
A:我国会同时使用公历(格里高里历)与农历。
Q:「一年有 12 个月」吗?
A:我国的农历有闰月,即一年可能有 13 个月。
物理学
Q:「在山顶的计算机和在平原上的计算机的时间是一致的」吗?
A:由于相对论效应导致的时间膨胀(Time dilation,https://en.wikipedia.org/wiki/Time_dilation),在山顶的计算机的运动速度更快,所以时间比在平原上的时间快。
这两个的差距很小,通常可以忽略。但是在 GPS 以及北斗卫星的运行过程中,就必须要考虑相对论效应。
特殊情况
Q:「所有的整数都是理论上可能的年份」吗?
A:公元零年不存在。公元元年的前一年是公元前一年。
Q:「在同一时间,地球表面所有的点都属于且只属于某一个时区」吗?
A:极点不属于任何一个时区,因此极点在任何一条子午线上。于是,各个国家的考察队都用自己所在国家的时区表示极点的时间,也就是同一个时间,两个考察队可能分别用 UTC+8 12:00 和 UTC-8 12:00 来表示。
结语
本文续上篇,继续讲解了关于时间的概念的种种复杂之处。笔者希望再次强调这个主要结论,就是正确对时间编程非常困难。处理时间时,一定要非常谨慎。
下一篇将进一步探讨时间编程的复杂性,介绍以下几个方面的问题:
- 通信。不存在一个可靠的时钟是分布式系统的根本难点。我们总不能用 Lamport 时钟计时。
- 溢出。各种各样的千年虫。
- 二义性。未明确定义的时间标准放飞了程序员,导致了时间的多种多样的实现。
三、编程复杂性
本文继续介绍计算机系统对时间的处理策略,并分类介绍时间处理的谬误与陷阱。
注意:《编程复杂性》系列只算是半成品。受笔者见识所限,文中提及的内容可能远不及时间编程的复杂之万一,归类也可能并不准确,很多陷阱都是盘根错节,难以理清。
无效的时间表示
无效时间表示指时间的表示形式无法正确定位所指向的时间,通常由于时间超出了表示形式所支持的时间范围。
背景:计算机系统时间表示
在计算机系统内表示时间通常有两种形式:单值(绝对时间,absolute time)和多值(民用时间,civil time)。
单值的时间表示通常形式为基于某个时间锚点,距离锚点的时间。例如:
Unix Time是当前时间距离1970/1/1T00:00:00ZUTC 时间为锚点的时间,通常为秒数或更细粒度的时间单位。NTP Time format时间戳是当前时间距离1900/1/1T00:00:00ZUTC 时间为锚点的时间。NTP 的时间戳有 8 字节。前 4 字节是作为无符号整型,表示秒数;后 4 字节表示次秒粒度。
多值的时间表示通常将年/月/日/时/分/秒/… 作为一个时刻的子组件表示。例如 Python 的 datetimes 是使用以下的八元组表示:(year, month, day, hour, minute, second, microsecond, [optional] tzinfo)。
忽略闰秒后,Unix 时间与标准日有精确的对应关系。因此,Unix 时间与 UTC 时间形成了一一映射关系。同样,一旦时区信息描述了与 UTC 的精确时差,本地时间与 UTC 可以形成一一映射关系。因此,假设以下条件满足,绝对时间与民用时间是等价(即可以相互转换)的:
- 绝对时间忽略闰秒
- 民用时间包含时区信息,且以与 UTC 时差形式存在
时间表示的局限通常有两种:
- 历法局限性:所使用的历法只支持有限时间区间。例如,格里高里历从 1582 年开始定义,无法严格表示 1582 年之前的时间。
- 实现局限性:指时间库的实现导致的人为局限。例如,Python 的时间库只支持 0001-9999 年份。
实现局限性
实现的局限性是为了降低实现的复杂度,放弃对少见场景或复杂情况的支持。时间库的取舍在当时可能是合理的,但是随着时间(!)的推进,可能原来合理的设计会带来新的问题。
典型的问题有数值溢出、超出区间以及精度不足。
数值溢出
Year 2000/Y2K
https://en.wikipedia.org/wiki/Year_2000_problem
最著名的千年虫问题。
在上古时期计算机资源还非常昂贵的时候,一位数字都可能会造成可观的影响。为了节省资源,有相当的电脑系统从 COBOL 年代使用 YYMMDD 六位数字表示日期。显然,2000 年无法被正常地识别。当时预估 Y2K 最高可能会造成 6000 亿美元的经济损失。
不过,这个问题虽然很著名,但是实际上造成的影响并不像当时媒体宣传得那么严重。当然,这可能反而由于媒体宣传,使得政府和公司等都在 2000 年临近时花费了大量精力修复相关问题,避免了最坏情况的发生。
当时有不少人以为这是一种病毒(甚至以为这是一种真正的病毒),以为末日即将来临,甚至开始囤水囤食物。例如在香港,有不少骗徒宣称有千年虫蛀虫药,诱骗对千年虫问题一知半解的民众购买,是当时典型的街头骗案。
Year 2022
2022 年的开年大事故。微软的 Exchange 服务器出现问题,无法处理邮件。
事故原因很简单,Exchange 使用 YYMMDDHHMMSS 的整数格式存储,导致在 2022 年新年时超过了 int32 最大值 2147483648,导致溢出。
Exchange 当时紧急使用了 211233000001 这种格式来表示 2022/1/2 以续了一命。后续修复了这个问题。
这是一个典型的风险后置的例子 —— 好好的跑着的代码改它干嘛?出事了再说。
Year 2036
前文提及,NTP Time Format 使用 32 bit unsigned int 来表示从 1900/01/01 开始的秒数。
大部分人在使用时间戳时往往没有感受,但其实一年有很多秒,2^31 秒只能表示大约 68 年的时间区间。
seconds_per_year = 365 * 86400 = 31536000
presentable_years = 2147483648 / seconds_per_year = 68
由于 NTP 无符号整型,所以它可以表示大约 68 * 2 = 136 年的时间区间。NTP 是从 1900/01/01 作为锚定时间,所以它在 2036 年就会转期,重新从 0 开始计数。
离现在还有 14 年,这是下一个千年虫。预祝所有的系统都可以正确地处理 NTP 的转期。
这个问题相对不严重,因此 NTP 时间表示通常只在操作系统层面处理,不会透传到软件层面——也就是 NTP 时间表示通常不会用来作为时间的储存格式。所以只要操作系统及时更新即可。另外,NTP Date Format 是更新的 NTP 格式,已经转换为了 128 bit int 来表示时间,使用 64 bit 表示秒。这在宇宙完结之前应该不会溢出了。
Year 2038
前文提及,2^31 秒只能覆盖约 68 年的时间。而 Unix Time 默认是用 signed 32 bit int 来表示秒数,所以只能表示约 68 年的时间。Unix Time 是将 1970/01/01 作为锚点,所以它将会在 2038 年溢出。
Y2038 是一个堪比 Y2K 的严重问题。
当前大部分操作系统和比较知名的依赖都做了适配并且进行了更新。但是,可以预见,到时一定会有一些老的系统鸡飞狗跳并且再发生一系列事故。
超出区间
Year 1970
无法使用 Unix time 表示任何 1970-01-01 之前的数字。负值的 Unix time 是未定义的。
因此,不要使用 Unix time 表示所有可能早于 1970 年的时间。例如,不要使用 Unix time 存储生日。说起来您可能不信,但是这个世界上有人是早于 1970 年出生的。
Year 9999
Python 时间库使用民用时存储时间。它的年份的范围是 datetime.MINYEAR(Year 1)至 datetime.MAXYEAR(Year 9999)。使用 Python 时间库存储超过公元 9999 年会有问题。在天文学等领域,已经开始造成困惑。
精度不足
历史上,CPU 的时钟频率很低,无法提供高精度的时间。因此,有着久远历史的计算机系统在涉及时间时,往往会设计为较低的精度。但是现在随着 CPU 时钟频率的提高,以及计算机应用对更高的精度的要求,这使得这些系统可能会造成精度损失。
当前,随着 CPU 时钟频率的提升,大部分新的时间提供函数可以返回 ns 的精度。但是要注意,这并不意味着这些函数的准确度是 ns 级别。注意这两者的区别是:
- 精度是测量值之间的差值
- 准确度是测量值与真实值之间的误差
首先,即使时间函数返回值的精度是 ns 级别,也不能说明所有的返回值的精度(即时钟精度)实际上是 ns 级别。时钟的真实精度取决于操作系统和硬件实现。
其次,前文提到过,由于时钟漂移,所以系统时钟与真实时间一定会产生相对的偏差。偏差值比实际精度的规模会大得多。例如,如果 ntpd 每天同步时间,则几乎可能会有 0.1 秒的偏差。
时间函数精度 <= 时钟精度 <<= 时钟偏差
过度简化的时间表示
主要指在时间表示时忽略必要的信息所导致的问题。
民用时间不记录时区
如果不涉及国际化业务,通常会为了简单起见,使用民用时间存储时间戳时,不存储对应时区,而依赖系统自带时区(通常是所在地时区)。这样做的主要问题是,并没有公认「正确」的本地时区 – 到底应该使用某个固定的时区(如东八区),还是 UTC,还是使用数据中心所在的时区?
在国内,8 小时是一个神奇的常数。一旦发现记录的时间和预期相差 8 个小时,往往是因为某些地方设置了时区而某些地方未设置时间。
一个可行(但不完备)的检验是在单元测试中修改你的系统时区,检查测试是否仍然通过。系统时区通过 TZ 环境变量定义。例如在 Bazel 中,默认时间是 UTC。可以通过设置 --test_env=TZ=Asia/Shanghai 调整时间为中国标准时间(CST)。如果添加 --test_env=TZ=Asia/Shanghai 与否会影响测试是否通过,那么代码中存在潜在的风险。
高危操作也是变更系统时区。这可能会导致非常隐蔽的问题发生。
考虑以下案例:CTO 们上任最喜欢干的一件事就是归零化,即统一所有机房的时区到 UTC。这确实是个几乎无害的优化 —— 唯一的缺点是确实可能有害。
假设我们有一个 CRON job,定期转账。
5 2 * * 1-5# 「At 02:05 on every day-of-week from Monday through Friday.」
注意 CRON 使用的是 $TZ 环境变量给出的系统时区。本来是 UTC+8,转换为 UTC,相当于时钟回调 8 个小时。那么,如果在今天已经转过一次账,回调 8 个小时后,CRON 可能会在这一天再次触发转账。
某些系统提供了 CRON_TZ 作为 CRON job 的时区。显式地声明 Cron 的时区有利于减少无意识更改时区导致 Cron 错误触发的问题。
以下是几个导致事故的例子:
- https://blog.davidojeda.dev/4-time-zone-bugs-i-ran-into#the-bug-wrong-database-time-zone
- 建议升级 clickhouse-go 驱动 · gohangout · GitHub
- Go MySql driver doesn’t set time correctly - Stack Overflow
时间戳缺少单位
指使用数值 timestamp 表示一个时刻。但是,该 timestamp 并没有标明单位。这使用户很难猜测到底是什么单位。
把 timestamp 按错误的单位理解会导致各种问题发生。
时间运算
本章讨论对时间进行运算的易错点。
中文里,「时间」具有多义性,可以表示:
- Time,即时间这个抽象概念。
Instant,即时间点,或时刻。在英文中,Time 通常也会用以表示Instant,如 what time is it?Duration,即时间量,或时长。
当讨论「时间」,需要明确到底是时间点还是时间量。
非平凡的时间运算通常有两种:
Instant + Duration = Instant,即给定一个时间点,和一个时间偏移量,计算经过偏移后的时间点。Instant - Instant = Duration,即计算两个时间之间的时间量差值。
(Duration +/- Duration = Duration 是平凡运算,这里不赘述。Instant + Instant 是不良定义的。)
前文提及了两种时间表示:绝对时间和民用时间,并且提及两者在绝大多数情况下可以互相转化。所以,时间点用绝对时间还是用民用时间表示,通常并没有影响。运算的复杂之处在于时间量(Duration)。前文介绍过,由于以下原因,绝对时间和民用时间的时间量并没有稳定的对应关系:
- 一年有 365 天。(闰年)
- 一月有 30 天。(每个月的天数都不尽相同)
- 一天有 24 小时。(夏令时)
- 一小时有 60 分钟。(夏令时)
- 一分钟有 60 秒。(闰秒)
例如,当我们说「一个月后」,我们可能是指:
- 30 天(30 * 86400 秒)后
- 下个月同一日的同一时刻
由于上述的各种原因,「下一月同一日的同一时刻」有非常非常多的可能性。
为了方便起见,本文沿用 Joda Time 的术语 Duration/Period 来区分两个概念:
Duration是实耗时间/运行时间的绝对时间的长度。Duration的时间是绝对的,并且不受起始/终止时间影响。Duration可以通过秒、标准时、标准日等具有绝对时长定义的单位表示。例如,一小时指一标准时,即 3600 秒。一天指一标准日,即 86400 秒。Period作为运行时间考虑到历法系统的民用时长度。Period的表示可以是自然天/年,因此Period的实耗时间(即Duration)受起始时间的影响。例如,2022.3.1 开始的一自然年与 2023.3.1 开始的一自然年时长不同,后者由于闰年,多一自然天。
在日常开发中,算术操作导致的问题主要分为两类:
- 混淆
Duration与Period。 - 运算结果不是有效时间,或者具有歧义。
以下以几个常见场景的例子说明可能出现的问题。
计算 X min 后的时间
考虑以下计算:Instant + Duration。只不过 Instant 是用 DateTime 表示的,Duration 是以 n Hours m Minutes 表示的。具体怎么做?
看上去是一个很简单——进位就是。例如:2003:09:12:12:57:23 + 2:58 从秒开始进位 (23 + 58 >= 60),如果溢出继续向上进位 (57 + 2 + 1 >= 60)。
这样做会出现问题。
- 容易犯错,尤其是进位可能会一直上升到年。例如,在进位月份时,知道该天是不是当月的最后一天还涉及到闰年。前后需要处理的边界情况非常多。
- 更严重的问题是:没有考虑时区变化,计算出来的结果可能是错误的。
这里将 Duration 错当成了 Period。
考虑以下情况:
Pacific Time
2022-03-13T01:00:00Z + 10800S = ?
如果按前文的计算方式,会得到错误的结果:2022-03-13T04:00:00Z。
但是在 2022-03-13 凌晨两点时,夏令时开始,时钟前调了一个小时。所以,3 小时的 Duration 实际上了相差 4 小时的 Period。
计算两个日期之间有几天
一个常见的任务是给定两个日期,计算它们相隔相差多少自然天。
考虑下列的常见处理:
time_t then_start_of_day = then_day.time()...; // 过去一天的零点,以秒为单位,本地时间
time_t today_start_of_day = today.time()...; // 今天的零点,以秒为单位,本地时间
constexpr kSecondsPerDay = 24 * 60 * 60; // 除以标准日时长
days_between = (today_start_of_day - then_start_of_day) / kSecondsPerDay;这可能会出错,因为把 Period 误作为 Duration:
如果 [then, today] 的某一天由于 夏令时,只有小于 24 小时的民用时,那么这一天实际上会小于 kSecondsPerDay。也就是说,上述的算法由于下取整,会比实际的天数 少一天。
更好的办法是区分时间与日期,调用时间库计算 then_day 与 today 日期之间相差的自然日。
定时任务
周期性事件是业务开发中非常容易遇到的场景。而且在大部分场景下,「周期」都是指民用时间周期,即 Period。通常的例子包括:
- 定期扣费、信用卡还单。
- 过生日。
注意到定时任务会涉及到前述的问题——算术结果不存在。例如:
- 闰年:1992.2.29 出生的人在 2022.2.29 的生日并不存在。
- 一月所含天数不确定:一个定期在每月 31 号扣费的服务,一年有 5 个月不存在 31 天。
对于不存在的时间,有几种处理方式。没有绝对的正确与否,需要按照业务的实际情况判断。
- 跳过。适用于对发生的时间敏感,但对是否发生不敏感的情况。例如,往往在 2.29 出生的人每四年过一次生日。
- 跳至最近的有效时间。适用于对必须发生敏感,但对于发生的具体时间点并不敏感。例如,按每月 31 号扣费时,每次 31 号并不存在时回退到当月最后存在的一天。等价于按「每月最后一天」扣费。
- 报错或者禁止可能导致无效的时间设定。谨慎使用,对用户造成影响较大。
当然,还有一个方案是不使用 Period 而使用绝对时间。月卡,是常见的「一月后的今天」,例如充值续费等。应该如何定义这一个月呢?
Period/一个自然月:(MM, DD) -> (MM +1, DD or L)。其中,L 是当月的最后一天,如果 DD > MM+1 月的天数。Duration/「30 天」:续费固定为 30 天。
使用 Duration 有很多好处:
- 计算简单。无需进行复杂的时间转换,直接向时间戳添加 720 小时即可。
- 降本增效。注意到假设用户在一年的每一天都等概率地按月续费会员,那么,我们粗略地估计一年有约 365 天,如果只提供按 30 天续费,大约可提升(365 - 360)/365 = 1/73 的运营收入。(注:玩笑)
按天聚合数据
下面的例子是 (Instant - Instant = Period) 的隐蔽问题。
举个例子,考虑一个服务器在硅谷的业务,需要按天聚合业务数据并进行数据分析。
一个自然的做法是:(简化实现,不考虑效率等因素)
val init_date = Date(2015, 3, 24, "US/Mountain View")
for i in 0 until 100 {
val today = init_date.addDays(i)
val tomorrow = init_date.addDays(i + 1)
val daily_events = events_between(today.toMicros(), tomorrow.toMicros())
// daily_events 是每一天的事件
}这个做法看上去很自然,也很合理,好像也没有上文所说的朴素运算导致的问题。那么,这样会有什么风险呢?
答案是会出现数据偏移。注意到我们通常使用的时区是基于地区,tzdata 会自动切换夏令时。因此,在一年中切换至夏令时和切换至常规时间的两天,它们的一天分别有 23 小时和 25 小时,所以它们虽然确实是「一天(一自然日)」的数据,但这一天可能会比正常日长或短,可能会导致数据异常。
这时,如果使用 Duration,反而不会产生时长的偏移。但这样会产生另一个问题:一年会有大概一半的时间的聚合不再是从00:00, 24:00) 算做一天,而会是 [1:00, 1:00)。这样可能会导致其它的数据偏移发生。另外,即使如此,在夏令时切换的那两天,数据仍然可能会产生偏差——因此一天是 [0:00, 25:00),而另一天是 [1:00, 24:00),仍然可能产生数据的偏移。
当聚合遇上夏令时,没有完美的方案。理解业务模型,选取更符合业务的取样法。
重复的时间(反方向的钟)
前文提及,夏令时可能会导致运算的结果不存在。但是,更严重的问题是夏令时导致的时间回调,也就是同一个本地时间会发生两次。考虑如下时间,转换成绝对时间是多少?
date = "2022-11-06T01:30:00Z" Pacific Time
答案是不确定的——因此夏令时结束在该天的凌晨两点发生,这时时钟会回调一个小时。所以,凌晨一点到两点的时间,都会发生两次。
考虑这个场景:
约定在这个时间向用户转账 5000 万。错误的处理会导致转账两次。这是导致非常严重的事故。
对于具有二义性的时间,有几种处理方式。没有绝对的正确与否,需要按照业务的实际情况判断:
- 触发两次,即在所有可能的解释都触发。适用于对发生的时间敏感,但对是否重复发生不敏感的情况。例如闹钟。
- 触发一次,选取回调前发生或者回调后发生。适用于对于发生次数敏感的情况。例如定时支付。具体选取哪次,应该视业务场景而定。
- 报错或者禁止可能导致无效的时间设定。谨慎使用,对用户造成影响较大。
非单调/非连续时钟
相关概念:
- 闰秒
- NTP
这章指的是绝对时间的时钟的非单调/非连续,如 Unix Time 时钟。历法(尤其是夏令时)导致的非单调/非连续问题在上章已经介绍。
非单调/非连续肇因
系统时钟是不可靠的。导致时钟非单调/非连续的主要原因通常是计算机内的时钟是可调整的。和现实中的钟表一样,用户可以随意地调整计算机内的时钟。
注意到 NTPD 实际上可以维持时钟的单调性和连续性:
- 当 NTP 时间与时钟时间相差超过 1000 秒时,
NTPD会报错并退出,不会调整时间。 - 当 NTP 时间与时钟时间相差低于 1000 秒但高于 123 毫秒时,
NTPD会通过渐变的方式调整时钟,不会跳变。
另外,前文介绍过,当 NTPD 使用渐变的方式处理闰秒时,也不会破坏单调性和连续性。
但是,如果 NTPD 使用跳变处理时间,则时钟的单调性和连续性会被破坏。
所以,使用系统时钟进行以下操作都可能有问题:
时间戳作为判定事件先后顺序依据。
在分布式系统中,不同计算机之间的时间并不可靠,不能够作为线性一致性(Linearizability)的依据是常识。但是单机系统中,事件的 Happens-Before 应该是可以判定的。一个自然的行为是使用事件的时间戳作为事件发生顺序的判定。但是,连续两次取样的时间先后顺序可能是反的,那连本地的 Happens-Before 关系都变得不可判定,则我们在分布式系统中会几乎无法执行任何有效操作。
所以应该使用其它方式声明事件的先后顺序,例如使用单调时钟(见下文「三个时钟」章节),或者使用全局自增计数器。
计算耗时。
由于时钟的不可靠,不应该使用
end_wall_clock_time - start_wall_clock_time来计算耗时。尤其在进行 Profiling 时,不应该使用时钟时间。
正确的方法是使用单调时钟。见下文「三个时钟」章节。简而言之,你不需要时钟来实现一个秒表。
闰秒导致问题的一个著名案例是 Cloudflare 由于 Go time 不是单调的导致全球宕机:
- Go 在 1.9 之前的时钟使用了系统时钟。系统时钟并不是单调的。
- Go team 知道这个问题,但是没有处理。
- Cloudflare 在 2017 年由于闰秒导致时间回跳,
end_wall_clock_time - start_wall_clock_time计算得到负值,发生panic,导致全球服务器宕机。(https://arstechnica.com/information-technology/2017/01/cloudflare-leap-second-software-panic-snafu-new-years-day/) - Go 基于这个事故设计了新的接口即支持 MonotonicClock。
- (作为一个好的例子)在 Go 2 Draft 里面专门讲了这个故事:https://go.dev/blog/toward-go2
三个(单调)时钟
(以下讨论均基于 Linux。)
当我们在谈论「系统时钟」时,我们在谈论什么?
Linux 提供了多种多样的时钟类型。CLOCK_REALTIME 就是我们前文中所描述的、也是大多数时间库使用的系统时钟,即:
- 可以调整时间
- 操作系统层面
- 墙上时钟时间(Wall clock time)
Linux 提供了单调时钟。单调时钟主要有三种:
CLOCK_MONOTONIC
- 不可设定时间。
- 等于自系统启动之后所经历的秒数。因此,
CLOCK_MONOTONIC是单调的,但是并不会返回时钟时间。 CLOCK_MONOTONIC受时间渐变的影响。当 NTP 通过调整秒长来实现渐变时,CLOCK_MONOTONIC的一秒也会被调整。CLOCK_MONOTONIC在系统挂起期间也会挂起,不会前进。
CLOCK_MONOTONIC_RAW
- 等同于
CLOCK_MONOTONIC,除了不受时间渐变的影响。也就是说,当 NTP 通过调整秒长来实现渐变时,CLOCK_MONOTONIC_RAW并不受影响。
CLOCK_BOOTTIME
- 等同于
CLOCK_MONOTONIC,除了在系统挂起时也会计时。
除了上述三种,还有一个单调时钟是 CLOCK_MONOTONIC_COARSE,提供了更为快速的、但是精度更低的单调时钟。
(三个火枪手有四个不是常识吗.jpg)
主流的编程语言的时间库都提供了对单调时钟的支持。例如:
- C/C++ 可以直接调用
clock_gettime - Java
System.nanoTime()通常会返回单调时钟的时间 - Python PEP 418 提供了对单调时钟的支持,即
time.monotonic - Go 的
Time同时存储了realtime和monotonic time
详细的内容我们在下篇「如何正确时间编程」进行介绍。
客户端-服务端不同步
相关概念
- NT
- 时钟漂
tzdata
指在客户和服务器端两边时间不一致导致的各种问题。
在分布式系统中,关于时间,我们不应该有过多的假设。
不可靠客户端
在涉及到客户端开发时,一个常识是不要信任客户端的输入。但是容易被人忽略的是,客户端的时钟时间也是一种输入。
尤其是在监控、日志收集等系统中,简单地使用客户端提供的时间戳可能会导致大量的问题。
案例:故障:流式系统使用限流方式为拒绝
Event Time > WINDOW的事件上报。但是,Event Time是由客户端使用本地时间上报。故障发生在某个机型的客户端时间由于系统 bug 导致比当前时间晚,使得Event Time > WINDOW的阈值完全无法触发。这使得大量的请求涌至服务器端并且限流措施不生效,发生雪崩。我们事实上并不控制客户端设置的时间,也不控制他们使用的NTP。因此,不要信任任何客户端时钟时间。案例:Candy Crush 这种可以离线游戏的游戏,没有任何策略可以制止用户调整时钟时间以快速获取游戏机会。例子:在客户端编程上,使用currentTimeMillis + 时间来定时可能会意外的发生。毕竟,客户端的时间是可能随时会调整的。
不可靠 NTP
通常每个云服务商(Tencent/Huawei/Alibaba/Amazon/Google/Microsoft/…)都有自己提供的 NTP。但是,单个厂商提供的 NTP 可能不稳定。
因此,通常最好配置多个 NTP 共同校验。
故障:(根因未知)某为机型某段时间出现大批量时间错位,上报的客户端时间与服务端时间相差甚多。
可能是由于 NTP 同步失效或不可用。因此,关键的要点即使在客户端开启 NTP 仍然成立:不要信任客户端时间。
C-S 未同步的 tzdata
闰秒、时区调整(如正常时间/夏令时的切换、一次性的时区调整等)都是通过 tzdata 来同步的。而 tzdata 通常都是由系统/语言自己定期更新。
因此,可能会发服务器端和客户端所使用的 tzdata 不一致。不幸的是,除了及时地更新我们所能控制的机器的 tzdata,我们几乎做不了什么。
典型的案例是埃及在 2016 年宣布了夏令时后又在议会吵一架导致直接取消了夏令时。这使用所有人必须同时切换或者同时不切换 tzdata 才能保持一致。但是这显然不可能,而导致的问题又甩锅到了程序员头上。
Time Zone Chaos Inevitable in Egypt
不可靠的服务器
过期的 tzdata
续上文。
tzdata 需要及时同步。所以一个线上跑几年不重启的服务,可能 tzdata 不更新,会导致问题出现。
尤其是像 Java,它的 tzdata 实际上是在 JRE 中。而要更新 JRE 我们往往需要重新部署。如果太久没有部署,下次部署可能会非常痛苦。
(所以 DevOps 是有用的。经常发布有益。)
服务器侧未同步的 tzdata
续上文。即使在本地单机,更新 tzdata 也并不简单。
在同一台电脑上,tzdata 可能存放了至少三份:操作系统、语言时间库、三方时间库。
如果三个 tzdata 不保持同步,那就可能会出现问题。但是系统、语言和库的 tzdata 使用了各自不同的同步机制。例如:
- 操作系统:Unix-like 的操作系统的
tzdata存放在/usr/share/zoneinfo/。但是不同的 Linux 发行版,都可能自己的tzdata的更新工具。 - 语言库:Java 没有使用系统层面的
tzdata,而是在JRE存放了一份tzdata。Oracle 提供了 TzUpdater 用以更新JRE中的tzdata。 - 时间库:
JodaTime存放了自己的global-tz。用户需要自己负责将global-tz与 Java 的tzdata保持同步。
(所以统一的基础库维护是好处的。)
注意上文只讨论了 Java 一门库。不同的语言有各自不同的处理。好日子还在后头呢。
NTPD 不生效
像是所有与时间相关的系统一样,NPTD 可能不生效。笔者所见的几个案例如下:
陷阱:一台久未启动的机器,在初次启动时,可能会有大量的时钟漂移。
NTPD可能在服务启动之前仍然没有正常进行第一次同步。故障案例:某业务临时扩容时,新启动的机器由于久未启动,与正常时间相差 10 分钟。杀掉 Pod 并重启后正常。陷阱:NTPD的同步可配置,而且周期可能会较大。这使得不同机器的时间由于时钟漂移,可能相差超出预期。
编程复杂性(六)—— 错误的假设
一些关于时间的编程中的除了历法之外的错误假设。
错误假设:对 time() 连续调用会返回相同值
一个隐蔽的错误假设是当连续调用获取当前时间时,会返回相同值。下面的例子:
createTime := time.Now()
updateTime := time.Now()
// CAUTION: it is possible that createTime != updateTime我们通记录的时间的精度要求不高,也就是这里 createTime 比实际发生的时间早一点或者晚一点,并没有什么实质影响。
但是,初次创建时 createTime 和 updateTime 如果不相同,很容易产生困惑。更好的办法是:
now := time.Now()
createTime := now
updateTime := now更危险的是,连续获取间也未必每次都产生不同的时间戳。所以这里可能会产生一些难以复现的问题,并且在单元测试中甚至也无法捕获。
错误假设:对 time() 连续调用会返回不同值
相反的错误假设是认为连续两次获取当前时间会返回不同的值。
时间库可能返回的精度较低,如 System.getCurrentMillis 只能返回毫秒精度的时间。
另一方面,虽然现代 CPU 的时钟频率已经够高,并且很多时间库已经支持纳秒级别的精度,但是实际返回的时间精度可能并达不到所支持的级别。这是由底层的系统时钟的精度限制的。
错误:使用创建时间的时间戳作为事件/资源 UID。这可能并不能保证唯一性。
uuid := time.Now().String() // Danger! Might have duplicated UUID.错误假设:Thread.sleep 是精确的
Thread.sleep 或者任何的类似的依赖系统抢占任务,时间都不会特别精确。在遇到 Stop the world GC 时,误差可能甚至会超过 1 秒。因此不要依赖 Thread.sleep 来执行对时间有高精度要求的操作。
由于篇幅关系,本文只介绍 Go/Java/Kotlin/C++ 的编程推荐实践。其它语言如 Python/JavaScript/ObjectiveC/Swift 等待补充。
编者补充:原文此处保留写作时的状态;JavaScript 已在第四部分单独补充。
四、正确处理时间
通用原则
时间表示
优先使用绝对时间(Absolute Time)而非民用时间(Civil Time)。即,优先使用 Unix Time 风格的时间戳。
绝对时间在以下方面优于民用时间:
- 便于数值计算,例如比较两个时刻的先后关系(
time 1 < time 2),或是计算Instant + Duration。这个计算对人类友好——我们可以轻易地判断出两个时间戳的前后,并不比民用时间更困难。 - 节省空间,例如存储或是传输。通常民用时间的表示至少会需要(Y,M,D,H,M,S,NS,TZ)八个域,相比绝对时间(S,NS)的空间多。如果民间时间用字符串表示,则额外空间开销更高。
- 无关时区。系统应该尽可能不依赖时区。如无必要,毋添时区,这称为 Occam’s Rimzor(Occam’s Razor on Time Zone)原则(我胡编的)。
当以下条件满足时,适宜使用绝对时间:
- 存储或传输已经发生。过去的时间是已经「被盖章(stamped)」的,即不再可变,所以使用绝对时间可以保持完整信息(不考虑闰秒)。
- 对未来的时刻要求并不严格。即,不担心时区调整或者闰秒等因素造成的影响。
- 时区并不重要。
以下场合可能更适合使用民用时间:
- 必须记录时区。
- 记录 1970-01-01 之前的时间,例如记录生日时。
- 记录日期而非时刻。使用该日的零点指示该天虽然可行,但是很 hacky,并且容易受时区变动的影响。更好的办法是使用日期类型,见下。
- 指向未来的某个民用时刻,例如 2025-11-11T13:22:44。这可以不受时区变动的影响。
- 需要记录闰秒事件,即出现 24:00:00 这种时刻。
(换言之,除了这些小众情况,使用绝对时间通常是更好的选择。)
如何表示绝对时间:
优先使用类型表示时间戳。语言或者传输协议通常都有提供标准的时间类型,如:
- Go:
time.Time - Java:
java.time.Instant - protobuf:
google.protobuf.Timestamp
它们通常都是对绝对时间的封装,不会带来显著的额外性能开销。
如果必须使用原始数值类型时(例如兼容历史代码,或有严格的性能要求必须使用),命名中应该包含对应的单位。
为了保证算术安全性,尽量使用有符号类型,而非无符号类型。
安全的选项是使用 int64 表示秒数,用 int32 表示纳秒数,即 (seconds: int64, nanoseconds: int32) 二元组。
如何表示民用时间:
永远标明所在时区,不要假设「本地时间」。即使显式设置了本地时区 TZ="Asia/Shanghai" 也是如此,因为并非所有依赖的系统都会尊重本地时区——系统可能会自动 fallback 到 UTC 时间。
优先使用 UTC 储存时间。UTC 是大部分系统都默认使用的时间,可以减少很多可能导致的麻烦。即使使用北京时间可能更方便,使用 UTC 时间通常更安全。即使要在用户界面显示本地时间,仍然可以在显示时再根据本地时区(即 TZ)在运行时转换为本地时间。
对闰秒、时区的介绍可参考前文「历法复杂性」。
对民用时间、绝对时间的介绍可参考前文「编程复杂性」。
测试相关
时钟是测试不稳定的来源。例如如果在一个方法中取 time.Now() 并作为实现的一部分,则可能会因为 Now() 的不确定性导致测试不稳定。通常情况下,有三种解决方案:
- 依赖注入
Clock接口。Clock可以只有提供当前时间的接口。这样测试时可以使用Fake Clock保证确定性的行为。 - 强制修改系统本地时钟时间。可能需要一些全局性的方法,如:DateTimeUtils(Joda-Time 2.12.1 API)。注意并发执行可能会有问题,另外需要在 tear down 时重置。
- 基于当前时间测试。这种处理方案通常会选择不再测试时间相关的行为,避免不确定性。例如,基于 Diff 的测试,如
RPCReplay/Diff Testing/…,通常情况下在结果对比时应该忽略所有的时间戳字段。
单调时钟
编程复杂性前文提及,Wall Clock 挂钟时间是不单调的,可能受 NTP/闰秒/settime 等因素影响。所以,应该确保服务器采取了足够的保证时钟同步(Clock synchronization)的措施,并依据这些措施所提供的 SLA 的假设处理时间。
但是,即使如此,也不应该使用挂钟时间计时。应该总是使用单调时钟进行计时。绝大多数操作系统提供了单调时钟的 API,且大部分语言都支持单调时钟 API。应当单调时钟在对应语言/时间库的 API。
时间运算
如前文所述,最重要的一点是区分 Period 与 Duration。这一点在出海业务,需要处理夏令时的时候,尤为重要。
大部分时间库会提供 Duration 的支持,但未必会支持 Period。当后者不被支持时,通常需要回退到本地日期/本地时间后进行计算。
Period 与 Duration 的区别请参阅[编程复杂性]。
日期表示
优先使用 Date only 的类型。
如果要表示日期,虽然可以使用时刻(如用 Day 00:00:00)模拟,但是如同[编程复杂性]所解释的,这样做相当于对时间做朴素的算术运算,可能会导致问题。如果有 Date 类型,并且支持 add(days) 操作,相对更安全。
这本质上等同于 Duration/Period 问题。Period 与 Duration 的区别请参阅[编程复杂性]。
选择时间库的不完全检查清单
使用时间库。 永远不要进行自己处理时间,尤其不要自己处理时区。
前文提及,您永远无法正确地处理时间。在实际开发中,我们通常采取近似的时间处理。您可能需要认真地选择您对时钟和时间系统的要求,抛弃对业务实际上并没有影响的假设(例如可以处理未来时间、具有高精度、需要处理闰秒等),以此选择简化的时间系统。(然后,等出现和时间相关的故障时,再来对照这个系列文章复盘判断哪些假设实际上必须成立)
1/2 是一个有效的时间库的最低配置,即时刻和时区,以此达成绝对时间与民用时的转化。其它的假设通常都是可选并且由业务形态决定。
以下是选用时间库的 Checklist:
- 是否包含了足够所需的时间概念的抽象?
- 是否提供「
Instant」(时刻)类型? - 是否支持「
Duration」(时间段)类型?是否区分了Duration与Period(或Interval)? - 是否提供「
Local Date」类型(一个抽象的、与时区无关、与时分秒无关的日期),并支持相关的计算? - 是否提供「
Time Of Day」类型(一个与日期无关、与时区无关的挂钟时间),并支持相关的计算?
- 是否提供「
- 是否正确地处理了时区?
- 是否正确地处理了夏令时?
- 如果民用时间和绝对时间的转换(例如由于夏令时,或时区更新)出现了二义性(
Overlap)或不存在的时间(Gap),是如何处理的? - 是否可以正确地更新
tzdata?
- 如何处理闰秒?
- 是否具有足够的时间精度?
- 是否支持单调时钟以测量时长?
- 是否支持星期?
- 是否包含时钟界面,还是仅提供静态的时间提供方法?
- 是否支持所需的非公历历法,例如是否需要支持阴历?
Go
使用 time.Time 标准库
Q:「是否包含了足够所需的时间概念的抽象」?
A:time.Time 提供了 Instant 与 Duration。
Instant是一个标准的时间类型,表示一个时刻。Duration自身是int64类型,数值表示两个时刻之间有多少纳秒。time.Time不提供Period/Local Date/Time Of Day这层抽象。所以,如果业务需要,需要自行处理。
Q:「是否正确地处理了时区」?
A:time.Time 使用 tzdata 支持时区。所以可以认为 time.Time 对时区的支持符合 IANA 标准。
time.Time 可以正确处理夏令时。time.Time 具有 API 判断该时刻在所在时区是否为夏令时:Time.IsDST
当 time.Time 的转换(即 time.Date API)出现 Overlap,或出现 Gap,结果是未定义行为:
- 对于
Overlap,即同一个民用时由于标准时区与夏令时时区可以转换为两个绝对时间,time.Date会使用其中一个时间。(注意:使用具体哪个时间没有任何保证,可能随 Go 标准库版本更新而改变) - 对于
Gap,即该民用时由于夏令时被跳过,time.Date的行为是未定义的,通常会前跳或者后跳一个小时,到一个确定性的时间。例如:2011/03/13 2:15:00 美西时间会被回跳到 1:15:00。(注意,这个行为没有任何保证,可能随 Go 标准库版本更新而改变)
time 库会优先使用系统的 tzdata。
如果无法找到系统的 tzdata,time 库会使用内置的 tzdata 库。它把 tzdata 的压缩后的结果放在了代码中(显然毫无可读性)。import tzdata 库大概会增加 ~450 KB 的程序空间。
tzdata package - time/tzdata - Go Packages

https://cs.opensource.google/go/go/+/refs/tags/go1.19.3:src/time/tzdata/zipdata.go 大概长这样
Q:「如何处理闰秒」?
A:time.Time 忽略闰秒。
这是指当闰秒出现时,time.Time 不做任何特殊处理,而是由操作系统层面处理。time.Time 认为闰秒出现两次。换言之,与 Unix Time 的处理方式相同。
Q:「是否具有足够的时间精度」?
A:time.Time 的精度为纳秒。
当前,大部分现代的时间库都提供了纳秒级别的精度,虽然计算机的时钟精度往往达不到纳秒级别。具体请参见[编程复杂性]一章。
Q:「是否支持单调时钟」?
A:time.Time 精巧地支持了单调时钟。在正常使用的情况下 time.Time 优于其它时间库的处理,但是需要理解背后的原理才不会出错:
time.Time 同时保存了挂钟时间和单调时钟的时间。当执行需要返回确切时间时,time.Time 会使用挂钟时间;而当需要测量时间,尤其是比较时间先后(time 1 < time 2)和计算时长(time 1 - time 2)时,time.Time 会使用单调时钟的时间。这是一个优秀的设计,但这意味着大部分对 time.Time 的操作不应该使用朴素的计算,如:
- 进行时间的减法需要使用
time.Sub,而不应该使用t1.UnixNanos() - t2.UnixNanos() - 进行时间的比较应该使用
time.Before,而不应该使用t1.UnixNanos() < t2.UnixNanos() - 比较时间相同应该使用
time.Equal,而不应该使用t1 == t2。但这里的原因恰好相反:time.Equal只使用挂钟时间进行比较,所以同一时间在不同时区的表达会被认为是同一时间。但==会朴素地比较挂钟时间、单调时钟时间和Location(即时区)。所以,基于同样的原则,time.Time通常不应该作为map的 key。
同时,一旦进行与民用时相关的操作,如 t.AddDate,t.Round,t.Truncate,t.in,t.Local,t.UTC 等,单调时钟的时间会被抹除,因为这时单调时间不再有意义。一旦 time.Time 的单调时钟时间被抹除,那么类似于 time.Sub/time.Before 等操作均会回退到使用挂钟时间。
如果明确希望抹除 time.Time 中的单调时钟和时区信息,可以使用 t.Truncate(0)。
另一个较为隐蔽的例子是 time.Time 被 JSON marshal 再 unmarshal 后的值也会被抹除单调时间和时区信息。因此,会出现以下现象:
t := time.Now()
before := Event{
Time: t,
}
b, err := json.Marshal(before)
if err != nil {
return err
}
var after Event
if err := json.Unmarshal(b, &after); err != nil {
return err
}
fmt.Println(before == after)
// False! Monotonic time and location got wiped out in event2.通过使用 before.Equal(after) 即可规避。这再次提醒我们应该始终使用 time.Time 提供的 API,而不要使用 Go 操作符 == / <。
Q:「是否支持星期」?
A:time.Time 支持获得 weekday。并且 time.Weekday 是定义好的枚举类型:
time package - time - Go Packages
Q:「是否包含时钟界面,还是仅提供静态的时间提供方法」?
A:time.Time 没有提供时钟界面,而只提供了 time.Now() 的全局方法提供时间。
因此,如果测试有需要,应该封装一个 Clock 接口。
Q:「是否支持所需的非公历历法,例如是否需要支持阴历」?
A:time.Time 不支持其它历法。
Java
tl;dr:JDK 1.8 及之后,使用 java.time.*。在 JDK 1.8 之前,使用 Joda Time。
不要使用 java.util.Date 和 java.util.Calendar。
移动端:如果 java.time.* 与 JodaTime 都不可用,优先使用 System.currentTimeMillis()。
java.util.Date / Calendar / GregorianCalendar 的有诸多缺陷,简述如下:
Date/Calendar是可变的。SimpleDateFormat是线程不安全的。API容易误用。Date是一个时间,而不是一个日期- 月份从 0 开始(即 1 月 index = 0)
- 年份从 1900 开始
Calendar有性能问题- 处理时区很困难。
「这些 API 很糟糕」不是笔者的主观判断,而是 Java 社区的普遍认知:
Class Date represents a specific instant in time, with millisecond precision. The design of this class is a very bad joke - a sobering example of how even good programmers screw up. Most of the methods in Date are now deprecated, replaced by methods in the classes below.Class Calendar is an abstract class for converting between a Date object and a set of integer fields such as year, month, day, and hour.Class GregorianCalendar is the only subclass of Calendar in the
JDK. It does the Date-to-fields conversions for the calendar system in common use. Sun licensed this overengineered junk from Taligent - a sobering example of how average programmers screw up.– Java Programmers FAQ https://stackoverflow.com/questions/1571265/why-is-the-java-date-api-java-util-date-calendar-such-a-mess
在 Java 8 以前,由于官方库过于糟糕,公认的 Java 最优秀的时间库是 JodaTime。
JodaTime 解决了上述 API 的大部分问题。JodaTime 引入了很多新的概念,令方案看上去很复杂,但这只是因为 JodaTime 充分考虑和支持了前述清单里的各种场景。
JSR 310 引入了 java.time.* 新的时间 API,于 JDK 1.8 正式加入。作者正是 Joda Time 的作者。
The Java Community Process(SM)Program - JSRs: Java Specification Requests - detail JSR# 310
使用 java.time.* 或 JodaTime
Q:「是否包含了足够所需的时间概念的抽象」?
A:java.time.* 均有提供。
java.time.Instant是数值的时间戳,如1670245951000000000 = 2022-12-05T13:12:31+00:00。java.time.Duration是一段绝对的时间,如42 seconds。java.time.LocalDate存储了一个无时钟时间的日期,如2022-12-05。java.time.LocalTime存储了一个无日期的时钟时间,如11:30。java.time.LocalDateTime存储了一个无时区的日期+时钟时间,如2022-12-05T11:30。java.time.ZonedDateTime存储了一个有时区信息的完整时间。ZonedDateTime与Instant可以互相切换(Instant -> ZonedDateTime需要附加时区信息)。
JodaTime 类似。
Q:「是否正确地处理了时区」?
A:java.time.* 正确处理了时区。java.time.* 使用 JRE 自带的 tzdata。
java.time.* 可以正确处理夏令时。
Overlap:通常情况下,java.time.*会转换到两个相同时刻的靠前的时刻。Gap:通常情况下,java.time.*会将该时间后移 [夏令时与标准时时差],通常为一小时,以获得一个存在的时刻。
更多参见 ZonedDateTime(Java Platform SE 8)
JRE 可以使用 TZUpdater 更新 tzdata 信息。Timezone Updater Tool
JodaTime 对时区的处理基本一致。但是 JodaTime 维护了自己的 tzdata 库,所以需要定期更新 JodaTime 库。
Q:「如何处理闰秒」?
A:java.time.* 忽略闰秒。
这是指当闰秒出现时,java.time.* 不做任何特殊处理,而是由操作系统层面处理。java.time.* 认为闰秒出现两次。换言之,与 Unix Time 的处理方式相同。
JodaTime 亦然。
Q:「是否具有足够的时间精度」?
A:java.time.Instant 的精度是纳秒(即 1e-9 秒),java.time.Duration 的精度也是纳秒。
但 JodaTime 的时间精度只到微秒(即 1e-6 秒)
Q:「是否支持单调时钟」?
A:java.time.* 不支持单调时钟。
使用 System.nanoTime() 以获取单调时钟。
System.nanoTime()(Java Platform SE 7)
nanoTime 有两个值得注意的点:
nanoTime是与某一个锚定时间的以纳秒计的时差,但nanoTime并不是从启动以来的纳秒数。nanoTime甚至可能是负的——因为锚定点可能在未来。nonoTime是JVM能返回的最高准度的时间。但仍然很可能低于其精度(纳秒)。不过,nanoTime保证准度不低于System.currentTimeMillis()
安卓端可以使用 SystemClock 类获得更多的单调时钟。以下两个 API 尤其有用:
SystemClock.uptimeMillis返回自启动以来的毫秒数,但不计入系统深度睡眠时间。SystemClock.elapsedRealtime返回自启动以来的毫秒数,并计入系统深度睡眠时间。
Q:「是否支持星期」?
A:java.time.* 支持星期,并提供了 DayOfWeek enum 作为 Weekday。
Q:「是否包含时钟界面,还是仅提供静态的时间提供方法」?
A:java.time.Clock 提供了抽象 Clock 类。可以通过注入 Fake Deterministic Clock 依赖以便于单元测试。
Q:「是否支持所需的非公历历法,例如是否需要支持阴历」?
A:java.time.* 只支持 ISO-8601 历法,也就是 proleptic Gregorian rules(即支持格里高里历扩展到 1582 年以前)。
Kotlin
同 Java。唯一的区别在于 kotlin.time 库,额外添加了使用 monotonic 时钟测量时长的方法:
kotlin.time 库是对 java.time 库的扩展,因此最低需要在 JDK 8 上运行。
JavaScript(编者补充)
tl;dr:新代码里,已经发生的时刻可以继续用时间戳、ISO 字符串或 Date 承载;展示层优先用 Intl.DateTimeFormat;函数式日期工具可以考虑 date-fns;需要 Moment 风格的轻量 API 可以考虑 Day.js;需要更明确的时区、区间和不可变对象模型可以考虑 Luxon;不要再为新项目默认引入 Moment.js;如果运行环境支持,或可以接受 polyfill,复杂时间建模应继续关注 Temporal。
先说一个历史背景:JavaScript 的 Date 像 Java,不只是错觉。早期 JavaScript 规范就明确说过,JavaScript 处理日期的方式和 Java 很相似;Allen Wirfs-Brock 与 Brendan Eich 的《JavaScript: The First 20 Years》也把 JavaScript 1.0 的 Date 描述为从 Java 1.0 的 java.util.Date 直接移植而来。所以,月份从 0 开始、可变 setter、基于 epoch 毫秒数、字符串解析混乱这些味道,确实很大程度上继承了 Java 早期时间 API 的历史包袱。
JavaScript 里常见的时间处理方案大致可以分成这些:
Date+Intl:标准能力。Date负责承载 epoch 毫秒时刻,Intl.DateTimeFormat负责本地化展示。date-fns:函数式工具库。它提供大量纯函数,不修改传入的Date,也比较利于按函数引入。@date-fns/tz:date-fns生态里的时区补充,提供TZDate/TZDateMini,让date-fns计算可以在指定时区里发生。Day.js:Moment 风格的轻量替代,核心小、对象不可变,但 UTC 与时区能力需要插件。Luxon:Moment 团队生态里的现代方案,提供DateTime、Duration、Interval,对象不可变,使用原生Intl处理国际化和时区。Moment.js:历史事实标准,适合维护老项目,不适合作为新项目默认选择。Temporal:标准方向,更接近本文一直强调的完整时间模型。js-joda:更接近 Java 8java.time的不可变类型系统。如果项目明确想要LocalDate/Instant这类 Java 风格抽象,可以和Temporal一起评估。
使用 Date / Intl / 常见时间库
Q:「是否包含了足够所需的时间概念的抽象」?
A:只看 Date 和 Intl,不包含。
Date 本质上只是在一个对象里包了一层 epoch 毫秒值。它可以表示一个时刻,但没有把 Instant、LocalDate、LocalTime、ZonedDateTime、Duration、Period 这些概念拆开。于是很多不同语义都被塞进同一个对象里:一个具体时刻、一个本地日历日期、一个本地时钟时间、一次展示格式化、一次日期加减,看起来都像是在操作 Date,但背后语义完全不同。
Intl.DateTimeFormat 也不是完整时间模型。它主要解决「如何展示给用户看」,可以处理 locale、日历、数字格式和时区展示,但不负责做业务日期运算。
各个库的情况也不同:
date-fns提供的是函数集合。它的优点是纯函数、模块化、不会改入参;但底层仍主要围绕原生Date工作,不会自动补齐完整时间类型系统。Day.js和Moment.js提供的是包裹对象和链式 API。Day.js对象不可变,Moment.js对象可变;两者都不是像java.time.*那样完整拆出所有时间概念的标准模型。Luxon明确提供DateTime、Duration、Interval,抽象层次比Date、date-fns、Day.js更完整。Temporal最接近本文前面真正需要的时间模型:Temporal.Instant表示绝对时刻,Temporal.ZonedDateTime表示带时区时间,Temporal.PlainDate表示纯日期,Temporal.PlainTime表示本地时钟时间,Temporal.Duration表示时间段。
const instant = Temporal.Instant.from('2026-06-02T01:00:00Z');
const shanghaiTime = instant.toZonedDateTimeISO('Asia/Shanghai');
const billingDate = Temporal.PlainDate.from('2026-06-02');
const nextBillingDate = billingDate.add({ months: 1 });
shanghaiTime.toString();
// "2026-06-02T09:00:00+08:00[Asia/Shanghai]"
nextBillingDate.toString();
// "2026-07-02"这段代码里,instant 是一个绝对时刻,shanghaiTime 是这个时刻在上海时区的民用时间,billingDate 是纯日期,nextBillingDate 是按日历语义加一个月。它们不再被迫塞进同一个 Date 类型里,读代码的人也更容易判断这一步到底是在做 Duration 还是 Period。
Q:「是否正确地处理了时区」?
A:Date 本身不适合处理复杂时区。
Date 主要只有两套视角:本机本地时区和 UTC。getFullYear()、getMonth()、getDate() 这一组方法按运行环境的本地时区读取;getUTCFullYear()、getUTCMonth()、getUTCDate() 这一组方法按 UTC 读取。同一个 Date 在不同时区机器上读出来的「年月日」可能不同。尤其不要把纯日期直接当 Date 使用,例如 new Date('2026-06-02') 会变成 UTC 零点对应的时刻,在美西时区展示时可能还是前一天。
Intl.DateTimeFormat 可以把同一个时刻格式化到指定时区,适合展示:
const formatter = new Intl.DateTimeFormat('zh-CN', {
dateStyle: 'full',
timeStyle: 'long',
timeZone: 'Asia/Shanghai',
});
formatter.format(new Date('2026-06-02T01:00:00Z'));
// 例如:"2026年6月2日星期二 GMT+8 09:00:00"但是,Intl 不负责回答「这个本地时间在夏令时切换日是否存在」「Gap 和 Overlap 怎么选」「下一个账单日在这个时区是哪一天」这类业务问题。
如果需要时区运算:
date-fns本体仍主要处理Date;需要指定时区时,可以看@date-fns/tz的TZDate/TZDateMini。Day.js需要显式引入utc和timezone插件。Luxon使用原生Intl支持 IANA 时区,适合需要清楚表达 zone 的业务。Moment.js需要配合Moment Timezone。Temporal.ZonedDateTime是标准方向,但要确认运行环境或 polyfill 成本。
import { TZDate } from '@date-fns/tz';
import { addDays } from 'date-fns';
const date = new TZDate(2026, 5, 2, 'Asia/Shanghai');
const nextDay = addDays(date, 1);
nextDay.toString();
// 例如:"Wed Jun 03 2026 00:00:00 GMT+0800 (China Standard Time)"import dayjs from 'dayjs';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
dayjs.extend(utc);
dayjs.extend(timezone);
const meetingTime = dayjs.tz('2026-06-02 09:00', 'Asia/Shanghai');
meetingTime.toISOString();
// "2026-06-02T01:00:00.000Z"import { DateTime } from 'luxon';
const meetingTime = DateTime.fromISO('2026-06-02T09:00:00', {
zone: 'Asia/Shanghai',
});
meetingTime.plus({ days: 1 }).toISO();
// "2026-06-03T09:00:00.000+08:00"无论选哪一个,都要专门看夏令时的 Gap / Overlap 行为。时区库能帮你处理规则,但不能替你决定业务语义。
Q:「如何处理闰秒」?
A:通常可以认为 JavaScript 这一组方案都忽略闰秒。
Date 使用的是接近 Unix time 的 epoch 毫秒模型,不表达闰秒。date-fns、Day.js、Luxon、Moment.js 这类围绕 JavaScript runtime 或 IANA 时区规则工作的库,通常也不会把闰秒作为业务可见的 23:59:60 来处理。Temporal 的日常使用也不应该被理解成闰秒处理器。
所以,如果业务真的依赖闰秒、TAI 或天文时间,应使用专门的时间系统,不要把普通 JavaScript 日期库当成答案。
Q:「是否具有足够的时间精度」?
A:Date 以及大多数常见库的业务时间精度是毫秒。
Date 内部以毫秒表示时刻。date-fns、Day.js、Luxon、Moment.js 这些常见库大多也围绕毫秒级时间工作。Temporal 的模型支持纳秒级表示,但这不等于机器真实时钟有纳秒级准确度,也不等于外部系统传来的数据有纳秒级可信度。
需要特别区分「表示精度」和「时钟精度」。业务字段能保存毫秒或纳秒,只说明这个格式能表示这么细;系统时钟能不能准确给出这么细,是另一个问题。
Q:「是否支持单调时钟」?
A:Date.now() 不支持单调时钟,不应该用来测量耗时。
浏览器里测量耗时应优先用 performance.now():
const startedAt = performance.now();
await doSomething();
const elapsedMs = performance.now() - startedAt;
elapsedMs;
// 例如:12.345,单位是毫秒Node 里可以用 performance.now(),也可以用 process.hrtime.bigint():
const startedAt = process.hrtime.bigint();
await doSomething();
const elapsedNs = process.hrtime.bigint() - startedAt;
elapsedNs;
// 例如:12345678n,单位是纳秒这件事和选 date-fns、Day.js、Luxon 没有直接关系。它们主要解决日期表示、解析、格式化和运算;耗时测量应该走 runtime 提供的单调时钟能力。
Q:「是否支持星期」?
A:支持,但「星期」背后的业务规则仍然要小心。
Date 提供 getDay() / getUTCDay();date-fns 有对应的 weekday 工具函数;Day.js、Luxon、Moment.js、Temporal 也都可以取得星期信息。
真正麻烦的不是取出星期几,而是「一周从哪天开始」「工作日是哪几天」「节假日是否调休」「某个 locale 的周序号如何定义」。这些问题已经超出 Date.getDay() 的能力范围,需要结合 locale、业务日历或专门的假期数据处理。
Q:「是否包含时钟界面,还是仅提供静态的时间提供方法」?
A:JavaScript 标准 API 主要提供静态的当前时间入口,不提供统一的 Clock 接口。
Date.now()、new Date()、performance.now()、Temporal.Now 都更像静态入口。测试时不要让业务代码到处直接调用这些入口,应该注入一个很小的 Clock:
export const systemClock = {
now: () => new Date(),
monotonicNow: () => performance.now(),
};
systemClock.now();
// Date 对象,例如:2026-06-02T01:00:00.000Z
systemClock.monotonicNow();
// 例如:1234.56,单位是毫秒业务代码依赖 clock.now(),测试里传入固定时间。这样比在测试里到处 mock 全局 Date 更稳。
Q:「是否支持所需的非公历历法,例如是否需要支持阴历」?
A:展示层可以依赖 Intl 的 calendar 能力,业务运算层不能只靠 Date。
Date 的计算模型不适合表达非公历业务。Intl.DateTimeFormat 可以做一些非公历展示,例如使用不同 calendar 进行本地化格式化;但展示出来不等于业务模型已经支持对应历法。
date-fns、Day.js、Luxon、Moment.js 主要面向公历 / ISO 日历使用。Temporal 的模型更接近可扩展方向,但实际落地仍要确认运行环境、polyfill 和目标 calendar 支持。如果业务真的需要农历、宗教历、节气、调休和本地假期,通常需要专门的日历数据或业务库。
实际选型建议。 如果只需要记录事件发生时间,传输层优先使用 Unix timestamp 或带 offset 的 ISO 字符串,例如 2026-06-02T01:00:00Z。如果只需要展示给用户看,优先用 Intl.DateTimeFormat,并显式指定 timeZone。如果要测量耗时,浏览器用 performance.now(),Node 里用 performance.now() 或 process.hrtime.bigint(),不要用 Date.now() 直接相减。
如果项目偏函数式工具、只做常规日期加减和格式化,可以考虑 date-fns;如果还要指定时区计算,再评估 @date-fns/tz。如果老代码已经是 Moment 风格,又想轻量迁移,可以考虑 Day.js。如果项目要明确处理 IANA 时区、区间和不可变对象,可以考虑 Luxon。如果是新项目、运行环境允许,并且业务确实有本地日期、账单周期、排班、预约、跨时区会议、夏令时边界这些复杂需求,应优先认真评估 Temporal。
相关资料:
- Date - JavaScript | MDN
- Representing dates & times - JavaScript | MDN
- Intl.DateTimeFormat - JavaScript | MDN
- Temporal - JavaScript | MDN
- Performance.now() - Web APIs | MDN
- Performance measurement APIs | Node.js Documentation
- process.hrtime.bigint() | Node.js Documentation
- date-fns
- @date-fns/tz
- Luxon
- Moment.js Project Status
- Day.js
- js-joda
- JavaScript 1.1 Specification Preliminary Draft
- JavaScript: The First 20 Years
C++
C++ 当前情况较为复杂。C++ 有三种类型的时间库:
- C 的时间库,即
std::time() - C++ 标准,即
std::chrono - 三方库,代表有 boost time library、abseil time library、HowardHinnate/Date
应该使用 std::chrono 库。但是,std::chrono 在 C++ 11 加入,只提供了时钟功能,并不支持时区与历法。完整的对历法和时区的支持在 C++ 20 才加入。但公司当前的代码规范仍在 C++ 17。另外,除了 MSVC,当前其它编译器对该标准库特性的支持都只是部分支持。
值得注意的是 HowardHinnant/date 历法库是 C++ 20 的历法库的孵化者。
所以,在下文中主要介绍 absl::Time,以及 std::chrono 库 C++11 引入的时钟。笔者选取 absl::Time 作为三方库作为讲解是因为:
使用 Absl 时间库
Q:「是否包含了足够所需的时间概念的抽象」?
A:absl Time Library 均有提供。
absl::Time 是数值的时间戳,如 1670245951000000000 = 2022-12-05T13:12:31+00:00。
absl::Duration 是一段绝对的时间,如 42 seconds。
absl 对民用时的处理较为显式。absl 定义了多个级别的抽象的时间概念:
absl::CivilSecond=(Y,M,D,H,M,S)absl::CivilMinute=(Y,M,D,H,M)absl::CivilHour=(Y,M,D,H)absl::CivilDay=(Y,M,D)absl::CivilMonth=(Y,M)absl::CivilYear=(Y)
不同的 Civil* 之间可以互相转化(向下去尾,向上补零)。
因此,Local Date 可以用 CivilDay 表示。但是,absl 时间库不支持 TimeOfDay,即本地时钟的表示。
这样的好处在于显式地区分了 Period 与 Duration——即 Civil* 所能处理的时间段一定是 Period,而 absl::Time 处理的时间段一定是 Duration。这样还避免了对时间的误操作,例如,只有在 CivilDay 及更细的粒度才提供 API 指明「明天」,而 CivilMonth 显然从语义上就无法实现。因此,应该选择正确的民用时单位。

absl Absolute Time / TimeZone / Civil Time 的类型
std::chrono 只提供了 Instant 和 Duration,相当于只提供了时钟。
std::chrono::time_point代表一个时刻,由时间锚点 +Duration组成。time_point.time_since_epoch()返回 Unix 时间。std::chrono::duration代表时间区间。
Q:「是否正确地处理了时区」?
A:absl::TimeZone 定义了时区。absl 可以正确处理夏令时。
Overlap:absl::Time会转换到两个相同时刻的靠前的时刻。Gap:absl::Time会调整到Gap的末端——即夏令时转换时刻,Transition Time。例如,如果时区后调一个小时,即 2:00 -> 3:00,则 2:30 会被调整到 3:00。
更多参见 https://abseil.io/docs/cpp/guides/time#civil-to-absolute
Q:「如何处理闰秒」?
A:absl::Time 明确不支持闰秒。absl::Time 假设一分钟永远有 60 秒。absl::Time 要求闰秒总是使用闰秒抹除的方式处理。
关于闰秒抹除详见[历法复杂性]。
Q:「是否具有足够的时间精度」?
A:absl::Time 支持纳秒级别的时间精度。absl::Duration 亦然。
std::chrono::time_point 与 std::chrono::duration 也支持纳秒级别的精度。
Q:「是否支持单调时钟」?
A:absl::Time 不支持单调时钟。
std::chrono 提供了单调时钟。std::chrono C++11 提供了三种时钟:
std::chrono::system_clock:即常规挂钟时间的时钟std::chrono::steady_clock:单调时钟std::high_resolution_clock:系统所能提供的最高精度的时钟。通常不推荐使用
Q:「是否支持星期」?
A:absl::Time 支持星期,支持星期的计算。absl::Weekday 是星期的类型:
abseil-cpp/civil_time.h Weekday
Q:「是否包含时钟界面,还是仅提供静态的时间提供方法」?
A:absl::time 仅提供了静态的时间提供方法,即 absl::time::Now()。
因此,如果测试有需要,应该封装一个 Clock 接口。
std::chrono 也只提供了静态的时间提供方法,即 std::chrono::system_clock.now() 和 std::chrono::steady_clock.now()。
Q:「是否支持所需的非公历历法,例如是否需要支持阴历」?
A:absl::Time 只支持格里高里历。
std::chrono 也只支持格里高里历。但是它的孵化者 HowardHinnant/date 支持儒略历和伊斯兰历。
关于儒略历请参考[历法复杂性]。
Protobuf
Protobuf 作为传输协议,只需要考虑时间表示。
- 使用
google.protobuf.Timestamp表示时间戳 - 使用
google.protobuf.Duration表示时间段
结语
至此,本系列的文章暂时告一段落。这个系列的开端只是笔者想简单介绍以前处理夏令时遇到的各种问题,开始查资料后就发现时间比笔者想象得复杂得多,内容也从最初规划的两千字扩展到现在的近五万字的系列文章。希望系列内容对大家有所帮助。
就在本文编写的过程之中,闰秒已经在 2022 年 11 月 21 号决定在 2035 年之前取消了:
The leap second will be scrapped by 2035 - The Verge
希望在未来,「时间」这个自古以来的抽象概念可以变得越来越清晰,而使我们不再需要处理如此之多的困难。也希望在这之前,我们可以避免时间导致的各种 bug/故障/事故。
参考资料
https://en.wikipedia.org/wiki/Leap_second
https://en.wikipedia.org/wiki/Daylight_saving_time#By_country_and_region
https://en.wikipedia.org/wiki/Time_formatting_and_storage_bugs
https://en.wikipedia.org/wiki/Clock_drift
https://en.wikipedia.org/wiki/Gregorian_calendar
https://en.wikipedia.org/wiki/Leap_year
https://en.wikipedia.org/wiki/ISO_8601
Falsehoods programmers believe about time zones
Falsehoods programmers believe about time: @noahsussman: Infinite Undo
More falsehoods programmers believe about time;…: @noahsussman: Infinite Undo
Erik Naggum — A Long, Painful History of Time
http://algeri-wong.com/yishan/great-unsolved-problems-in-computer-science.html
How we fixed a strange ‘week year’ bug - Technology in government
How to Handle Timezones and Synchronize Your Software with International Customershttps://www.youtube.com/watch?v=-5wpm-gesOY
Time Zone News: Countries That Change Their Clocks
Five different ways to handle leap seconds with NTP | Red Hat Developer