Java_Date_Time_梳理文档

Java 时间转换与时区处理指南

Java 日期/时间类型概览

Java 提供了多种日期时间类来表示和处理时间点,其中常用的包括 InstantOffsetDateTimeLocalDateTime。它们之间的区别在于是否包含时区或偏移信息:

  • Instant:表示时间轴上精确的一个时间点(通常以UTC时间1970-01-01T00:00:00Z开始计算的秒/纳秒偏移)。它不包含任何时区或偏移概念,等同于一个 UTC 时间点。例如,Instant.now() 返回当前时刻对应的UTC时间。如果要将 Instant 格式化成人类可读的日期时间,需要提供时区信息,否则formatter无法将其转换成人类日期字段。
  • LocalDateTime:表示本地的日期和时间,不含时区或时区偏移(Zone Offset)信息。它好比墙上钟表显示的时间,只说明“当地”的年月日时分秒,但无法确定唯一瞬间。同样的LocalDateTime在不同地区表示的实际瞬间并不相同(例如波士顿和斯洛文尼亚同一时刻墙钟显示相同LocalDateTime,其对应的 Instant 不同)。因此,将LocalDateTime转换为具体时间点,必须提供对应的时区或偏移量。反之,从一个Instant转换为LocalDateTime也需要指定时区。一般来说,LocalDateTime主要用于表示本地事件时间或用于构造其他带时区信息的时间对象,在应用中直接使用它来代表瞬时时间需要谨慎。PostgreSQLtimestamp without time zone类型通常会映射为Java的LocalDateTime类型。
  • OffsetDateTime:在LocalDateTime的基础上增加了相对于UTC的固定偏移(Offset)信息。例如 2007-12-03T10:15:30+01:00 就是OffsetDateTime,表示此本地时间比UTC快1小时。OffsetDateTime包含了本地日期时间和对应的UTC偏移量,因此表示的是唯一的时间轴上的瞬间。给定OffsetDateTime,可以精确转换为Instant;反过来,将Instant转换为OffsetDateTime需要提供一个ZoneOffset。需要注意Offset仅表示与UTC的差值,不包含夏令时等复杂规则(这些由ZonedDateTime处理)。通常OffsetDateTime适合用于网络通信或数据库交互等技术场景,例如通过JDBC与PostgreSQL的timestamp with time zone类型交互时,就建议使用OffsetDateTime。它所存储的时间点精度与Instant相同(纳秒级)。

我们可以做个小测试: 当前我们在上海 东八区 (UTC +8:00),打印三种类型的now()

1
2
3
4
5
6
7
8
9
10
11
12
@Test
fun `test Instant offsetDateTime and LocalDateTime`() {
/*
* 打印结果:
* Instant.now(): 2025-08-07T06:38:42.219218Z
OffsetDateTime.now(): 2025-08-07T14:38:42.219474+08:00
LocalDateTime.now(): 2025-08-07T14:38:42.219551
* */
println("Instant.now(): ${Instant.now()}")
println("OffsetDateTime.now(): ${OffsetDateTime.now()}")
println("LocalDateTime.now(): ${LocalDateTime.now()}")
}

对于这个测试结果我们观测到

方法调用 控制台输出 含义
Instant.now() 2025-08-07T06:38:42.219218Z UTC 时间点 → “06:38”
OffsetDateTime.now() 2025-08-07T14:38:42.219474+08:00 本地日期时间 + 偏移 +08:00
LocalDateTime.now() 2025-08-07T14:38:42.219551 本地日期时间,无任何时区/偏移信息

DateTimeFormatter的使用

Java的java.time.format.DateTimeFormatter用于对日期时间进行格式化和解析。常见使用包括:

  • 自定义格式模式:可以使用 DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") 创建自定义格式。例如模式"yyyy-MM-dd HH:mm:ss"对应如“2025-08-06 03:24:14”的字符串格式。Formatter是线程安全且可重用的。

转换Instant到String

格式化:调用formatter.format(temporal)将日期时间对象格式化为字符串。需要注意,如果格式化的对象不包含所需的信息,会报错。例如,用上述不含时区信息的formatter直接格式化Instant会抛出异常,因为Instant没有内置时区,formatter无法确定年月日等。解决办法是:指定时区

