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时区。简单,对吧?
等等。美国每年两次调整夏令时。春天"向前拨"一小时,秋天"向后拨"一小时。这意味着:
- 夏令时开始那天,凌晨2点直接变成3点。如果任务是"每周一凌晨2:30执行",那天会直接跳过。
- 夏令时结束那天,凌晨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作为协调基准。但显示时转换为各参与者的本地时间。存储时区偏好,而不是存储多个时区的时间戳。
最后的检查清单
- 你的系统是否区分了"时间线上的点"和"日历日期"?
- 你的应用使用的时区数据库是否保持更新?
- 未来事件是否存储了完整的原始信息(本地时间 + 时区ID)?
- 是否考虑了DST转换时的边界情况?
- 前后端时间格式是否统一使用ISO 8601?
- 数据库字段类型是否正确选择了带时区的类型?
- 测试用例是否覆盖了不同的时区和DST场景?
时间处理没有万能方案。但理解底层原理,知道每个决策的代价,至少能让你在午夜被叫醒排查时区bug时,知道自己面对的是什么。
参考资料
- Noah Sussman, “Falsehoods Programmers Believe About Time”, 2012
- Zain Rizvi, “Falsehoods Programmers Believe About Time Zones”, 2020
- Jon Skeet, “Storing UTC is not a Silver Bullet”, 2019
- Cloudflare Blog, “How and Why the Leap Second Affected Cloudflare DNS”, 2017
- IANA Time Zone Database - https://www.iana.org/time-zones
- PostgreSQL Wiki, “Don’t Do This” - timestamp without time zone
- Java SE Documentation - java.time (JSR-310)
- Python Documentation - zoneinfo module
- Noda Time User Guide - Handling ambiguity
- W3C, “Working with Time and Timezones”, 2025
- Tinybird, “Best Practices for Timestamps and Time Zones in Databases”, 2025
- Ryan Thomson, “A Practical Guide to Timezones for Developers”, 2023
- Oracle Java Magazine, “Handling Time Zones in Distributed Systems”, 2023
- Wired, “The Inside Story of the Extra Second That Crashed the Web”, 2012
- IETF RFC 6557 - Procedures for Maintaining the Time Zone Database