背景
公司业务的全球化特性,使得很多项目都需要考虑时区和夏令时的场景,这两个概念相对复杂,而且网络上的文章往往缺少对其原理的说明,导致测试和开发人员在处理相关代码时往往考虑不周全,引起一些奇怪问题,比如:
- 测试用例在本地运行成功,上传到服务器(如 Jenkins)运行却会失败;
- 夏令时开始或结束后的一段时间,推送给用户的消息错误,导致线上问题。
这些问题只会在特定时刻,或者满足特定条件时才会出现,如果不能清晰理解时区和夏令时的概念,甚至都不知道可能会出现这些问题,也就谈不上正确处理了,所以准确、深入地理解概念很有必要。
本文将从时区数据库出发,探究时区和夏令时的原理,结合 Python 内建 datetime 模块的代码实例,尝试说清楚时区和夏令时到底是什么。
术语
对文中的一些概念作说明,以便达成一致,防止理解上出现歧义。
时区(Time Zone)
时区,顾名思义就是时间的区域,在某一时区(地理区域)内,大家都使用统一的时间定义。
理论上,时区应该按照经度划分,每 15 度划分一个时区(360 / 24 = 15),相邻时区的当地时间相差一个小时。但这不符合人们的使用习惯,也不能满足法律、经济和社会的需求,最终时区没有严格按照经度划分,而倾向于沿着国家的边界或行政区域的边界划分,划分后的时区再考虑地理位置确定相对于 UTC 的偏移量。
时区对应的地理区域并不规则,且常因为政治原因而变动,而计算机系统除了需要查询某一个时区当前的当地时间,往往还需要知道历史上某一时刻该时区的当地时间,这就衍生出了时区数据库的概念(时区数据库的设计主要是用于计算机程序和操作系统),用于记录时区和夏令时规则的变化历史。
本文的目的是解释时区和夏令时在计算机系统的运作机制,故所讨论的时区指的是 IANA 时区数据库 中的时区,其中的 时区名(也用作时区数据库中的 ID)格式为 AREA/LOCATION,如 'America/New_York'。
夏令时(Daylight Saving Time)
夏令时是一种将时钟向前拨的做法,以便更好地利用夏季较长的日照时间。通常在春天将时钟向前拨一小时,秋天往回拨一小时恢复为标准时间。
原理分析
再来思考几个问题:
- 以美国为例,美国有多个时区,有的使用夏令时,有的不使用夏令时,程序怎么区分一个时区是否使用夏令时?
- 全球有多个使用夏令时的时区,但是一年中夏令时的开始和结束时间是不一样的,程序怎么知道具体的时区在某一时刻是否开始了夏令时?
- 不同程序和系统都在使用时区,怎么保证大家使用的是相同的数据?
这些问题,由 IANA 维护的 Time Zone Database(时区数据库)来回答。
时区数据库
下面说明时区数据库保存了什么数据,如何阅读文件中的内容。
首先,到官网下载最新版本数据压缩包:
解压后的文件:
解压后的目录中包含几种类型的数据,我们只看包含时区和夏令时规则的文件,以我们使用较多的 northamerica 为例。
这个文件是一个 text 纯文本文件,可以直接打开查看内容:
文件中包含 3 种数据:
- rule lines
- zone lines
- link lines
本文只介绍前面两种。
rule line
rule line 是保存夏令时调整规则的行,格式为:
Rule NAME FROM TO - IN ON AT SAVE LETTER/S |
|---|
以 rule 名字为 US 的规则集为例,说明各个字段的含义:
-
NAME - 名字;
-
FROM - 实行该规则的第一年;
-
TO - 实行该规则的最后一年(max 表示实行后一直使用);
-
IN - 规则生效的月份;
-
ON - 规则在哪一天生效(Sun>=8 表示该月 8 号或之后的第一个星期日);
-
AT - 规则在该天几点几分生效;
-
SAVE - 规则生效时要添加到当地标准时间的时间调整量;
当地标准时间(local standard time)与标准偏移量(standard offset)有关,标准偏移量只与地理位置有关,比如 America/New\_York 时区,不管是否开始夏令时,标准偏移量都是 -5:00(相对于 UTC ),并不是说夏令时开始后,标准偏移量就变成了 -4:00,而应该说夏令时开始后,会在标准偏移量的基础上调整(增加)一个小时,最终两者相加后的 UTC 偏移量变成了 -4:00。
这个地方需要从术语层面区分,不然容易引起误解,即标准偏移量与夏令时无关,夏令时只是在标准偏移量基础上做调整。
要知道某时区此刻的当地标准时间,必须先知道此刻的 UTC 时间,因为当地标准时间是 UTC 时间和标准偏移量的计算结果。
网络时间协议(Network Time Protocol)就是为了让计算机知道此刻的 UTC 时间,它意图将所有计算机的 UTC 时间同步到几毫秒的误差内。
通过 NTP 可以知道此刻的 UTC 时间,通过标准偏移量可以知道此刻当地标准时间。
再加上时间调整量,就可以知道时区的当地时间。
- LETTER/S - 规则生效时,时区缩写的可变部分(比如 EST/EDT 中的可变部分是 S/D)。
示例
以最后两条规则为例:
- 倒数第二条表示从 2007 年至今,都是在 3 月 8 号或 8 号以后的第一个星期日(即 3 月的第二个星期日)的凌晨 2 点,将挂钟时间在标准时间的 2 点基础上,增加一个小时,时区缩写的可变字母为 D。
实际操作是 1:59 后不会跳到 2:00,而会跳到 3:00。 - 最后一条表示从 2007 年至今,都是在 11 月 1 号或 1 号以后的第一个星期日(即 11 月的第一个星期日)的凌晨 2 点,将挂钟时间恢复到标准时间,时区缩写的可变字母为 S。
因为此前一直在标准时间基础上增加了一个小时,这个时候想要恢复到标准时间,就需要等一个小时,实际的操作是到 1:59 后不会跳到 2:00,而是会跳回 1:00,也就是会有两个 1:MM。
zone line
zone line 是保存时区的行,格式为:
Zone NAME STDOFF RULES FORMAT [UNTIL] |
|---|
以 zone 名字为 America/New_York 的时区为例,说明各个字段的含义:
- NAME - 时区名字(Names of timezones);
时区名字的格式为Area/Location。
前面的 Area 可以是七大洲、五大洋的名字,或者是 “Etc”。之所以使用洋的名字,是因为一些岛屿难以定位到某一个洲上。“Etc” 是一个特殊的区域,可以使用 “Etc/UTC” 表示 UTC。
后面的 Location 是 area 中的一个具体位置的名字,通常是一个城市或岛屿。之所以不使用国家名作为 Location,主要是由于频繁的政治和边界变化,他们是不稳定的,而大城市的名字往往更加长久。
-
STDOFF - standard offset(标准偏移量),相对于 UTC 时间的偏移量,不包含夏令时引起的调整;
-
RULES - 应用于时区的 rule 的名字,即前面 rule line 的名字;
-
FORMAT - 时区缩写的样式,%s 会使用 rule 中的 LETTER/S 替换;
-
UNTIL - UTC 偏移量(标准偏移量)或 rule 规则变更的时间点;
示例
以规则的最后一行为例:
从 1967 年(上一行的截止时间)开始,相对于 UTC 的标准偏移量为 -5:00,使用的夏令时调整规则和 US 一致,所以具体来说:
- 对于 1967 年,标准偏移量为 -5:00,夏令时规则为:
在 4 月最后一个星期日凌晨 2:00 开始夏令时,调整 1 个小时,时区缩写变为 EDT;
在 10 月最后一个星期日凌晨 2:00 结束夏令时,恢复标准时间,时区缩写变为 EST。 - 对于今年(2024 年),标准偏移量为 -5:00,夏令时规则为:
在 3 月第二个星期日凌晨 2:00 开始夏令时,调整 1 个小时,时区缩写变为 EDT;
在 11 月第一个星期天凌晨 2:00 结束夏令时,恢复标准时间,时区缩写变为 EST。
回顾问题
回顾前面提的问题:
Q:以美国为例,美国有多个时区,有的使用夏令时,有的不使用夏令时,程序怎么区分一个时区是否使用夏令时?
准确地说,不能说一个时区是否使用夏令时,因为有可能一个时区在某段时间内使用了夏令时,后来觉得夏令时不好,就放弃使用了。比如中国,就曾经使用过夏令时,后来放弃了(上海时区,从 1949.5.28 开始使用 PRC rule,PRC rule 在 1986 年至 1991 年使用了夏令时,1991 年之后没有使用夏令时):
Q:全球有多个使用夏令时的时区,但是一年中夏令时的开始和结束时间是不一样的,程序怎么知道具体的时区在某一时刻是否开始了夏令时?
由时区使用的 rule 决定,见 America/New_York 时区的例子。
Q:不同程序和系统都在使用时区,怎么保证大家使用的是相同的数据?
都使用 IANA 时区数据库的数据。
注意:时区数据库是有版本的,如果一个时区变更了规则,而系统没有更新最新版本,可能导致此系统和其他系统得到的时间不一致。而且并不是所有系统都使用了 IANA 时区数据库。
程序实现
IANA 时区数据库发布的数据,是以人类可读的格式来展示规则和时区信息,程序并不会直接读取这些文件,也不会实时到 IANA 去获取数据,而是使用事先经过 zic 工具(IANA 提供的编译程序)编译后的二进制文件。
zic 程序编译时,会为一个时区生成一个二进制文件。这些二进制文件是各个系统通用的,Linux 和 macOS 默认保存在 /usr/share/zoneinfo 目录下。
所以在程序中指定时区,实际就是指定 /usr/share/zoneinfo 目录下的文件的相对路径。
比如 America/New_York 时区就是 America 目录下的 New_York 文件
# cd /usr/share/zoneinfo/
# ls -al --group-directories-first
total 416
drwxr-xr-x. 20 root root 4096 May 21 2023 ./
drwxr-xr-x. 153 root root 4096 Dec 2 2023 ../
drwxr-xr-x. 2 root root 4096 May 21 2023 Africa/
drwxr-xr-x. 6 root root 8192 May 21 2023 America/
drwxr-xr-x. 2 root root 187 May 21 2023 Antarctica/
drwxr-xr-x. 2 root root 26 May 21 2023 Arctic/
drwxr-xr-x. 2 root root 4096 May 21 2023 Asia/
drwxr-xr-x. 2 root root 196 May 21 2023 Atlantic/
drwxr-xr-x. 2 root root 4096 May 21 2023 Australia/
drwxr-xr-x. 2 root root 59 May 21 2023 Brazil/
drwxr-xr-x. 2 root root 136 May 21 2023 Canada/
drwxr-xr-x. 2 root root 45 May 21 2023 Chile/
drwxr-xr-x. 2 root root 4096 May 21 2023 Etc/
drwxr-xr-x. 2 root root 4096 May 21 2023 Europe/
drwxr-xr-x. 2 root root 176 May 21 2023 Indian/
drwxr-xr-x. 2 root root 53 May 21 2023 Mexico/
drwxr-xr-x. 2 root root 4096 May 21 2023 Pacific/
drwxr-xr-x. 18 root root 4096 May 21 2023 posix/
drwxr-xr-x. 18 root root 4096 May 21 2023 right/
drwxr-xr-x. 2 root root 197 May 21 2023 US/
-rw-r--r-- 1 root root 2102 Apr 4 2023 CET
-rw-r--r-- 1 root root 2294 Apr 4 2023 CST6CDT
-rw-r--r-- 2 root root 2411 Apr 4 2023 Cuba
-rw-r--r-- 1 root root 1876 Apr 4 2023 EET
-rw-r--r-- 2 root root 9309 Apr 4 2023 Egypt
-rw-r--r-- 2 root root 3517 Apr 4 2023 Eire
-rw-r--r-- 1 root root 118 Apr 4 2023 EST
-rw-r--r-- 1 root root 2294 Apr 4 2023 EST5EDT
-rw-r--r-- 7 root root 3661 Apr 4 2023 GB
-rw-r--r-- 7 root root 3661 Apr 4 2023 GB-Eire
-rw-r--r-- 10 root root 118 Apr 4 2023 GMT
-rw-r--r-- 10 root root 118 Apr 4 2023 GMT0
-rw-r--r-- 10 root root 118 Apr 4 2023 GMT-0
-rw-r--r-- 10 root root 118 Apr 4 2023 GMT+0
-rw-r--r-- 10 root root 118 Apr 4 2023 Greenwich
-rw-r--r-- 2 root root 1233 Apr 4 2023 Hongkong
-rw-r--r-- 1 root root 119 Apr 4 2023 HST
-rw-r--r-- 14 root root 156 Apr 4 2023 Iceland
-rw-r--r-- 2 root root 1280 Apr 4 2023 Iran
-rw-r--r-- 1 root root 4446 Jan 7 2023 iso3166.tab
-rw-r--r-- 3 root root 9113 Apr 4 2023 Israel
-rw-r--r-- 2 root root 481 Apr 4 2023 Jamaica
-rw-r--r-- 2 root root 292 Apr 4 2023 Japan
-rw-r--r-- 2 root root 309 Apr 4 2023 Kwajalein
-rw-r--r-- 1 root root 3392 Jan 20 2023 leapseconds
-rw-r--r-- 2 root root 641 Apr 4 2023 Libya
-rw-r--r-- 1 root root 2102 Apr 4 2023 MET
-rw-r--r-- 1 root root 118 Apr 4 2023 MST
-rw-r--r-- 1 root root 2294 Apr 4 2023 MST7MDT
-rw-r--r-- 4 root root 2443 Apr 4 2023 Navajo
-rw-r--r-- 4 root root 2434 Apr 4 2023 NZ
-rw-r--r-- 2 root root 2047 Apr 4 2023 NZ-CHAT
-rw-r--r-- 2 root root 2679 Apr 4 2023 Poland
-rw-r--r-- 2 root root 3483 Apr 4 2023 Portugal
-rw-r--r-- 3 root root 3535 Apr 4 2023 posixrules
-rw-r--r-- 5 root root 556 Apr 4 2023 PRC
-rw-r--r-- 1 root root 2294 Apr 4 2023 PST8PDT
-rw-r--r-- 2 root root 764 Apr 4 2023 ROC
-rw-r--r-- 2 root root 645 Apr 4 2023 ROK
-rw-r--r-- 3 root root 384 Apr 4 2023 Singapore
-rw-r--r-- 3 root root 1930 Apr 4 2023 Turkey
-rw-r--r-- 1 root root 109050 Apr 4 2023 tzdata.zi
-rw-r--r-- 8 root root 118 Apr 4 2023 UCT
-rw-r--r-- 8 root root 118 Apr 4 2023 Universal
-rw-r--r-- 8 root root 118 Apr 4 2023 UTC
-rw-r--r-- 1 root root 1873 Apr 4 2023 WET
-rw-r--r-- 2 root root 1518 Apr 4 2023 W-SU
-rw-r--r-- 1 root root 17551 Jan 23 2023 zone1970.tab
-rw-r--r-- 1 root root 18855 Jan 23 2023 zone.tab
-rw-r--r-- 8 root root 118 Apr 4 2023 Zulu
# cd America/
# ls -al --group-directories-first
total 608
drwxr-xr-x. 6 root root 8192 May 21 2023 ./
drwxr-xr-x. 20 root root 4096 May 21 2023 ../
drwxr-xr-x. 2 root root 219 May 21 2023 Argentina/
drwxr-xr-x. 2 root root 133 May 21 2023 Indiana/
drwxr-xr-x. 2 root root 42 May 21 2023 Kentucky/
drwxr-xr-x. 2 root root 51 May 21 2023 North_Dakota/
-rw-r--r-- 3 root root 2339 Apr 4 2023 Adak
-rw-r--r-- 2 root root 2354 Apr 4 2023 Anchorage
-rw-r--r-- 21 root root 229 Apr 4 2023 Anguilla
-rw-r--r-- 21 root root 229 Apr 4 2023 Antigua
-rw-r--r-- 1 root root 882 Apr 4 2023 Araguaina
-rw-r--r-- 21 root root 229 Apr 4 2023 Aruba
-rw-r--r-- 1 root root 2037 Apr 4 2023 Asuncion
-rw-r--r-- 4 root root 177 Apr 4 2023 Atikokan
-rw-r--r-- 3 root root 2339 Apr 4 2023 Atka
-rw-r--r-- 1 root root 1022 Apr 4 2023 Bahia
-rw-r--r-- 1 root root 1152 Apr 4 2023 Bahia_Banderas
-rw-r--r-- 1 root root 464 Apr 4 2023 Barbados
-rw-r--r-- 1 root root 574 Apr 4 2023 Belem
-rw-r--r-- 1 root root 1614 Apr 4 2023 Belize
-rw-r--r-- 21 root root 229 Apr 4 2023 Blanc-Sablon
-rw-r--r-- 1 root root 630 Apr 4 2023 Boa_Vista
-rw-r--r-- 1 root root 231 Apr 4 2023 Bogota
-rw-r--r-- 1 root root 2393 Apr 4 2023 Boise
-rw-r--r-- 2 root root 1069 Apr 4 2023 Buenos_Aires
-rw-r--r-- 1 root root 2254 Apr 4 2023 Cambridge_Bay
-rw-r--r-- 1 root root 1442 Apr 4 2023 Campo_Grande
-rw-r--r-- 1 root root 834 Apr 4 2023 Cancun
-rw-r--r-- 1 root root 249 Apr 4 2023 Caracas
-rw-r--r-- 3 root root 1069 Apr 4 2023 Catamarca
-rw-r--r-- 1 root root 196 Apr 4 2023 Cayenne
-rw-r--r-- 4 root root 177 Apr 4 2023 Cayman
-rw-r--r-- 2 root root 3575 Apr 4 2023 Chicago
-rw-r--r-- 1 root root 1102 Apr 4 2023 Chihuahua
-rw-r--r-- 1 root root 1538 Apr 4 2023 Ciudad_Juarez
-rw-r--r-- 4 root root 177 Apr 4 2023 Coral_Harbour
-rw-r--r-- 3 root root 1069 Apr 4 2023 Cordoba
-rw-r--r-- 1 root root 315 Apr 4 2023 Costa_Rica
-rw-r--r-- 3 root root 343 Apr 4 2023 Creston
-rw-r--r-- 1 root root 1414 Apr 4 2023 Cuiaba
-rw-r--r-- 21 root root 229 Apr 4 2023 Curacao
-rw-r--r-- 1 root root 698 Apr 4 2023 Danmarkshavn
-rw-r--r-- 1 root root 1597 Apr 4 2023 Dawson
-rw-r--r-- 1 root root 1033 Apr 4 2023 Dawson_Creek
-rw-r--r-- 4 root root 2443 Apr 4 2023 Denver
-rw-r--r-- 2 root root 2230 Apr 4 2023 Detroit
-rw-r--r-- 21 root root 229 Apr 4 2023 Dominica
-rw-r--r-- 3 root root 2332 Apr 4 2023 Edmonton
-rw-r--r-- 1 root root 662 Apr 4 2023 Eirunepe
-rw-r--r-- 1 root root 236 Apr 4 2023 El_Salvador
-rw-r--r-- 4 root root 2374 Apr 4 2023 Ensenada
-rw-r--r-- 1 root root 714 Apr 4 2023 Fortaleza
-rw-r--r-- 1 root root 2223 Apr 4 2023 Fort_Nelson
-rw-r--r-- 4 root root 1665 Apr 4 2023 Fort_Wayne
-rw-r--r-- 1 root root 2192 Apr 4 2023 Glace_Bay
-rw-r--r-- 2 root root 8820 Apr 4 2023 Godthab
-rw-r--r-- 1 root root 3193 Apr 4 2023 Goose_Bay
-rw-r--r-- 1 root root 1841 Apr 4 2023 Grand_Turk
-rw-r--r-- 21 root root 229 Apr 4 2023 Grenada
-rw-r--r-- 21 root root 229 Apr 4 2023 Guadeloupe
-rw-r--r-- 1 root root 292 Apr 4 2023 Guatemala
-rw-r--r-- 1 root root 231 Apr 4 2023 Guayaquil
-rw-r--r-- 1 root root 268 Apr 4 2023 Guyana
-rw-r--r-- 2 root root 3424 Apr 4 2023 Halifax
-rw-r--r-- 2 root root 2411 Apr 4 2023 Havana
-rw-r--r-- 1 root root 456 Apr 4 2023 Hermosillo
-rw-r--r-- 4 root root 1665 Apr 4 2023 Indianapolis
-rw-r--r-- 1 root root 2094 Apr 4 2023 Inuvik
-rw-r--r-- 2 root root 2202 Apr 4 2023 Iqaluit
-rw-r--r-- 2 root root 481 Apr 4 2023 Jamaica
-rw-r--r-- 2 root root 1041 Apr 4 2023 Jujuy
-rw-r--r-- 1 root root 2336 Apr 4 2023 Juneau
-rw-r--r-- 3 root root 2427 Apr 4 2023 Knox_IN
-rw-r--r-- 21 root root 229 Apr 4 2023 Kralendijk
-rw-r--r-- 1 root root 217 Apr 4 2023 La_Paz
-rw-r--r-- 1 root root 395 Apr 4 2023 Lima
-rw-r--r-- 2 root root 2835 Apr 4 2023 Los_Angeles
-rw-r--r-- 2 root root 2771 Apr 4 2023 Louisville
-rw-r--r-- 21 root root 229 Apr 4 2023 Lower_Princes
-rw-r--r-- 1 root root 742 Apr 4 2023 Maceio
-rw-r--r-- 1 root root 437 Apr 4 2023 Managua
-rw-r--r-- 2 root root 602 Apr 4 2023 Manaus
-rw-r--r-- 21 root root 229 Apr 4 2023 Marigot
-rw-r--r-- 1 root root 231 Apr 4 2023 Martinique
-rw-r--r-- 1 root root 1432 Apr 4 2023 Matamoros
-rw-r--r-- 2 root root 1128 Apr 4 2023 Mazatlan
-rw-r--r-- 2 root root 1069 Apr 4 2023 Mendoza
-rw-r--r-- 1 root root 2257 Apr 4 2023 Menominee
-rw-r--r-- 1 root root 1004 Apr 4 2023 Merida
-rw-r--r-- 1 root root 1406 Apr 4 2023 Metlakatla
-rw-r--r-- 2 root root 1222 Apr 4 2023 Mexico_City
-rw-r--r-- 1 root root 1668 Apr 4 2023 Miquelon
-rw-r--r-- 1 root root 3137 Apr 4 2023 Moncton
-rw-r--r-- 1 root root 994 Apr 4 2023 Monterrey
-rw-r--r-- 1 root root 1536 Apr 4 2023 Montevideo
-rw-r--r-- 6 root root 3477 Apr 4 2023 Montreal
-rw-r--r-- 21 root root 229 Apr 4 2023 Montserrat
-rw-r--r-- 6 root root 3477 Apr 4 2023 Nassau
-rw-r--r-- 3 root root 3535 Apr 4 2023 New_York
-rw-r--r-- 6 root root 3477 Apr 4 2023 Nipigon
-rw-r--r-- 1 root root 2350 Apr 4 2023 Nome
-rw-r--r-- 2 root root 714 Apr 4 2023 Noronha
-rw-r--r-- 2 root root 8820 Apr 4 2023 Nuuk
-rw-r--r-- 1 root root 1524 Apr 4 2023 Ojinaga
-rw-r--r-- 4 root root 177 Apr 4 2023 Panama
-rw-r--r-- 2 root root 2202 Apr 4 2023 Pangnirtung
-rw-r--r-- 1 root root 268 Apr 4 2023 Paramaribo
-rw-r--r-- 3 root root 343 Apr 4 2023 Phoenix
-rw-r--r-- 1 root root 1429 Apr 4 2023 Port-au-Prince
-rw-r--r-- 3 root root 634 Apr 4 2023 Porto_Acre
-rw-r--r-- 21 root root 229 Apr 4 2023 Port_of_Spain
-rw-r--r-- 1 root root 574 Apr 4 2023 Porto_Velho
-rw-r--r-- 21 root root 229 Apr 4 2023 Puerto_Rico
-rw-r--r-- 1 root root 1885 Apr 4 2023 Punta_Arenas
-rw-r--r-- 3 root root 2865 Apr 4 2023 Rainy_River
-rw-r--r-- 1 root root 2086 Apr 4 2023 Rankin_Inlet
-rw-r--r-- 1 root root 714 Apr 4 2023 Recife
-rw-r--r-- 2 root root 980 Apr 4 2023 Regina
-rw-r--r-- 1 root root 2086 Apr 4 2023 Resolute
-rw-r--r-- 3 root root 634 Apr 4 2023 Rio_Branco
-rw-r--r-- 3 root root 1069 Apr 4 2023 Rosario
-rw-r--r-- 4 root root 2374 Apr 4 2023 Santa_Isabel
-rw-r--r-- 1 root root 604 Apr 4 2023 Santarem
-rw-r--r-- 2 root root 9415 Apr 4 2023 Santiago
-rw-r--r-- 1 root root 465 Apr 4 2023 Santo_Domingo
-rw-r--r-- 2 root root 1442 Apr 4 2023 Sao_Paulo
-rw-r--r-- 1 root root 1902 Apr 4 2023 Scoresbysund
-rw-r--r-- 4 root root 2443 Apr 4 2023 Shiprock
-rw-r--r-- 1 root root 2324 Apr 4 2023 Sitka
-rw-r--r-- 21 root root 229 Apr 4 2023 St_Barthelemy
-rw-r--r-- 2 root root 3638 Apr 4 2023 St_Johns
-rw-r--r-- 21 root root 229 Apr 4 2023 St_Kitts
-rw-r--r-- 21 root root 229 Apr 4 2023 St_Lucia
-rw-r--r-- 21 root root 229 Apr 4 2023 St_Thomas
-rw-r--r-- 21 root root 229 Apr 4 2023 St_Vincent
-rw-r--r-- 1 root root 560 Apr 4 2023 Swift_Current
-rw-r--r-- 1 root root 264 Apr 4 2023 Tegucigalpa
-rw-r--r-- 1 root root 1514 Apr 4 2023 Thule
-rw-r--r-- 6 root root 3477 Apr 4 2023 Thunder_Bay
-rw-r--r-- 4 root root 2374 Apr 4 2023 Tijuana
-rw-r--r-- 6 root root 3477 Apr 4 2023 Toronto
-rw-r--r-- 21 root root 229 Apr 4 2023 Tortola
-rw-r--r-- 2 root root 2875 Apr 4 2023 Vancouver
-rw-r--r-- 21 root root 229 Apr 4 2023 Virgin
-rw-r--r-- 2 root root 1597 Apr 4 2023 Whitehorse
-rw-r--r-- 3 root root 2865 Apr 4 2023 Winnipeg
-rw-r--r-- 1 root root 2288 Apr 4 2023 Yakutat
-rw-r--r-- 3 root root 2332 Apr 4 2023 Yellowknife
在 datetime 中的应用实例
datetime 模块对时区和夏令时的处理遵循前面说的规则,下面以时区 America/New_York 为例说明。
前面说到,America/New_York 时区的标准偏移量为 -5:00,今年(2024 年)的夏令时规则为:
- 在 3 月第二个星期日凌晨 2:00 开始夏令时,调整 1 个小时,时钟过了 1:59 后会跳到 3:00(而不是 2:00),时区缩写变为 EDT;
- 在 11 月第一个星期日凌晨 2:00 结束夏令时,恢复标准时间,时钟到 1:59 后不会跳到 2:00,而是会跳回 1:00 ,时区缩写变为 EST。
今年 3 月第二个星期日为 2024.3.10,11 月第一个星期日为 2024.11.3:
astimezone() 方法模拟了当地挂钟的行为,使用此方法来测试:
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
HOUR = timedelta(hours=1)
def test_timezone_dst_start():
"""测试夏令时开始时当地挂钟的行为。
时区 America/New_York 夏令时开始于 2024-03-10 02:00:00。"""
# 此时夏令时尚未开始,UTC 偏移量为 -5:00,所以 UTC 05:00 时 America/New_York 为 00:00
utc_5_clock = datetime(2024, 3, 10, 5, 0, 0, 0, timezone.utc)
for i in range(4):
utc_dt = utc_5_clock + i * HOUR
ny_dt = utc_dt.astimezone(ZoneInfo("America/New_York"))
print(
utc_dt.strftime("%Y-%m-%d %H:%M:%S %Z"),
" = ",
ny_dt.strftime("%Y-%m-%d %H:%M:%S %Z"),
)
>>>
2024-03-10 05:00:00 UTC = 2024-03-10 00:00:00 EST
2024-03-10 06:00:00 UTC = 2024-03-10 01:00:00 EST
2024-03-10 07:00:00 UTC = 2024-03-10 03:00:00 EDT
2024-03-10 08:00:00 UTC = 2024-03-10 04:00:00 EDT
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
HOUR = timedelta(hours=1)
def test_timezone_dst_end():
"""测试夏令时结束时当地挂钟的行为。
时区 America/New_York 夏令时结束于 2024-11-03 02:00:00。"""
# 此时夏令时已开始,UTC 偏移量为 -4:00,所以 UTC 04:00 时 America/New_York 为 00:00
utc_4_clock = datetime(2024, 11, 3, 4, 0, 0, 0, timezone.utc)
for i in range(4):
utc_dt = utc_4_clock + i * HOUR
ny_dt = utc_dt.astimezone(ZoneInfo("America/New_York"))
print(
utc_dt.strftime("%Y-%m-%d %H:%M:%S %Z"),
" = ",
ny_dt.strftime("%Y-%m-%d %H:%M:%S %Z"),
ny_dt.fold,
)
>>>
2024-11-03 04:00:00 UTC = 2024-11-03 00:00:00 EDT 0
2024-11-03 05:00:00 UTC = 2024-11-03 01:00:00 EDT 0
2024-11-03 06:00:00 UTC = 2024-11-03 01:00:00 EST 1
2024-11-03 07:00:00 UTC = 2024-11-03 02:00:00 EST 0
- astimezone() 模拟了当地挂钟的行为,将相邻的两个 UTC 小时映射为相同的当地小时(UTC 时间 5:MM 和 6:MM 都被映射为 1:MM),所以当地时间 1:MM 就可能有两种含义,前一个小时表示夏令时,后一个小时表示标准时间。datetime 使用 fold 属性来区分,5:MM 的 fold 属性为 0,6:MM 的 fold 属性为 1。注意,两个 datetime 实例如果只是 fold 属性不同,对比结果是相等。
甚至会出现一天不等于 24 小时的奇观:
>>> from datetime import datetime
>>> from zoneinfo import ZoneInfo
>>> tz = ZoneInfo("America/New_York")
>>> dt_20240310 = datetime(2024, 3, 10, 0, 0, 0, 0, tz)
>>> dt_20240311 = datetime(2024, 3, 11, 0, 0, 0, 0, tz)
>>> dt_20240311 - dt_20240310
datetime.timedelta(days=1)
>>> (dt_20240311.timestamp() - dt_20240310.timestamp()) / 3600
23.0
>>> from datetime import datetime
>>> from zoneinfo import ZoneInfo
>>> tz = ZoneInfo("America/New_York")
>>> dt_20241103 = datetime(2024, 11, 3, 0, 0, 0, 0, tz)
>>> dt_20241104 = datetime(2024, 11, 4, 0, 0, 0, 0, tz)
>>> dt_20241104 - dt_20241103
datetime.timedelta(days=1)
>>> (dt_20241104.timestamp() - dt_20241103.timestamp()) / 3600
25.0
回顾背景中的问题
经过前面的说明,我们再回过头来看背景中提到的问题。
-
测试用例在本地运行成功,上传到服务器(如 Jenkins)运行却会失败;
原因:本地开发机器的系统时区一般是上海时区,而 Jenkins 服务器的系统时区是 UTC,测试用例中使用的日期时间对象不包含时区信息(naive),datetime 会假定使用系统时区,所以得到的结果差了 8 个小时。
解决方法:使用包含时区信息的日期时间对象,对象的属性总是 tzinfo 属性指定的时区下的当地时间,不管在什么机器上执行代码都能得到同样的结果。 -
夏令时开始或结束后的一段时间,推送给用户的消息错误,导致线上问题。
原因:夏令时开始时,会调整一段时间(如 1 小时),导致 UTC 偏移量有所变化,特别是开始的那一刻,时钟直接从 1:59 跳到 3:00。夏令时结束时,同样会导致 UTC 偏移量有所变化,而且会出现两个 1:MM,所以容易出问题。
解决方法:在时区数据库中查找指定时区的规则,找到该时区夏令时开始或结束的日期和时间,并添加对应的处理逻辑。