你永远无法理解时间
「明天这个时候」和「24 小时后」听起来差不多,遇到夏令时切换,却可能相差一小时。两次系统时间相减,也未必能得到操作耗时:如果中间发生校时,结果甚至可能是负数。
这些问题常常来自概念混用。日历上的日期、钟表上的读数、事件发生的时刻,以及实际经过的时长,遵守的规则并不相同。写定时任务、处理账单或分析日志时,得先确认业务需要哪一种时间。
一、基本概念
以下命题,哪些为真,哪些为假?
- 一年总有 365 天。
- 一天总有 24 小时。
- 一分钟总有 60 秒。
- 地球上所有人当前的时间都一样。
- 历法时间总是连续、单调向前的。例如,
2012-03-25 01:59:59的下一秒一定是2012-03-25 02:00:00。 - 计算机的系统时间总是准确的。
- 计算机的系统时间总是单调向前的。即,顺序调用系统库获取当前时间的函数,返回的时间总是递增(或非递降)的。
- Unix 时间戳只能以秒为单位。
- 服务器端和客户端的系统时间总是一致的。
- Unix 时间总是单调向前的。即,永远不会发生
Epoch Timestamp的倒退。 - 计算机可表示的时间没有上限,也没有下限。
计时和历法系统很像一个维护了几千年的软件。人们先从昼夜和四季中发现规律,建立日、月、年的模型;随后遇到模型与现实不符的地方,再补上闰日、闰秒和各种地方规则。
模型还要照顾社会生活。一个地区与谁做生意、怎样安排作息、采用哪种历法,都会影响当地如何记录时间。程序既要处理这些约定,也要面对存储范围、时钟误差和库实现的限制。
日常开发不必自己解决所有天文和历法问题,但需要知道时间库替我们处理了什么,以及哪些决定仍属于业务。手动拼年月日、写死两地时差、用系统时间测耗时,往往就是问题的起点。
答案:以上命题均为假。
这些反例要从秒、日、年各自的定义说起。
注:本文不讨论「时间的本质是什么」。这更多是哲学问题或是物理问题。
哪些时间现象是古人容易发现的?日出日落,春种秋收。古人很早就发现了这两个循环,并且由此衍生出了日和年这两个概念。
此时,日是一个昼夜的时长。年是一个季节周期的时长。
为了方便人类的计时,一天被分成了 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。
这三个定义得到的时长不同。标准日固定为 86400 秒,而现代秒的定义已经与地球自转解耦。
太阳日与恒星日的差别来自地球公转。地球自转一圈时,也沿轨道前进了一小段,还要再转一点,太阳才会回到同样的位置。因此,恒星日比太阳日短。图中的 1→2 是恒星日,1→3 是太阳日。

因此,我们在现在的时间系统里使用的是另一个概念,平太阳日:
平太阳日是经由观察太阳相对于恒星的周日运动,所获得的平均太阳时,经由人为的调整而显示在时钟上的时间。
平太阳日就是「平均」出来的一天长度。
平太阳日与固定的 86400 秒仍有差别,这要从现代秒的定义继续看。
秒
在现代时间定义中,秒是时间的基础单位。
秒的定义是精确的,即一秒所表示的时间永远是相同的。例如,如果命题「一分总有 60 秒」为假,那么只可能是因为分的长度不是固定的(60 * 1 秒),而不可能是秒的长度发生变化。
现代秒以铯-133 原子的跃迁频率定义:
将铯-133 原子不受扰动的基态超精细能级跃迁频率固定为 9192631770 赫兹;对应的 9192631770 个振荡周期定义一秒。
进而衍生出毫秒、微秒、纳秒、飞秒等更小的时间单位,都有准确的定义:
1 秒
= 1e3 毫秒
= 1e6 微秒
= 1e9 纳秒
= 1e15 飞秒
秒的单位长度固定,地球自转速度却会变化。用原子钟累计的时间,与根据地球自转得到的时间,会逐渐产生偏差。闰秒(Leap Second)用来控制两者的差距。
插入正闰秒时,UTC 读数从 23:59:59 进入 23:59:60,随后才到次日 00:00:00,这一天有 86401 秒。24:00:00 只是一天结束的表示法,不能表示额外的闰秒;RFC 3339 的示例也给出了闰秒在其他 UTC 偏移下的写法。
如果需要减去一秒,会在近期的一天跳过一秒:该天会从 23:59:58 一秒后跳到 00:00:00。
这样既保留了固定的秒长,也让民用计时接近地球自转,但转换层要承担额外复杂度。第 27 届国际计量大会的第 4 号决议决定,在 2035 年或之前扩大 UT1 与 UTC 的允许差值,以减少这种频繁调整带来的问题。
年
年的概念源自于地球的公转。类似于日,年也有几种定义:
恒星年(Sidereal year)是太阳在天球上返回到对恒星而言的相同位置上的时间。直观地解释,当天空的星象连续出现的时间间隔即为恒星年。
回归年(Tropical year)以季节循环为参照,与太阳相对春分点的位置有关。
两者也不相同。地轴会缓慢进动,带动春分点的位置变化,这就是岁差(axial precession)。恒星年与回归年的长度因此相差约 20 分钟。

