Skip to content
Published on

时间戳与时区,正确处理

分享
Authors

引言 — 时间为何如此难缠

处理时间的代码,表面上看起来很简单。获取当前时间、保存、显示在屏幕上,似乎这样就结束了。可是某一天,Bug 报告出现了。"预约时间差了一个小时。""报表上的日期晚了一天。""只有特定用户的登录时间不对劲。"这类 Bug 的大多数,都是天真地处理时间所付出的代价。

时间之所以难缠,是因为它并非纯粹的物理量,而是层层叠加着人类约定的概念。地球的自转、标准时的政治边界、夏令时、闰秒,乃至历史上的偏移量变更。在我们随口称为"时间而已"的东西背后,藏着一套复杂得惊人的规则。

幸运的是,驯服这种复杂性的原则是牢靠的。本文将这些原则逐一梳理。用一句话概括就是:以 UTC 存储,以本地时间显示。 其余的内容,都是关于为什么、以及如何守住这条原则的故事。如果想亲自验证这些概念,建议一边阅读,一边打开本站的 Unix 时间戳转换器UTC 转换器世界时钟

大原则 — 以 UTC 存储,以本地时间显示

处理时间最重要的规则,是把存储与显示分离开。

  • 存储:始终以 UTC(协调世界时)存储。UTC 是不受地区或夏令时影响的单一基准线。
  • 显示:只有在展示给用户看的那一刻,才转换为该用户所在的时区。

为什么存储必须使用 UTC?如果像"首尔时间 2026-06-14 15:00"这样原样保存地区时间,这个值就无法独立成立。在有夏令时的地区,同一个挂钟时刻在 1 年内可能出现两次,也可能根本不存在;而如果服务器和客户端处于不同地区,解读方式就会产生分歧。以 UTC 存储,这一切模糊性都会消失。UTC 的某个特定瞬间,无论在地球上的哪个角落,都只指向唯一一个绝对的瞬间。

显示则相反。用户并不想看到 UTC。首尔用户想看到韩国时间,纽约用户想看到东部时间。所以只有在把存储的 UTC 绘制到屏幕上时,才转换为用户的时区。这是一种存储层持有单一真相、显示层持有多个本地化视图的结构。

epoch vs ISO 8601 — 两种表示方式

说要把时间以 UTC 存储,那么实际上要以什么格式存储呢?大体上有两种方式。

Unix 时间戳(epoch)。 是从 1970 年 1 月 1 日 00:00:00 UTC 起流逝的秒数(或毫秒数)。例如 1749884400 这样的一个整数。

1749884400  →  2025-06-14T07:00:00Z (UTC)

优点很明确:它是完全没有时区、纯粹的绝对瞬间。比较和计算都归结为整数运算,占用的存储空间也小。缺点是人类无法直接读懂。

ISO 8601 字符串。 是像 2025-06-14T07:00:00Z 这样人类可读的标准格式。末尾的 Z 表示"Zulu",也就是 UTC。要明确标出偏移量,就写成 2025-06-14T16:00:00+09:00 这样。

2025-06-14T07:00:00Z         ← UTC (Z = +00:00)
2025-06-14T16:00:00+09:00    ← 用首尔的偏移量表示同一瞬间

这两个字符串指向同一个绝对瞬间。ISO 8601 的优点是可读性和偏移量的显式标注。日志、API 响应、供人阅读的数据适合用 ISO 8601;而当内部计算和存储效率更重要时,epoch 更方便。重要的是,无论用哪一种,都不能丢失时区信息。像 2025-06-14 16:00:00 这样既没有偏移量也没有 Z 的"naive"字符串,本身就是模糊的,是时间 Bug 的常见根源。

偏移量 ≠ 时区 — 最常见的误解

这里出现了处理时间时最重要的概念区分。偏移量与时区不同。

  • 偏移量(offset):与 UTC 之间的时差。像 +09:00-05:00 这样的简单数字。只是某个特定瞬间的状态而已。
  • 时区(time zone):某个地区如何决定其时间的一套规则集合。以 Asia/SeoulAmerica/New_York 这样的名称来标识。

为什么这个区分很重要?因为偏移量会随时间变化。America/New_York 在冬天是 -05:00(EST),但在夏天因为夏令时会变成 -04:00(EDT)。也就是说,把"纽约时间"固定成单一的偏移量是错误的。偏移量随每个瞬间而变化,而承载这种变化规则的,正是时区。

名为 America/New_York 的时区:
  2025-01-15  →  偏移量 -05:00 (EST, 冬季)
  2025-07-15  →  偏移量 -04:00 (EDT, 夏季, 夏令时)

