跳到正文
返回

业务覆盖全球,数据库里的时间到底该怎么存

业务一旦覆盖全球,「时间」这个字段就会从最普通的 created_at 变成最容易埋雷的基础设施问题。

最常见的事故不是数据库挂了,而是这些细碎但致命的问题:

我的结论很简单:数据库不要只存「一个时间」,而要先分清这个字段代表哪一种时间语义。

先分清四种时间

讨论数据库怎么存之前,先别急着选 timestampdatetimebigint。真正的问题是:这个字段表达的到底是什么?

我一般把业务时间分成四类。

类型例子应该表达什么
事件瞬间下单时间、支付时间、创建时间、过期时间全球唯一的某一刻
本地日期生日、账单日、签约生效日某个地区日历上的一天
本地时间规则每天 9 点开卖、每周一 10 点发报表用户或门店所在时区里的规则
展示时间页面上显示「刚刚」「8 月 21 日」给某个用户看的格式

这四类东西如果都塞进一个 timestamp 字段,后面一定会乱。

事件时间:统一存 UTC

下单、支付、退款、登录、创建、更新、删除、过期——这些都是「事件瞬间」。

事件瞬间的特点是:不管人在上海、纽约还是伦敦,这件事发生的那一刻只有一个。

这类字段应该统一按 UTC 存:

created_at
paid_at
refunded_at
expired_at
updated_at

应用层和 API 也尽量传 ISO 8601 UTC:

{
  "createdAt": "2026-08-21T00:09:23.000Z"
}

这样做有几个好处:

  1. 排序稳定 — 所有地区的数据可以直接按时间排序,不用先猜时区
  2. 比较简单 — 过期、延迟、SLA、冷却期都能直接比较两个 instant
  3. 跨系统友好 — 日志、消息队列、数据仓库、监控系统通常都更适合用 UTC 对齐
  4. 迁移成本低 — 以后业务扩到新国家,旧数据不需要重新解释

一句话:事件发生在全球同一个时间轴上,数据库就应该存全球同一个时间轴。

但 UTC 不是万能药

很多团队听完「统一存 UTC」之后,会走到另一个极端:所有和时间有关的字段都存 UTC。

这也会出事。

比如用户设置「每天早上 9 点给我发日报」。这个 9 点不是 UTC 的 9 点,而是用户所在时区的 9 点。如果用户在洛杉矶,今天和半年后的 UTC 偏移还可能不一样,因为夏令时会变。

所以这种场景不能只存一个 UTC 时间。

正确建模应该至少包含三部分:

report_local_time = '09:00:00'
report_timezone = 'America/Los_Angeles'
next_run_at = '2026-08-21T16:00:00.000Z'

含义是:

调度器只扫 next_run_at <= now()。任务跑完后,再根据 report_local_time + report_timezone 计算下一次 next_run_at

这比「每天加 24 小时」安全得多。夏令时那天,一天可能不是 24 小时。

时区要存 IANA,不要只存 offset

很多人会存 +08:00-05:00 这种 offset。它看起来简单,但不够表达业务现实。

+08:00 只能说明某一刻和 UTC 的偏移,不能说明这个地方是谁、未来会不会变、有没有夏令时。

比如 America/New_York 有时是 -05:00,有时是 -04:00。如果你只存 -05:00,半年后用户说「纽约早上 9 点」,系统就不知道该不该切到 -04:00

所以:

offset 是结果,timezone 是规则。数据库里要存规则。

日期字段就存 DATE

还有一类字段根本不是「某一刻」,而是「某一天」。

比如:

这些字段如果强行存成 2026-08-21T00:00:00Z,就会出现一个很烦的问题:一转换时区,日期可能变成 8 月 20 日。

这不是显示问题,是建模错了。

如果业务语义是「某个日历日」,就用 DATE:

birthday DATE
billing_date DATE
settlement_date DATE

不要为了「统一」把它们也塞进 timestamp。统一错了,比不统一更难救。

查询「今天」时,不要在数据库里硬截日期

全球业务里还有一个经典坑:查某个用户「今天的订单」。

错误写法通常长这样:

WHERE DATE(created_at) = CURRENT_DATE

问题有三个:

  1. CURRENT_DATE 用的是数据库会话时区,不一定是用户时区
  2. DATE(created_at) 可能让索引失效
  3. 用户的「今天」不是服务器的「今天」

更稳的做法是:先在应用层根据用户时区算出本地今天的起止时间,再转换成 UTC 区间查询。

比如用户在 Asia/Tokyo,要查东京时间 2026-08-21 的订单:

local_start = 2026-08-21 00:00:00 Asia/Tokyo
local_end   = 2026-08-22 00:00:00 Asia/Tokyo

转换成 UTC 后:

WHERE created_at >= '2026-08-20T15:00:00.000Z'
  AND created_at <  '2026-08-21T15:00:00.000Z'

注意这里用的是半开区间 [start, end),不是 BETWEEN。这样不会漏掉边界,也不会因为毫秒、微秒精度不同出问题。

数据库字段怎么选

不同数据库的类型细节不一样,但原则可以统一。

场景推荐存法
事件瞬间UTC instant,字段名用 *_at
业务日期DATE,字段名用 *_date
本地时间规则TIME / cron 字段 + IANA timezone
一次性预约UTC instant + 原始 timezone/local time,方便展示和审计
周期性任务local rule + timezone + next_run_at UTC

字段命名也要帮人减少误解:

created_at              -- UTC instant
paid_at                 -- UTC instant
billing_date            -- date-only
store_timezone          -- IANA timezone
promotion_starts_at     -- UTC instant
promotion_local_start   -- 用户填写的本地时间,可选保留
next_run_at             -- UTC instant for scheduler

我不喜欢叫 timedate_timestart_time 这种泛名。半年后没人知道它是 UTC、local time,还是 date-only。

默认策略

如果让我给全球化业务定一套默认规则,我会这么写进团队规范:

  1. 所有事件时间按 UTC 存储和传输。 API 输出 ISO 8601,带 Z 或明确 offset。
  2. 数据库连接、服务运行环境、日志系统默认 UTC。 不依赖服务器本地时区。
  3. 展示时才转用户时区。 用户没设置时区时,用组织、门店、国家/地区或产品默认时区。
  4. 业务规则存 IANA timezone。 不用 +08:00 当长期规则。
  5. 日期就是日期。 生日、账期、结算日用 DATE,不要用 timestamp 伪装。
  6. 查询本地日期时,先算本地边界,再转 UTC 区间。 永远用 [start, end)
  7. 对夏令时做产品决策。 遇到不存在的 02:30 怎么办?重复的 01:30 跑一次还是两次?规则要写清楚。

最后

时间字段最怕的不是复杂,而是「看起来很简单」。

单区域业务里,服务器、用户、数据库都在同一个时区,很多问题会被环境巧合掩盖。业务一全球化,这些巧合全部消失。

我的经验是:先按业务语义分清 instant、date、local time rule,再决定数据库类型。

能代表全球唯一瞬间的,存 UTC。代表用户本地日历和本地规则的,就把 timezone 和原始规则一起存下来。

不要让一个孤零零的 timestamp 承担它表达不了的语义。后面每一次跨时区、做报表、跑定时任务,都会回来找你算账。


分享这篇文章:

上一篇
AI 时代,git worktree 从「可选」变成了「必需」
下一篇
ego 解放双手:让 AI 代理真正去用浏览器,而不是让你去迁就它