Java 基础体系 · 第 9/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
Java 日期时间:Instant、LocalDateTime、ZoneId、Duration 和序列化
Java 的日期时间 API 位于 java.time 包中。它解决的不是一个问题,而是至少四个不同问题:
- 时间线上的一个瞬间:
Instant - 没有时区含义的本地日期时间:
LocalDateTime - 把本地日期时间映射到时间线的规则:
ZoneId - 两个时间线瞬间之间的精确长度:
Duration - 把这些对象转换为 JSON、数据库值或二进制数据:序列化
如果把这些概念混用,代码通常不会立刻编译失败,而是在跨时区、夏令时切换、接口交互或数据恢复时产生难以诊断的错误。
1. 先建立时间模型:日期、墙上时间和时间线
1.1 时间线上的瞬间
Instant 表示 UTC 时间线上的一个确定位置。它可以抽象为:
其中:
- 是相对于 Unix epoch 的秒数;
- 是当前秒内的纳秒,范围是
0到999_999_999; - Unix epoch 是
1970-01-01T00:00:00Z。
例如:
Instant instant = Instant.parse("2025-03-30T01:30:00Z");
System.out.println(instant);
System.out.println(instant.getEpochSecond());
System.out.println(instant.getNano());
输出类似:
2025-03-30T01:30:00Z
1743298200
0
Instant 不包含“北京时间”“纽约时间”或“欧洲柏林时间”等地区信息。它只表示时间线上的位置。
两个 Instant 的先后关系可以直接比较:
Instant start = Instant.parse("2025-01-01T00:00:00Z");
Instant end = Instant.parse("2025-01-01T00:00:03.500Z");
Duration elapsed = Duration.between(start, end);
System.out.println(elapsed); // PT3.5S
System.out.println(elapsed.toMillis()); // 3500
这里的 Duration 表示真实经过了 3.5 秒,与服务器位于哪个时区无关。
1.2 本地日期时间
LocalDateTime 表示:
年月日 + 时分秒 + 纳秒
例如:
LocalDateTime local = LocalDateTime.of(
2025, 3, 30, 9, 30
);
它表示“某个地方的 2025 年 3 月 30 日 09:30”,但还不能回答:
- 这是哪个时区?
- 对应 UTC 的什么时刻?
- 该时间是否因夏令时而不存在?
- 如果发生重复,它对应重复时段中的哪一个?
因此,下面两个值在 Java 类型上完全不同,但文字形式相同:
2025-03-30T09:30
它们可能分别位于:
2025-03-30T09:30+08:00[Asia/Shanghai]
2025-03-30T09:30+01:00[Europe/Berlin]
对应的 Instant 并不相同。
LocalDateTime 适合表达“本地业务时间”,例如:
- 门店每天 09:00 开门;
- 用户填写的生日;
- 会议室在某个地区的 14:00 开会;
- 数据库中需要暂存但尚未确定时区的日期时间。
它不适合单独表示:
- 日志事件发生时刻;
- HTTP 请求创建时间;
- 消息投递时间;
- 分布式系统中的超时截止点。
这些场景需要 Instant,或者需要带偏移量、带地区规则的类型。
1.3 偏移量和时区不是同一个概念
偏移量(offset)是某个具体时刻相对于 UTC 的差值,例如:
+08:00
-05:00
Z
时区(zone)是一个可以随日期变化的规则集合,例如:
Asia/Shanghai
Europe/Berlin
America/New_York
+08:00 只说明“当前比 UTC 快 8 小时”,而 Asia/Shanghai 还携带地区规则标识。两者的区别可以用类型表示:
OffsetDateTime offsetDateTime =
OffsetDateTime.parse("2025-07-01T12:00:00+08:00");
ZonedDateTime zonedDateTime =
ZonedDateTime.parse("2025-07-01T12:00:00+08:00[Asia/Shanghai]");
OffsetDateTime 保存:
- 本地日期时间;
- 一个固定偏移量。
ZonedDateTime 保存:
- 本地日期时间;
- 当前偏移量;
- 一个
ZoneId及其规则。
偏移量能够映射到一个确定的 Instant:
其中:
- 是 UTC 时间线上的瞬间;
- 是本地日期时间;
- 是偏移量。
例如:
2025-01-01T08:00:00+08:00
对应:
2025-01-01T00:00:00Z
但只保存 +08:00,不能证明原始地区一定是 Asia/Shanghai;很多地区可能在某段时期使用相同偏移量。
2. ZoneId:把本地时间映射到时间线的规则
2.1 ZoneId 的两种主要形式
ZoneId 可以是固定偏移量:
ZoneId fixed = ZoneId.of("UTC");
ZoneId offset = ZoneId.of("+08:00");
也可以是基于 IANA tz database 的地区 ID:
ZoneId berlin = ZoneId.of("Europe/Berlin");
ZoneId shanghai = ZoneId.of("Asia/Shanghai");
固定偏移量的规则不会因为日期变化:
+08:00 永远是 +08:00
地区时区的规则可能因日期、历史版本和政治决定变化:
Europe/Berlin 在冬季可能是 +01:00,在夏季可能是 +02:00
Java 使用时区规则提供者加载这些规则。规则数据属于运行时环境的一部分,因此同一个地区 ID 在不同 JDK 版本或时区数据库更新后,历史或未来日期的偏移判断可能发生变化。
2.2 普通日期的映射是一对一
假设:
LocalDateTime local =
LocalDateTime.of(2025, 1, 15, 12, 0);
ZoneId zone = ZoneId.of("Europe/Berlin");
ZonedDateTime zoned = local.atZone(zone);
Instant instant = zoned.toInstant();
System.out.println(zoned);
System.out.println(instant);
在该日期,柏林的偏移量是 +01:00,因此:
2025-01-15T12:00+01:00[Europe/Berlin]
2025-01-15T11:00:00Z
此时本地时间、偏移量和瞬间之间可以唯一对应。
2.3 夏令时跳跃产生“无效本地时间”
在欧洲柏林,2025 年 3 月 30 日从冬令时切换到夏令时。时钟从:
01:59:59 +01:00
直接跳到:
03:00:00 +02:00
因此:
02:30
在这一天的 Europe/Berlin 中根本不存在。
可以通过 ZoneRules 观察映射状态:
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.zone.ZoneRules;
LocalDateTime local =
LocalDateTime.of(2025, 3, 30, 2, 30);
ZoneId zone = ZoneId.of("Europe/Berlin");
ZoneRules rules = zone.getRules();
System.out.println(rules.getValidOffsets(local));
System.out.println(rules.getTransition(local));
对于这个本地时间:
rules.getValidOffsets(local)
返回空列表,表示没有任何合法偏移量。
但直接调用:
ZonedDateTime result = local.atZone(zone);
System.out.println(result);
通常会输出:
2025-03-30T03:30+02:00[Europe/Berlin]
这是 java.time 的常见解析策略:对于夏令时造成的间隔(gap),将本地时间向前调整到有效时间。这个行为不是“02:30 被准确转换成了某个瞬间”,而是“输入的无效本地时间被调整成了 03:30”。
如果业务不能接受静默调整,应先检查规则:
if (rules.getValidOffsets(local).isEmpty()) {
throw new IllegalArgumentException(
"本地时间在该时区不存在: " + local + " " + zone
);
}
这在预约、排班和账单截止时间中尤其重要。
2.4 夏令时回拨产生“一对多本地时间”
2025 年 10 月 26 日,柏林从夏令时回到冬令时。时间线大致是:
02:59:59 +02:00
回拨一小时
02:00:00 +01:00
所以:
02:30
会出现两次,分别对应:
2025-10-26T02:30+02:00
2025-10-26T02:30+01:00
此时:
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
LocalDateTime local =
LocalDateTime.of(2025, 10, 26, 2, 30);
ZoneId zone = ZoneId.of("Europe/Berlin");
ZonedDateTime first = local.atZone(zone);
ZonedDateTime second = first.withLaterOffsetAtOverlap();
System.out.println(first);
System.out.println(second);
System.out.println(first.toInstant());
System.out.println(second.toInstant());
典型输出为:
2025-10-26T02:30+02:00[Europe/Berlin]
2025-10-26T02:30+01:00[Europe/Berlin]
2025-10-26T00:30:00Z
2025-10-26T01:30:00Z
atZone 在重叠区间通常选择较早的偏移量;如果业务要求选择后一个时间,需要显式调用:
withLaterOffsetAtOverlap()
更严格的写法是直接检查合法偏移量:
var offsets = zone.getRules().getValidOffsets(local);
if (offsets.size() != 1) {
throw new IllegalArgumentException(
"本地时间不唯一或不存在: " + local + " " + zone
);
}
Instant instant = local.toInstant(offsets.getFirst());
这里的状态可以形式化为:
validOffsets.size() == 1:本地时间唯一;validOffsets.size() == 0:本地时间不存在;validOffsets.size() == 2:本地时间重复。
LocalDateTime + ZoneId 并不总能唯一确定一个 Instant。还需要在 gap 和 overlap 情况下定义业务策略。
3. Instant、LocalDateTime 和 ZonedDateTime 的转换
3.1 从瞬间转换为指定地区的本地时间
从 Instant 转换到某个地区时,映射是确定的:
Instant instant = Instant.parse("2025-01-01T00:00:00Z");
ZoneId zone = ZoneId.of("Asia/Shanghai");
ZonedDateTime local = instant.atZone(zone);
System.out.println(local);
输出:
2025-01-01T08:00+08:00[Asia/Shanghai]
因为 Instant 已经确定了时间线位置,ZoneId 只负责按照规则显示它。
可以继续提取:
LocalDateTime localDateTime = local.toLocalDateTime();
但这一步会丢失时区和偏移量。若之后再把它放入另一个地区,结果可能完全不同:
LocalDateTime value = instant
.atZone(ZoneId.of("Asia/Shanghai"))
.toLocalDateTime();
Instant wrong = value
.atZone(ZoneId.of("America/New_York"))
.toInstant();
wrong 不等于原来的 instant。原因是 LocalDateTime 只保留了“显示出来的墙上时间”。
3.2 从本地时间转换为瞬间
如果本地时间本来就是用户输入的会议时间,正确转换通常是:
LocalDateTime meetingTime =
LocalDateTime.of(2025, 6, 1, 9, 0);
ZoneId userZone = ZoneId.of("Asia/Tokyo");
Instant scheduledAt = meetingTime
.atZone(userZone)
.toInstant();
这里的逻辑是:
- 用户输入
2025-06-01 09:00; - 业务上下文提供
Asia/Tokyo; ZoneId规则得到当日偏移量;- 组合成
ZonedDateTime; - 转为唯一的
Instant。
若用户输入中已经包含偏移量,应使用 OffsetDateTime:
OffsetDateTime input =
OffsetDateTime.parse("2025-06-01T09:00:00+09:00");
Instant instant = input.toInstant();
若输入包含地区 ID,应使用 ZonedDateTime:
ZonedDateTime input =
ZonedDateTime.parse(
"2025-06-01T09:00:00+09:00[Asia/Tokyo]"
);
Instant instant = input.toInstant();
3.3 withZoneSameInstant 与 withZoneSameLocal
这是处理时区转换时最容易混淆的两个操作。
withZoneSameInstant 保持 Instant 不变,只改变显示地区:
ZonedDateTime shanghai =
ZonedDateTime.of(
2025, 1, 1, 8, 0, 0, 0,
ZoneId.of("Asia/Shanghai")
);
ZonedDateTime newYork =
shanghai.withZoneSameInstant(
ZoneId.of("America/New_York")
);
System.out.println(shanghai);
System.out.println(newYork);
两者表示同一个时间线瞬间,但本地时钟显示不同。
withZoneSameLocal 保持本地字段不变,改变地区规则:
ZonedDateTime sameLocal =
shanghai.withZoneSameLocal(
ZoneId.of("America/New_York")
);
这可能改变对应的 Instant,因为“08:00”被解释成了另一个地区的 08:00。
因此:
- 展示同一事件在另一个地区的时间:使用
withZoneSameInstant; - 重新解释同一个本地钟面时间:使用
withZoneSameLocal,并确认这确实是业务意图。
4. Duration:精确的经过时间
4.1 定义和内部模型
Duration 表示时间线上两个点之间的长度,内部也是秒和纳秒组合:
它可以是正数、零或负数:
Duration positive = Duration.ofSeconds(5);
Duration zero = Duration.ZERO;
Duration negative = Duration.ofSeconds(-5);
System.out.println(positive); // PT5S
System.out.println(zero); // PT0S
System.out.println(negative); // PT-5S
Duration 适用于:
- 请求耗时;
- 重试间隔;
- 缓存 TTL;
- 连接超时;
- 两个
Instant之间的真实时间差。
例如:
Instant before = Instant.now();
// 执行某项操作
Thread.sleep(20);
Instant after = Instant.now();
Duration elapsed = Duration.between(before, after);
System.out.println(elapsed.toMillis());
Instant.now() 依赖系统时钟,测试时不容易稳定复现。可以注入 Clock:
Clock clock = Clock.fixed(
Instant.parse("2025-01-01T00:00:00Z"),
ZoneOffset.UTC
);
Instant now = Instant.now(clock);
System.out.println(now);
Clock 是时间来源的抽象,适合把“现在”从业务逻辑中隔离出来。
4.2 Duration 与 Period 不同
Duration 主要用于精确的秒和纳秒:
Duration.ofHours(24)
Duration.ofMinutes(30)
Duration.ofSeconds(10)
Period 用于日期单位:
Period.ofDays(1)
Period.ofMonths(1)
Period.ofYears(1)
月份和年份不是固定秒数。例如:
- 1 个月可能是 28、29、30 或 31 天;
- 1 天在夏令时切换日可能不是 24 个实际小时。
因此,下面两个操作的语义不同:
ZonedDateTime start = ZonedDateTime.of(
2025, 3, 29, 12, 0, 0, 0,
ZoneId.of("Europe/Berlin")
);
ZonedDateTime plusOneDay =
start.plusDays(1);
ZonedDateTime plus24Hours =
start.plus(Duration.ofHours(24));
System.out.println(start);
System.out.println(plusOneDay);
System.out.println(plus24Hours);
典型结果:
2025-03-29T12:00+01:00[Europe/Berlin]
2025-03-30T12:00+02:00[Europe/Berlin]
2025-03-30T13:00+02:00[Europe/Berlin]
解释如下:
plusDays(1)是日历运算,目标是“第二天的 12:00”;- 3 月 29 日到 3 月 30 日发生春季跳跃;
- 这两个本地中午之间实际经过 23 小时;
Duration.ofHours(24)要求沿时间线经过完整 24 小时;- 所以结果显示为第二天 13:00。
反过来,在秋季回拨时,日历上的相同时间可能实际经过 25 小时。不能把“1 天”自动等价为 Duration.ofHours(24)。
4.3 对 LocalDateTime 使用 Duration 的限制
可以写:
LocalDateTime a = LocalDateTime.of(2025, 1, 1, 10, 0);
LocalDateTime b = LocalDateTime.of(2025, 1, 1, 11, 30);
Duration duration = Duration.between(a, b);
但这只计算两个本地字段之间的差值,不包含时区规则。若跨越夏令时切换,它不一定代表真实经过时间。
需要真实经过时间时,应先映射为 Instant:
ZoneId zone = ZoneId.of("Europe/Berlin");
Instant aInstant = a.atZone(zone).toInstant();
Instant bInstant = b.atZone(zone).toInstant();
Duration actual = Duration.between(aInstant, bInstant);
如果业务定义的是“墙上时钟相差多久”,才可以直接对 LocalDateTime 使用 Duration。
5. 解析、格式化和异常边界
5.1 ISO 格式与类型必须匹配
常见解析方法如下:
Instant instant =
Instant.parse("2025-01-01T00:00:00Z");
LocalDateTime local =
LocalDateTime.parse("2025-01-01T08:00:00");
OffsetDateTime offset =
OffsetDateTime.parse("2025-01-01T08:00:00+08:00");
ZonedDateTime zoned =
ZonedDateTime.parse(
"2025-01-01T08:00:00+08:00[Asia/Shanghai]"
);
以下输入不能直接交给 Instant.parse:
2025-01-01T08:00:00
因为它没有偏移量或 Z,无法确定时间线位置。解析会抛出 DateTimeParseException。
以下输入也不能直接交给 LocalDateTime.parse:
2025-01-01T08:00:00+08:00
因为它含有偏移量,应使用 OffsetDateTime.parse,或者明确调用 toLocalDateTime() 丢弃偏移量。
5.2 自定义格式必须显式描述语义
DateTimeFormatter formatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
LocalDateTime value =
LocalDateTime.parse("2025-01-01 08:30:00", formatter);
这里得到的是 LocalDateTime,不是 Instant。格式中没有时区和偏移量,所以不能把解析结果直接当成全球统一时间。
如果输入包含偏移量:
DateTimeFormatter formatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss XXX");
OffsetDateTime value =
OffsetDateTime.parse(
"2025-01-01 08:30:00 +08:00",
formatter
);
DateTimeFormatter 通常是线程安全的,可以作为静态常量复用。日期时间对象本身也是不可变对象,诸如 plusDays、withZoneSameInstant 等方法不会修改原对象,而是返回新对象。
5.3 解析异常属于输入契约的一部分
解析外部输入时,应区分:
- 格式错误;
- 类型与输入语义不匹配;
- 时区 ID 不存在;
- 本地时间处于 gap;
- 本地时间处于 overlap;
- 值超出日期时间类型的范围。
示例:
try {
Instant value = Instant.parse(input);
// 继续业务处理
} catch (DateTimeParseException e) {
throw new IllegalArgumentException(
"时间必须是带 Z 或偏移量的 ISO-8601 时间: " + input,
e
);
}
保留原始异常作为 cause,可以形成错误链,便于日志记录和统一异常处理。对于 Web API,不应把完整堆栈直接返回客户端,但应在服务端保留足够的上下文。
6. 序列化:先确定要保存什么语义
序列化不是简单的“把对象转成字符串”。真正需要先决定的是:
- 是否要保存一个确定的时间线瞬间;
- 是否要保存用户看到的本地日期时间;
- 是否要保存地区时区;
- 是否要保存原始偏移量;
- 是否需要跨语言、跨版本读取。
不同目标对应不同表示。
| Java 类型 | 表示的语义 | 常见外部形式 |
|---|---|---|
Instant |
全球唯一瞬间 | 2025-01-01T00:00:00Z |
OffsetDateTime |
本地时间加具体偏移量 | 2025-01-01T08:00:00+08:00 |
ZonedDateTime |
本地时间、偏移量和地区规则 | 2025-01-01T08:00:00+08:00[Asia/Shanghai] |
LocalDateTime |
没有时区的本地时间 | 2025-01-01T08:00:00 |
LocalDate |
只有日期 | 2025-01-01 |
Duration |
精确时间长度 | PT1H30M |
6.1 Instant 的 JSON 序列化
对于跨服务事件,通常优先使用带 Z 的 ISO-8601 字符串:
{
"eventId": "e-100",
"occurredAt": "2025-01-01T00:00:00Z"
}
它明确表示 UTC 时间线上的一个瞬间。
如果使用 Jackson,需要注册 Java Time 支持,并显式关闭时间戳形式:
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
ObjectMapper mapper = new ObjectMapper()
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
String json = mapper.writeValueAsString(
new Event("e-100", Instant.parse("2025-01-01T00:00:00Z"))
);
System.out.println(json);
record Event(String eventId, Instant occurredAt) {}
输出的具体小数秒格式可能取决于值本身和 Jackson 配置,但语义应是 ISO-8601 的时间线瞬间。应用应在同一接口契约中固定格式,不能让客户端猜测字段到底是本地时间还是 UTC。
某些系统使用 epoch milliseconds:
{
"occurredAt": 1735689600000
}
这种形式便于部分语言处理,但会带来两个问题:
- 可读性差;
- 毫秒精度不足以表达 Java
Instant的全部纳秒精度。
如果协议只支持毫秒,应明确规定截断或舍入策略:
Instant original = Instant.parse("2025-01-01T00:00:00.123456789Z");
Instant milliseconds = original.truncatedTo(java.time.temporal.ChronoUnit.MILLIS);
System.out.println(milliseconds);
// 2025-01-01T00:00:00.123Z
截断会丢失精度,不能在反序列化后恢复。
6.2 LocalDateTime 的序列化不会产生时区
{
"startsAt": "2025-01-01T09:00:00"
}
这个值没有说明:
- 发生在哪个地区;
- 对应哪个 UTC 时间;
- 是否曾经处于夏令时重叠;
- 客户端应按哪个时区显示。
因此,以下接口设计是不同的:
{
"startsAt": "2025-01-01T09:00:00"
}
表示“未指定时区的本地时间”。
{
"startsAt": "2025-01-01T09:00:00+08:00"
}
表示“带偏移量的时间”。
{
"startsAt": "2025-01-01T09:00:00",
"timeZone": "Asia/Shanghai"
}
表示“本地时间和地区规则分开传输”。这种设计可以保留用户选择的地区,但服务端仍必须处理 gap 和 overlap。
对于预约类数据,一个更完整的请求模型可以是:
{
"localTime": "2025-10-26T02:30:00",
"zoneId": "Europe/Berlin",
"overlapPolicy": "LATER"
}
服务端应显式解析:
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.zone.ZoneRules;
import java.util.List;
record ScheduleRequest(
LocalDateTime localTime,
String zoneId,
OverlapPolicy overlapPolicy
) {}
enum OverlapPolicy {
EARLIER, LATER, REJECT
}
static Instant resolve(ScheduleRequest request) {
ZoneId zone = ZoneId.of(request.zoneId());
ZoneRules rules = zone.getRules();
List<ZoneOffset> offsets =
rules.getValidOffsets(request.localTime());
if (offsets.isEmpty()) {
throw new IllegalArgumentException(
"本地时间不存在: " + request.localTime()
);
}
if (offsets.size() == 1) {
return request.localTime().toInstant(offsets.getFirst());
}
return switch (request.overlapPolicy()) {
case EARLIER ->
request.localTime().toInstant(offsets.getFirst());
case LATER ->
request.localTime().toInstant(offsets.getLast());
case REJECT ->
throw new IllegalArgumentException(
"本地时间重复,必须指定有效策略: "
+ request.localTime()
);
};
}
这段代码没有依赖 atZone 的默认调整行为,而是把异常和歧义变成了接口契约的一部分。
6.3 Duration 的序列化
Duration 的标准文本形式是 ISO-8601 duration:
Duration duration = Duration.ofMinutes(90);
System.out.println(duration);
// PT1H30M
JSON 可以写成:
{
"timeout": "PT30S"
}
如果系统协议更适合整数,也可以规定:
{
"timeoutMillis": 30000
}
但必须明确单位。下面这种字段容易造成协议错误:
{
"timeout": 30
}
它可能被不同客户端解释成 30 秒、30 毫秒或 30 分钟。
序列化 Duration 时还要确认负值和小数精度是否允许:
Duration duration = Duration.ofSeconds(-1, 500_000_000);
System.out.println(duration);
// PT-0.5S
如果协议只接受非负毫秒,应在边界校验:
if (duration.isNegative()) {
throw new IllegalArgumentException("超时时间不能为负数");
}
7. Java 原生序列化与 JSON 序列化不是一回事
java.time 类型可以参与 Java 对象序列化,但 ObjectOutputStream 的目标是 Java 对象图的二进制持久化,不是跨语言接口协议。
示例:
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.time.Instant;
Instant source = Instant.parse("2025-01-01T00:00:00Z");
byte[] bytes;
try (ByteArrayOutputStream buffer = new ByteArrayOutputStream();
ObjectOutputStream output = new ObjectOutputStream(buffer)) {
output.writeObject(source);
bytes = buffer.toByteArray();
}
Instant restored;
try (ObjectInputStream input = new ObjectInputStream(
new ByteArrayInputStream(bytes))) {
restored = (Instant) input.readObject();
}
System.out.println(restored);
这里有几个边界:
- 二进制格式与 Java 类实现和序列化契约相关;
- 不适合直接作为跨语言 HTTP API 格式;
- 反序列化不可信字节流存在安全风险;
- 类结构演化可能导致兼容性问题;
- 序列化异常需要通过
IOException、ClassNotFoundException等错误链处理。
在数据库和消息系统中,通常更适合保存明确的标量:
occurred_at = 2025-01-01T00:00:00Z
或:
epoch_second = 1735689600
nano = 0
保存 Instant 时,数据库字段应能表达所需精度。若数据库只有毫秒,Java 纳秒部分会丢失,读回后不应声称与原值完全相等。
8. 数据库映射时不要用字符串语义代替类型语义
对于事件发生时间,推荐逻辑模型是:
record AuditEvent(
String id,
Instant occurredAt
) {}
数据库可以使用支持时区或 UTC 语义的时间列,也可以使用 epoch 数值。但“数据库驱动如何处理带时区时间”存在实现差异,不能只凭列名判断语义。
例如,某些数据库的 timestamp without time zone 实际只保存字段,不保存时区;把 Instant 写入其中时,必须事先约定:
所有写入值先转换为 UTC,再去掉区域信息保存。
读取时则必须按同一规则解释:
数据库字段被视为 UTC,再恢复为 Instant。
如果写入时按应用服务器本地时区解释、读取时按数据库会话时区解释,就会产生固定小时数的偏移,而且在不同部署环境中表现不同。
数据库集成测试至少应覆盖:
- 应用服务器时区不是 UTC;
- 数据库会话时区不是应用时区;
- 跨夏令时日期;
- 纳秒或毫秒精度;
NULL;- 超出数据库可表示范围的日期。
9. Web API 中的时间边界
Spring MVC 和 WebFlux 的控制器都处在同一个关键边界:把不可信的 HTTP 字符串转换成 Java 时间类型。
例如,事件接口可以要求:
{
"occurredAt": "2025-01-01T00:00:00Z"
}
对应 DTO:
record CreateEventRequest(Instant occurredAt) {}
这比使用:
record CreateEventRequest(LocalDateTime occurredAt) {}
更能保证请求携带的是确定瞬间。对“用户所在地区的预约时间”,则不能盲目改成 Instant,因为客户端最初提交的往往是本地时间和地区:
record CreateAppointmentRequest(
LocalDateTime localTime,
String zoneId
) {}
控制器完成格式绑定后,还应完成业务级校验:
@PostMapping("/appointments")
ResponseEntity<?> create(
@RequestBody CreateAppointmentRequest request
) {
Instant scheduledAt = resolve(new ScheduleRequest(
request.localTime(),
request.zoneId(),
OverlapPolicy.REJECT
));
// 保存 scheduledAt
return ResponseEntity.ok().build();
}
错误路径包括:
zoneId无效,ZoneRulesException;- 文本格式错误,
DateTimeParseException; - gap 时间不存在;
- overlap 时间不唯一;
- 业务不允许过去时间;
Duration为负数或超过上限。
这些错误应在 Web 层转换为稳定的 4xx 错误结构,而不是把 Java 异常类名直接暴露给客户端。MVC 和 WebFlux 的异常处理机制不同,但时间类型的语义和校验规则不因执行模型改变。
10. 生产代码中的常见错误
错误一:把 LocalDateTime.now() 当作全球当前时间
LocalDateTime now = LocalDateTime.now();
这只得到当前机器默认时区的本地字段。两台位于不同时区的服务器可能在同一瞬间得到不同结果。
记录事件时间应使用:
Instant now = Instant.now();
需要展示时再选择地区:
LocalDateTime displayTime =
now.atZone(ZoneId.of("Asia/Shanghai"))
.toLocalDateTime();
错误二:用 ZoneId.systemDefault() 作为业务时区
LocalDateTime local = value;
Instant instant = local.atZone(ZoneId.systemDefault())
.toInstant();
这会把机器部署位置隐式写入业务结果。开发机、测试环境和生产环境的默认时区可能不同。
如果业务时区来自用户,应从数据中读取;如果系统规定固定地区,应使用显式配置的 ZoneId;如果处理事件时间,应让输入直接携带偏移量或使用 Instant。
错误三:把 Duration.ofDays(1) 当作“明天同一时间”
zonedDateTime.plus(Duration.ofDays(1));
这表示沿时间线经过 24 小时,不一定是日历上的下一天同一时刻。日历语义应根据类型和需求使用:
zonedDateTime.plusDays(1);
但即使使用 plusDays,也仍需了解 gap 和 overlap 可能影响偏移量。
错误四:序列化 LocalDateTime,却在文档中称其为 UTC
{
"createdAt": "2025-01-01T08:00:00"
}
这个字符串没有 UTC 证据。若要表达 UTC,应包含:
{
"createdAt": "2025-01-01T00:00:00Z"
}
或者使用明确的 +00:00 偏移量。
错误五:只保存偏移量,却声称保留了地区
{
"time": "2025-01-01T08:00:00+08:00"
}
这保存了确定的瞬间,但没有保存 Asia/Shanghai 这一地区身份。若日后需要根据当地规则计算“下一次当地 09:00”,还需要额外保存:
{
"time": "2025-01-01T08:00:00+08:00",
"zoneId": "Asia/Shanghai"
}
11. 一套可复用的选择逻辑
可以用下面的判断过程选择类型:
flowchart TD
A[需要表示一个时间值] --> B{是否必须定位到全球时间线?}
B -->|是| C{输入是否包含地区规则?}
C -->|否| D[Instant 或 OffsetDateTime]
C -->|是| E[ZonedDateTime + ZoneId]
B -->|否| F{是否是日历/本地业务时间?}
F -->|是| G[LocalDateTime 或 LocalDate]
F -->|否| H[重新定义字段语义]
D --> I{是否表示时间长度?}
E --> I
G --> J{是否是固定秒数长度?}
J -->|是| K[Duration]
J -->|否| L[Period 或日历运算]
关键路径不是“选一个看起来最完整的类型”,而是先确定业务问题:
- 事件发生了什么时刻:
Instant - 用户所在地区的钟面时间:
LocalDateTime + ZoneId - 带有固定 UTC 偏移的外部时间:
OffsetDateTime - 某地区的本地时间和规则都需要保留:
ZonedDateTime - 真实经过多少秒:
Duration - 日历上增加几天、几月或几年:
Period或plusDays、plusMonths
12. 最终边界:不要让一个类型承担多个语义
一个时间字段如果同时需要表达“事件发生时刻”和“用户当地显示时间”,单独一个 LocalDateTime 或单独一个 Instant 都可能不够。
较稳妥的数据模型通常拆分为:
record Appointment(
Instant scheduledAt,
ZoneId participantZone,
LocalDateTime requestedLocalTime
) {}
其中:
scheduledAt用于排序、过期判断、消息投递和审计;participantZone用于再次按用户地区展示或执行日历规则;requestedLocalTime可选,用于保留用户原始输入和审计差异。
如果只需保存最终确定的瞬间,可以只保存 Instant。如果只需保存“每天 09:00”这种规则,则应建模为本地时间或专门的调度规则,而不是伪造一个没有时区依据的 Instant。
Java 25 的 java.time API 已经提供了不可变类型、明确的时间线模型、时区规则和解析格式;真正需要由应用负责的是语义选择、歧义策略和序列化契约。只要先回答“这个值表示时间线上的什么,还是表示某个地区的钟面什么”,Instant、LocalDateTime、ZoneId、Duration 和序列化之间的边界就会清晰。
系列导航与关联阅读
- 系列入口:Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:Java I/O 与 NIO:Stream、Channel、Buffer、文件和网络边界
- 下一篇:Java 反射与注解:Class、MethodHandle、元注解、处理器和边界
- 延伸:Java 异常处理:受检异常、错误链、资源关闭和 API 契约
- 延伸:Spring Web MVC 与 WebFlux:请求链、校验、异常、流式和选择边界
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论