Java · java.time 时间处理

告别 Date/SimpleDateFormat 的线程坑 — Instant/LocalDateTime/ZonedDateTime 三件套, UTC 存储 + 时区展示的黄金约定

旧世界雷区 (Date 时代) SimpleDateFormat 共享实例 Thread-1 format Thread-2 parse 内部共享 Calendar 状态互相踩 → 2025 串成 5021 或 ArrayIndexOutOfBoundsException DateTimeFormatter — 不可变 static final 预编译, 任意线程共享零锁 新 API 默认安全, 不需要 ThreadLocal 花招 Date 也是可变的: setYear 谁都能悄悄改 旧 → 新 互转速查 Date → Instant: date.toInstant() Instant → Date: Date.from(instant) Calendar → ZDT: cal.toInstant().atZone(z) SDF → DTF: ofPattern 预编译一次 cal.add(DAY,1) 溢出静默滚动 vs java.time 抛异常 java.sql.Timestamp: 能换就换 OffsetDateTime 老代码不重写, 先止血 SDF 限 ThreadLocal 或每次新建 读到什么时区写注释, 别让人猜 根治只有一条路: 全面迁到 java.time 三类时间的关系 Instant — 机器视角 UTC 时间轴上的绝对点 (epoch 秒 + 纳秒) atZone(zone) toInstant() ZonedDateTime — 完整时刻 LocalDateTime + ZoneId(带 TZDB 夏令时规则) 知道"哪的十点", 换算不歧义 toLocalDateTime() atZone(zone) LocalDateTime — 人类挂历 2030-01-01T10:00 但不知道是"哪的"十点 无时区语义 → 序列化后各端解释不同 同一时刻的三个钟面 2026-09-26T18:00+08:00 = 10:00Z = 12:00+02:00 withZoneSameInstant 换钟面, 时刻不动 两种"量"也要分家 Duration — 绝对时长 (秒+纳秒): 耗时/超时 Period — 挂历量 (年月日): 账单/生日 夏令时切换日 ≠ 24 小时: 加天数用 plusDays(钟面) ZonedDateTime vs OffsetDateTime ZDT: ZoneId 规则库(夏令时会变), 展示给人 ODT: 固定偏移, 序列化/线协议首选 JDBC 4.2: TIMESTAMP ↔ OffsetDateTime 直映射 黄金约定流水线 ① 存储: 全 UTC JDBC URL: connectionTimeZone=UTC 列类型 TIMESTAMP, 实体用 Instant/ODT DATETIME 无时区语义 — 新表别用 ② API 出口: RFC3339 2026-09-26T18:00:00+08:00 带偏移传输, 不裸传 LocalDateTime Jackson 关掉 WRITE_DATES_AS_TIMESTAMPS ③ 展示: 用户时区渲染 浏览器 Intl / 用户 profile 时区 服务端只出 UTC 带偏移, 不猜客户端 铁律: 计算全程 UTC 只在展示最后一公里转时区 永远不做"服务器本地时间"中间态 读出歧义的经典 DB 时区 ≠ JVM 时区: 同一行数据 在不同机器读出不同钟面 可测试性: Clock 注入 now() 每次不同, 两次取 now 做一致性 比较必错 — 测试用 Clock.fixed 钉死 Legend Instant/存储 ZonedDateTime/推荐 LocalDateTime/API 出口 铁律/挂历量 线程坑/歧义

机器时间 vs 人类时间

  • • Instant: UTC 时间轴绝对点, 存储/计算用它
  • • LocalDateTime: 挂历, 人看得懂但不知道哪的
  • • ZonedDateTime = 挂历 + ZoneId, 展示层换算

不可变是免费的线程安全

  • • DateTimeFormatter static final 随便共享
  • • SimpleDateFormat 共享实例 = 日期串台事故
  • • 所有 java.time 类型都不可变, 改了就返回新对象

黄金约定三段式

  • • DB 全 UTC: TIMESTAMP + connectionTimeZone=UTC
  • • API 出 RFC3339 带偏移, 不裸传 LocalDateTime
  • • 前端按用户时区渲染, 计算永不碰本地时区

