时间要存成唯一的瞬间,也要存成用户以为的那个墙上时间。
一、凌晨两点的提醒
以下情境为教学假设,人物、引语和数字不代表经核实的真实案例。
路遥做一个习惯打卡工具,用户可以设置「每天九点提醒」。他把提醒时间换算成 UTC 存进数据库。东京用户的九点存成 00:00 UTC,柏林用户的九点存成 07:00 或 08:00 UTC。
三月的最后一个周日,欧洲进入夏令时。柏林用户的提醒变成了本地十点;十一月的第一个周日,美国退出夏令时,纽约用户的提醒变成了八点。用户开始投诉:「我明明设的是九点。」
更隐蔽的问题发生在切换的那一夜:如果提醒设在 02:30,而 02:00–03:00 这一小时在春季根本不存在,任务要么没跑,要么跑了两次。
这不是代码 bug,是模型错了。「每天九点」不是一个瞬间,是一个本地墙上时间加一个时区规则。
二、存什么,取决于它是什么
| 数据类型 | 存什么 | 例子 | 为什么 |
|---|---|---|---|
| 已经发生的事件 | UTC 瞬间 | 付款时间、登录时间 | 唯一、可排序、无歧义 |
| 未来的重复计划 | 本地墙上时间 + IANA 时区 | 每天 09:00 + Europe/Berlin | 夏令时切换后仍是本地九点 |
| 一次性未来的约定 | UTC 瞬间 + 原时区 | 会议 2026-11-02 14:00 Asia/Tokyo | 需要显示原始时区 |
| 纯日期 | 本地日期,不带时间 | 生日、账单日 | 加时区会产生「差一天」 |
| 经过时长 | 秒或毫秒 | 处理超时 30 秒、有效 336 小时 | 与墙上日期不同;“14 个自然日”应另按日历规则定义 |
两个最常见的错误:
- 把「每天九点」存成 UTC。 夏令时一换,本地时间就漂移一小时。
- 把「生日」存成带时区的时间戳。 用户在东八区出生,服务器在西五区,显示出来可能差一天。
IANA 时区标识(如 Asia/Shanghai、America/New_York)比 UTC 偏移量(如 +08:00)更可靠,因为偏移量会随夏令时与政策变化,而标识会随规则库更新。
三、夏令时的两个真实故障
夏令时制造两类特殊时刻:
不存在的时刻。 按当前时区规则,America/New_York 在 2026 年 3 月 8 日跳过 02:00–03:00,Europe/Berlin 在 3 月 29 日也跳过该区间。两地当天的 02:30 都不存在。这里只指这两个 IANA 时区,不能推广为美国或欧洲所有地区。
重复的时刻。 America/New_York 在 2026 年 11 月 1 日重复 01:00–02:00;Europe/Berlin 在 10 月 25 日重复的却是 02:00–03:00。柏林的 02:30 分别对应 UTC 00:30 与 01:30,不能把纽约重复的本地时段直接套到柏林。
处理策略要提前写死,不能靠运行时碰运气:
| 情况 | 建议策略 | 用户可见的结果 |
|---|---|---|
| 不存在的时刻 | 顺延到之后第一个有效时刻 | 02:30 的提醒在 03:00 触发 |
| 重复的时刻 | 只在第一次触发,第二次跳过 | 不重复提醒 |
| 时区规则变更 | 以规则库更新为准,记录变更 | 可能整体平移,需公告 |
时区规则不是永久的。某个国家可能取消夏令时、调整偏移量或更改边界,这些都会通过 IANA 时区数据库更新。自己硬编码偏移量的系统,会在某一天集体出错;依赖系统时区库的系统,只需要更新依赖。
四、日期、数字与货币的显示
存储与显示分离之后,显示层统一交给本地化 API:
日期时间 → Intl.DateTimeFormat(locale, { dateStyle, timeStyle, timeZone })
数字 → Intl.NumberFormat(locale, { style, currency, minimumFractionDigits })
相对时间 → Intl.RelativeTimeFormat(locale, { numeric: 'auto' })
列表连接 → Intl.ListFormat(locale, { type: 'conjunction' })
排序 → Intl.Collator(locale)
复数 → Intl.PluralRules(locale)
几个容易漏的细节:
- 每周从周几开始:美国默认周日,多数欧洲国家默认周一。日历组件要按区域取,而不是写死周一。
- 周数与年份:ISO 周编号与本地周编号可能不同,跨年那一周尤其容易出错。
- 货币小数位:日元、韩元没有小数位,第纳尔类货币有三位。不要统一保留两位。
- 数字分组:印度使用「2,2,3」分组(如 12,34,567),不是三位一组。
- 百分比与单位:百分号的位置、空格的有无随语言变化,不要手工拼接。
- 电话号码:存 E.164 格式(
+819012345678),显示时再本地化。 - 地址:不要假设「省/州」字段一定存在,日本用都道府县,英国用邮编定位。
这些规则都可以从 CLDR 数据里取到,不需要自己维护一张表。
三个必须写的测试用例。 时间问题很难靠人工点出来,只能靠用例:
用例 1:不存在的时刻
时区 America/New_York,计划 2026-03-08 02:30
预期:顺延到 03:00,只触发一次
用例 2:重复的时刻
时区 Europe/Berlin,计划 2026-10-25 02:30
预期:只在第一次出现时触发,第二次跳过
用例 3:旅行改时区
用户在 Asia/Shanghai 设了每天 09:00,一周后飞到 Europe/Berlin
预期:按 Europe/Berlin 的 09:00 触发(或保留原时区,但必须有明确策略)
用例 3 没有唯一正确答案,取决于产品语义。“当地每天早晨提醒”可以跟随旅行后的时区;已经约定的跨时区会议一般应保留同一个瞬间,仅改变显示。关键是逐类定义,不能把所有任务一起平移。
下面是完整 Python 3.9+ 检查程序,保存为 dst_check.py 后执行 python3 dst_check.py。环境需具备 IANA 时区数据;它验证时刻是否存在或重复,不实现前述顺延与只触发一次的调度策略。
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
def candidates(local, zone_name):
zone = ZoneInfo(zone_name)
found = set()
for fold in (0, 1):
instant = local.replace(tzinfo=zone, fold=fold).astimezone(timezone.utc)
if instant.astimezone(zone).replace(tzinfo=None) == local:
found.add(instant)
return sorted(found)
assert len(candidates(datetime(2026, 3, 8, 2, 30), "America/New_York")) == 0
assert len(candidates(datetime(2026, 3, 29, 2, 30), "Europe/Berlin")) == 0
assert len(candidates(datetime(2026, 11, 1, 1, 30), "America/New_York")) == 2
assert len(candidates(datetime(2026, 10, 25, 1, 30), "Europe/Berlin")) == 1
berlin = candidates(datetime(2026, 10, 25, 2, 30), "Europe/Berlin")
assert [d.isoformat() for d in berlin] == [
"2026-10-25T00:30:00+00:00", "2026-10-25T01:30:00+00:00"
]
print("PASS: New York and Berlin gaps/folds use distinct local hours")
跨年与闰日。 三个边界:
- 12 月 31 日所在周的编号,ISO 周与本地周可能不同,甚至跨到下一年的第 1 周。
- 2 月 29 日只在闰年存在,按年重复的计划要决定「平年跳过」还是「顺延到 3 月 1 日」。
- 每月 31 日的计划,在 2 月与小月要有明确策略:跳过、顺延到月末,还是提前到 30 日。
用户改时区之后。 已排期的任务怎么办?两种策略都得有实现,并且对用户可见:
| 策略 | 行为 | 适合 |
|---|---|---|
| 跟随当前时区 | 仅对声明跟随的重复提醒按新时区重算 | 当地每天早晨提醒 |
| 保留原时区或瞬间 | 重复规则保留原时区;已约定会议保留 UTC 瞬间 | 组织固定时区的例会、跨时区约定 |
最糟的是第三种:一部分任务跟随、一部分保留,且没有文档说明。
五、行动清单与复算验收
- 盘点所有时间字段,按「已发生 / 未来重复 / 一次性未来 / 纯日期 / 时长」分类
- 把重复计划改为「本地墙上时间 + IANA 时区」
- 把生日、账单日改为纯日期类型,去掉时区
- 为「不存在」与「重复」两种时刻写死处理策略,并加测试
- 所有显示走本地化 API,删除手写的日期与数字拼接
- 检查货币小数位、周起始日、电话与地址字段
- 记录时区规则库的更新方式与责任人
验收标准:在夏令时切换的两天里,重复提醒仍按本地时间触发一次;生日在各时区显示为同一天;日元金额没有多余的小数位。 三条都过,时间模型才算对。
使用边界与实操验收
本文讲时间的存储与显示,不涉及按地区定价与支付方式,那属于第 08 篇;结账页的币种展示属于结账页:币种、税费、失败重试与拒付 ;文案里的变量与复数属于第 03 篇。
本文也不主张「所有时间都存 UTC」。正确的说法是:瞬间存 UTC,计划存本地时间加时区,纯日期存日期。 三者混用,问题就会在夏令时或跨年时集中爆发。
验证以测试用例为准:至少覆盖春季切换、秋季切换、跨年、闰日与用户在旅行中改时区五种情况。用真实时区库跑,不要用固定的 UTC 偏移量模拟。
关键参考与核验范围
本文的时区日期与示例为教学演示。核验日期:2026-10-23。
- IANA Time Zone Database :全球时区规则与历史变更的权威来源,也是各语言运行时更新的对象。
- MDN: Intl.DateTimeFormat
:日期时间本地化格式化的接口说明,含
timeZone与dateStyle等选项。
上述两条只用于说明时间处理的技术依据,不构成对任何库的推荐。2026 年各地区的夏令时起止日期在本次核验中未逐条确认,实施前请以当年官方公告为准。
系列导航
- 上一篇:本地化测试:漏译、回退与截图回归
- 下一篇:本地价格与本地支付:按地区定价的边界
- 回到目录:一个人把产品做出海:多语言、时区与本地化
- 相关阅读:结账页:币种、税费、失败重试与拒付 、本地价格与本地支付:按地区定价的边界
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。