一段"按天统计订单"的代码用了 LocalDateTime,在开发机上跑得好好的。上线后跨时区的用户发现:23:30 下的订单被算进了第二天。因为"天"是用服务器本地时区切出来的,而服务器在新加坡,用户在雅加达——同一个"天"在两处不是同一段物理时间。
这个问题的根源不是时区配置,而是类型选错:LocalDateTime 表示"墙上时间",它没有时区,因此也无法对应一个确定时刻。能比较、能相减、能落库排序的时间,必须是时刻(Instant)或带时区的时间。
1. 四种时间类型的分工
| 类型 | 表示 | 何时用 |
|---|---|---|
Instant |
时间轴上的一个点 | 存储、比较、计算差值 |
OffsetDateTime |
点 + 偏移 | 跨系统交换(带偏移) |
ZonedDateTime |
点 + 时区规则 | 需要按当地规则换算 |
LocalDateTime |
墙上时间(无时区) | 只做"日期+时间"的展示或输入 |
第四项不是"低级选项",它在"营业时间 09:00 开门"这类场景里是正确的——因为那确实是一个墙上时间,与地理位置绑定。
四种类型之间可以互相转换,但转换需要时区信息,而时区信息往往不在值里。这就是为什么"落库后再转"经常出错:转的时候用哪个时区,取决于当时运行在哪台机器上。
问题出在用它表示事件发生时刻:这类值一旦跨时区就比较错了。
// 反例:用墙上时间表示事件时刻
LocalDateTime createdAt = LocalDateTime.now(); // 无时区,落库后含义丢失
// 正例:存储用 Instant,展示再转
Instant now = Instant.now(); // 明确是时刻
ZonedDateTime local = now.atZone(ZoneId.of("Asia/Jakarta"));
// 营业时间这类"墙上时间"才用 LocalDateTime / LocalTime
LocalTime openAt = LocalTime.of(9, 0);
2. 存储与传输的约定
| 位置 | 约定 |
|---|---|
| 数据库 | timestamp(带时区)或存 UTC 毫秒 |
| API 传输 | ISO-8601 带偏移(2026-11-10T13:52:00+08:00) |
| 日志 | 带偏移或明确声明 UTC |
| 前端展示 | 按用户时区格式化 |
第二项的关键是必须带偏移:2026-11-10T13:52:00 这样没有偏移的字符串,接收方只能猜。而猜的方式通常是"按本地时区",于是差几小时。
还有一个落地细节:数据库连接串的时区设置会静默改变语义。同一个 timestamp 字段,在不同 serverTimezone 参数下读出来的 Instant 不同,而两边代码都没有错。
// 三处时区设置必须显式,不能依赖默认
spring:
datasource:
url: jdbc:mysql://db:3306/shop?serverTimezone=UTC&useLegacyDatetimeCode=false
jackson:
time-zone: UTC
serialization:
write-dates-as-timestamps: false # 输出 ISO-8601+偏移
第三项同理:日志里只有本地时间的系统,跨时区排障时无法对齐。这在多地域部署里会造成"日志时间对不上监控时间"的长期困扰。
// 一次传输里三种写法的后果
"2026-11-10T13:52:00" → 接收方按本地解释,差 1–8 小时
"2026-11-10T13:52:00+08:00" → 明确,任何时区都能正确解析
"2026-11-10T05:52:00Z" → 同上(UTC 表示),推荐用于存储与日志
3. "按天统计"为什么最容易错
| 做法 | 问题 |
|---|---|
| 用服务器时区切天 | 跨时区用户被算错 |
| 用用户时区切天 | 统计口径随用户变化 |
| 用固定业务时区切天 | 需要明确声明并统一 |
| 用 UTC 切天 | 与运营直觉不符 |
第三项是正解,但前提是把"业务时区"写成一条明确规则,例如"所有日报按 UTC+8 切天"。没有这条规则时,每个开发者都会按自己习惯写,结果同一份报表有多种切法。
切天规则还要考虑"谁来解释结果":运营看日报时不会换算时区,所以要么报表标注时区,要么按运营所在时区切。选哪种都可以,但不能两种混用——那样两份报表永远对不上。
第四项在跨国业务里常被采用,代价是"日报的边界与运营的上班时间不对齐",需要额外的解释。
-- 反例:依赖数据库会话时区切天
SELECT DATE(created_at) d, COUNT(*) FROM orders GROUP BY d;
-- created_at 为 timestamp 时,DATE() 会按会话时区解释 → 结果随连接变化
-- 正例:显式转换到业务时区再切天
SELECT DATE(CONVERT_TZ(created_at, '+00:00', '+08:00')) d, COUNT(*)
FROM orders GROUP BY d;
4. 夏令时:一年两次的偏移
| 现象 | 原因 |
|---|---|
| 某天少/多一小时 | 夏令时切换 |
| 某个本地时间不存在 | 春季跳过的时段 |
| 某个本地时间出现两次 | 秋季重复的时段 |
| 定时任务执行两次 | 落在重复时段 |
第二项与第三项是"墙上时间"的根本缺陷:本地时间不是一一映射到时刻的。所以凡是要"精确执行一次"的任务,都不该用墙上时间做触发条件。
第二项(不存在的本地时间)也会造成故障相反的结果:春季那天 02:30 不存在,任务可能直接不执行。一次不执行比执行两次更难发现,因为没有任何告警。
第四项在有过夏令时的地区是经典故障:02:30 的任务在秋季那天会执行两次,而它可能是一次扣款。
// 定时任务:用时刻判断,避免重复执行
// 反例:cron 在夏令时重叠时段可能触发两次 → 需要幂等
// 正例:任务本身幂等 + 用"已执行标记"去重
if (!jobRepo.markExecutedIfAbsent("settle-2026-11-10")) return;
settle();
// 关键:幂等由业务保证,而不是指望调度器只触发一次
5. 精度与时区混用
| 问题 | 表现 |
|---|---|
| 秒与毫秒混用 | 时间差 1000 倍 |
| 数据库精度截断 | 排序不稳定 |
Date 与 Instant 互转丢精度 |
边界比较出错 |
| 字符串比较代替时间比较 | 跨时区错误 |
第一项在跨系统集成里非常常见:一方按秒传,另一方按毫秒解析,结果时间落到 1970 年附近。这类错误在展示上会立刻暴露(年份明显不对),属于"幸运的错误"。
第三项(Date 与 Instant 互转)在遗留代码里很常见:java.util.Date 精确到毫秒但没有时区语义,与 Instant 混用时,边界判断(如"是否早于某时刻")容易差一天。
第二项更隐蔽:数据库只存到秒而应用用到毫秒,于是同一秒内的多条记录排序不稳定,分页时会重复或遗漏。
// 精度约定:三处必须一致
1. 存储精度:datetime(3) 或 bigint 毫秒,二选一并全库统一
2. 传输精度:ISO-8601 保留毫秒(.SSS)
3. 排序兜底:时间相同则按 id 二次排序,避免分页抖动
6. 排错:五种"时间相关的问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 报表跨时区错位 | 用本地时间切天 | 显式业务时区 |
| 日志与监控对不上 | 日志本地时间 | 统一 UTC |
| 定时任务重复执行 | 夏令时重叠 | 幂等 + 去重标记 |
| 分页重复/漏数据 | 排序键不唯一 | 加 id 二次排序 |
| 时间差 1000 倍 | 秒/毫秒混用 | 统一精度 |
第四项与时间无关但与"时间作为排序键"有关:任何用非唯一键排序的分页都会抖动,高并发写入时尤其明显。
7. 边界:什么情况可以用墙上时间
| 场景 | 为什么 | 做法 |
|---|---|---|
| 营业时间、开门时间 | 与地理绑定 | LocalTime |
| 用户输入的生日 | 无时刻含义 | LocalDate |
| 单人单时区系统 | 无跨时区比较 | 可用但需注明 |
| 纯展示字段 | 不参与计算 | 随意 |
最后一条与"输入"有关的边界:用户填的时间要连同时区一起收。只收"2026-11-10 14:00",系统只能猜;而猜错在预约、签到、到期这类场景里会直接变成业务事故。
判断口径一句话:这个时间会不会被用来比较两个事件谁先谁后。会,必须是时刻;只用于"展示或输入",墙上时间才对。
8. 自测
LocalDateTime为什么不能表示"事件发生时刻"?它在哪类场景里反而是正确的选择?- 传输时间字符串必须带什么?不带会造成什么后果?
- "按天统计"的四种做法里,哪一种需要先把规则写成明文?为什么?
- 为什么墙上时间不是一一映射到时刻?这对定时任务意味着什么?
- 秒与毫秒混用会造成什么可观测现象?为什么"时间相同"会导致分页抖动?
- 判断"能否使用墙上时间"的那个问题是什么?