💡 一句话理解

java.time 的设计核心是把"机器时间"和"人类时间"拆开: Instant 是 UTC 时间轴上的绝对点, 给存储和计算用; LocalDateTime 是一张无时区挂历, 只该出现在"用户输入/展示"两端; 中间所有换算都靠 ZonedDateTime 挂上 ZoneId 完成。再配上"UTC 存储 + 出口带时区 + 前端按用户渲染"的黄金约定, 跨时区数据就不会漂。最后记住两件礼物: 全家不可变(DateTimeFormatter 终于能 static final 共享了), 以及 Clock 可注入 — 时间从"环境"变成了"输入", 测试从此可重复。

🧠 必知必会 必考 & 必会

机器 vs 人类时间
机器时间(Instant/Duration): 时间轴上的绝对量, 无时区歧义, 存储/比较/超时全用它。人类时间(LocalDateTime/LocalDate/ZonedDateTime): 挂历量, 面向人 — "每天 8 点"是挂历语义, 必须配 ZoneId 才能落到时间轴。
Instant i = Instant.parse("2026-09-26T10:00:00Z");   // 机器: UTC 绝对点
LocalDateTime ldt = LocalDateTime.of(2026, 9, 26, 18, 0); // 人类: 无时区挂历
ldt.atZone(ZoneId.of("Asia/Shanghai")).toInstant();  // → 同一时刻 10:00Z
Instant
UTC epoch 秒+纳秒。Instant.now()、Instant.parse("2026-09-26T10:00:00Z")。时间戳字段(过期时间/创建时间)一律存它; JDBC 对应 TIMESTAMP 列(连接时区=UTC 时)。
Instant t = Instant.parse("2026-09-26T10:00:00Z");
t.getEpochSecond();              // → 1790416800
t.plus(Duration.ofSeconds(30));  // → 10:00:30Z, 计算全在 UTC
LocalDate/LocalDateTime
无时区挂历。合法用途: 用户输入"生日 3 月 1 日"、营业时间"9:00-18:00"。非法用途: 当绝对时刻存储或比较超时 — 序列化后不同端会按各自时区解释出不同时刻。
LocalDateTime ldt = LocalDateTime.of(2026, 9, 26, 18, 0);
ldt.atZone(ZoneId.of("Asia/Shanghai"));   // → 18:00+08 = 10:00Z
ldt.atZone(ZoneOffset.UTC);               // → 18:00Z, 完全另一时刻
// 关键: 同一串挂历, 配哪个时区就是哪个时刻
ZonedDateTime vs OffsetDateTime
ZDT 绑 ZoneId(如 Asia/Shanghai), 带夏令时规则库, 规则会随 TZDB 更新, 适合"给人看/按用户时区调度"。ODT 绑固定偏移(+08:00), 无规则语义, 适合序列化与线协议 — JSON/API 出口首选。
ZonedDateTime zdt = ldt.atZone(ZoneId.of("America/New_York")); // 规则库
OffsetDateTime odt = zdt.toOffsetDateTime();  // 只剩 -04:00 偏移
// API 出口用 ODT 序列化; 展示给人/调度用 ZDT
Duration vs Period
Duration=秒+纳秒的绝对时长; Period=年月日的挂历量。P1M 不是 30 天; 夏令时切换日是 23/25 小时, Duration.ofDays(1) 与 plusDays(1)(钟面语义)在那天不等。
LocalDate d = LocalDate.of(2026, 1, 31).plus(Period.ofMonths(1));
// → 2026-02-28  月度语义 P1M (≠ 固定 30 天)
Duration.ofDays(1);               // PT24h 绝对时长, 两者别混用
DateTimeFormatter
不可变线程安全: static final DateTimeFormatter F = ofPattern("uuuu-MM-dd HH:mm:ss") 全局共享。优先用预定义常量 ISO_LOCAL_DATE_TIME/RFC_1123_DATE_TIME, 零解析开销。
private static final DateTimeFormatter TS =
    DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss");  // 不可变