红色的是自转轴。自转轴会缓慢转动,以~25772 年为周期绕行一周。
为了让日历中的季节保持稳定,公历需要用整数天数逼近回归年的长度。儒略历与格里高里历采用了不同的闰年规则,因此平均年长也不同:
- 儒略历:365.25 天。
- 格里高里历:365.2425 天。
日历需要按整天排列,因此用平年和闰年交替补偿这个差值:
一年有 365 或 366 天。闰年是为了弥补因人为历法规定的年度天数 365 日和平均回归年的大约 365.24219 日的差距而设立的。依我们现用历法,闰日在 2 月 29 号,大概每四年一次。
UTC
协调世界时(英语:Coordinated Universal Time,简称 UTC)是最主要的世界时间标准,其以原子时的秒长为基础,在时刻上尽量接近于格林威治标准时间。
记录跨地区的事件时刻时,UTC 提供了共同的时间基准;展示给用户时,再转换为当地时间。
时区
人们曾根据当地太阳的位置确定时间,因而不同经度有不同的地方时。时区把一个地区的钟表读数统一起来,方便交通、通信和日常协调。我国使用的北京时间通常对应 UTC+8。
不同地区采用不同的钟表规则,同一时刻的读数之差就是时差;它与经度有关,但不完全由经度决定。
行政边界、经济往来和历史沿革都会影响时区安排。程序中只保存一个固定偏移,往往不足以处理历史日期和未来日程。
夏令时和国际日期变更线,会进一步改变当地钟点或日期的对应关系。
夏令时
夏时制又称夏令时、日光节约时间,英文为 daylight saving time,简称 DST。常见做法是在春季把钟表拨快一小时,秋季再拨回。人们如果仍按同样的钟点上下班,实际作息就随之提前。
假设某地区的规则是春季 02:00 拨到 03:00、秋季 03:00 拨回 02:00:
- 夏令时到来时,会由 3 月 25 日 1:59 直接跳到 3:00,即 3 月 25 日 2:00 - 3:00 这一小时并不存在。
- 夏令时结束时,会在 9 月 25 日 3:00 跳回到 2:00,即 9 月 25 日 2:00 - 3:00 这一小时会发生两次。
本地钟表读数因此可能跳过一段,也可能倒退;按钟点预约和按时长倒计时需要分别处理。
设定「半小时后响」的闹钟,与预约「凌晨 2 点半响」,就会遇到不同的问题。
前者要测量经过的时长。如果从 01:45 起经过 30 分钟,恰好跨过春季拨表,钟表已经显示 03:15;实际只过了半小时。实现倒计时应使用单调时钟,避免系统校时影响。
后者按本地钟点触发。在这个假设规则下,春季切换日的 02:30 不存在,秋季切换日的 02:30 则出现两次。闹钟应该跳过、顺延,还是选其中一次,需要单独约定。
国际日期变更线
(剧透注意:以下有《八十天环游地球》的核心诡计揭示)
国际日期变更线是一条假想的子午线,在跨越这条线时会发生日期的变更。
国际日期变更线是为了防止绕地球一圈后,像《八十天环游地球》一样,沿自转方向绕地球一圈时,79 天的时间会经历 80 个昼夜。
Unix Time
Unix 时间以 1970-01-01T00:00:00Z 为 epoch,按固定单位表示相对这个锚点的时间值。接口常用秒、毫秒,也可能采用更细的单位。
它把每个标准日按 86400 秒编码,不累计闰秒。运行中的系统可以通过重复读数、跳变或抹涂等方式处理校时,因此不能从「保存的是 Unix 时间」推出「读取的时钟一定单调」。
NTP
计算机的硬件时钟有误差,运行一段时间后会偏离参考时间,这称为时钟漂移(clock drift)。
NTP(Network Time Protocol)通过网络交换时间信息,估算网络延迟与本机时钟偏差,再校准系统时间。实际误差取决于时间源、网络和本机环境。
NTP 会定时请求服务器并重新校准时间。调用 System clock 拿到的是校准后的时间,因此连续两次调用可能返回差距很大的结果,甚至出现时间倒流。
解答篇
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:不一定。
- 具体范围取决于数据类型和 API。有符号的时间戳可以表示 1970 年之前的时刻,但并非所有接口都接受负值。
- 当使用
int32存储Unix Timesecond 时,它会在 2038 年溢出,这是新的千年虫问题。 - 程序员还成功地创建了更多 bug,见后文介绍。
秒的单位、日历上的日期和系统时钟已经出现了几种不同的约束。接下来再看这些约束在真实历法和时区规则中怎样叠加。
二、历法复杂度
第一部分区分了「日、秒、年」这些基础单位,接下来可以看它们在历法里怎样互相打补丁。
进阶问题篇
注:本篇仅列出问题,接下来将分篇开展解答。
以下命题,哪些为真,哪些为假?
有关闰年
- 闰年每 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.00781 天。因此,约每 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 天,约每 1.7 万年累计一天。实际回归年长度仍会变化。
这个提议没有成为通行的格里高里历规则,但曾影响软件实现。SAS 在 2010 年记录过一个兼容性问题:Excel 接受 4000-02-29,SAS 的相关处理却把它转换成了 3 月 1 日,之后的日期随之错位。文章同时说明相关导入差异已在 SAS 9.1.3 和 9.2 中修复。这是历史案例,不能直接当成当前所有版本的行为。
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 视为闰年也使用户更容易将工作表从一个程序移动到另一个程序。
直接修改这个规则,会影响已有工作簿和序列日期的兼容性,所以 Excel 保留了 1900 年这个特例;其他世纪年份仍按通常规则处理,例如 2100 年不是闰年。详情见微软对 1900 年闰年问题的说明。
闰年导致的问题主要在于不应对日期进行朴素算术运算。例如:
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 记录中检索与闰年有关的历史问题。
闰年是如此之易错,以至于有人编排了一个段子:
寻找外星生命也许还有一个低成本思路:观测哪些行星的公转周期 / 自转周期是整数。一个技术能力足够的高等文明,人工调整公转和自转周期,可能比正确处理闰年简单多了。
解答
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 都可能用回跳的方式处理闰秒。
解析
闰秒通常在 UTC 的 6 月或 12 月末插入,并在全球同一时刻生效。UTC 的 23:59:60,在北京时间下对应次日 07:59:60,本地钟点取决于 UTC 偏移。
IERS 通过 Bulletin C 通报是否调整闰秒,公告每半年发布一次。地球自转速度有变化,无法像闰年一样按固定周期列出未来所有闰秒。2026 年 7 月的公告仍将 UTC−TAI 的差值列为 −37 秒,自 2017 年 1 月 1 日起保持不变。
由于在计算机系统里对闰秒的处理没有明确的细节规定,因此,这里会有大量的不一致,对实际处理造成了很多困难:
时间分配系统不一致
大部分时间分配系统只会在提前 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:59:60,而相当多的时间库不接受秒字段为 60。两种方式都要求调用方了解底层如何处理闰秒。

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

具体采用跳变还是渐变,要看 NTP 实现和配置。
- 闰秒抹涂(leap smearing),见下。
闰秒抹涂
闰秒抹涂(Leap Smear)将一秒的调整分散到一段时间里。Google 的方案采用以闰秒为中心、从 UTC 中午到次日中午的 24 小时窗口:
将闰秒均匀地抹涂到以闰秒为中心的 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。
不同服务采用不同的抹涂窗口和曲线,混用时间源时仍可能产生偏差。试图统一处理的方案,也会增加需要协调的约定。

参考资料:
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 时间不累计闰秒。需要精确计算跨闰秒的物理时长时,要使用明确支持相应时间标度的系统,不能直接把 Unix 时间差当成完整的 SI 秒数。
Q:「所有的 NTP 都可能用回跳的方式处理闰秒」吗?
A:NTP 对闰秒主要有三种处理方式:回跳、转向和抹涂。每种方式又有不同的实现。
时区
时区是时间系统里最不像数学题的一部分:它由地理启发,却由政治、经济和社会习惯决定。
命题
时区的直觉往往来自地理课上的 24 等分模型,真实世界要乱得多:
- 这个世界上有 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 度,以格林尼治天文台所在地,也就是经度 0 为中心,每 15 度划出一个时区。按这套规则,(W 7.5,E 7.5)是零区,(E 7.5,E 22.5)是东一区,东八区就是(E 112.5,E 127.5);东十二区和西十二区为同一时区。
这是理论时区。真实的行政边界、交通往来和经济联系,往往使法定时区偏离这些整齐的经度带。
实际时区,也称法定时区,是我们平时所指的时区的概念。实际时区通常用与 UTC 的时差来表示,如中国标准时间使用的是东八区,也就是 UTC+8。人类社会所使用的实际时区远超 24 个。
实际的 UTC 偏移不止 24 种,同一个偏移也可能对应多个地区时区。
例如,我国和新加坡都使用 UTC+8,但 Asia/Shanghai 与 Asia/Singapore 的历史规则不同。新加坡直到 1982 年才采用 UTC+8;处理更早的日期时,仅凭今天的偏移就无法正确转换。
UTC 偏移的范围可以超过 24 小时。
UTC-12 和 UTC+12 相差整整一天。例如,UTC 的 2022-10-08 12:00,分别对应两地的 2022-10-08 00:00 和 2022-10-09 00:00。还有地区使用 UTC+13、UTC+14,这与国际日期变更线的安排有关。
实际使用的国际日期变更线是一条在东经 180 度子午线附近、基本上只经过太平洋表面的折线。国际日期变更线的选择绕过国家,也就是尽量不让同一个国家在不同的两天。毕竟,旁边的地区时差是 1 个小时还好理解一些,但如果差的是 23 个小时,就很让人困惑了。考虑到这条子午线上绝大部分是海洋,这个调整不是特别困难。但是在国际日期变更线周围的国家,尤其是太平洋岛国,并不严格地根据地理位置是在国际日期变更线的哪一侧来决定自己是在早一天还是晚一天。

