业务一旦覆盖全球,「时间」这个字段就会从最普通的 created_at 变成最容易埋雷的基础设施问题。
最常见的事故不是数据库挂了,而是这些细碎但致命的问题:
- 用户在东京下单,客服在洛杉矶看订单,两边看到的日期不一样
- 活动说「当地时间 0 点开始」,结果有的国家提前一天、有的国家晚一天
- 财务按自然日结算,跨时区订单被算进了错误账期
- 夏令时切换那天,定时任务重复跑了一次,或者直接没跑
- 表里明明有
2026-08-21 00:30:00,但没人知道这是北京时间、UTC,还是服务器本地时间
我的结论很简单:数据库不要只存「一个时间」,而要先分清这个字段代表哪一种时间语义。
先分清四种时间
讨论数据库怎么存之前,先别急着选 timestamp、datetime、bigint。真正的问题是:这个字段表达的到底是什么?
我一般把业务时间分成四类。
| 类型 | 例子 | 应该表达什么 |
|---|---|---|
| 事件瞬间 | 下单时间、支付时间、创建时间、过期时间 | 全球唯一的某一刻 |
| 本地日期 | 生日、账单日、签约生效日 | 某个地区日历上的一天 |
| 本地时间规则 | 每天 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"
}
这样做有几个好处:
- 排序稳定 — 所有地区的数据可以直接按时间排序,不用先猜时区
- 比较简单 — 过期、延迟、SLA、冷却期都能直接比较两个 instant
- 跨系统友好 — 日志、消息队列、数据仓库、监控系统通常都更适合用 UTC 对齐
- 迁移成本低 — 以后业务扩到新国家,旧数据不需要重新解释
一句话:事件发生在全球同一个时间轴上,数据库就应该存全球同一个时间轴。
但 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'
含义是:
report_local_time保存用户理解的业务规则report_timezone保存 IANA 时区,比如Asia/Shanghai、America/Los_Angelesnext_run_at保存下一次实际触发的 UTC 时间,方便调度器查询
调度器只扫 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。
所以:
- 存时区规则:用
Asia/Shanghai、Europe/Berlin、America/New_York - 不要只存 offset:
+08: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
问题有三个:
CURRENT_DATE用的是数据库会话时区,不一定是用户时区DATE(created_at)可能让索引失效- 用户的「今天」不是服务器的「今天」
更稳的做法是:先在应用层根据用户时区算出本地今天的起止时间,再转换成 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
我不喜欢叫 time、date_time、start_time 这种泛名。半年后没人知道它是 UTC、local time,还是 date-only。
默认策略
如果让我给全球化业务定一套默认规则,我会这么写进团队规范:
- 所有事件时间按 UTC 存储和传输。 API 输出 ISO 8601,带
Z或明确 offset。 - 数据库连接、服务运行环境、日志系统默认 UTC。 不依赖服务器本地时区。
- 展示时才转用户时区。 用户没设置时区时,用组织、门店、国家/地区或产品默认时区。
- 业务规则存 IANA timezone。 不用
+08:00当长期规则。 - 日期就是日期。 生日、账期、结算日用
DATE,不要用 timestamp 伪装。 - 查询本地日期时,先算本地边界,再转 UTC 区间。 永远用
[start, end)。 - 对夏令时做产品决策。 遇到不存在的 02:30 怎么办?重复的 01:30 跑一次还是两次?规则要写清楚。
最后
时间字段最怕的不是复杂,而是「看起来很简单」。
单区域业务里,服务器、用户、数据库都在同一个时区,很多问题会被环境巧合掩盖。业务一全球化,这些巧合全部消失。
我的经验是:先按业务语义分清 instant、date、local time rule,再决定数据库类型。
能代表全球唯一瞬间的,存 UTC。代表用户本地日历和本地规则的,就把 timezone 和原始规则一起存下来。
不要让一个孤零零的 timestamp 承担它表达不了的语义。后面每一次跨时区、做报表、跑定时任务,都会回来找你算账。