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 时间线上的一个确定位置。它可以抽象为:

I=(s,n)I = (s, n)

其中:

  • ss 是相对于 Unix epoch 的秒数;
  • nn 是当前秒内的纳秒,范围是 0999_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

I=LOI = L - O

其中:

  • II 是 UTC 时间线上的瞬间;
  • LL 是本地日期时间;
  • OO 是偏移量。

例如:

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. InstantLocalDateTimeZonedDateTime 的转换

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();

这里的逻辑是:

  1. 用户输入 2025-06-01 09:00
  2. 业务上下文提供 Asia/Tokyo
  3. ZoneId 规则得到当日偏移量;
  4. 组合成 ZonedDateTime
  5. 转为唯一的 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 withZoneSameInstantwithZoneSameLocal

这是处理时区转换时最容易混淆的两个操作。

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 表示时间线上两个点之间的长度,内部也是秒和纳秒组合:

D=(s,n)D = (s, n)

它可以是正数、零或负数:

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 DurationPeriod 不同

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]

解释如下:

  1. plusDays(1) 是日历运算,目标是“第二天的 12:00”;
  2. 3 月 29 日到 3 月 30 日发生春季跳跃;
  3. 这两个本地中午之间实际经过 23 小时;
  4. Duration.ofHours(24) 要求沿时间线经过完整 24 小时;
  5. 所以结果显示为第二天 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 通常是线程安全的,可以作为静态常量复用。日期时间对象本身也是不可变对象,诸如 plusDayswithZoneSameInstant 等方法不会修改原对象,而是返回新对象。


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. 序列化:先确定要保存什么语义

序列化不是简单的“把对象转成字符串”。真正需要先决定的是:

  1. 是否要保存一个确定的时间线瞬间;
  2. 是否要保存用户看到的本地日期时间;
  3. 是否要保存地区时区;
  4. 是否要保存原始偏移量;
  5. 是否需要跨语言、跨版本读取。

不同目标对应不同表示。

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);

这里有几个边界:

  1. 二进制格式与 Java 类实现和序列化契约相关;
  2. 不适合直接作为跨语言 HTTP API 格式;
  3. 反序列化不可信字节流存在安全风险;
  4. 类结构演化可能导致兼容性问题;
  5. 序列化异常需要通过 IOExceptionClassNotFoundException 等错误链处理。

在数据库和消息系统中,通常更适合保存明确的标量:

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
  • 日历上增加几天、几月或几年PeriodplusDaysplusMonths

12. 最终边界:不要让一个类型承担多个语义

一个时间字段如果同时需要表达“事件发生时刻”和“用户当地显示时间”,单独一个 LocalDateTime 或单独一个 Instant 都可能不够。

较稳妥的数据模型通常拆分为:

record Appointment(
        Instant scheduledAt,
        ZoneId participantZone,
        LocalDateTime requestedLocalTime
) {}

其中:

  • scheduledAt 用于排序、过期判断、消息投递和审计;
  • participantZone 用于再次按用户地区展示或执行日历规则;
  • requestedLocalTime 可选,用于保留用户原始输入和审计差异。

如果只需保存最终确定的瞬间,可以只保存 Instant。如果只需保存“每天 09:00”这种规则,则应建模为本地时间或专门的调度规则,而不是伪造一个没有时区依据的 Instant

Java 25 的 java.time API 已经提供了不可变类型、明确的时间线模型、时区规则和解析格式;真正需要由应用负责的是语义选择、歧义策略和序列化契约。只要先回答“这个值表示时间线上的什么,还是表示某个地区的钟面什么”,InstantLocalDateTimeZoneIdDuration 和序列化之间的边界就会清晰。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。