程序员眼中的日期与时间

本文使用了 JavaScript 脚本来生成动态网页内容。请确保已开启浏览器的 JavaScript 支持

大多数的应用程序都需要同日期(date)与时间(time)打交道,这取决于具体用例。一些应用程序使用日期与时间来决定何时用户可以获得日常登录奖励。另一些应用则需要根据用户所在地的时区来向他们展示下订单的时间。这些种种用例要求我们的程序员使用一套成熟且精密的方式来管理日期与时间,并且了解在日期与时间处理时会遇到的一些常见问题及其解决方案。

一张装饰用图片,展示了使用马赛克风格瓷砖拼装的树叶图案,经过染色滤镜效果处理
摄于新加坡滨海市区线市中心站,2023 年春季

纪元

让我们从纪元(Epoch)开始讲起。纪元是元年开始的时间。比如在我们广泛使用的纪年方式公历(又称格里高利历,Gregorian calender)中,今年就是  年。然而除了公历以外,很多国家和地区还采用了自己的纪年方式,比如农历、印度历或是希伯来历。他们的纪年方式与公历可能存在不同。

为了更细致的表示日期与时间,国际标准化组织(ISO,International Organization for Standardization)在 ISO 8601《数据元和交换格式 信息交换 日期和时间表示法》中规定了基于公历纪年的日期与时间表示方法:将日期使用年、月、日进行表示,而本地时间则基于以 24 小时制为基础的时、分、秒表示。该标准还规定了时间、时间间隔与时区偏移量的基本表示方式。

更多信息……

在 ISO 8601 中,表示日期除了可以使用年、月、日的方法以外,还可以通过年、该年的第几天进行表示,或者通过年、第几周、该周的星期几进行表示;

在计算机领域中,除了将日期与时间使用 ISO 8601 表示以外,UNIX 时间(UNIX time)也被广泛使用。它是自公历 1970 年 1 月 1 日 00:00:00 UTC 以来的秒数:。虽然许多规范中对于请求计算机时间时应该返回何值并没有明确规定,但绝大多数系统会使用 UNIX 时间戳(UNIX timestamp)作为系统时钟。

Python 代码实例:获取当前的系统时间
print("Local system time is:", time.time())

时区

到目前为止,我们已经学到了如何表示一个特定的日期与时间。但很多人容易忽略的一点是,这对用户来说是没有意义的。因为取决于用户所在的地理位置,用户的本地时间相对于协调世界时(UTC,Coordinated Universal Time)会根据某种规则产生偏移,这种偏移规则被称为时区(timezone)。“包含时区信息的日期与时间”才是对于某个时刻(date-time)的精确描述,有了偏移的规则信息,我们可以很容易的将它转换为 UTC 时间,或者转换到其他时区下的一个特定的时间(这些表示的都是相同的时刻)。当在程序设计语言中创建一个日期与时间对象时,最好搞清楚这个对象是否包含时区信息,以避免不容易被发现的隐式错误。

UTC(协调世界时)近似于 GMT(格林尼治标准时间,Greenwich Mean Time),后者是位于英国伦敦郊区的皇家格林尼治天文台当地的平太阳时。而 UTC 则是以原子钟来记录的时间。人们曾在 UTC 时间的基础上插入闰秒,以确保 UTC 反应以地球旋转为基础表示的时间度量标准。在 2022 年的国际计量大会(CGPM)上,表示计划在 2035 年之前废弃闰秒。

注意我们在上文段中的措辞非常谨慎:我们使用了“某种偏移规则”来描述时区的概念,是为了不希望让你将时区时间相对于 UTC 的偏移量这两者之间产生混淆。为什么呢?我们举两个例子:

澳大利亚悉尼(Australia/Sydney)时区现时每年会调整两次时间偏移规则。当地时间每年 10 月的第一个星期日的凌晨 2 点,悉尼地区会开始实施夏令时(DST,Daylight saving time)制,所有的时钟将会跳快一小时。而第二年 4 月的第一个星期日的凌晨 3 点,会将时间调慢一个小时,以恢复到标准时间。在这个例子中:

时区实施夏令时?偏移量这种偏移方式被称为
Australia/SydneyUTC+11澳大利亚东部夏令时(AEDT,Australian Eastern Daylight Time)
Australia/SydneyUTC+10澳大利亚东部标准时间(AEST,Australian Eastern Standard Time)

在上例中,Australia/Sydney 是时区——一种规则,而 AEDT 与 AEST(或者 UTC+10 与 UTC+11)是某种特定的偏移量。这两者之间是不同的,所以我们才将时区称为“某种偏移的规则”。

那么,如果一个地区现今没有实行夏令时,那么它的时区表示与偏移量表示之间是否就可以混用了呢?考虑 Asia/Singapore (新加坡)时区与 UTC+8 偏移量之间的关系。思考一下,然后给出答案。