实际的国际日期变更线的形状
它们的选择更多地受政治、经济和社会因素影响——在国际日期变更线的国家大部分是太平洋岛国,他们倾向于与和他们外贸政经最紧密的经济体待在同一天,也就是亚太地区的几个主要经济体——澳大利亚,东南亚,还有东亚。所以,有好几个国家虽然在国际日期变更线的东侧,他们仍然选择与西侧的国家待在同一天。这导致了 UTC+12,UTC+13,UTC+14。
例如,新西兰主岛在夏令时期间采用 UTC+13。
时差可能与 UTC 相差 n + 1/2 小时。
非整小时偏移并不罕见,印度就是一个人口众多的例子。
印度使用的 Indian Standard Time(IN,IND)与 UTC 的时差为 5:30,即 UTC+5:30。
印度使用 UTC+5:30 超过了一个世纪。虽然这个世界上大部分国家都把自己的时区调整到了整时,印度始终使用半时时区。
时差可能与 UTC 相差 n + 1/4 小时。
尼泊尔采用 UTC+5:45。IANA 的 Asia/Kathmandu 记录显示,它在 1986 年从 UTC+5:30 调整为 UTC+5:45。
历史上,时区非常混乱。
在二战结束前,尤其是在一战以前,经纬度的设计尚未在全球得到公认时,各地的时区可谓五花八门。
举几个典型的例子:
荷兰历史上曾使用约 UTC+00:20 的本地时间,后来改用中欧时间。常见缩写 CET 对应 UTC+1,夏令时 CEST 对应 UTC+2。
利比里亚曾使用 UTC-00:44:30,1972 年改为 UTC+0,见 IANA 的 Africa/Monrovia 历史记录。
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
// 原问题环境:Java 1.6.0_22,Asia/Shanghai
public static void main(String[] args) throws ParseException {
SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
formatter.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai"));
Date start = formatter.parse("1927-12-31 23:54:07");
Date end = formatter.parse("1927-12-31 23:54:08");
System.out.println((end.getTime() - start.getTime()) / 1000);
// 原问题中的输出为 353,结果依赖所用的历史时区数据。
}这个问题来自旧版 Java 和它使用的历史时区数据。某次回拨让一段本地钟点重复出现,解析器选择不同的对应时刻后,读数相差一秒,时间戳却可能差几分钟。
原问题的 Java 1.6.0_22 环境得到的是 353 秒。回答随后记录了数据修订:TZDB 2013a 调整了转换细节,2014f 又把这次变更移到了 1900 年末。历史记录也会修订,因此不能把某个旧版本的输出直接解释成已经确定的历史事件。复现这类问题时,需要同时记录 Java 版本和 tzdata 版本。

时区是「区域」,不是「国家领土」。
有些国家,因为跨越的经度很长,所以为了方便当地的人生活,有超过一个时区。
例如,美国有多个时区。我们常说的美国有四个时区,是指:
- 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 东部时间,即纽约时间。
这四个时区只覆盖美国本土的常见划分,阿拉斯加、夏威夷及海外领地另有安排,例如:
- 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)— 威克岛时间
俄罗斯、澳大利亚和法国等国家也涉及多个时区;法国的海外领地尤其分散。查询时需要具体到目标地区。
统一使用北京时间,也不会让各地的日出日落同步。新疆、西藏与东部地区经度相差较大,同一钟点对应的太阳位置不同。新疆还存在 UTC+6 的地方时间用法,沟通日程时需要确认对方采用的时间口径。
时区仍然在不断调整。
全球协作让时区整体变得更规整,但即使到了 21 世纪,时区也仍然会被政策、经济和社会需求改变。
Time Zone News: Countries That Change Their Clocks 记录了每年地区时间系统的变化。
萨摩亚在 2011 年的变更更明显。IANA 的 Pacific/Apia 记录显示,当地在 12 月 29 日结束时向前跳过一天,直接进入 12 月 31 日。标准偏移从 UTC-11 改为 UTC+13;当时还在实行夏令时,实际钟表偏移是从 UTC-10 变成 UTC+14。
于是,当地的 2011 年 12 月 30 日星期五整个不存在。从星期四的最后一秒直接进入星期六,不只是凌晨预约,按日期统计、日历展示和周期任务也都需要处理这次跳变。
解答
Q:「这个世界上有 24 个时区」吗?
A:有 24 个理论时区。实际时区远超 24 个。
Q:「时区都与 UTC 时间偏差整数个小时」吗?
A:印度与 UTC 偏差 5.5 小时。
Q:「或者偏差半小时的整数倍」吗?
A:尼泊尔与 UTC 偏差 5.75 小时。
Q:「或者偏差 1/4 小时的整数倍」吗?
A:日常遇到的偏移常见整小时、半小时或 15 分钟的倍数,但历史上的偏移还可能精确到秒,不能把这些常见粒度当成永久限制。
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:跨越国际日期变更线时,相邻地区也可能处在不同日期,不能只按经度相邻就推断时差不超过一小时。
Q:「国际日期变更线是一条直线(或者更准确的,测地线)」吗?
A:国际日期变更线要尽量绕过陆地以免穿过国家造成混乱。因此它有好几处扭曲。
Q:「可以从一个地点所在的经度判断所在时区」吗?
A:新疆和相似经度的印度时区差 2.5 小时。
Q:「可以从一个地点所在的地理位置判断所在时区」吗?
A:地理位置不可靠,受行政区域的影响。
Q:「可以从一个地点所在的国家判断所在时区」吗?
A:不一定。
- 很多国家使用超过一个时区。
- 美国多数地区实行夏令时,Arizona 州的大部分地区不实行。
Q:「可以从一个地点所在的省/州/地区判断所在时区」吗?
A:不一定。
- 美国的时区不是按州划分的,所以会出现同一个州使用不同的时区。
- Navajo Nation 包含 Arizona 州内的地区,却实行夏令时;同一个州内也可能采用不同规则。
Q:「可以从一个地点所在的市判断所在时区」吗?
A:以色列和巴勒斯坦分别公布夏令时安排。即使两地的标准偏移相同,起止日期也可能不同,相关业务往来需要明确采用哪一套规则。
Q:「在 21 世纪,一个国家不会变更自己所在的时区」吗?
A:地区会调整 UTC 偏移和夏令时规则,也可能像萨摩亚在 2011 年那样改变日期所在的一侧。
Q:「周四之后必有周五」吗?
A:萨摩亚在 2011 年 12 月 29 日星期四结束后,直接进入 12 月 31 日星期六。
Q:「1927-12-31 23:54:07 的上海到 1927-12-31 23:54:08 的上海之间只有一秒」吗?
A:旧版 Java 和旧时区数据曾得到几分钟的差值;历史数据修订后,转换行为也会变化。
夏令时
夏令时可以看成时区系统里的临时切换规则。它由地区政策决定,没有全球统一标准。
命题
以下(DST = 夏令时,daylight saving time)
DST的偏移是一个小时(即夏季调快一个小时,冬季回调一个小时)。DST在所有的时区都同时发生变更。DST每年都在同一时间发生变更。DST总是对整个国家生效。DST总是对一个省/州/地区生效。DST总是在上半年开始,下半年结束。DST总是夏令时,即,总是夏季调快、冬季回调。DST的变更总是会提前充足时间预告。
解析
夏令时改变的是同一地区在不同日期采用的 UTC 偏移。例如,美国西海岸的标准时间 PST 为 UTC-8,夏令时间 PDT 为 UTC-7。在 IANA 模型中,两种偏移都可以由同一个 America/Los_Angeles 时区标识管理。
具体偏移量、起止日期和切换钟点由当地规则决定。只知道「这个地区实行夏令时」,还不足以完成换算。
中国的六年夏令时
我国在 1986 年至 1991 年间,连续六年推行围绕北京时间的夏时制。今天虽然已经不用每年拨表,程序处理那几年的日期时,仍然需要按历史规则换算。
从 1986 年实行,到 1992 年暂停
1983 年,清华大学教授谢行健在首都能源与环境学术讨论会上提出了实行夏时制的建议。此后,北京能源学会等参与推动,建议经过有关部门研究,最终在 1986 年落地。
1986 年 4 月 12 日的《关于在全国范围内实行夏时制的通知》(《浙江政报》1986 年第 8 期,第 21–22 页)把充分利用日光、节约照明用电作为主要目标。第一年需要宣传,并为铁路、民航、邮电等部门留出准备时间,所以推迟到 5 月 4 日开始,9 月 14 日结束。

本节剪报均引自百度百科「北京夏令时」词条。
具体拨法是:开始时,把凌晨 2 点拨到 3 点,北京时间的 UTC 偏移从 +08:00 变成 +09:00,称为北京夏令时;结束时,在北京夏令时凌晨 2 点拨回 1 点,恢复 +08:00。结束的「2 点」是拨表前的夏令时读数。
1987 年从 4 月 12 日开始、9 月 13 日结束,见北京市当年的执行通知。1987 年 9 月 9 日的国务院办公厅公告(公报第 709 页)又明确,从 1988 年起,每年在 4 月中旬第一个星期日开始、9 月中旬第一个星期日结束。
| 年份 | 开始日期(北京时间凌晨 2 点) | 结束日期(北京夏令时凌晨 2 点) |
|---|---|---|
| 1986 | 5 月 4 日 | 9 月 14 日 |
| 1987 | 4 月 12 日 | 9 月 13 日 |
| 1988 | 4 月 17 日 | 9 月 11 日 |
| 1989 | 4 月 16 日 | 9 月 17 日 |
| 1990 | 4 月 15 日 | 9 月 16 日 |
| 1991 | 4 月 14 日 | 9 月 15 日 |
这些起止日期也与 IANA 时区数据库 asia 文件中的 PRC 规则一致。规则里的 Sun>=11 表示当月 11 日或之后的第一个星期日,日期会随年份变化。北京市 1988 年的执行通知明确写明,当年的开始日期是 4 月 17 日。

