2017年元旦凌晨,Cloudflare的DNS服务突然开始大面积失败。用户访问使用CNAME记录的网站时,DNS解析直接报错。工程师们紧急排查后发现,罪魁祸首竟然是一秒钟——一个闰秒。

这不是什么边缘案例。2012年6月30日的闰秒让Reddit、Mozilla、LinkedIn等大量网站同时瘫痪。时间,这个看似简单的概念,在计算机系统中布满了陷阱。

那些你以为是真理的错误认知

Noah Sussman在2012年整理了一份清单,标题就叫《程序员对时间的错误认知》。这份清单后来被不断补充,至今仍是开发者必读。

“一天有24小时”——错。在夏令时结束那天,一天可能有25小时;夏令时开始那天,一天可能只有23小时。

“时间总是向前流动”——错。这正是Cloudflare事故的根因。Go语言的time.Now()不保证单调性,当闰秒插入导致系统时间回退,计算出的往返时间变成了负数,最终触发panic。

“UTC偏移量在-12到+12之间”——错。实际上范围是-12到+14。太平洋上的一些岛国为了和澳大利亚做生意,硬生生把自己的时区设到了UTC+13和UTC+14。这意味着国际日期变更线变得极度曲折。

“每个时区对应唯一的UTC偏移量”——错。印度是UTC+5:30,尼泊尔是UTC+5:45。那多出来的15分钟是因为他们希望首都加德满都的正午时分,太阳正好位于喜马拉雅山顶上方。如果那座山移动了怎么办?这个问题没人想过。

“一个国家的时区永远不会变”——大错特错。2011年12月29日午夜,萨摩亚群岛决定从国际日期变更线的东边跳到西边。他们把时区从UTC-11改成UTC+13,直接跳过了12月30日。那天萨摩亚人醒来就是12月31日,平白无故少了一天。

IANA时区数据库:全人类时间的守护者

你可能从未听说过IANA时区数据库(也叫tzdata、zoneinfo或Olson数据库),但你用的每台电脑、每部手机都在依赖它。

这个数据库由一群志愿者维护,记录了全球每个地区历史上所有的时区变化。为什么需要历史记录?因为时区规则一直在变。每年都有国家修改自己的夏令时政策,调整UTC偏移量,甚至彻底更换时区。

维护流程是这样的:某国政府宣布修改时区规则 → 有人向tzdata邮件列表提交变更 → 维护者审核后合并 → 各操作系统发布更新 → 你的应用最终获得正确数据。

问题在于,从政府宣布到你的应用更新,可能存在数天甚至数周的延迟。2016年土耳其突然取消夏令时,只提前几周通知。很多系统根本来不及更新就出问题了。

更棘手的是:你的应用使用的时区数据版本,可能和数据库里存储数据时使用的版本不同。 这会带来什么后果?

存UTC就够了?Jon Skeet说你想得太简单

Stack Overflow传奇用户Jon Skeet在2019年写了一篇博客,标题直截了当:《存UTC不是万能的》。

他举了一个例子:假设你在2019年3月帮一个会议组织者注册了2022年7月阿姆斯特丹的会议,开始时间是上午9点。根据当时的时区数据,阿姆斯特丹在7月是UTC+2。

然后,2020年荷兰政府决定从当年10月起永久使用"冬令时"(UTC+1)。时区数据库更新了,但你的数据库里存的是UTC时间——你把"9点阿姆斯特丹时间"转成了UTC时间存储。

问题来了:当规则改变后,那个存储的UTC时间对应的阿姆斯特丹本地时间变成了10点。你的倒计时器会平滑地倒数到0,然后——会议还没开始,因为实际上还有一小时。

核心问题在于:你存储的是"派生数据",而不是"原始信息"。

正确做法是保存用户告诉你的原始信息:本地时间(9点)+ 时区ID(Europe/Amsterdam)。UTC时间可以作为优化存储,但它应该是可重新计算的派生值。

夏令时转换时的"幽灵时间"和"消失时间"