可以通过DateTimeFormatter.withZone(ZoneId)为formatter附加一个时区。这样,格式化Instant时,formatter会先将Instant转换为该时区的本地时间,再按模式输出。例如:

1
2
3
4
5
6
7
8
9
10
11
@Test
fun `test use DateFormatter convert Instant to String`(){
/*
打印结果: 2025-08-07 14:38:42, 可以看到把时区给加上了
* */
val instant = Instant.parse("2025-08-07T06:38:42.219218Z")
val fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.withZone(ZoneId.of("Asia/Shanghai"))
val text = fmt.format(instant)
println(text)
}

上述代码会先将Instant按上海时区转换,然后输出yyyy-MM-dd HH:mm:ss格式的字符串(相当于北京时间)。如果未调用withZone,格式化Instant将抛出UnsupportedTemporalTypeException异常

1
UnsupportedTemporalTypeException: Unsupported field: YearOfEra

转换String到Instant

Instant.parse 只接受 ISO-INSTANT格式

  • 规范要求末尾 必须是 Z(零时区),或者带 两位数的时区偏移+08:00 而不是 +8:00)。

所以如下所示,这两种字符串都可以直接parse为instant类型,不用再做其他转换。

1
2
3
4
5
6
@Test
fun `test parse string to instant`(){
val instant1 = Instant.parse("2025-08-07T06:38:42.219218Z")
val instant2 = Instant.parse("2025-08-07T14:38:42.219218+08:00")
assertThat(instant1.toString(),equalTo(instant2.toString()))
}

但是有些api会返回这样的字符串:2025-08-07 14:38:42 并不带有偏移量,这时候就需要DateTimeFormatter出马,给这个字符串一个时区偏移量

1
2
3
4
5
6
7
8
9
@Test
fun `test parse string to instant without timezone info`(){
val instant1 = Instant.parse("2025-08-07T06:38:42Z")
val str = "2025-08-07 14:38:42"
val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.withZone(ZoneId.of("Asia/Shanghai"))
val instant3 = Instant.from(dateFormatter.parse(str))
assertThat(instant1.toString(),equalTo(instant3.toString()))
}

转换LocalDateTime到String

DateTimeFormatter也可以把LocalDateTime序列化成字符串。我们经过测试注意到,和序列化Instant不一样,这里加不加withZone,对结果没有影响。这是因为:LocalDateTime 不含任何时区/偏移字段,它只携带 “年月日时分秒” 这 6 个字段,没有时区信息。因此formatter只会取这六个字段进行序列化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Test
fun `test use DateFormatter convert LocalDateTime to String with Zone`(){
/*
打印结果: 2025-08-07 14:38:42, 可以看到把时区给加上了
* */
val localDateTime = LocalDateTime.parse("2025-08-07T14:38:42.219551")
val fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.withZone(ZoneId.of("Asia/Shanghai"))
val text = fmt.format(localDateTime)
assertThat(text,equalTo("2025-08-07 14:38:42"))
}

@Test
fun `test use DateFormatter convert LocalDateTime to String without Zone`(){
/*
打印结果: 2025-08-07 14:38:42, 加不加withZone结果都是一样的
* */
val localDateTime = LocalDateTime.parse("2025-08-07T14:38:42.219551")
val fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
val text = fmt.format(localDateTime)
assertThat(text,equalTo("2025-08-07 14:38:42"))
}

转换String到LocalDateTime

LocalDateTime.parse( String ) 默认使用 DateTimeFormatter.ISO_LOCAL_DATE_TIME
它严格要求 yyyy-MM-dd'T'HH:mm:ss(中间必须是 T)。

因此,如果我们要把 "2025-08-07 14:38:42"这样的字符串转换成LocalDateTime,就需要用DateTimeFormatter的帮助。由于LocalDateTime不包含时区信息,所以DateTimeFormatter没有必要配置zoneId