实务上的影响很大。存储未来的会议时,不能只存偏移量。就算现在首尔是 +09:00,也不能把 6 个月后的会议时间就此固定为 +09:00——如果在此期间该国改变了夏令时政策,会议时刻就会出错。未来的事件必须连同时区名称(Asia/Seoul)一起存储,而不是偏移量,这样即使规则发生变化,也能重新计算出正确的挂钟时刻。

IANA tz 数据库 — 世界时间规则辞典

如果时区是一套规则集合,那么这些规则存放在哪里?答案是 IANA 时区数据库(tz database,又称 zoneinfo、Olson database)。它是一个收录了全世界所有地区时间规则、并持续更新的公开数据库。

这个数据库收录的内容庞大得惊人。它不仅记录当前的偏移量和夏令时规则,还记录历史上的变更。例如某个国家何时引入又何时废止了夏令时,何时改变了标准时偏移量本身,都记录在内。所以如果用 Asia/Seoul 计算 1988 年某个瞬间的时间,甚至会反映出那一年首尔曾实行夏令时这一历史事实。

核心教训是这样的:不要自己硬编码时区规则。 把"韩国是 UTC+9"这种说法写死在代码里,现在虽然正确,却无法保证未来,而且在有夏令时的地区,即便是现在也是错的。应该改用操作系统和语言运行时提供的、基于 IANA 数据库的库,并保持该数据库处于最新状态。各国改变时间政策的频率比想象中更高,而每一次变更,tz 数据库都会随之更新。

夏令时的地狱 — 不存在的时刻与重复的时刻

夏令时(DST)是时间 Bug 最肥沃的土壤。问题的本质在于,夏令时切换的瞬间,挂钟时刻并不连续。

春季 — 消失的时刻(gap)。 夏令时开始时,时钟会向前跳一个小时。比如凌晨 2 点这一刻一到,立刻就变成 3 点。结果,那一天那个地区,凌晨 2 点 30 分这个时刻根本不存在。

春季切换 (夏令时开始):
  01:59:59  →  03:00:00   (整个 2 点档完全消失)
  "02:30" 是那天不存在的时刻

如果用户设置了每天 02:30 触发的闹钟,那么在夏令时开始那天,这个闹钟应该在什么时候响?这是一个没有标准答案的模糊情形,不同的库和策略会有不同的处理方式。

秋季 — 重复的时刻(overlap)。 夏令时结束时,时钟会向后拨一个小时。结果,1 点 30 分这个挂钟时刻那天会出现两次

秋季切换 (夏令时结束):
  01:59:59 (夏令时)  →  01:00:00 (标准时)   (1 点档重复出现)
  "01:30" 是那天出现两次的时刻

这时,一条"发生在 01:30 的事件"日志,就无法判断是两个 01:30 中的哪一个。没有偏移量,就无法区分这两个瞬间。这两种现象(gap 和 overlap)正是不能原样存储地区时间的决定性理由。以 UTC 存储,这种模糊性从一开始就不会产生,因为 UTC 没有夏令时。

真实案例 — 在夏令时切换时刻运行的批处理任务

有一个支付系统,每天凌晨 2 点 30 分运行结算批处理任务,基准是地区时间。平时一切正常,但到了春季夏令时切换那天,由于那个时刻不复存在,批处理当天没有执行。一整天的结算数据就此缺失,而且没有人看到任何错误日志——因为在调度器看来,只是"那个时刻没有到来"而已。

教训有两条。第一,重复调度最好避开夏令时切换带(通常是凌晨 1 点到 3 点)。第二,必须确认调度器用的是地区时间还是 UTC。许多事故都源自"以地区时间在凌晨运行的批处理任务"。

闰秒 — 1 分钟变成 61 秒的瞬间

关于时间的另一个迷思是"1 分钟永远是 60 秒"。这并非事实。地球自转并不完全规律,因此基于原子钟的时间与基于地球自转的时间之间会积累出微小的误差。为了校正这种误差,偶尔会插入闰秒(leap second)。在这样的日子里,一天的最后 1 分钟会变成 61 秒。

插入闰秒时:
  23:59:58
  23:59:59
  23:59:60   ← 确实存在的时刻!(平时不存在的 60 秒)
  00:00:00

大多数应用程序都不需要直接处理闰秒,因为 Unix 时间本身就被定义为"忽略"闰秒。但闰秒确实引发过实际事故。过去曾有几个大型系统,在插入闰秒的瞬间因时钟紊乱而发生故障。近来,为了应对这个问题,广泛使用一种把闰秒平滑"抹开"(leap smearing)到系统中的技术:在一整天里让时钟极其细微地放慢,从而避开那 61 秒的 1 分钟。要点在于,要认识到"1 秒永远是 1 秒、1 分钟永远是 60 秒"这个假设,在物理上是可能错误的。

