从时区数据库的角度理解时区和夏令时

495 阅读27分钟

背景


公司业务的全球化特性,使得很多项目都需要考虑时区和夏令时的场景,这两个概念相对复杂,而且网络上的文章往往缺少对其原理的说明,导致测试和开发人员在处理相关代码时往往考虑不周全,引起一些奇怪问题,比如:

  • 测试用例在本地运行成功,上传到服务器(如 Jenkins)运行却会失败;
  • 夏令时开始或结束后的一段时间,推送给用户的消息错误,导致线上问题。

这些问题只会在特定时刻,或者满足特定条件时才会出现,如果不能清晰理解时区和夏令时的概念,甚至都不知道可能会出现这些问题,也就谈不上正确处理了,所以准确、深入地理解概念很有必要。

本文将从时区数据库出发,探究时区和夏令时的原理,结合 Python 内建 datetime 模块的代码实例,尝试说清楚时区和夏令时到底是什么。

术语

对文中的一些概念作说明,以便达成一致,防止理解上出现歧义。

时区(Time Zone)

时区,顾名思义就是时间的区域,在某一时区(地理区域)内,大家都使用统一的时间定义。

理论上,时区应该按照经度划分,每 15 度划分一个时区(360 / 24 = 15),相邻时区的当地时间相差一个小时。但这不符合人们的使用习惯,也不能满足法律、经济和社会的需求,最终时区没有严格按照经度划分,而倾向于沿着国家的边界或行政区域的边界划分,划分后的时区再考虑地理位置确定相对于 UTC 的偏移量。

时区对应的地理区域并不规则,且常因为政治原因而变动,而计算机系统除了需要查询某一个时区当前的当地时间,往往还需要知道历史上某一时刻该时区的当地时间,这就衍生出了时区数据库的概念(时区数据库的设计主要是用于计算机程序和操作系统),用于记录时区和夏令时规则的变化历史。

本文的目的是解释时区和夏令时在计算机系统的运作机制,故所讨论的时区指的是 IANA 时区数据库 中的时区,其中的 时区名(也用作时区数据库中的 ID)格式为 AREA/LOCATION,如 'America/New_York'。

夏令时(Daylight Saving Time)

夏令时是一种将时钟向前拨的做法,以便更好地利用夏季较长的日照时间。通常在春天将时钟向前拨一小时,秋天往回拨一小时恢复为标准时间。

原理分析


再来思考几个问题:

  • 以美国为例,美国有多个时区,有的使用夏令时,有的不使用夏令时,程序怎么区分一个时区是否使用夏令时?
  • 全球有多个使用夏令时的时区,但是一年中夏令时的开始和结束时间是不一样的,程序怎么知道具体的时区在某一时刻是否开始了夏令时?
  • 不同程序和系统都在使用时区,怎么保证大家使用的是相同的数据?

这些问题,由 IANA 维护的 Time Zone Database(时区数据库)来回答。

时区数据库

下面说明时区数据库保存了什么数据,如何阅读文件中的内容。

首先,到官网下载最新版本数据压缩包: image2024-8-13_8-34-42.png

解压后的文件:

image2024-8-13_8-36-41.png

解压后的目录中包含几种类型的数据,我们只看包含时区和夏令时规则的文件,以我们使用较多的 northamerica 为例。

这个文件是一个 text 纯文本文件,可以直接打开查看内容:

image2024-8-13_8-42-2.png

文件中包含 3 种数据:

  • rule lines
  • zone lines
  • link lines

本文只介绍前面两种。

rule line

rule line 是保存夏令时调整规则的行,格式为:

Rule  NAME  FROM  TO    -  IN   ON       AT     SAVE   LETTER/S

以 rule 名字为 US 的规则集为例,说明各个字段的含义:

image2024-8-13_8-50-17.png

  • 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)。

示例

以最后两条规则为例:

image2024-8-13_9-2-59.png

  • 倒数第二条表示从 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 的时区为例,说明各个字段的含义:

image2024-8-13_9-7-10.png

  • 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 规则变更的时间点;

示例

以规则的最后一行为例:

image2024-8-13_9-24-0.png

从 1967 年(上一行的截止时间)开始,相对于 UTC 的标准偏移量为 -5:00,使用的夏令时调整规则和 US 一致,所以具体来说:

  • 对于 1967 年,标准偏移量为 -5:00,夏令时规则为:
    image2024-8-13_9-29-40.png
    在 4 月最后一个星期日凌晨 2:00 开始夏令时,调整 1 个小时,时区缩写变为 EDT;
    在 10 月最后一个星期日凌晨 2:00 结束夏令时,恢复标准时间,时区缩写变为 EST。
  • 对于今年(2024 年),标准偏移量为 -5:00,夏令时规则为:
    image2024-8-13_9-2-59-1.png
    在 3 月第二个星期日凌晨 2:00 开始夏令时,调整 1 个小时,时区缩写变为 EDT;
    在 11 月第一个星期天凌晨 2:00 结束夏令时,恢复标准时间,时区缩写变为 EST。

回顾问题

回顾前面提的问题:

Q:以美国为例,美国有多个时区,有的使用夏令时,有的不使用夏令时,程序怎么区分一个时区是否使用夏令时? 准确地说,不能说一个时区是否使用夏令时,因为有可能一个时区在某段时间内使用了夏令时,后来觉得夏令时不好,就放弃使用了。

比如中国,就曾经使用过夏令时,后来放弃了(上海时区,从 1949.5.28 开始使用 PRC rule,PRC rule 在 1986 年至 1991 年使用了夏令时,1991 年之后没有使用夏令时):

image2024-8-14_8-49-32.png

image2024-8-14_8-49-52.png

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:

image2024-8-14_9-33-7.png image2024-8-14_9-32-35.png

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,所以容易出问题。
    解决方法:在时区数据库中查找指定时区的规则,找到该时区夏令时开始或结束的日期和时间,并添加对应的处理逻辑。

参考文档