也许正确!这取决于你要表示的时刻的范围。如果我们要表示目前的一个时刻的话,使用 UTC+8 或者 Asia/Singapore 之间的效果是相同的,因为目前新加坡时区(SGT)使用的偏移量固定为 UTC+8,且没有夏令时问题。但这并不适用于表示新加坡地区在 1982 年 1 月 1 日以前的时间。因为,新加坡为了跟踪马来西亚标准时间,从 UTC 时间的 1981 年 12 月 31 日 16:00 开始将时区偏移从 UTC+7:30 更改为 UTC+8。而在这之前,新加坡时区还有过几次变更:

UTC 开始时间UTC 结束时间新加坡地区的时间偏移量原因
(从最早)1905 年 1 月 1 日+6:55:25
1905 年 1 月 1 日1933 年 1 月 1 日+7:00
1933 年 1 月 1 日1936 年 1 月 1 日+7:20第一次全年采用夏令时间
1936 年 1 月 1 日1942 年 2 月 16 日+7:30夏令时间经立法会修改
1942 年 2 月 16 日1945 年 9 月 12 日+9:00新加坡日治时期,与日本时间一致
1945 年 9 月 12 日1981 年 12 月 31 日 16:00+7:30恢复战前标准时间
1981 年 12 月 31 日 16:00(现今)+8:00跟随马来西亚标准时间
新加坡地区的时区偏移量演变

这种时区对应偏移量的规则特别复杂。因此,互联网号码分配局(IANA,Internet Assigned Numbers Authority)专门维护了一个被称为 tzzoneinfo 的时区数据库,用于表示各个时区相对于 UTC 的偏移量的规则信息。以澳大利亚悉尼时区(Australia/Sydney)为例:

节选自 tz2023c
# New South Wales
# Rule	NAME	FROM	TO	-	IN	ON	AT	SAVE	LETTER/S
Rule	AN	1971	1985	-	Oct	lastSun	2:00s	1:00	D
Rule	AN	1972	only	-	Feb	27	2:00s	0	S
Rule	AN	1973	1981	-	Mar	Sun>=1	2:00s	0	S
Rule	AN	1982	only	-	Apr	Sun>=1	2:00s	0	S
Rule	AN	1983	1985	-	Mar	Sun>=1	2:00s	0	S
Rule	AN	1986	1989	-	Mar	Sun>=15	2:00s	0	S
Rule	AN	1986	only	-	Oct	19	2:00s	1:00	D
Rule	AN	1987	1999	-	Oct	lastSun	2:00s	1:00	D
Rule	AN	1990	1995	-	Mar	Sun>=1	2:00s	0	S
Rule	AN	1996	2005	-	Mar	lastSun	2:00s	0	S
Rule	AN	2000	only	-	Aug	lastSun	2:00s	1:00	D
Rule	AN	2001	2007	-	Oct	lastSun	2:00s	1:00	D
Rule	AN	2006	only	-	Apr	Sun>=1	2:00s	0	S
Rule	AN	2007	only	-	Mar	lastSun	2:00s	0	S
Rule	AN	2008	max	-	Apr	Sun>=1	2:00s	0	S
Rule	AN	2008	max	-	Oct	Sun>=1	2:00s	1:00	D
# Zone	NAME		STDOFF	RULES	FORMAT	[UNTIL]
Zone Australia/Sydney	10:04:52 -	LMT	1895 Feb
			10:00	Aus	AE%sT	1971
			10:00	AN	AE%sT

对于大多数 *NIX 发行版,你可以从 /usr/share/zoneinfo 中找到这个数据库的编译版本。原始的代码规则代码可以从 Time Zone Database 下载。一些 Linux 发行版允许通过命令来调整当前系统时钟使用的时区,如 Debian 系统下:

sudo dpkg-reconfigure tzdata

计时与时间准确性

日期与时间、时刻、时区和偏移量之间的概念够复杂了?是的,有了上述工具,你已经可以应付大多数的向用户展示时间的场景了。但计时除外。我们需要理解一些额外概念,才能准确地在程序里处理与耗时有关的问题。让我们从计算机如何了解时间开始说起。

当你启动一个许久未使用的设备时,设备上准确地显示了当前的时间信息(也许可能有几秒到几分钟的差异),你感到这一切理所应当。这多亏了现代电子设备中通常包含的名为实时时钟(RTC,Real-time clock)的自带电池的芯片。该芯片通常使用石英晶体谐振器(quartz crystal resonator)实现,而该等材质通过压电效应能够满足一般精度的计时需求。