不要信任客户端时钟

在分布式系统中,有一条必须铭记的原则:不要信任客户端的时钟。 用户设备的时钟可能是错的:也许电池耗尽后刚重新开机,也许用户故意改过,也许只是单纯地偏差了几分钟。

这会成为问题的场景有很多。

  • 安全令牌过期:如果用客户端时钟判断是否过期,用户就能通过篡改时钟来规避过期。过期必须以服务器时间来判定。
  • 事件顺序:如果按客户端时间戳对多台设备发来的事件排序,时钟有偏差的设备就会把顺序搞乱。
  • 先到先得处理:像优惠券先到先得这样的逻辑,如果按客户端时间判断,是不公平的。

权威时刻由服务器决定。服务器之间也通过 NTP(Network Time Protocol)对齐时钟,但这也并不完美,所以如果需要精确的顺序,就要使用单调递增序列或逻辑时钟(logical clock)之类的额外手段。而且要记住,即便是服务器的时钟,也可能因为 NTP 同步而偶尔向后跳跃。

NTP 与倒退的时钟 — 时间并非只会向前流动

正如刚才提到的,时钟并不总是只向前走。服务器通过 NTP 对齐到准确时刻,如果本地时钟本来就比实际时间快,NTP 就会把时钟往调。那一刻,从代码的角度看,时间就像是倒着流动了一样。

这为什么危险?我们来想一想一段测量经过时间的朴素代码。

start = now()          // 挂钟时刻
... 执行工作 ...
elapsed = now() - start

如果在这期间 NTP 把时钟往回拉,elapsed 就可能变成负数。对于超时逻辑来说,这会产生要么立即过期、要么永远不过期的 Bug。因此,测量经过时间应该使用单调时钟(monotonic clock),而不是挂钟。 单调时钟绝不会倒退,只测量经过的量。原则是:挂钟用来回答"现在几点",单调时钟用来回答"经过了多久"。

日期 vs 瞬间 — 生日没有时区

还有一个重要的区分:"日期"和"瞬间"是不同种类的值。

  • 瞬间(instant):时间轴上的一个点。像"2025-06-14T07:00:00Z"这样,是绝对的、全球通用的。事件发生时刻、日志时间戳都属于这一类。
  • 纯日期(date-only / civil date):与时区无关的日历上的某一天。生日、节假日、合同到期日之类。"6 月 14 日"无论在首尔还是纽约,都是同一个 6 月 14 日。

混淆这两者会产生 Bug。如果把生日存储为一个瞬间(例如 2000-06-14T00:00:00Z),在夏令时或时区转换的过程中就可能被推后或提前一天,产生"生日差了一天"这个经典 Bug。因为如果用户处于偏西的时区,UTC 午夜在那个地区就会是前一天。

原则是这样的:处理特定瞬间时,以 UTC 瞬间存储;处理日历上的日期时,以不含时区的日期类型(LocalDatedate 等)存储。必须先问一句"这是一个绝对瞬间,还是一个日历上的日子",才能选对类型。

实务清单

把到目前为止的原则压缩成实务规则。

  • 存储用 UTC,显示用本地时间。 数据库和 API 内部统一使用 UTC 瞬间。
  • 不要存储 naive datetime。 既没有偏移量也没有 Z 的时刻是模糊的。要始终连同时区信息一起保存。
  • 未来的事件要连同时区名称一起存储。 只存偏移量,会在规则变更时变得脆弱。
  • 不要硬编码时区规则。 使用基于 IANA tz 数据库的库,并保持其最新。
  • 经过时间要用单调时钟测量。 挂钟可能因 NTP 而倒退。
  • 不要信任客户端时钟。 过期、顺序、公平性都要以服务器时间判定。
  • 区分日期与瞬间。 生日和节假日要用不含时区的日期存储。
  • 重复调度要避开夏令时切换带。 凌晨 1 点到 3 点是危险区间。

结语

处理时间,就是处理物理与人类约定交汇的那个点。所以朴素的直觉常常会背叛我们。一天永远是 24 小时、1 分钟永远是 60 秒、时钟只会向前走、时区是固定的偏移量——这些都不是事实。

但面对这份复杂性,我们能抓住的原则出乎意料地简单:以 UTC 存储、以本地时间显示、未来事件连同时区名称一起存储、用单调时钟测量经过时间、以及不信任客户端时钟。守住这些原则,就能预防绝大多数与时间相关的 Bug。如果想亲自摸一摸这些概念,可以到 Unix 时间戳转换器UTC 转换器世界时钟 里确认数值是如何转换的。

参考资料