LocalDateTime.parse("2026-09-26 18:00:00", TS);   // 多线程随便用
pattern 四坑字母
yyyy 年 vs YYYY 周年(跨年周可能差一年); HH 24 小时制 vs hh 12 小时制(丢了 a 就分不清昼夜); mm 分 vs MM 月; 字面量"年/日"要用单引号包裹否则按 pattern 字母解析抛异常。
LocalDate d = LocalDate.of(2025, 12, 29);   // 周一, 属 2026 第 1 周
d.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); // → 2025-12-29
d.format(DateTimeFormatter.ofPattern("YYYY-MM-dd")); // → 2026-12-29 周年!
LocalTime.of(15, 0).format(DateTimeFormatter.ofPattern("hh:mm")); // → 03:00
TZDB 与夏令时
ZoneId 的规则来自 IANA 时区库, JVM 更新(TZUpdater)会改历史与未来规则。用 ZoneId.of("America/New_York"), 绝不用 EST/CST 缩写 — CST 同时是中美两个时区, 解析按方言走。
ZoneId.of("America/New_York");  // ✓ 全称, 带 TZDB 夏令时规则
// ZoneId.of("EST")  → 无夏令时的固定偏移 (legacy 特例)
// 老 API getTimeZone("CST") 按方言解析, 中美歧义
Clock 注入
Clock.systemUTC() 生产用, Clock.fixed(...) 测试用; 过期判断/倒计时逻辑依赖注入的 Clock, 时间变成可钉死的输入。默认时区的暗依赖也一并消除。
class TokenService {
    private final Clock clock;              // 生产 systemUTC(), 测试 fixed
    boolean expired(Token t) { return t.expireAt().isBefore(clock.instant()); }
}
旧 API 互转
date.toInstant() / Date.from(i); Calendar 经 toInstant().atZone(zone); java.sql.Timestamp 在 JDBC 4.2 后可直接 get/setObject(OffsetDateTime)。
Instant i = oldDate.toInstant();      // Date → Instant
Date back = Date.from(i);             // Instant → Date
ZonedDateTime z = cal.toInstant().atZone(ZoneId.systemDefault());
JDBC 映射
实体时间字段用 Instant/OffsetDateTime/LocalDateTime 均可, 前提是连接时区定死 UTC; JDBC 4.2 之前的老驱动需要 Timestamp 中转层。DATETIME 列无时区语义, 读出按会话时区解释 — 新表用 TIMESTAMP 并写死约定。
// 连接时区定死 UTC + TIMESTAMP 列
OffsetDateTime t = rs.getObject("created_at", OffsetDateTime.class);
ps.setObject(1, OffsetDateTime.now(ZoneOffset.UTC));  // JDBC 4.2
truncate 语义
truncatedTo(ChronoUnit.DAYS) 截到"当天 00:00"是以对象自己的时间线为准; Instant 的"天"在东八区对应本地 08:00 — 按天聚合先 atZone 到目标时区再截断。
Instant i = Instant.parse("2026-09-26T02:30:00Z");
i.truncatedTo(ChronoUnit.DAYS);            // → 00:00Z = 东八区 08:00
i.atZone(ZoneId.of("Asia/Shanghai")).truncatedTo(ChronoUnit.DAYS);
// 对: → 2026-09-26T00:00+08:00 本地零点
now() 的一致性
两次 now() 必不相等; 同一请求里要"统一的当前时间"就取一次传参(或注入 Clock 后一次取值), 否则边界测试永远碰运气。
if (a.isBefore(LocalDateTime.now()) && LocalDateTime.now().isBefore(b)) {}
// 错: 两个 now() 值不同, 边界随机红
LocalDateTime now = LocalDateTime.now();  // 对: 取一次传下去

🏭 生产实战 real world

场景 1 · 全局约定改造: DB/服务全 UTC, 出口统一格式化