1
2
3
4
5
6
7
@Test
fun `test parse string to localdatetime without timezone info`(){
val localDateTime1 = LocalDateTime.parse("2025-08-07T14:38:42")
val str = "2025-08-07 14:38:42"
val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
val localDateTime2 = LocalDateTime.parse(str,dateFormatter)
assertThat(localDateTime1.toString(),equalTo(localDateTime2.toString()))

转换 OffsetDateTime 到 String

DateTimeFormatter也可以把OffsetDateTime序列化成字符串。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Test
fun `test use DateFormatter convert OffsetDateTime to String`(){
/*
打印结果: 2025-08-07 14:38:42, 可以看到把时区给加上了
* */
val localDateTime = OffsetDateTime.parse("2025-08-07T14:38:42.219474+08:00")
val fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.withZone(ZoneId.of("Asia/Shanghai"))
val text = fmt.format(localDateTime)
assertThat(text,equalTo("2025-08-07 14:38:42"))
}

@Test
fun `test use DateFormatter convert OffsetDateTime to String without Zone`(){
/*
打印结果: 2025-08-07 14:38:42, 可以看到把时区给加上了
* */
val localDateTime = OffsetDateTime.parse("2025-08-07T14:38:42.219474+08:00")
val fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
val text = fmt.format(localDateTime)
assertThat(text,equalTo("2025-08-07 14:38:42"))
}
@Test
fun `test use DateFormatter convert OffsetDateTime to String with another Zone`(){
/*
打印结果: 2025-08-07 14:38:42, 可以看到把时区给加上了
* */
val localDateTime = OffsetDateTime.parse("2025-08-07T14:38:42.219474+08:00")
val fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.withZone(ZoneId.of("Asia/Tokyo"))
val text = fmt.format(localDateTime)
assertThat(text,equalTo("2025-08-07 15:38:42"))
}
测试 Formatter 设置 参与格式化的对象
1 上海时区withZone(Asia/Shanghai) OffsetDateTime 内部已经带 +08:00 偏移 formatter 先把对象转换成 Instant,再用 上海时区 还原本地时间——因为上海当前偏移就是 +08:00,恢复后仍是 14:38:42
2 不指定时区 没有 override zone OffsetDateTime 自己的 +08:00 就足够生成本地时间 → 14:38:42
3 指定东京时区 withZone()设定为东京时区 1. 对象本身:2025-08-07T14:38:42+08:00 对应的 Instant = 2025-08-07T06:38:42Z
2. formatter指定偏移量+9:00, 因此格式化成2025-08-07 15:38:42
  • 示例:解析淘宝API的时间实现如下:

    1
    2
    3
    4
    5
    6
    7
    private val TAOBAO_FMT: DateTimeFormatter = DateTimeFormatter
    .ofPattern("yyyy-MM-dd HH:mm:ss")
    .withZone(ZoneId.of("Asia/Shanghai")) // 淘宝按北京时间解析

    fun parseTaobaoDateTime(dateTimeStr: String): OffsetDateTime =
    LocalDateTime.parse(dateTimeStr, TAOBAO_FMT)
    .atOffset(ZoneOffset.ofHours(8))

    这里 dateTimeStr 形如“yyyy-MM-dd HH:mm:ss”的字符串,没有时区信息,但根据注释知道它代表北京时间。所以:

    1. Formatter使用模式yyyy-MM-dd HH:mm:ss并通过withZone(Asia/Shanghai)设定了解析/格式化的默认时区为上海(东八区)。
    2. LocalDateTime.parse 使用该formatter,将字符串解析成一个LocalDateTime对象。由于模式中没有偏移符号,解析结果是一个没有偏移的LocalDateTime,相当于“2025-08-06 03:24:14”这一天北京时间的本地时间。
    3. 随后调用.atOffset(ZoneOffset.ofHours(8)),将LocalDateTime按+08:00偏移附加,转换为OffsetDateTime。

    这一系列操作后得到的OffsetDateTime表示的就是北京时间2025-08-06 03:24:14对应的绝对时间点。在这种用法中,withZone(ZoneId.of("Asia/Shanghai"))和随后.atOffset(+8)在效果上都体现了“将本地时间视为东八区时间”这一意图。不过它们作用的层次不同:withZone是作用在解析/格式化器上的(提供默认时区用于解析或用于将Instant等转换成人类时间),而atOffset是作用在时间对象上的(为LocalDateTime直接赋予一个偏移得到OffsetDateTime)。在解析这一步中,两者结合确保了最终结果具有正确的偏移。如果没有使用withZone,那么解析字符串时其实不涉及时区,上面的LocalDateTime.parse会得到同样的LocalDateTime结果;关键是通过atOffset(ZoneOffset.ofHours(8))明确了这段本地时间处于UTC+8偏移。(正如该答案评论所指出,formatter即使有withZone,解析LocalDateTime时也会忽略该Zone设置,仅在需要Instant/ZonedDateTime等绝对时间类型时才用上它)。

  • atOffset 与 withZone 区别:正如上面分析,LocalDateTime.atOffset(offset)是将一个无时区的LocalDateTime转为带固定偏移的OffsetDateTime;而DateTimeFormatter.withZone(zone)是为格式化/解析提供一个默认Zone上下文。两者可以达到类似目的(将某个本地时间与特定偏移关联),但使用场景不同:

    • 如果已经有一个LocalDateTime对象,需要赋予其偏移用于存储或传输,使用atOffset。例如localDateTime.atOffset(ZoneOffset.UTC)得到UTC偏移的时间。
    • 如果在格式化/解析阶段就想指定时区参考,则使用withZone。例如格式化Instant时必须指定withZone,否则无法直接格式化;解析没有时区的信息时也可用withZone提供一个默认偏移。

    两者不是冗余的,而是API在不同层面提供的灵活性。对于上面的淘宝时间例子,其实完全可以不在DateTimeFormatter中指定.withZone,而仅在解析后调用.atOffset(ZoneOffset.ofHours(8))来附加偏移(正如代码所做)。formatter加与不加withZone对LocalDateTime.parse的结果没有区别,但如果同一个formatter还用于格式化Instant等,那么withZone就有用了。例如可以用TAOBAO_FMT直接格式化Instant得到北京时间字符串,这正是withZone的用武之地。

转换 String 到 OffsetDateTime

OffsetDateTime.parse(String) 只接受 完整 ISO-8601 字符串——也就是 日期-时间后面必须紧跟偏移量Z±HH:mm)。否则会解析失败