每年3月第二个周日凌晨2点,美国大部分地区会进入夏令时。时钟直接从1:59:59跳到3:00:00。2:00:00到2:59:59这段时间——从未存在过

如果你有个定时任务设在这个时间执行,会发生什么?取决于你用的库和数据库,结果可能是:执行两次、执行一次、报错、或者行为未定义。

每年11月第一个周日凌晨2点,夏令时结束。时钟从1:59:59回退到1:00:00。1:00:00到1:59:59这段时间——重复了两次

如果你在这天凌晨1:30有个会议,是哪个1:30?第一次的还是第二次的?你的应用如何区分?

Noda Time库的作者为此设计了"宽松解析器"(Lenient Resolver),在遇到模糊时间时选择某种策略。但这是否适合你的业务?需要你自己决定。

时区偏移量和时区ID:两个不同的概念

很多人混淆了这两个概念,导致严重的设计缺陷。

时区偏移量是某个时刻相对于UTC的差值,比如UTC+8。

时区ID是一个地理区域的标识符,比如Asia/Shanghai。

为什么这个区别很重要?因为同一个时区ID,在不同的日期和时间,可能有不同的偏移量。上海的冬天是UTC+8,但如果中国实施夏令时,夏天就会变成UTC+9。

反过来,同一个偏移量可能对应多个时区ID。UTC+8同时被中国、新加坡、马来西亚、菲律宾等使用。但这些国家的时区规则历史不同,未来也可能分道扬镳。

存储偏移量而不是时区ID,等于丢失了"这个时间属于哪个时区规则管辖"的信息。当规则改变时,你无法正确重新计算。

各语言的时区处理陷阱

JavaScript的Date对象是个灾难

JavaScript的Date对象设计于网景时代,充满了历史遗留问题。月份从0开始计数——new Date(2024, 0, 1)是1月1日,不是"不存在"的日期。

更严重的是,Date对象内部只存储UTC时间戳,所有本地时间操作都依赖于运行时的时区设置。同样的代码,在服务器和浏览器上可能产生不同结果。

这也是为什么现代JavaScript项目几乎都使用第三方库:date-fns、Luxon、Day.js或js-joda。即使使用这些库,也要小心区分"本地时间"和"带时区的时间"。

Python的pytz和zoneinfo

Python 3.9之前,处理时区主要靠pytz库。但pytz有个著名的坑:你不能直接把pytz的时区对象传给datetime构造函数。

# 错误用法
dt = datetime(2024, 1, 1, 12, 0, tzinfo=pytz.timezone('Asia/Shanghai'))

# 正确用法
tz = pytz.timezone('Asia/Shanghai')
dt = tz.localize(datetime(2024, 1, 1, 12, 0))

Python 3.9引入了标准库zoneinfo,终于解决了这个问题:

from zoneinfo import ZoneInfo
dt = datetime(2024, 1, 1, 12, 0, tzinfo=ZoneInfo('Asia/Shanghai'))

Java的java.time包

Java 8引入的java.time包(JSR-310)是目前设计最完善的日期时间API之一。关键类:

  • Instant:时间线上的一点,机器视角,总是UTC
  • LocalDateTime:不带时区的日期时间,人类视角
  • ZonedDateTime:带时区的日期时间
  • OffsetDateTime:带偏移量的日期时间

使用原则:在系统边界使用Instant(如数据库存储、API传输),在用户界面使用ZonedDateTime(显示本地时间),尽量避免使用LocalDateTime(它无法准确定位时间线上的点)。

数据库中的时间类型

PostgreSQL提供了四种时间类型:

  • timestamp without time zone:不带时区信息,本质上是"某个时区的本地时间",但数据库不知道是哪个时区
  • timestamp with time zone:存储时转换为UTC,读取时转换回会话时区
  • date:仅日期
  • time with/without time zone:仅时间

PostgreSQL官方Wiki明确建议:不要使用timestamp without time zone。因为数据库无法知道这些时间戳原本是哪个时区的,这在跨时区应用中会导致混乱。