三地部署后同一笔订单的创建时间差 8/13 小时, 报表对不上账; 按清单一次改造, 全链路统一 UTC:

// 改造清单 (新服务直接照抄):
// 1) JDBC URL: jdbc:mysql://...?connectionTimeZone=UTC&forceConnectionTimeZoneToSession=true
// 2) 实体时间字段统一 Instant/OffsetDateTime, 列类型 TIMESTAMP
// 3) API 出口 RFC3339 带偏移, 关掉时间戳数字序列化
@Bean
Jackson2ObjectMapperBuilderCustomizer utcJackson() {
    return b -> b.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
}
// 4) JVM 启动参数 -Duser.timezone=UTC, 把"默认时区"这个暗依赖也钉死
// 5) 存量表: 写双读旧的一次迁移窗口内换列, 别在线上双写超过一周

改造后三地报表同一时刻对齐, "时间差 8 小时"类工单清零。

场景 2 · "每天 8 点发券"按用户时区分组分批

全球用户都想在自己本地的早上 8 点收到券; 服务器只有一个 UTC 时钟 — 按时区分组调度:

Map<ZoneId, List<User> byZone = users.stream()
    .collect(groupingBy(u -> ZoneId.of(u.getTimeZone())));   // "Asia/Shanghai"
for (var en : byZone.entrySet()) {
    ZoneId zone = en.getKey();
    ZonedDateTime next8 = LocalDate.now(zone).atTime(8, 0).atZone(zone); // 今天本地 08:00
    if (!next8.isAfter(ZonedDateTime.now(zone))) next8 = next8.plusDays(1); // 过了排明天
    scheduler.schedule(() -> sendBatch(en.getValue()),
        Duration.between(Instant.now(), next8.toInstant()).toMillis(),
        TimeUnit.MILLISECONDS);    // 调度内部统一换算回 UTC Instant, 只在入口看钟面
}

场景 3 · DateTimeFormatter 预编译 static final, 格式化百万次省一个核

导出接口每次请求都 ofPattern, 压测显示 12% CPU 花在"反复解析同一串 pattern":