此时,对于"2025-08-07 14:38:42"这样格式的字符串,就需要DateFormatter的帮助,但是仅仅靠formatter也没有用,如下所示:

1
2
3
4
5
6
7
8
@Test
fun `test parse string to offsetDateTime wrong case`() {
val str = "2025-08-07 14:38:42"
val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.of("Asia/Shanghai"))
assertThrows<DateTimeParseException> {
OffsetDateTime.parse(str, dateFormatter)
}
}

也就是说,如果我直接用这个formatter去解析string的话,还是会报错,因为解析器能把 年月日时分秒 提取出来,却得不到 ZoneOffsetOffsetDateTime 又必须要 offset,于是抛出DateTimeParseException

因此,合理的做法是,现将其转换为LocalDateTime,再通过atOffset方法转换成OffsetDateTime

1
2
3
4
5
6
7
8
9
@Test
fun `test parse string to offsetDateTime correctly`() {
val offsetDateTime1 = OffsetDateTime.parse("2025-08-07T14:38:42+08:00")
val str = "2025-08-07 14:38:42"
val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
val localDateTime = LocalDateTime.parse(str, dateFormatter)
val offsetDateTime2 = localDateTime.atOffset(ZoneOffset.ofHours(8))
assertThat(offsetDateTime1.toString(), equalTo(offsetDateTime2.toString()))
}

小结

使用DateTimeFormatter时,牢记以下要点:

  • 对于Instant等无时区的对象进行格式化,一定要有时区信息(可在格式化前将Instant转换为ZonedDateTime/OffsetDateTime,或直接在Formatter上withZone一个ZoneId)。
  • 解析字符串成Instant/OffsetDateTime时,如果字符串不含时区信息,需要结合约定的时区。可以先parse为LocalDateTime再指定偏移,或用withZone+ZonedDateTime.parse一步到位。
  • withZone不会影响解析LocalDateTime的结果,但会在你解析成Instant/ZonedDateTime或者格式化Instant时发挥作用。同一个formatter可以反复使用,因此在formatter上预设withZone是很方便的方式。