两条时间轴都按真实发生顺序向右推进;秋季两个「01:30」分别带 +09:00 和 +08:00 偏移,实际相隔一小时。
1992 年 3 月 3 日的《国务院办公厅关于暂停实行夏时制的通知》(国办发〔1992〕8 号,公报第 152 页)决定,从 1992 年起暂停实行。因此,最后一次恢复标准时间是在 1991 年 9 月 15 日,1992 年没有再次进入夏令时。

剪报的消息头为「新华社北京 3 月 5 日电」,正式通知的落款日期是 3 月 3 日。
节电目标与实际代价
拨快一小时后,钟表上的早上 8 点,相当于标准时间的早上 7 点。如果上下班仍按相同的钟点安排,人们的活动就整体提前,有机会更多利用早晨的自然光,并在下班后保留更多白昼。白昼的总长度没有增加,变化的是活动时间与日照的对应关系。
| 方面 | 预期收益 | 成立条件或代价 |
|---|---|---|
| 照明用电 | 减少晚间开灯时间 | 收益取决于当地日照、作息和用电结构,不能把照明节电直接当成总能耗必然下降 |
| 日间活动 | 按相同下班钟点离开时,距日落更久 | 上班、上学也相应提前,需要适应新的起床和休息时间 |
| 统一安排 | 多个行业按同一套钟表时间协调 | 钟表、报时、班次和时刻表都要同步;切换当晚还会出现缺失或重复的钟点 |
1986 年 11 月 30 日《人民日报》刊载的新华社消息(文本存档)援引水电部门的抽样统计,称当年全国节约照明用电 6.27 亿千瓦时。这个数字的口径是当年的照明用电,不能直接外推为每年固定收益或全部能源的净节约量。
1992 年的暂停通知则说明了地区差异和实施代价:一些地区确实节约了照明用电,但我国南北温差、东西时差较大,又统一使用北京时间,西北、西南和长江流域以南地区没有取得节电效果;同时,群众生活和铁路运输等工作遇到了不便。这份评估针对当时我国统一实施的效果,其他地区和年代仍需结合当地情况判断。
北京 1988 年的执行通知还给机关工作人员留了一段适应期,4 月 18 日至 5 月 15 日上午 8:30 上班,5 月 16 日起才调整为上午 8:00。这些钟点都是拨表后的夏令时读数。
暂停之后,各地仍可以根据季节合理调整作息。北京市 1992 年的安排就保留了冬夏两套上下班时间。调整办公时间只改变日程;实行夏令时则改变当地钟表读数与 UTC 的对应关系,两者对程序的影响不同。
为什么 JavaScript 会显示中国夏令时间
在本地时区采用 Asia/Shanghai 历史规则的浏览器里,下面这个日期可能显示为 GMT+0900(中国夏令时间):

new Date(1987, 6, 12) 的月份从 0 开始,所以这里是 7 月 12 日。它按运行环境的本地时区解释年月日;1987 年 7 月正处在中国夏令时期间,因此采用 +09:00。截图中时区名称的语言和措辞取决于运行环境。
在同样的时区条件下,可以用偏移量和 UTC 输出核对,而不依赖那段中文名称:
const date = new Date(1987, 6, 12);
date.getTimezoneOffset();
// -540:该时刻的本地时间比 UTC 快 540 分钟
date.toISOString();
// "1987-07-11T15:00:00.000Z"
new Date('1987-07-12T00:00:00+08:00').toISOString();
// "1987-07-11T16:00:00.000Z"最后一行显式指定了固定偏移 +08:00,它与按当年北京夏令时解释的零点相差一小时。Date 的本地时间解释与偏移规则依赖宿主环境;在 UTC 环境里直接运行第一行,结果会不同。
Asia/Shanghai 包含历史变更,+08:00 只指定固定偏移。处理历史日期或未来预约时,应根据需要保留地区时区,不能把二者混用。
生活安排与程序里的注意事项
生活里最直观的是拨表、赶车、夜班和跨国约会;程序里则需要把缺失时间、重复时间和实际时长分别处理。以下场景沿用切换一小时的例子,其他地区还要查询当地规则。
当年的《请您今晚别忘了拨表》就分别提醒了值夜班和可以睡前拨表的人,还特别叮嘱赴机场、火车站送客的读者,留意公共场所的钟表已经拨快。赶车的人如果仍看着未调整的手表,就可能晚到一小时。

| 场景 | 容易出现的问题 | 处理方式 |
|---|---|---|
| 设备 | 手机、闹钟等自动校时设备已完成切换,又手动拨一次;起床安排与往常不同 | 确认设备时区和自动调整设置;机械钟按当地公告拨动,重要日程提前核对 |
| 排班 | 交通票面时间、夜班交接钟点可能跨过跳变;钟表上的一小时不一定对应一小时工时 | 确认运营方时刻表和时间口径;记录起止时刻及偏移,用实际时间差计算时长,计薪另按制度约定 |
| 预约 | 跨国会议或周期预约涉及的两地在不同日期切换,原有时差可能暂时变化 | 保存地区时区和当地预约时间;每次按该日期换算,避免全年写死两地相差几小时 |
| 任务 | 凌晨定时任务可能遇到中国 1987 年 4 月 12 日不存在的 02:30,或 9 月 13 日重复的 01:30 |
明确跳过、顺延或拒绝不存在的时间;明确重复时选第一次、第二次还是两次,并给任务执行加去重依据 |
| 统计 | 按天统计或出账时,切换日可能有 23 或 25 小时;固定加 86400 秒可能跨到错误钟点 | 按业务时区生成当天零点到次日零点的区间;按需分别统计自然日总量和每小时平均值 |
| 档案 | 迁移历史数据时,把 1986–1991 年的所有北京时间都按 +08:00 转换,产生一小时偏差 |
已知时刻保留原偏移或 UTC 值;只有当地读数时结合历史规则核对,重复时间缺少偏移就要保留歧义 |
| 日期 | 生日、纪念日仅有年月日,却强行补零点、转成时间戳,跨时区展示可能变成前一天 | 用纯日期表示;只有确实要记录出生时刻等信息时,才补充时间和时区 |
测试也要覆盖普通日期和切换边界。例如在 Asia/Shanghai 下检查 1987 年冬季的 +08:00、7 月的 +09:00,以及上述 02:30 的缺失、01:30 的重复。时间库可以提供转换规则,遇到歧义时如何履约、计费或执行任务,仍需要业务明确决定。
解答
Q:「DST 的偏移是一个小时(即夏季调快一个小时,冬季回调一个小时)」吗?
A:澳大利亚的 Lord Howe Island 的夏令时只调整半个小时。
Q:「DST 在所有的时区都同时发生变更」吗?
A:每个地区的政府组织会宣布自己的夏令时的进入和结束时间。因此,这个变更并没有明确的规律。
Q:「DST 每年都在同一时间发生变更」吗?
A:同上,由于是各个政府自行决定时间。
Q:「DST 总是对整个国家生效」吗?
A:美国多数地区实行夏令时,Arizona 州大部分地区不实行。
Q:「DST 总是对一个省/州/地区生效」吗?
A:Navajo Nation 位于 Arizona 州内的地区实行夏令时,同州其他地区可能不实行。
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
地区可能改变 UTC 偏移、调整夏令时起止日期,历史记录也可能被修订。程序通常通过 IANA 时区数据库获取这些规则。
这套数据库也称 TZ database、Zoneinfo 或 Olson database,使用 Asia/Shanghai、America/New_York 这样的地区名称。它重点记录 1970 年以来的变更,同时保留部分更早的历史数据。
系统、运行时和第三方库可能分别携带时区数据库,更新方式也不同。Python 的时间库并不会一概自动联网更新;Java 运行时也有自己的数据版本。部署时需要确认实际加载了哪一份规则,而不只是确认操作系统安装了最新版。
RFC 6557描述了数据库的维护流程。这是长期的社区协作,历史上由 David Olson、Paul Eggert 等人参与维护。

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 至少有三种解释:
YMD:2011 年 10 月 9 日。MDY:2009 年 11 月 10 日。DMY:2009 年 10 月 11 日。
把月份写成英文可以减少月、日混淆,但两位年份仍可能有歧义。必须用字符串传输时,日期宜采用 YYYY-MM-DD;完整时刻还要携带 UTC 标记或数值偏移。格式约定应写进接口,避免靠用户或系统默认习惯猜测。
各地习惯可参考日期格式对照,跨系统交换则优先使用 ISO 8601 或协议明确规定的格式。
其它历法
Q:「一年有 365 或 366 天」吗?
A:这种说法适用于公历。农历按月相安排月份,并通过闰月协调季节,一年不一定是 365 或 366 天。
Q:「同一时间只有一个历法会生效」吗?
A:我国会同时使用公历(格里高里历)与农历。
Q:「同一时间,同一个国家只会有一个历法生效」吗?
A:我国会同时使用公历(格里高里历)与农历。
Q:「一年有 12 个月」吗?
A:我国的农历有闰月,即一年可能有 13 个月。
物理学
Q:「在山顶的计算机和在平原上的计算机的时间是一致的」吗?
A:钟速还受相对论效应影响。在其他条件相近时,高处的时钟因引力势不同,会比低处的时钟走得稍快;NIST 的原子钟实验测出了这种差异。速度导致的时间效应也要另行考虑。
这些差异在普通业务中通常可以忽略,但卫星导航系统需要做相应修正。
特殊情况
Q:「所有的整数都是理论上可能的年份」吗?
A:传统公元纪年没有公元零年,公元元年前是公元前一年。天文学和部分时间库可能采用不同的年份编号,跨系统转换需要确认约定。
Q:「在同一时间,地球表面所有的点都属于且只属于某一个时区」吗?
A:极点处无法通过经度唯一确定当地钟表时间,所用时间需要靠站点或活动约定。沟通日程时仍要注明采用的时区。
三、编程复杂性
把时间写进程序后,还会遇到另一组约束:数据类型有范围上限,本地时间可能缺少时区,系统时钟可能被调整。相同的日期字符串,在不同环境中甚至可能解析成不同的时刻。
无效的时间表示
无效时间表示指时间的表示形式无法正确定位所指向的时间,通常由于时间超出了表示形式所支持的时间范围。
背景:计算机系统时间表示
在计算机系统内表示时间通常有两种形式:单值(绝对时间,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)。

