代码里的时区:一份实用指南
· 约 1 分钟
几乎所有时区缺陷都能追溯到四个错误之一。这里给出该怎么做,涵盖 JavaScript、Python、Java、Go 和 SQL,讲的是道理而不只是规则。
生产环境里几乎所有时区缺陷都能追溯到四个错误之一:存了当地时间、存了偏移而不是时区、在错误的层次做换算,或者手工搭了一张偏移表。下面的一切,都是从避开这四件事推出来的。
规则一:以 UTC 存储时刻
时刻是时间轴上的一个点。当地时间是一次钟面读数,它需要一个时区和一套消歧策略,才谈得上有意义。
-- 好:一个时刻
created_at TIMESTAMPTZ NOT NULL
-- 坏:没有时区的钟面读数
created_at TIMESTAMP NOT NULL
PostgreSQL 的 TIMESTAMPTZ 并不存储时区——它在写入时归一到 UTC,在读取时换算。对于「这件事什么时候发生的」,这正是你想要的。
规则二:存时区标识符,不要存偏移
如果你需要知道_在哪里_——为了一个周期性事件、一项用户偏好、一家店的营业时间——那就存 Europe/London,不要存 +01:00。
偏移是关于某一个时刻的事实。时区是一套规则,它能熬过政府改主意。英国在一月是 +00:00,在七月是 +01:00;存下其中任何一个并称之为「用户的时区」,一年里有半年是错的。
event_start_local TIMESTAMP NOT NULL, -- 他们选定的钟面时间
event_time_zone TEXT NOT NULL, -- 'Europe/London'
event_start_utc TIMESTAMPTZ NOT NULL -- 解析出的时刻,规则变动时重算
对周期性事件而言,当地时间与时区才是事实来源;UTC 时刻只是派生索引。若 tzdata 变了——它一年要变好几次——重新生成派生列即可,用户的 09:00 依旧是 09:00。
规则三:在边缘做换算
业务逻辑按时刻工作。换算成人可读的当地时间,发生在尽可能晚的时刻,发生在那个知道读者是谁的层次里。
推论更要紧:绝不要让服务器自身的时区渗进行为里。 在部署环境中设置 TZ=UTC,并把任何结果取决于服务器本地设置的代码路径视为缺陷。这类故障只有在有人部署到第二个地区时才会冒头。
规则四:永远不要自建偏移表
每个现代运行环境都自带 IANA 数据库。用它。
JavaScript
// 按某个时区格式化
new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Kathmandu',
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date());
// 不依赖库拿到偏移:把该时区的钟面读数与 UTC 相比
function offsetMinutes(timeZone, instant) {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
hourCycle: 'h23',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).formatToParts(new Date(instant));
const get = (type) => Number(parts.find((p) => p.type === type).value);
const asUtc = Date.UTC(
get('year'),
get('month') - 1,
get('day'),
get('hour'),
get('minute'),
get('second'),
);
return (asUtc - Math.floor(instant / 1000) * 1000) / 60000;
}
务必显式设置 hourCycle: 'h23'。某些 ICU 版本在 en-US 下把午夜报成 24 时,这会让日期整整偏一天,而且是一个查起来真正折磨人的缺陷。
Python
from datetime import datetime, timezone
from zoneinfo import ZoneInfo # 自 3.9 起进入标准库
now = datetime.now(timezone.utc)
local = now.astimezone(ZoneInfo("Asia/Kathmandu"))
永远不要用 datetime.utcnow()。它返回一个_看起来_像 UTC 却不带时区的朴素日期,而把它与带时区的日期混用,会在最糟的时刻抛出异常。正确的调用是 datetime.now(timezone.utc)。
Java
Instant now = Instant.now();
ZonedDateTime local = now.atZone(ZoneId.of("Asia/Kathmandu"));
java.time 设计得很好:Instant 表示时间轴上的点,LocalDateTime 表示没有时区的钟面读数,ZonedDateTime 表示两者合一。类型系统会强制执行本文一再坚持的那个区分。
Go
loc, _ := time.LoadLocation("Asia/Kathmandu")
local := time.Now().In(loc)
LoadLocation 读取系统的 tzdata。在一个空白容器里根本没有这份数据——请导入 _ "time/tzdata" 把它嵌进可执行文件,否则每次时区查找都会悄无声息地退回 UTC。
一年里的那两天
拨快抹掉一小时;拨慢重复一小时。选一套策略,一以贯之地执行,并把你的处置告诉用户:
- 不存在的当地时间(美国春季切换日的 02:30):按缺口的大小往前推,于是 02:30 变成 03:30。
- 有歧义的当地时间(美国秋季切换日的 01:30):取第一次、也就是较早的那一次。
这正是本站采用的策略,它们与 ECMAScript Temporal 的默认值 disambiguation: 'compatible' 一致——值得对齐,好让你的行为与平台保持一致。
缺口未必总是一小时。豪勋爵岛拨动 30 分钟,所以请从那次转换本身读出大小,而不要想当然。
测试
四个用例能兜住大部分:
- 半小时时区 ——
Asia/Kolkata(+05:30)。兜住「整点小时」的假设。 - 45 分钟时区 ——
Asia/Kathmandu(+05:45)。兜住「半小时为界」的假设。 - 南半球时区 ——
Australia/Sydney。兜住「夏天就是六月」。 - 豪勋爵岛 ——
Australia/Lord_Howe。兜住写死的 60 分钟跳变,而且只有它能兜住。
为你的用户实际所在的每一个时区,各加一个拨快日期和一个拨慢日期;同时在持续集成里锁定 tzdata 版本,免得一次数据库更新因为不相干的原因弄挂构建。
周期性事件是另一个问题
一次性事件是一个时刻。周期性事件是一条规则,两者需要不同的存储方式。
「每周二 09:00,Europe/London」并不等于「从这一时刻起每 604800 秒」。英国一拨钟,两者就分岔:规则把会议钉在当地 09:00,而固定间隔会把它漂到 08:00 或 10:00。
存下规则——当地时间、时区标识符、重复模式——再由它物化出时刻。当 tzdata 变动时,重新生成那些物化的时刻;不要去迁移它们。用户同意的是规则。
那些别扭的情形,正是从一年两次的间断中来的。每天 02:30 的会议会发现:在春天的某一天,这个时间并不存在;而在秋天的某一天,它会发生两次。选一套策略,一以贯之地执行,并且知道不同日历系统选了不同的策略——这就是为什么同一个周期性事件,在一年中恰好两天,会在两个人的日历里差出一小时。
关于 Temporal 的一点说明
ECMAScript 的 Temporal 用一组类型取代了 Date,像 java.time 那样区分时刻、钟面时间和带时区的日期时间。Temporal.ZonedDateTime 携带时区标识符;Temporal.Instant 是时间轴上的一个点;Temporal.PlainDateTime 是没有时区的钟面读数,而类型系统会阻止你把它们弄混。
它的 disambiguation 选项,恰好把本文一再回到的那套「一年两天」的策略暴露了出来:'compatible'(默认——向前跨过缺口,在有歧义的一对里取较早者)、'earlier'、'later' 和 'reject'。最后一个值得在任何「悄悄差一小时比报错更糟」的场合认真考虑。