凌晨一点来了两次
一句话总结
存储时用时刻(UTC),只在显示时使用本地时间。以本地时间存储的记录,每年有两次会重叠或消失。
为什么需要它
“我们的服务只在韩国使用,所以直接用本地时间存了。”这句话几年都不会引发任何问题。韩国目前不使用夏令时,所以韩国时间与 UTC 的差始终固定为 9 小时。问题出在之后。一旦有了美国客户,开始按当地时间发送通知,或者增加了一个云区域,或者把结算标准改为当地营业日,这个设计就会崩溃。而且崩溃的日子每年只有两天,平时的测试绝对抓不到。
工作原理
本地时间与时刻之间的转换规则由各国政治性地决定,把这些历史汇总在一起的,就是 IANA 时区数据库。Asia/Seoul、America/New_York 这样的名字并不是城市名,而是该地区全部时间规则的名称。这个数据库每当有政治决定就会更新。确认时的最新版本是 2026d(2026-09-11 发布),该版本的变更之一,是加拿大西北地区永久改用 -06。规则就是这样不断变化,所以不能把偏移量像 +09:00 这样的数字写死在代码里。按名称存储,转换交给数据库。
夏令时结束的那天,时钟要拨回一小时。于是那一小时的本地时间会出现两次。开始的那天,时钟要拨快一小时,所以那一小时根本不存在。Python 为了区分这两种情况,在 datetime 中设置了 fold 这个属性。PEP 495 就是这项提案,0 表示两个候选中较早的一个,1 表示较晚的一个。
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
ny = ZoneInfo("America/New_York")
def classify(naive):
early = naive.replace(tzinfo=ny, fold=0)
late = naive.replace(tzinfo=ny, fold=1)
if early.utcoffset() == late.utcoffset():
return "normal" # 후보가 하나뿐 — 평범한 시각
back = early.astimezone(timezone.utc).astimezone(ny).replace(tzinfo=None)
return "ambiguous" if back == naive else "nonexistent"
classify(datetime(2025, 11, 2, 1, 30)) # ambiguous — 두 번 온다
classify(datetime(2025, 3, 9, 2, 30)) # nonexistent — 오지 않는다
判定的后半部分是关键。仅凭两个候选的偏移量不同,无法区分“出现两次的时间”和“不存在的时间”——两种情况下偏移量都不同。区分要靠往返。把本地时间转换成时刻,再转换回本地时间,如果得到原来的值,这个时间就是真实存在的(出现两次的那一种);如果得到的是另一个值,那它本来就是不存在的时间。
因此,存储一侧的规则就变得简单了:时刻用 UTC 存储。PostgreSQL 的日期时间类型中之所以建议使用 timestamptz,就是这个原因。名字容易混淆,但这个类型并不存储时区——它把输入转换为 UTC 存储,读取时按会话的时区显示。反过来,timestamp(without time zone)原样保存所写的数字,没有人知道这个数字属于哪个地区。
不过,未来的约定是个例外。“下个月第一个星期一上午 9 点开会”不是时刻,而是以本地时间做出的约定。如果在此期间那个国家修改了夏令时规则,已经固化为 UTC 的值就会在错误的时间响起。这类情况应当同时存储本地时间和时区名称,读取时再进行转换。
在现场相遇的样子
最常见的事故是以本地时间命名的批处理任务。夏令时结束的凌晨,“01:00 结算”会跑两次。两次都正常结束,日志里没有错误,总执行次数也与平时相同。只是当天的销售额被计入了两次。
第二常见的是设在不存在的时间上的预约。夏令时开始的凌晨 2 点这段时间,在该地区并不存在,所以设在这个时间的任务会悄悄地被跳过一次。因为不是失败,而是根本没有发生,所以也不会收到失败告警。直到第二天早上有人说“昨天没有报告啊”,才有人发现。
第三种是人的误解。看到只留有本地时间的记录,有人会问:“01:30 有两条,是重复的吗?”那两条其实是相隔一小时的两个不同时刻。如果记录中没有偏移量,原则上就没有办法判别这一点。
下一项测验要确认什么
确认以本地时间存储会发生哪两类事故,如何用 fold 区分这两种情形,以及“存储用 UTC”这条规则有哪些例外。