Instant 表示时刻;图中固定在 2026-06-02T01:00:00Z,纽约在这一天采用 UTC−4。同一时刻在两地的日期可以不同。
忽略闰秒后,Unix 时间与标准日有精确的对应关系。因此,Unix 时间与 UTC 时间形成了一一映射关系。同样,一旦时区信息描述了与 UTC 的精确时差,本地时间与 UTC 可以形成一一映射关系。因此,假设以下条件满足,绝对时间与民用时间是等价(即可以相互转换)的:
- 绝对时间忽略闰秒
- 民用时间包含时区信息,且以与 UTC 时差形式存在
时间表示的局限通常有两种:
- 历法局限性:所使用的历法只支持有限时间区间。例如,格里高里历从 1582 年开始定义,无法严格表示 1582 年之前的时间。
- 实现局限性:指时间库的实现导致的人为局限。例如,Python 的时间库只支持 0001-9999 年份。
实现局限性
时间库会在范围、精度和实现成本之间取舍。原本够用的字段,可能随着年份增长或业务变化,逐渐逼近上限。
典型的问题有数值溢出、可表示范围不足,以及精度损失。
数值溢出
字段能容纳的数值有限,年份增长、计数回绕或编码方式变化,都可能越过这个上限。
Year 2000/Y2K
早期一些系统为了节省空间,用 YYMMDD 六位数字表示日期。年份只有两位,00 就无法独立区分 1900 年与 2000 年,进而影响排序、到期判断和日期运算。这就是著名的 Y2K 问题。
修复不仅涉及把字段加长,还要检查旧数据、接口与依赖是否采用相同的世纪解释。
Year 2022
2022 年 1 月,部分本地部署的 Exchange Server 邮件积压在传输队列中。微软的故障说明将原因定位到反恶意软件引擎的日期版本检查。
版本号 2201010001 超过了 32 位有符号整数上限 2147483647,转换失败后扫描引擎无法正常加载。修复更新改用了以 211233 开头的版本号,例如 2112330001。它是更新版本号,不是一个真实的「12 月 33 日」。
把日期编码成整数,并不会消除范围限制;还需要确认编码后的最大值是否落在目标类型内。
Year 2036
NTP 时间戳的整数秒部分是 32 位无符号值,以 1900 年 1 月 1 日为起点计数。
大部分人在使用时间戳时往往没有感受,但其实一年有很多秒,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 开始计数。
这就是 NTP 的 2036 年转期问题,处理时需要结合上下文区分它属于哪一个计数周期。
这个问题相对不严重,因此 NTP 时间表示通常只在操作系统层面处理,不会透传到软件层面——也就是 NTP 时间表示通常不会用来作为时间的储存格式。所以只要操作系统及时更新即可。另外,NTP Date Format 是更新的 NTP 格式,已经转换为了 128 bit int 来表示时间,使用 64 bit 表示秒。这在宇宙完结之前应该不会溢出了。
Year 2038
使用 32 位有符号整数保存 Unix 秒数时,正值部分只能覆盖约 68 年。从 1970 年起计数,就会在 2038 年越过上限。这是这种存储表示的限制,不能推广到所有 Unix 时间类型。
Y2038 是一个堪比 Y2K 的严重问题。
采用更宽的时间类型可以避开这一上限,但旧系统、协议字段和第三方依赖仍可能使用 32 位秒数。检查时要覆盖完整的数据链路。
可表示范围
即使没有整数溢出,API 自己也可能限制可接受的年份或时间值。
Year 1970
有符号的 epoch 时间值可以表示锚点之前的时刻。例如,JavaScript 的 new Date(-1000).toISOString() 得到 1969-12-31T23:59:59.000Z,Date 的范围说明也包含 epoch 之前的日期。
具体 API、操作系统和协议是否接受负值,仍需分别确认。生日通常适合纯日期类型,因为它一般只有年月日语义,把它强行转换成某个时区的零点会引入不必要的偏移。
Year 9999
Python 时间库使用民用时存储时间。它的年份的范围是 datetime.MINYEAR(Year 1)至 datetime.MAXYEAR(Year 9999)。使用 Python 时间库存储超过公元 9999 年会有问题。在天文学等领域,已经开始造成困惑。
精度不足
时间字段能表示多细、时钟多久更新一次、读数离真实时间多远,是三个不同的问题:
- 表示精度:类型或格式能表示的最小单位,例如毫秒、纳秒。
- 分辨率:实际读数能区分多小的变化间隔。
- 准确度:测量值与参考时间接近到什么程度。
一个 API 返回整数纳秒,不代表每隔一纳秒读数就会变化,更不代表读数与真实时间只差一纳秒。底层硬件、操作系统实现和校时状态都可能影响结果。
时间表示缺少必要信息
一个值即使落在合法范围内,也可能因为缺少时区或单位而被错误解释。
民用时间不记录时区
如果不涉及国际化业务,通常会为了简单起见,使用民用时间存储时间戳时,不存储对应时区,而依赖系统自带时区(通常是所在地时区)。这样做的主要问题是,并没有公认「正确」的本地时区 – 到底应该使用某个固定的时区(如东八区),还是 UTC,还是使用数据中心所在的时区?
在国内,8 小时是一个神奇的常数。一旦发现记录的时间和预期相差 8 个小时,往往是因为某些地方设置了时区而某些地方未设置时间。
一个可行(但不完备)的检验是在单元测试中修改你的系统时区,检查测试是否仍然通过。系统时区通过 TZ 环境变量定义。例如在 Bazel 中,默认时间是 UTC。可以通过设置 --test_env=TZ=Asia/Shanghai 调整时间为中国标准时间(CST)。如果添加 --test_env=TZ=Asia/Shanghai 与否会影响测试是否通过,那么代码中存在潜在的风险。
高危操作也是变更系统时区。这可能会导致非常隐蔽的问题发生。
例如,把数据中心统一配置为 UTC 时,需要同时检查依赖本地时区的任务。假设一个工作日定时转账任务使用以下 cron 表达式:
5 2 * * 1-5,即周一至周五凌晨 02:05。
如果调度器原先按 UTC+8 解释它,改为 UTC 后,触发的绝对时刻就会移动八小时。系统时间戳本身没有回拨,但任务的解释规则已经改变;迁移当天是否可能重复或漏跑,还取决于调度器怎样重新加载和记录执行状态。
部分实现支持 CRON_TZ。显式指定任务时区,并用业务标识防止重复转账,比依赖进程的默认时区更可靠。
以下是几个导致事故的例子:
- 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 是不良定义的。)
同一个时刻可以写成时间戳,也可以写成年月日和钟点。转换规则明确时,两种表示可以互换;但它们的加减法未必相同。闰年、月份长短、夏令时和闰秒,都会改变日历单位与固定秒数之间的关系。
例如,当我们说「一个月后」,我们可能是指:
- 30 天(30 * 86400 秒)后
- 下个月同一日的同一时刻
下个月可能没有同一天,切换日也可能没有同一个钟点。这里沿用 Joda-Time 的术语 Duration 和 Period 来区分:
- Duration 表示固定时长,例如 3600 秒或 86400 秒。
- Period 表示日历周期,例如一个自然日、一个月或一年。它对应多少秒,要结合起点、历法和时区规则判断。例如,从 2023 年 3 月 1 日到次年同日,会经过 2024 年的闰日。