// 差: 每次调用都解析 pattern + 新建 formatter
String s = now().format(DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss"));

// 好: 不可变, 预编译一次全局共享
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss");

// 更好: 预定义常量零解析, ISO 出口直接用
private static final DateTimeFormatter ISO = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
// JMH 量级: 每次构建 ~1μs → 静态复用 ~120ns; 日志百万次/天 ≈ 省下 15 分钟纯 CPU

场景 4 · Duration 管接口耗时与超时预算

"这次调用花了多少/超没超预算"用 Duration 表达, 天然可读可比较, 别再手写毫秒减法:

Instant t0 = clock.instant();
try {
    return callDownstream(req);
} finally {
    Duration cost = Duration.between(t0, clock.instant());
    metrics.observe(cost);                       // 直方图: P99 从这看
    if (cost.compareTo(budget) > 0)             // 预算超支即时告警
        log.warn("slow call cost={} budget={}", cost, budget);
}
// 预算拆账: 总 P99 300ms = 网关 20 + 下游A 200 + 下游B 80, 全用 Duration 声明
private static final Duration BUDGET = Duration.ofMillis(200);

场景 5 · Clock 注入: 过期判断/倒计时测试不再碰运气

token 过期逻辑直接 now(), 测试要么 sleep 要么构造"刚好临界"的数据, 偶发红; 把时间变成输入:

class TokenService {
    private final Clock clock;               // 构造器注入, 生产传 systemUTC()
    TokenService(Clock clock) { this.clock = clock; }

    boolean expired(Token t) {
        return t.expireAt().isBefore(clock.instant());  // "现在"是注入的
    }
}
// 测试: 固定钟钉死当前时刻, 边界用例一次写全
Clock frozen = Clock.fixed(Instant.parse("2026-09-26T00:00:00Z"), ZoneOffset.UTC);
var svc = new TokenService(frozen);
assertTrue(svc.expired(new Token("2026-09-25T23:59:59Z")));   // 过期 1 秒
assertFalse(svc.expired(new Token("2026-09-26T00:00:01Z")));  // 还剩 1 秒

场景 6 · 跨时区会议室: 同一时刻的三个钟面 + 冲突检测

上海提议的会议时间, 纽约/伦敦同事各自钟面是几点, 是否落在各自工作时间 — 一次换算全知道:

ZoneId SH = ZoneId.of("Asia/Shanghai"), NY = ZoneId.of("America/New_York"),
       LO = ZoneId.of("Europe/London");
ZonedDateTime sh = LocalDate.of(2026, 9, 28).atTime(9, 0).atZone(SH); // 上海 09:00
ZonedDateTime ny = sh.withZoneSameInstant(NY);   // 同一时刻的纽约钟面 21:00 前一天
ZonedDateTime lo = sh.withZoneSameInstant(LO);   // 伦敦钟面 02:00
boolean ok = ny.getHour() >= 8 && ny.getHour() < 20 && lo.getHour() >= 8 && lo.getHour() < 20;
// withZoneSameInstant: 时刻不动换钟面; withZoneSameLocal: 保钟面换时刻 — 用错就是事故

场景 7 · 夏令时切换日: 23/25 小时的排班与计费

美国区活动按"自然天"计费, 春令时那天只有 23 小时, 手写 24*3600 的代码多收了一小时的钱:

ZoneId ny = ZoneId.of("America/New_York");
LocalDate dst = LocalDate.of(2026, 3, 8);         // 春令时: 当天少 1 小时
ZonedDateTime a = dst.atStartOfDay(ny);
ZonedDateTime b = dst.plusDays(1).atStartOfDay(ny);
Duration dayLen = Duration.between(a, b);       // PT23H — 不是 24 小时!
// 计费/排班按 Duration 算自动正确; 按小时单价 × 24 就是多收钱
// 钟面语义: zdt.plusDays(1) 保持钟面 (02:30 的下一天还是 02:30, 不存在的时刻自动平移)
// 秋令时 11-1: 01:30 出现两次, 存库必须带偏移/规则否则读不回唯一时刻

场景 8 · 时间分片 key: 按天分表用哪个"天"

订单表按天分了 1024 张, 分片键取值时用服务器本地日期 — 8 小时差让 0 点后的单落错表, 迁移脚本全部对不上:

static final ZoneId SHARD_ZONE = ZoneOffset.UTC;   // 写死并文档化, 全链路一致
static String tableFor(Instant t) {
    LocalDate day = t.atZone(SHARD_ZONE).toLocalDate();   // UTC 天, 与存储约定同源
    return "order_" + day.format(DateTimeFormatter.BASIC_ISO_DATE); // order_20260926
}
// 跨天查询: [t0, t1) 先换算出覆盖的 UTC 天集合, 再逐表 UNION — 别用本地天猜
// 想按"业务本地天"分片可以, 但 SHARD_ZONE 必须是常量并进设计文档, 不许读系统默认

场景 9 · 账单/生日的 Period 月度对齐

会员账单"每月 15 日出账", 用 30 天近似的结果是半年后账单日漂到 17 号, 客服爆单:

LocalDate billStart = LocalDate.of(2026, 8, 15);
LocalDate billEnd   = billStart.plus(Period.ofMonths(1));   // 2026-09-15, 月度语义
Period cycle = Period.between(billStart, billEnd);         // P1M: 28/29/30/31 天都对
// 生日提醒: birthday.withYear(today.getYear()) 再比较今天
// 2/29 生日在平年要自己定规则 (2/28 或 3/1), java.time 不会替你做业务决定
// 千万别用 Duration.ofDays(30) 凑月 — 两种"量"混用是本页头号坑

场景 10 · 老代码 Date/Calendar → java.time 机械替换清单

三十万行遗留代码不敢大动; 按"等价替换"清单机械迁移, 每条都有映射, 不改行为只换类型:

// Date → Instant:            date.toInstant()  /  Date.from(instant)
// Calendar → ZonedDateTime:   cal.toInstant().atZone(ZoneId.systemDefault())
// SimpleDateFormat → DTF:     ofPattern 预编译 static final 后共享
// cal.add(DAY_OF_MONTH, 1):  老日历 1/31+1 天静默滚到 3/3; zdt.plusDays(1) 直接抛异常
//                            — 迁移时把"依赖静默溢出"的代码暴露出来是收益不是麻烦
// new Date() → clock.instant(); System.currentTimeMillis() 计时用途可保留
// java.sql.Timestamp → rs.getObject(col, OffsetDateTime.class) (JDBC 4.2+)
// 三行替换覆盖 90% 场景; 剩下的 (日历算法/夏令时逻辑) 先补测试再动

⚠️ 编码注意与常见坑 pitfalls

坑 1 · yyyy 写成 YYYY — 12 月末按"周年"格式化, 2025-12-29 输出 2026 (该周属于下一年第一周). 正解: 年用 yyyy(或更严的 uuuu), YYYY 只在真的要 week-year 时出现, 且必须配 ww。
var d = LocalDate.of(2025, 12, 29);
d.format(DateTimeFormatter.ofPattern("YYYY-MM-dd")); // 错: → 2026-12-29
d.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); // 对: → 2025-12-29
坑 2 · HH 写成 hh — 12 小时制格式化 15:00 输出 "03", 且没带 AM/PM 标识, 下游按凌晨三点解析. 正解: 24 小时制 HH; 保留 hh 就必须格式里带 a。
var t = LocalTime.of(15, 0);
t.format(DateTimeFormatter.ofPattern("hh:mm"));   // 错: → "03:00" 无昼夜
t.format(DateTimeFormatter.ofPattern("HH:mm"));   // 对: → "15:00"
t.format(DateTimeFormatter.ofPattern("hh:mm a"));  // 保留 hh 必须带 a
坑 3 · SimpleDateFormat 共享实例 — 多线程下内部 Calendar 状态互踩: 日期串成 5021、负数字段异常, 且难复现. 正解: 换 static final DateTimeFormatter(不可变); 老代码过渡用 ThreadLocal。
// 错: static SimpleDateFormat SDF = ...; 多线程共享 → 状态互踩
// 对: private static final DateTimeFormatter F =
           DateTimeFormatter.ofPattern("uuuu-MM-dd");  // 不可变
