时区、日期与数字:存 UTC,显示本地

把时间一律存成 UTC 并不够:每天九点的提醒如果按 UTC 存,夏令时切换后就会变成八点或十点。时区、日期、数字与货币各有自己的坑——不存在与重复的本地时刻、每周从周几开始、日元没有小数位、电话号码不能存本地格式。这一篇给一套存储与显示的分离规则。

时间要存成唯一的瞬间,也要存成用户以为的那个墙上时间。

一、凌晨两点的提醒

以下情境为教学假设,人物、引语和数字不代表经核实的真实案例。

路遥做一个习惯打卡工具,用户可以设置「每天九点提醒」。他把提醒时间换算成 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 年各地区的夏令时起止日期在本次核验中未逐条确认,实施前请以当年官方公告为准。

系列导航

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「Indie」更多文章

  1. 开源项目验收:十项能力、四维评分与三种不该开源的项目
  2. 治理、fork 与退出:17 位贡献者、82% 的提交与一次改协议的代价
  3. 双许可与企业版:九家询价、两家成交与 4,800 美元