不同时间类型的相互转换

在Java应用中,经常需要在 Instant、OffsetDateTime、LocalDateTime 之间转换。下面总结常用转换方式:

Instant → OffsetDateTime

调用 instant.atOffset(zoneOffset),将Instant附加一个偏移,得到对应的OffsetDateTime。例如:Instant.now().atOffset(ZoneOffset.ofHours(8)) 将当前UTC时间转换为东八区时间的OffsetDateTime(相当于北京时间)。也可以使用静态方法 OffsetDateTime.ofInstant(instant, zoneId) 达到相同效果。

我们看到,经过测试,这两者都可以将Instant转换为OffsetDateTime类型

1
2
3
4
5
6
7
8
@Test
fun `test instant to offsetDateTime`(){
val instant = Instant.parse("2025-08-07T06:38:42.219218Z")
val utcOffsetDateTime = instant.atOffset(ZoneOffset.UTC)
assertThat(utcOffsetDateTime.toString(),equalTo("2025-08-07T06:38:42.219218Z"))
val beijingOffsetDateTime = instant.atOffset(ZoneOffset.ofHours(8))
assertThat(beijingOffsetDateTime.toString(),equalTo("2025-08-07T14:38:42.219218+08:00"))
}
1
2
3
4
5
6
7
8
@Test
fun `test instant to offsetDateTime use OffsetDateTime ofInstant`(){
val instant = Instant.parse("2025-08-07T06:38:42.219218Z")
val utcOffsetDateTime = OffsetDateTime.ofInstant(instant, ZoneOffset.UTC)
assertThat(utcOffsetDateTime.toString(),equalTo("2025-08-07T06:38:42.219218Z"))
val beijingOffsetDateTime = OffsetDateTime.ofInstant(instant,ZoneOffset.ofHours(8))
assertThat(beijingOffsetDateTime.toString(),equalTo("2025-08-07T14:38:42.219218+08:00"))
}

OffsetDateTime → Instant

调用 offsetDateTime.toInstant() 即可取得对应的Instant绝对时间点。这会将OffsetDateTime按其Offset换算到UTC。例如OffsetDateTime.parse("2025-08-06T03:24:14+08:00").toInstant()得到2025-08-05T19:24:14Z(UTC前一天19:24:14)。

1
2
3
4
5
6
@Test
fun `test offsetDateTime to Instant use OffsetDateTime toInstant`(){
val offsetDateTime = OffsetDateTime.parse("2025-08-07T14:38:42.219474+08:00")
val insatnt = offsetDateTime.toInstant()
assertThat(insatnt.toString(),equalTo("2025-08-07T06:38:42.219218Z"))
}

LocalDateTime → OffsetDateTime

需要提供一个ZoneOffset偏移,用 localDateTime.atOffset(offset) 或静态方法 OffsetDateTime.of(localDateTime, offset) 来创建。例如:OffsetDateTime.of(localDateTime, ZoneOffset.ofHours(8))。这一步相当于假定该LocalDateTime是在给定偏移时区下发生的。本质上,LocalDateTime + Offset 才能确定唯一时间点。

如下所示,当我在转换时指定不同的offset,出来的时刻也是不一样的

1
2
3
4
5
6
7
8
9
10
@Test
fun `test localdatetime to offsetdatetime use localdatetime atoffset`(){
val localDateTime = LocalDateTime.parse("2025-08-07T14:38:42.219551")
val offsetDateTimeUTC = localDateTime.atOffset(ZoneOffset.UTC)
//零时区
assertThat(offsetDateTimeUTC.toString(),equalTo("2025-08-07T14:38:42.219551Z"))
val offsetDateTimeBeijing = localDateTime.atOffset(ZoneOffset.ofHours(8))
//东8区
assertThat(offsetDateTimeBeijing.toString(),equalTo("2025-08-07T14:38:42.219551+08:00"))
}