坑 4 · LocalDateTime 当绝对时刻用 — 存库/比较超时用它, 序列化到别的机器后各自按本地时区解释, 时刻漂 8 小时. 正解: 存储与计算用 Instant/OffsetDateTime; LocalDateTime 只出现在输入/展示边界。
entity.setCreatedAt(LocalDateTime.now());  // 错: 各端解释漂 8 小时
entity.setCreatedAt(Instant.now());        // 对: UTC 绝对量存库
// LocalDateTime 只活在输入/展示边界
坑 5 · DATETIME 列读出歧义 — MySQL DATETIME 无时区, 读出按会话/JVM 时区解释; 迁库或改连接串后同一行读出不同钟面. 正解: 新表 TIMESTAMP + 连接 connectionTimeZone=UTC; 存量 DATETIME 在文档写死"本列=UTC"并统一映射。
// 错: jdbc:mysql://db/app  连接时区未定 → 同一行各机器读出不同钟面
// 对: ?connectionTimeZone=UTC&forceConnectionTimeZoneToSession=true
//     新表 TIMESTAMP; 存量 DATETIME 文档写死"本列=UTC"
坑 6 · ZonedDateTime 序列化丢规则 — 部分 JSON 默认序列化只带 +08:00 偏移, 反序列化成 OffsetDateTime, 夏令时规则丢失, 未来时刻换算可能错一小时. 正解: API 出口统一 ODT 带偏移; 内部传 ZDT 时确认序列化器保留 zone id 或约定传 ZoneId 字符串。
// 错: ZDT 默认序列化只留 +08:00 → 读回 ODT, 夏令时规则丢
zdt.toOffsetDateTime();        // 对: 出口显式降级, 约定一目了然
// 内部传 ZDT 时确认序列化器保留 zone id
坑 7 · 秒/毫秒混用 — getEpochSecond 给下游当毫秒, 时间全在过去 1970 附近; 反之毫秒当秒变 5 万年. 正解: 字段命名带单位 (expireAtSec), 边界统一 toEpochMilli() 或统一秒, 别混。
instant.getEpochSecond();   // → 1790416800     秒
instant.toEpochMilli();      // → 1790416800000  毫秒
// 错: 秒传给要毫秒的下游 → 全落在 1970; 命名带单位 expireAtSec
坑 8 · 老日历静默溢出 vs java.time 拒绝 — Calendar set(1, 31) 再加 1 月悄悄滚到 3 月; java.time 直接抛异常. 两种行为都"对", 迁移时表达式语义变了. 正解: 月末运算用 with(TemporalAdjusters.lastDayOfMonth()), 显式表达意图。
Calendar c = new GregorianCalendar(2026, Calendar.JANUARY, 31);
c.add(Calendar.MONTH, 1);   // 错: 静默滚到 2026-03-03
LocalDate.of(2026, 1, 31).with(lastDayOfMonth());  // 对: 显式月末
坑 9 · Duration 与 Period 混用 — "一个月"用 Duration.ofDays(30), 半年后漂一周; 夏令时日用 plus(Duration.ofDays(1)) 又少一小时. 正解: 挂历周期用 Period/plusMonths; 跨夏令时的"加一天"用 ZDT 的 plusDays(钟面语义)。
Duration.ofDays(30);                      // 错: 凑"一个月", 半年后漂移
bill.plus(Period.ofMonths(1));           // 对: 挂历月度语义
// 跨夏令时"加一天": ZDT.plusDays(1) 钟面语义
坑 10 · 两次 now() 做一致性比较 — a.isBefore(LocalDateTime.now()) 和三行后的第二个 now() 值不同, 边界用例随机红. 正解: 方法入口取一次 now 传下去, 或注入 Clock 单点取值。
if (a.isBefore(LocalDateTime.now()) && LocalDateTime.now().isBefore(b)) {}
// 错: 两个 now() 值不同, 边界随机红
LocalDateTime now = LocalDateTime.now();   // 对: 入口取一次传下去
坑 11 · 测试不注入 Clock 碰运气 — 过期/倒计时逻辑依赖真实时钟, 只能 sleep 或构造临界数据, 偶发失败且慢. 正解: 构造器注入 Clock, 测试 Clock.fixed; 顺带把"默认时区"暗依赖一起消除。
// 错: 逻辑里直接 now(), 测试只能 sleep/造临界数据
Clock frozen = Clock.fixed(Instant.parse("2026-09-26T00:00:00Z"), ZoneOffset.UTC);
new TokenService(frozen);   // 对: 时间变成钉死的输入
坑 12 · 时区缩写 EST/CST — CST 同时是美中/中古/澳中三个时区, 按 JVM 方言解析, 换环境结果不同. 正解: 永远 ZoneId.of("America/Chicago") 全称; 别接受缩写入参, 入口就转换并校验。
// 错: TimeZone.getTimeZone("CST")  三方言, 换环境结果变
ZoneId.of("America/Chicago");  // 对: Region/City 全称
// 入口校验并转换缩写, 不让缩写流进核心逻辑
坑 13 · truncatedTo(DAYS) 在 Instant 上截到"中午" — Instant 的时间线天界是 UTC 0 点, 东八区用户"按天聚合"截出来的是本地 08:00. 正解: 先 atZone(userZone) 再 truncatedTo, 或直接用 toLocalDate() 分组。
Instant i = Instant.parse("2026-09-26T02:30:00Z");
i.truncatedTo(ChronoUnit.DAYS);              // 错: 天界在本地 08:00
i.atZone(ZoneId.of("Asia/Shanghai")).truncatedTo(ChronoUnit.DAYS);
// 对: → 2026-09-26T00:00+08:00 本地零点
坑 14 · toInstant() 前忘配时区 — LocalDateTime.toInstant(zone) 用了系统默认时区, 容器时区一换批量任务跑偏. 正解: 显式传 ZoneOffset.UTC 或业务 ZoneId; 启动参数 -Duser.timezone=UTC 兜底。
ldt.atZone(ZoneId.systemDefault());  // 错: 暗依赖容器时区, 一换就跑偏
ldt.atZone(ZoneOffset.UTC);          // 对: 显式时区
// 启动参数 -Duser.timezone=UTC 兜底
坑 15 · pattern 字面量没转义 — "2026年MM月" 里的"年"被当 pattern 字符, 解析抛 IllegalArgumentException: Unknown pattern letter '年'. 正解: 汉字/特殊字符用单引号包裹 '年', 引号本身写两个转义。
DateTimeFormatter.ofPattern("yyyy年MM月");   // 错: Unknown pattern '年'
DateTimeFormatter.ofPattern("yyyy'年'MM'月'"); // 对: 单引号包裹
坑 16 · plusDays 跨夏令时不是 24 小时 — 纽约 3 月 8 日 plusDays(1) 实际经过 23 小时, 和"加 24 小时"的下游对账差一小时. 正解: 钟面语义用 plusDays, 物理时长用 plus(Duration.ofHours(24)) — 先想清楚业务要哪个。
ZonedDateTime a = ZonedDateTime.of(2026, 3, 8, 9, 0, 0, 0, NY);
Duration.between(a, a.plusDays(1));         // → PT23H 春令时日
a.plus(Duration.ofHours(24));               // → 次日 10:00 钟面被推
坑 17 · 老驱动不认 OffsetDateTime — JDBC 4.2 之前 rs.getObject(col, OffsetDateTime.class) 抛 SQLFeatureNotSupportedException. 正解: 老驱动走 Timestamp 中转层 (toInstant 互转), 驱动升级后删掉; 别让中转层变成永久债。
// 错 (JDBC 4.2 前): rs.getObject(col, OffsetDateTime.class) → 异常
OffsetDateTime odt = rs.getTimestamp(col).toInstant()
                       .atOffset(ZoneOffset.UTC);  // 对: Timestamp 中转