但即使使用timestamptz,也要注意它存储的只是UTC时间戳。时区信息本身(比如用户设置的时区偏好)需要另外存储。

周期性事件:时区处理的终极挑战

如果以上问题还不够复杂,考虑这个场景:你需要实现一个每周一早上9点执行的任务。

用户的时区是America/New_York,你的服务器在UTC时区。简单,对吧?

等等。美国每年两次调整夏令时。春天"向前拨"一小时,秋天"向后拨"一小时。这意味着:

  1. 夏令时开始那天,凌晨2点直接变成3点。如果任务是"每周一凌晨2:30执行",那天会直接跳过。
  2. 夏令时结束那天,凌晨2点回退到1点。如果任务是"每周一凌晨1:30执行",可能执行两次。

正确的设计需要存储:

  • 本地时间(比如9:00)
  • 时区ID(America/New_York)
  • 周期规则(每周一)

然后在每次调度时,根据当时的时区规则动态计算下一次执行的UTC时间。当用户修改时区设置,或时区规则更新时,需要重新计算所有未来的调度。

实战建议:不同场景的策略

场景一:记录事件发生时间

存储Instant(UTC时间戳)。这是过去已经发生的事件,时间线上的点已经确定。存储UTC时间戳是合适的。

场景二:用户生日、纪念日

存储LocalDate(仅日期)。生日是日历概念,不是时间线上的点。不要转成UTC,不要带时间部分。如果用户出生在中国时区的1月1日,这个日期本身就是信息,与UTC无关。

场景三:未来事件(会议、航班)

存储本地时间 + 时区ID。UTC时间可以作为派生数据缓存,但原始信息必须保留。当时区规则改变时,需要重新计算UTC时间。

场景四:周期性任务

存储调度规则 + 时区ID。不要预计算所有未来实例的UTC时间,而是动态计算下一次执行时间。

场景五:跨时区协作

统一使用UTC作为协调基准。但显示时转换为各参与者的本地时间。存储时区偏好,而不是存储多个时区的时间戳。

最后的检查清单

  1. 你的系统是否区分了"时间线上的点"和"日历日期"?
  2. 你的应用使用的时区数据库是否保持更新?
  3. 未来事件是否存储了完整的原始信息(本地时间 + 时区ID)?
  4. 是否考虑了DST转换时的边界情况?
  5. 前后端时间格式是否统一使用ISO 8601?
  6. 数据库字段类型是否正确选择了带时区的类型?
  7. 测试用例是否覆盖了不同的时区和DST场景?

时间处理没有万能方案。但理解底层原理,知道每个决策的代价,至少能让你在午夜被叫醒排查时区bug时,知道自己面对的是什么。


参考资料

  1. Noah Sussman, “Falsehoods Programmers Believe About Time”, 2012
  2. Zain Rizvi, “Falsehoods Programmers Believe About Time Zones”, 2020
  3. Jon Skeet, “Storing UTC is not a Silver Bullet”, 2019
  4. Cloudflare Blog, “How and Why the Leap Second Affected Cloudflare DNS”, 2017
  5. IANA Time Zone Database - https://www.iana.org/time-zones
  6. PostgreSQL Wiki, “Don’t Do This” - timestamp without time zone
  7. Java SE Documentation - java.time (JSR-310)
  8. Python Documentation - zoneinfo module
  9. Noda Time User Guide - Handling ambiguity
  10. W3C, “Working with Time and Timezones”, 2025
  11. Tinybird, “Best Practices for Timestamps and Time Zones in Databases”, 2025
  12. Ryan Thomson, “A Practical Guide to Timezones for Developers”, 2023
  13. Oracle Java Magazine, “Handling Time Zones in Distributed Systems”, 2023
  14. Wired, “The Inside Story of the Extra Second That Crashed the Web”, 2012
  15. IETF RFC 6557 - Procedures for Maintaining the Time Zone Database