OffsetDateTime → LocalDateTime

可以直接调用 offsetDateTime.toLocalDateTime() 获取其本地日期时间部分(Offset将被丢弃)。一般只有在不关心时区的场景下才这么做(比较危险),例如只想取出本地日历时间部分进行显示。但通常业务逻辑中最好保留偏移,除非有充分理由。

如下所示,当转到localDateTime的时候,丢失了+8:00,这时候如果下一个人拿到,就不知道他是属于哪个时区的了,容易出错

1
2
3
4
5
6
@Test
fun `test offsetdatetime to localdatetime use offsetDateTime toLocalDateTime`(){
val offsetDateTime = OffsetDateTime.parse("2025-08-07T14:38:42.219474+08:00")
val localDateTime = offsetDateTime.toLocalDateTime()
assertThat(localDatetime.toString(),equalTo("2025-08-07T14:38:42.219474"))
}

LocalDateTime → Instant

因为LocalDateTime不含时区,需要提供一个ZoneOffset才能确定唯一Instant。Java 8中可以使用 localDateTime.toInstant(offset) 直接得到Instant。

或者等效地,先将LocalDateTime附加偏移/时区再转Instant,如:localDateTime.atZone(zoneId).toInstant()localDateTime.atOffset(offset).toInstant()

1
2
3
4
5
6
7
8
@Test
fun `test localdatetime to instant use toInstant`(){
val localDateTime = LocalDateTime.parse("2025-08-07T14:38:42.219551")
val instantUTC = localDateTime.toInstant(ZoneOffset.UTC)
assertThat(instantUTC.toString(),equalTo("2025-08-07T14:38:42.219551Z"))
val instantBeijing = localDateTime.toInstant(ZoneOffset.ofHours(8))
assertThat(instantBeijing.toString(),equalTo("2025-08-07T06:38:42.219551Z"))
}

用了第二种方法,我们发现其实是先将LocalDateTime转换成ZonedDateTime这个中间态,然后再转换成Instant的

1
2
3
4
5
6
@Test
fun `test localdatetime to instant use atZone`(){
val localDateTime = LocalDateTime.parse("2025-08-07T14:38:42.219551")
val zoneDateTime: ZonedDateTime = localDateTime.atZone(ZoneId.of("Asia/Shanghai"))
assertThat(zoneDateTime.toInstant().toString(),equalTo("2025-08-07T06:38:42.219551Z"))
}

Instant → LocalDateTime

需要指定目标时区,将Instant转换为该时区的本地时间。例如使用静态方法:LocalDateTime.ofInstant(instant, zoneId) 可得到指定Zone下对应的LocalDateTime。

如下所示,指定不同的offset,可以把Instant变成不同的LocalDateTime

1
2
3
4
5
6
7
8
@Test
fun `test instant to localdatetime use LocalDateTime ofInstant`(){
val instant = Instant.parse("2025-08-07T06:38:42.219218Z")
val localDateTimeUTC = LocalDateTime.ofInstant(instant, ZoneOffset.UTC)
assertThat(localDateTimeUTC.toString(),equalTo("2025-08-07T06:38:42.219218"))
val localDateTimeBeijing = LocalDateTime.ofInstant(instant,ZoneOffset.ofHours(8))
assertThat(localDateTimeBeijing.toString(),equalTo("2025-08-07T14:38:42.219218"))
}

感想

尽量不要用LocalDateTime,除非是在把"2025-08-07 14:38:42"这种类型的字符串转换成OffsetDateTimeInstant的时候,需要LocalDateTime作为中间状态

PostgreSQL 中 TIMESTAMP WITH TIME ZONE 的存储与读取

PostgreSQL 提供了两种时间戳类型:TIMESTAMP WITHOUT TIME ZONETIMESTAMP WITH TIME ZONE(简称 timestamptz)。通常数据库使用的是后者。

