2407 字
约 8 分钟
17
S04-A13 · Instant 与 LocalDateTime:一个不是时刻

一段"按天统计订单"的代码用了 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. 自测

  1. LocalDateTime 为什么不能表示"事件发生时刻"?它在哪类场景里反而是正确的选择?
  2. 传输时间字符串必须带什么?不带会造成什么后果?
  3. "按天统计"的四种做法里,哪一种需要先把规则写成明文?为什么?
  4. 为什么墙上时间不是一一映射到时刻?这对定时任务意味着什么?
  5. 秒与毫秒混用会造成什么可观测现象?为什么"时间相同"会导致分页抖动?
  6. 判断"能否使用墙上时间"的那个问题是什么?
S04-A13 · Instant 与 LocalDateTime:一个不是时刻
https://www.clxhxhhr.top/posts/6461/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。