公元零年二月的一个错误
摘要
PHP 的 DateTimeImmutable 中一个罕见的错误导致公元零年二月出现日期偏差一天的问题;解决方法是使用替代的时间戳转换方法。
暂无内容
查看缓存全文
缓存时间: 2026/06/29 05:01
# 公元0年2月的一个小故障
来源:https://28times.com/blog/2026-06-26-february-of-the-year-0
28times图标28times标志 (https://28times.com/)博客 (https://28times.com/blog)\> 文章
一份关于我们发现并修复的罕见正确性问题技术报告。
最近,在添加对远古时间戳的支持时,一位团队成员在测试中发现某些时间戳未能正确处理。通过时间戳 `0000-02-03 04:00 Europe/Oslo` 可以轻松复现该问题。
初步调查表明,该问题影响所有时区,但仅出现在公元0年2月(以及1月的最后几天)。
大多数时间序列不会包含两千年前的时间戳。但当然,我们希望正确解析支持范围内的所有时间戳,即使是那些可追溯到古代的罕见情况。
## 追查 Bug 的时刻
我们开始寻找这个Bug,并理所当然地认为它一定出在我们自己的代码中。我们使用了PHP运行时提供的日历逻辑(`DateTimeImmutable`类),但在处理时间戳时仍有一些非平凡的操作。为了处理因时区转换而产生歧义的时间戳,我们内部计算了Unix时间戳,然后将其转换为PHP的`DateTimeImmutable`对象。
公元0年在两个方面有些特殊。首先,历史学家使用的传统儒略历中并不存在“公元0年”(他们会称其为公元前1年)。其次,在采用天文年份编号的投影公历(即我们在28times上使用的日历)中,公元0年是一个世纪闰年(https://en.wikipedia.org/wiki/Century_leap_year)。世纪闰年是例外中的例外:能被100整除的年份不是闰年,除非——像公元0年那样——它们也能被400整除。
这让我们隐约明白了为什么公元0年可能受到Bug影响。但由于其他世纪闰年(如公元2000年)并未受影响,这不能作为完整解释。
于是我们逐步排查代码,最终找到了问题所在。令人惊讶的是,问题并不在我们的代码中。它与我们将Unix时间戳转换为`DateTimeImmutable`对象所用的惯用方法有关。
## 根本原因
下面是在PHP中将Unix时间戳转换为`DateTimeImmutable`对象的三种方式。
1. `\\DateTimeImmutable::createFromFormat\('U', '\-62164356180'\)`
2. `\(new \\DateTimeImmutable\('@0'\)\)\-\>setTimestamp\(\-62164356180\)`
3. `new \\DateTimeImmutable\('@\-62164356180'\) //返回错误结果`
这三种方式本应完全等价——对于大多数时间戳也确实如此。但与前面两种不同,最后一种变体在公元0年2月的结果会偏差一天。在撰写本文时,所有最新的PHP版本都出现此问题。不巧的是,我们在代码中恰好使用了最后一种方法。
(你也可以将上述三种片段中的 `DateTimeImmutable` 替换为 `DateTime`。`DateTime` 在最后一种变体上存在同样的问题。)
## 修复问题
就我们自身而言,修复方法很简单:改用前两种方式之一。这也是我向其他使用 `DateTime` 或 `DateTimeImmutable` 的PHP程序员推荐的做法。
我还提交了一个拉取请求(https://github.com/derickr/timelib/pull/173)来修复 `timelib` 库中的这个Bug,该库提供了日期/时间功能。PHP的 `DateTimeImmutable` 内部使用了这个库。
问题的根源在于,`timelib` 有两个将Unix时间戳转换为投影公历日期的实现。其中一个实现中的范围检查使用了错误的日期——该日期落在公元0年1月,大约在世纪闰日之前一个月,而不是之后。这导致所有世纪闰日之前的结果偏差一天。我建议通过让所有调用者使用正确的算法来修复它。
这个问题有望在即将发布的 `timelib` 和 PHP 版本中得到修复。这无疑是一次令人满意的Bug修复——我们有了一个干净的变通方法,而且改进 `timelib` 和 PHP 也是一个不错的额外收获。
相似文章
PHP 的古怪特性
一位开发者在使用了五年后反思 PHP 的古怪之处,重点介绍了其数组实现和类型系统的奇特之处。
Y2K
一篇讨论Y2K bug及其历史意义的文章。
核心转储流行病学:修复一个18年的旧bug
OpenAI工程师详细描述了Rockset的C++数据基础设施中看似不可能的崩溃的诊断过程,揭示了一个Azure上的静默硬件损坏bug以及GNU libunwind中存在18年的竞态条件,最终通过崩溃数据的流行病学分析得以解决。
我最喜欢的Bug:无效的代理对
一篇博客文章,回顾了一个Bug:在CRDT库中,插入相邻的多字节表情符号导致了一个拼接操作,分割了代理对,并静默地破坏了协作编辑器的同步。
不是我,是编译器
一位Rust程序员发现了一个编译器错误,其中将'bool as u32'进行类型转换会产生不正确的结果,导致解析器错误。该错误已报告并链接到GitHub issue #158206。