沿用 Asia/Shanghai 的 1987 年夏令时规则,次日中午距起点只有 23 小时;固定经过 24 小时,则已经是次日下午 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。
以 America/Los_Angeles 为例:
2022-03-13T01:00:00-08:00经过 10800 秒后是什么时间?
当天凌晨 2 点开始夏令时,钟表直接拨到 3 点。固定经过三小时后,当地时间是 2022-03-13T05:00:00-07:00;直接对钟点加三,得到的 04:00 只经过了两小时。
这里用偏移量标明了本地时间。RFC 3339 中的 Z 表示 UTC 零偏移,不能给这个美西本地时间示例加上 Z。
计算两个日期之间有几天
给定两个日期,计算它们相隔多少自然天,是另一个常见任务。
考虑下列的常见处理:
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 天。
固定 30 天容易计算,但「一个自然月」和「30 天」是两种不同的产品约定。会员有效期、账单规则和界面文案需要使用同一套定义。
按天聚合数据
按天统计采用的是日历窗口,窗口内实际经过的时长却未必固定。
举个例子,考虑一个服务器在硅谷的业务,需要按天聚合业务数据并进行数据分析。
下面用伪代码表示按当地自然日取数据的思路:
val init_date = Date(2015, 3, 24, "America/Los_Angeles")
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 是每一天的事件
}如果时区在某一天切换夏令时,当天可能只有 23 小时或长达 25 小时。这个窗口仍然覆盖了完整的自然日,但直接比较两天的请求总量时,窗口长度已经不同。
改成固定 86400 秒的窗口,可以保持时长一致,却可能让边界从当地零点移到凌晨一点,无法继续对应同一个自然日。
按营业日出账,可以使用当地零点到次日零点;比较请求强度,则可以考虑按实际小时数归一化。窗口的选择要跟统计指标一致。
重复的时间(反方向的钟)
在 America/Los_Angeles,2022-11-06 01:30 可以对应两个不同的时刻:
- 回拨前:
2022-11-06T01:30:00-07:00,对应 UTC 的08:30。 - 回拨后:
2022-11-06T01:30:00-08:00,对应 UTC 的09:30。
如果预约在这个钟点转账,只保存本地日期和钟点就不足以确定触发时刻。调度两次还可能产生重复付款。业务可以选择:
- 两次都触发,例如确实需要覆盖每次出现的提醒。
- 只触发一次,并明确选回拨前还是回拨后,例如定时支付。
- 拒绝歧义输入,要求用户重新选择或补充偏移量。
时间库的默认选择也需要检查,不能把默认行为直接当成业务约定。
非单调/非连续时钟
夏令时改变的是本地读数。这里讨论另一类问题:系统时钟本身经过校准或手动调整后,时间戳可能跳变甚至倒退。
非单调/非连续肇因
系统时钟是不可靠的。导致时钟非单调/非连续的主要原因通常是计算机内的时钟是可调整的。和现实中的钟表一样,用户可以随意地调整计算机内的时钟。
NTP 校时可以采用渐变,也可以直接跳变。ntpd 4.2.8 的时钟状态机说明就区分了常规渐变,以及持续大偏差时的跳变处理。阈值、启动参数和实现都会影响行为。
因此,启用了 NTP 也不能保证挂钟永远连续、单调。
所以,使用系统时钟进行以下操作都可能有问题:
时间戳作为判定事件先后顺序依据。
不同机器的挂钟可能有偏差,同一台机器的挂钟也可能回拨。时间戳可以帮助观察事件,但单靠它不能证明事件之间的因果关系。
单机内需要稳定排序时,可以使用相应的单调读数或序列号;分布式系统还需要结合通信、逻辑时钟或其他协调机制。
计算耗时。
由于时钟的不可靠,不应该使用
end_wall_clock_time - start_wall_clock_time来计算耗时。尤其在进行 Profiling 时,不应该使用时钟时间。
测量耗时应使用单调时钟。秒表需要经过的时长,不需要一个可被校准的日期和钟点。