坑 18 · DB 与 JVM 时区不一致读出差 — 连接串没写 connectionTimeZone, MySQL 服务端是系统时区, 容器是 UTC, 写入读出差 N 小时. 正解: 连接串显式定死 + 实体用 Instant, 让驱动替你换算; 上线前用一条哨兵数据核对往返。
// 错: jdbc:mysql://db/app?useSSL=false  时区未定, 写读差 N 小时
// 对: ?connectionTimeZone=UTC + 实体 Instant, 驱动替你换算
//     上线前一条哨兵数据核对往返
坑 19 · 断言精确到纳秒必挂 — now() 带纳秒, assertEquals(createdAt, readBack) 在高精度时钟下偶发不等. 正解: 断言前 truncatedTo(ChronoUnit.MILLIS); 或对象相等比较改为业务字段级。
assertEquals(createdAt, readBack);              // 错: 纳秒偶发不等
assertEquals(createdAt.truncatedTo(ChronoUnit.MILLIS),
             readBack.truncatedTo(ChronoUnit.MILLIS));  // 对
坑 20 · 时区规则会变, 缓存未来时刻要小心 — TZDB 更新后某国改夏令时规则, 提前算好的"未来一年排班"全错且无人发现. 正解: 未来时刻只存 Instant 或"挂历+ZoneId", 渲染时再换算; 订阅 TZDB 变更公告, 重算任务可重跑。
// 错: 提前算好未来一年排班缓存 ZDT, TZDB 更新后全错
// 对: 只存 Instant 或"挂历+ZoneId", 渲染时再换算
//     订阅 TZDB 变更公告, 重算任务可重跑