除了设备本身实现了计时功能以外,一些种类的电子设备在启动后会通过卫星或互联网与可靠的时钟源进行同步,来修正本地时间的微小差异。这些可靠的时钟源通常采用铯原子钟或更精密的计时方式进行实现。服务器通常采用网络时间协议(NTP,Network Time Protocol)从授时服务器同步时间。该协议的设计使校对时间的过程尽量少受到可变网络延迟造成的影响。一些使用 Systemd 引导的 Linux 服务器使用 timesyncd 进行时间同步。

单调时钟与系统时钟

在单个进程内计算时间时,我们可能会通过计算两个时间点之间的差值来实现。如下列 Python 代码:

import time
start = time.time()
# do something here...
end = time.time()
print(end-start)

正确的方式往往都是简单的实现,是这样吗?上面这段代码的问题在于,我们使用 time.time 方法获取到的时间是系统时间(往往是一个 UNIX 时间戳),它有时也被称为“挂钟时间”(wall clock),反映的是系统范围内的实时时间。系统时间是非单调的,因为系统时间可能在任何时刻被调节(比如时间同步)。

为便于计时,操作系统中提供了几个保证单调的时钟。这些单调时钟在承诺的一定上下文内(例如每次重启操作系统之前)保持单调。大部分程序设计语言也为访问这些时钟提供了支持,它们是用来计算时间间隔的最佳方案。例如下列 Python 代码:

import time
start = time.monotonic_ns()
# do something cool...
end = time.monotonic_ns()
print("Is spent", end-start, "ns")

就是一个精确的计时实现。

持久化日期与时间

到目前为止我们已经讨论了所有与处理日期与时间有关的问题。但我们仍未讨论的一个关键课题是,如何持久化地存储这些日期与时间。在过去的实践中,我们已经见识过多种的存储这些日期与时间的方法,是时候来检讨这些方法及其局限性了。

存储时刻,还是带时区信息的对象?

当在文件系统或数据库中存储日期与时间时,直接将时刻转化为时间戳文本的形式特别受欢迎。因为这种形式简单,可移植,且与用户本身的时区无关:

Alice,1136239445
Bob,1664913600
Charlie,1693287987

当业务只需要将这些时间作为内部参考之用,而不需要关注用户的地区与时区时,这种方式很有用。这是因为 UNIX 时间戳是与地区无关的。当使用自定义的时间戳格式时,需要注意时间戳文本可能产生歧义:

01/02/2016

是 2016 年 1 月 2 日,还是 2016 年 2 月 1 日?数据的含义取决于我们如何解释这些数据;

ISO 8601 标准中规定了表示日期与时间的表示。许多编程语言中都有对于该标准表示法的支持。例如目前的本地时间可以完整表示为 (这是通过浏览器的 JavaScript 支持实现的)。这种格式对人类和计算机都很友好。

值得一提的是,当使用 JSON 格式交换数据时,虽然 JSON 格式并未规定时间与日期的字符串格式,但 JavaScript 使用了上一段的标准格式(这是兼容 ISO 8601 的)表示日期与时间。因此这也是在数据交换中推荐的格式之一。一些应用由于数据类型或精度的限制,无法完整读取以微秒或纳秒计的 UNIX 时间戳(时间戳数字会非常长),有时可能会将 UNIX 时间戳先转换为字符串,然后再附加于 JSON 数据中。

另一个用例则是同时存储时区以及日期与时间信息。一些数据库(如 PostgreSQL)就是这么做的。在 PostgreSQL 中的时间戳字段是可以包含时区信息的。而另一些数据库(比如 MySQL 或 MariaDB)则将日期与时间一律以 UTC 进行存储,而在存储或读取过程中依据时区设置进行转换,这样的实现更像是“装饰器”。当然,你也可以在单独的字段中存储来自用户的时区信息,这通常很容易做到。

通过浏览器得到的你的当前时区是:

存储用户的时区信息可能有一些好处,例如当向用户发送推广信息时,应当考虑在用户所在时区的白天发送相应信息,以避免打扰用户。但在处理自定义时区的日期与时间对象时,要特别注意避免一些微妙的问题,例如夏令时。如果你每年向客户多收或少收一个小时的费用,每年两次,这就是一个问题。解决这个问题的办法是在代码中使用 UTC,只在与终端用户互动时使用当地时间。

另外一个问题出现在处理按月订阅模式时,如果用户的账单周期位于每月的第 29、30 或 31 天,则如果当前月没有这几天,则需要在当前月的最后一天处理用户扣款。另一种策略是“每 30 天”扣款一次,但这完全取决于业务策略如何进行设计了。

总结

现代操作系统与程序设计语言共同为程序员提供了一套完整的处理日期与时间问题的工具链。通过这些工具,程序员应当能解决关于日期与时间有关的全部问题。这里的关键之处在于,程序员需要深刻理解这些概念以及概念之间的关系。因为保持逻辑正确性的责任始终在于程序员本身。

参考链接