数值为示意。上下两行是在同一次操作开始、结束时取样;挂钟经历了校时回拨。单调读数用于同一时钟范围内的耗时测量,不能拿不同机器的读数直接比较。
Cloudflare 在 2017 年的故障复盘记录了一个具体后果:RRDNS 用挂钟差值测量解析器响应时间,闰秒回拨让结果变成负数。负数经过平滑后进入上游选择算法,触发 rand.Int63n() 的 panic,部分 CNAME 解析失败。
复盘给出的峰值影响约为 0.2% 的 DNS 查询,并非全部服务器宕机。修复先拦住负值,再部署到受影响的节点。Go 后续在 time.Time 中加入了单调读数;设计说明讨论了相应 API 行为。
Linux 的单调时钟
(以下讨论均基于 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,提供了更为快速的、但是精度更低的单调时钟。
主流的编程语言的时间库都提供了对单调时钟的支持。例如:
- C/C++ 可以直接调用
clock_gettime - Java
System.nanoTime()通常会返回单调时钟的时间 - Python PEP 418 提供了对单调时钟的支持,即
time.monotonic - Go 的
Time同时存储了realtime和monotonic time
选择 API 时还要确认是否计算系统休眠期间的时间,这会影响超时、定时器和运行时长的含义。
客户端-服务端不同步
客户端与服务端可能使用不同的时间源和时区规则。NTP 解决时钟校准,tzdata 提供地区换算规则,二者也需要分别检查。
不可靠客户端
在涉及到客户端开发时,一个常识是不要信任客户端的输入。但是容易被人忽略的是,客户端的时钟时间也是一种输入。
尤其是在监控、日志收集等系统中,简单地使用客户端提供的时间戳可能会导致大量的问题。
例如,流式系统按客户端的事件时间选择统计窗口时,如果客户端时钟偏慢,数据可能被误判为过期;偏快则可能落到未来窗口。服务端需要规定可接受的偏差和补偿方式,不能把客户端时间直接作为可信的限流依据。
离线游戏如果只按本地挂钟恢复体力,也可能被用户通过改时钟绕过。客户端定时若依赖 currentTimeMillis + 时长,同样要考虑挂钟调整。
不可靠 NTP
云服务商通常提供 NTP 时间源,但客户端是否启用、能否连通、是否完成同步,都不由业务服务器控制。
配置多个时间源时,需要确认它们采用相容的闰秒策略,避免混用不同抹涂规则。即使客户端开启了 NTP,服务端仍需检查其时间戳是否合理。
客户端与服务端的时区规则不同
闰秒、时区调整(如正常时间/夏令时的切换、一次性的时区调整等)都是通过 tzdata 来同步的。而 tzdata 通常都是由系统/语言自己定期更新。
服务器端和客户端所使用的 tzdata 可能不一致。除了更新可控环境,还要在协议中明确传输的是 UTC 时刻、固定偏移还是地区时区,并规定出现差异时以哪一方的解释为准。
典型案例是埃及在 2016 年宣布夏令时后,因为议会争议又直接取消了它。要让系统保持一致,所有人都必须一起切换,或者一起不切换 tzdata。部署与客户端更新不同步,就可能让同一个本地钟点得到不同的换算结果。
Time Zone Chaos Inevitable in Egypt
不可靠的服务器
客户端之外,服务器自己的时间数据也不总是可靠。
过期的 tzdata
tzdata 需要及时更新。所以一个线上跑几年不重启的服务,可能 tzdata 不更新,会导致问题出现。
尤其是像 Java,它的 tzdata 实际上是在 JRE 中。而要更新 JRE 我们往往需要重新部署。如果太久没有部署,下次部署可能会非常痛苦。
服务器侧未同步的 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保持同步。
排查时应分别记录操作系统、运行时和第三方库的数据版本,确认实际执行转换的组件。
NTPD 不生效
扩容或冷启动时,新节点可能还没完成第一次校时,业务进程就已经开始处理请求。即使 NTP 进程存在,也需要检查同步状态、偏差和最后一次成功更新时间。
校时周期和启动顺序同样会影响结果。把「服务正在运行」等同于「时钟已经同步」,容易漏掉这些启动窗口。
连续取时与定时等待
即使暂时不考虑历法和时区,连续读取时钟、生成标识和等待一段时间,也各有边界。
错误假设:对 time() 连续调用会返回相同值
一个隐蔽的错误假设是当连续调用获取当前时间时,会返回相同值。下面的例子:
createTime := time.Now()
updateTime := time.Now()
// CAUTION: it is possible that createTime != updateTime创建记录时,业务也许不在意时间早晚几微秒,但可能要求 createTime 与 updateTime 完全相同。
这种情况下读取一次时钟,再复用同一个值即可:
now := time.Now()
createTime := now
updateTime := now连续取时既不能保证相同,也不能保证不同。具体结果会随调用间隔和时钟分辨率变化。
错误假设:对 time() 连续调用会返回不同值
相反的错误假设是认为连续两次获取当前时间会返回不同的值。
时间库可能返回的精度较低,如 System.currentTimeMillis() 只能返回毫秒精度的时间。
另一方面,虽然现代 CPU 的时钟频率已经够高,并且很多时间库已经支持纳秒级别的精度,但是实际返回的时间精度可能并达不到所支持的级别。这是由底层的系统时钟的精度限制的。
错误:使用创建时间的时间戳作为事件/资源 UID。这可能并不能保证唯一性。
uuid := time.Now().String() // Danger! Might have duplicated UUID.错误假设:Thread.sleep 是精确的
Thread.sleep 和类似依赖系统抢占的任务都不会特别精确。遇到 Stop the world GC 时,误差甚至可能超过 1 秒。对时间精度要求高的操作,不要依赖 Thread.sleep。
四、正确处理时间
代码里的时间类型要与业务含义对应。记录已经发生的事件、保存未来预约、计算会员有效期和测量请求耗时,可能需要不同的表示与时钟。
通用原则
先确定字段表示什么,再选择存储格式和运算 API。
时间表示
已经发生的事件,通常适合用绝对时刻记录,例如 UTC 时间或带明确单位的 epoch 时间戳。这样便于比较先后、计算固定时长,也不必在传输中依赖某台机器的本地时区。
下面几种业务还需要保留不同的信息:
- 生日、纪念日 只有年月日,适合纯日期类型。
- 未来的当地预约 需要保留本地日期、钟点和地区时区,才能在规则变化后按业务约定重新换算。
- 按月续费、排班 需要日历周期语义,不能一律替换成固定秒数。
- 闰秒或天文时间 需要专门确认时间标度和类型支持,普通 Unix 时间戳不足以覆盖所有需求。
表示时刻时,优先使用语言或协议的类型,例如 Go 的 time.Time、Java 的 java.time.Instant、Protobuf 的 google.protobuf.Timestamp。
如果必须使用原始数值,字段名应包含单位,例如秒或毫秒。需要兼容锚点之前的时刻时,还要确认有符号类型和下游协议的支持。常见的高精度表示是 (seconds: int64, nanoseconds: int32)。
需要表示当地时间时,显式保存或传入地区时区。仅设置服务器的 TZ="Asia/Shanghai",不等于所有依赖都会采用同样的默认值。
测试相关
业务代码直接调用当前时间,会让测试结果随执行时刻变化。常见处理方式有:
- 注入
Clock接口,测试中传入固定或可控的时钟。这通常最容易隔离。 - 替换库的当前时间提供者,例如 Joda-Time 的
DateTimeUtils。这类全局设置需要恢复,也要防止并行测试相互影响;它不等于修改操作系统时钟。 - 在回放或结果比对中忽略不属于断言目标的时间戳。如果时间本身就是业务规则的一部分,仍要用可控时钟测试它。
单调时钟
挂钟时间用于回答事件发生在何时,单调时钟用于测量经过多久。服务器需要校时,但校时不能替代单调时钟;超时、性能测量和重试间隔,应使用对应运行时提供的计时 API。
时间运算
Duration 表示固定时长,Period 表示日历周期。出海业务、账单和排班尤其需要区分两者。时间库只有 Duration 时,日历计算通常还需要结合当地日期、时间和时区规则完成。
日期表示
只有年月日的字段,优先使用纯日期类型。用当地零点模拟日期,会额外引入时刻和时区,跨地区展示或运算时更容易产生偏移。
选择时间库的不完全检查清单
选择时间库前,先确定业务需要哪些能力。日志记录、生日表单和跨国排班的要求差别很大,没有必要让每个业务都承担完整的天文计时复杂度。
至少确认时刻表示、时区换算和更新方式,再根据日历运算、精度、测试和非公历需求检查:
- 是否包含了足够所需的时间概念的抽象?
- 是否提供「
Instant」(时刻)类型? - 是否支持「
Duration」(时间段)类型?是否区分了Duration与Period(或Interval)? - 是否提供「
Local Date」类型(一个抽象的、与时区无关、与时分秒无关的日期),并支持相关的计算? - 是否提供「
Time Of Day」类型(一个与日期无关、与时区无关的挂钟时间),并支持相关的计算?
- 是否提供「
- 是否正确地处理了时区?
- 是否正确地处理了夏令时?
- 如果民用时间和绝对时间的转换(例如由于夏令时,或时区更新)出现了二义性(
Overlap)或不存在的时间(Gap),是如何处理的? - 是否可以正确地更新
tzdata?
- 如何处理闰秒?
- 是否具有足够的时间精度?
- 是否支持单调时钟以测量时长?
- 是否支持星期?
- 是否包含时钟接口,还是仅提供静态的时间提供方法?
- 是否支持所需的非公历历法,例如是否需要支持阴历?
Go
Go 的标准库提供了很强的 time.Time,但它也要求使用者理解挂钟时间、单调时钟和时区数据的边界。
使用 time.Time 标准库
Q:「是否包含了足够所需的时间概念的抽象」?
A:Go 的 time 包分别用 time.Time 表示时刻、time.Duration 表示固定时长。
time.Time对应这里的Instant概念。time.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
遇到 Overlap 或 Gap 时,time.Date 文档说明它会按转换前后的某一个偏移返回结果,但不保证具体选哪一个。业务需要指定第一次、第二次或拒绝输入时,要额外处理,不能依赖一次实验得到的默认选择。
时区规则的加载也要检查。环境没有可用数据时,可以显式引入 time/tzdata,把备用数据嵌入程序。它会增加程序体积,部署时需要在体积和数据可用性之间取舍。

上图是 Go 1.19.3 的嵌入时区数据源码节选。
Q:「如何处理闰秒」?
A:time.Time 忽略闰秒。
time.Time 不能直接表示 23:59:60。运行期间读数如何变化,还取决于操作系统和时间源采用跳变还是抹涂等策略。
Q:「是否具有足够的时间精度」?
A:time.Time 的精度为纳秒。
表示为纳秒,不代表底层时钟具有纳秒分辨率,两者的区别见精度不足。
Q:「是否支持单调时钟」?
A:time.Now() 返回的值同时包含挂钟读数和单调读数。
按 Go 的单调时钟规则,两端都有单调读数时,Sub、Before、After 和 Equal 等操作使用单调读数;任一端没有时,才回退到挂钟。因此,应使用时间类型提供的方法:
- 用
t1.Sub(t2)计算时长,而不是相减UnixNano()的结果。 - 用
t1.Before(t2)或After比较先后。 - 用
t1.Equal(t2)判断时刻相同。==还会比较Location和单调读数;需要用作 map key 时,应先统一表示。
AddDate、Round、Truncate、In、Local、UTC 会移除结果中的单调读数。显式移除时可以用 t.Round(0),它保留 Location;需要统一为 UTC 时另用 t.UTC()。
JSON 往返序列化也会丢失单调读数,并保留数值偏移而不保留原来的地区时区名称。例如:
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:序列化不保留单调读数,地区时区信息也可能改变在这个示例中,要判断事件中的时间是否指向同一时刻,应比较 before.Time.Equal(after.Time)。
Q:「是否支持星期」?
A:time.Time 支持获得 weekday。并且 time.Weekday 是定义好的枚举类型:
time package - time - Go Packages
Q:「是否包含时钟接口,还是仅提供静态的时间提供方法」?
A:time.Time 没有提供时钟接口,而只提供了 time.Now() 的全局方法提供时间。
因此,如果测试有需要,应该封装一个 Clock 接口。
Q:「是否支持所需的非公历历法,例如是否需要支持阴历」?
A:time.Time 不支持其它历法。
Java
Java 8 及之后优先使用 java.time.*。需要维护 Java 8 之前的项目时,可以评估 Joda-Time。
旧的 java.util.Date、Calendar 和 SimpleDateFormat 有几类常见问题:
Date和Calendar可变,SimpleDateFormat不是线程安全的。Date实际表示时刻,名字却容易让人以为它只表示日期。- 部分旧 API 的月份从 0 开始,年份以 1900 为基准,容易误用。
- 本地时间、时区和日历操作的职责不够清楚。
Joda-Time 提供了更明确的时间类型。JSR 310 随后把 java.time 引入 Java 8,让时刻、日期、当地时间和带时区时间可以分别建模。
使用 java.time.* 或 JodaTime
Q:「是否包含了足够所需的时间概念的抽象」?
A:java.time.* 均有提供。
java.time.Instant表示时刻,例如Instant.ofEpochSecond(1670245951)对应2022-12-05T13:12:31Z。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 的时间线按一天 86400 秒建模,不能把它当成直接表示 23:59:60 的容器。具体时间标度与闰秒约定,见 Instant 的 Java Time-Scale 说明。实际读时行为还取决于底层时钟。
JodaTime 亦然。
Q:「是否具有足够的时间精度」?
A:java.time.Instant 的精度是纳秒(即 1e-9 秒),java.time.Duration 的精度也是纳秒。
Joda-Time 的 Instant 表示精度是毫秒(1e-3 秒)。这些都是表示精度,仍需与底层时钟的分辨率、准确度区分。
Q:「是否支持单调时钟」?
A:java.time.* 不支持单调时钟。
使用 System.nanoTime() 以获取单调时钟。
System.nanoTime()(Java Platform SE 7)
nanoTime 有两个值得注意的点:
nanoTime是与某一个锚定时间的以纳秒计的时差,但nanoTime并不是从启动以来的纳秒数。nanoTime甚至可能是负的——因为锚定点可能在未来。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 日历,即前推格里高里历;java.time.chrono还提供日本历、民国历、泰国佛历和 Hijrah 历等支持。需要我国农历时,仍要另外确认对应实现和数据。
Kotlin
Kotlin/JVM 可以复用 java.time 处理日期和时区。kotlin.time则提供时长计算和时间测量能力,例如使用单调时间源测耗时。
kotlin.time 是多平台标准库能力,不能把它整体理解为只在 JDK 8 上运行的 java.time 扩展。
JavaScript
JavaScript 的 Date 可以记录时刻,但没有把纯日期、当地钟点和带时区时间分成独立类型。展示、日历运算和耗时测量需要使用不同的 API。
这种设计有历史原因。早期 JavaScript 的 Date 借鉴了 Java 1.0 的 java.util.Date,月份从 0 开始、可变 setter 等习惯也随之保留。《JavaScript: The First 20 Years》记录了这段背景。
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 是按日历语义加一个月。类型直接表达了各自的语义,日期加一个月也不会与固定时长加法混在一起。
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:这些常见的业务日期方案通常不直接表示闰秒。
Date 使用的是接近 Unix time 的 epoch 毫秒模型,不表达闰秒。date-fns、Day.js、Luxon、Moment.js 这类围绕 JavaScript runtime 或 IANA 时区规则工作的库,通常也不会把闰秒作为业务可见的 23:59:60 来处理。Temporal 同样需要按它的时间标度规则使用。
业务依赖闰秒、TAI 或天文时间时,应选择明确支持这些标度的专门系统。
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,单位是纳秒日期库负责表示、解析、格式化和日历运算;耗时测量则使用运行时的单调时钟。
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 风格的 std::time()、标准库 std::chrono,以及 Abseil、Boost.Date_Time、Howard Hinnant 的 date 库等第三方实现。
std::chrono 在 C++11 中提供时钟、时刻和时长,C++20 又加入了日历与时区支持。能否使用相应能力,要看项目的语言标准和标准库实现。
下面以 Abseil 说明显式的民用时间模型,同时对照 C++11 中的 chrono 时钟接口。项目仍停留在 C++17,或标准库时区能力不满足要求时,可以据此评估替代方案。
使用 Absl 时间库
Q:「是否包含了足够所需的时间概念的抽象」?
A:absl Time Library 均有提供。
absl::Time 表示绝对时刻,可以通过带单位的转换 API 与 Unix 时间值互换。
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 的类型
C++11 的 std::chrono 主要提供时刻和时长;日历与时区能力要到 C++20 才进入标准库。
std::chrono::time_point表示某个时钟上的时刻。time_since_epoch()返回距离该时钟 epoch 的时长,不能把任意时钟的 epoch 都当成 Unix epoch。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::duration 可以选择纳秒单位,time_point 的表示单位则取决于它使用的 duration;底层时钟的实际分辨率仍需另外确认。
Q:「是否支持单调时钟」?
A:absl::Time 不支持单调时钟。
std::chrono 提供了单调时钟。std::chrono C++11 提供了三种时钟:
std::chrono::system_clock:即常规挂钟时间的时钟std::chrono::steady_clock:单调时钟std::chrono::high_resolution_clock:系统所能提供的最高精度的时钟。通常不推荐使用
Q:「是否支持星期」?
A:absl::Time 支持星期,支持星期的计算。absl::Weekday 是星期的类型:
abseil-cpp/civil_time.h Weekday
Q:「是否包含时钟接口,还是仅提供静态的时间提供方法」?
A:Abseil 通过 absl::Now() 获取当前时刻,没有统一的可注入 Clock 接口。
因此,如果测试有需要,应该封装一个 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表示时间段
结语
写时间相关代码时,先确认字段的语义:事件时刻、纯日期、当地预约、日历周期或固定时长。类型选错,后面即使调用了成熟的时间库,也可能算出业务不需要的结果。
容易漏掉的边界适合放进测试:闰日、月底、夏令时的缺失与重复时间、历史时区,以及系统校时后的耗时计算。依赖外部时区规则时,还要知道数据从哪里加载、怎样更新。
时间库负责实现转换规则,业务负责决定遇到歧义时如何执行。把这两层约定写清楚,比记住「一天通常有多少秒」更能避免问题。
参考资料
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