理解它的行为对我们的Java代码存取时间尤为重要:

  • 存储:当我们往timestamptz字段存入值时,无论提供什么时区偏移,PostgreSQL都会将时间值转换为 UTC后存储。换句话说,TIMESTAMP WITH TIME ZONE并不真的存储时区信息,它只是利用插入值的偏移量把时间规范化为UTC,再保存时间点。例如,存入 '2025-08-06 11:24:14+08:00''2025-08-06 03:24:14+00:00'(UTC时间)实际上会存储为相同的UTC时间点,只是前者插入时减去了8小时转换为UTC。正因为如此,列类型名称中的“with time zone”容易让人误解,实际上它只是存了UTC时间戳
  • 不保存原始偏移:由于上述转换,数据库并不记录插入时附带的时区偏移。因此,从数据库读取timestamptz时,我们拿到的只是那个绝对时间点,至于它原先属于哪个时区,需要应用层自己知道或另存。例如,上面存入的两个值无论哪种偏移,存储后都是同一个UTC时间点,所以查询出来并不知道最初是哪种偏移

如下所示,并没有时区信息,都是以UTC时间存储的:

  • Java映射类型选择:对于PostgreSQL的timestamptz列,推荐使用 OffsetDateTimeInstant 类型在Java中接收:

    • OffsetDateTime直观地包含了日期时间和偏移。JPA/Hibernate 默认会将OffsetDateTime属性映射为数据库TIMESTAMP WITH TIME ZONE列。需要注意如前所述,读取时OffsetDateTime偏移将是UTC(除非你修改了会话时区返回的行为,但一般不建议那样做。如果拿到OffsetDateTime后需要转成本地时区显示,可以再调整偏移。
    • Instant也可用于表示绝对时间点,对应数据库的TIMESTAMP WITH TIME ZONE。例如,Spring Data JPA会将Instant自动映射为timestamptz列。Instant始终是UTC,不存在偏移困扰,非常适合处理绝对时间戳。当需要显示给用户时,再根据需要转换为特定Zone的时间。

    两者选择上,如果更多地关心人与本地时间语义(例如日志时间、业务发生当地时间等),OffsetDateTime可能方便一些;如果只关心统一的时间线排序或存储,Instant也很好用。在内部计算时,它们都可以互相转换而不丢失精度。

比如说这样一条数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
seven:
oid: "2577475308200925159"
seller_id: 2521
leyan_buyer_id: "550e8400-e29b-41d4-a716-446655440001"
buyer_nick: "s**"
buyer_open_uid: "AAHm5d-EAAeGwJedwSHpg8bT"
tid: "2577475308199925159"
status: "WAIT_SELLER_SEND_GOODS"
num_iid: 895091578431
title: "外面看不见里面单向透视玻璃贴膜防走光窗户贴纸防窥透光不透明人"
num: 1
tb_created_at: "2025-05-23T12:59:58Z"
tb_updated_at: "2025-05-23T15:32:54Z"
created_at: "2025-05-23T12:59:58Z"
updated_at: "2025-05-23T15:32:54Z"
price: 26.60
payment: 17.28
refund_status: "NO_REFUND"
buyer_rate: false

他在数据库中存储的样子是:2025-05-23 12:59:58.000000 +00:00这样的UTC时间

但是,我用 jooq 读取出来之后,会根据本地JVM的时区信息,自动将其转换成OffsetDateTime类型

如下所示:可以看出这里example.tbCreatedAt的类型是OffsetDateTime,偏移量已经加上

1
2
3
4
5
6
@Test
fun `test read time from pg`(){
val example = SellerOrder.find(sellerOrderSeven.oid!!)
val expected = OffsetDateTime.parse("2025-05-23T20:59:58+08:00")
assertThat(example.tbCreatedAt, equalTo(expected))
}
  • TIMESTAMP WITHOUT TIME ZONE:这里提及一下区别。如果数据库列是timestamp without time zone,则PostgreSQL不会对插入值做时区转换,一律按照字面值存储。例如插入 '2025-08-06 11:24:14+08:00' 在此情况下会忽略+08偏移,实际存储为2025-08-06 11:24:14(认为这是本地时间)。这种类型在Java中通常映射为LocalDateTime。
-------------本文结束,感谢您的阅读-------------