Java8 Time API 与 PostgreSQL 时间数据格式
2025-08-21
在现代应用开发中,时间数据的处理是每个应用都绕不开的话题。对于使用 PostgreSQL 的 Java 开发者来说,如何在这两者之间高效、准确地处理时间数据,却是一个不小的技术挑战。
本文将深入探讨 Java 和 PostgreSQL 之间处理时间数据的常见解决方案,并分享一些实战经验,帮助你避开常见的陷阱。
Time API 出现之前:混乱的史前时代
在 Java 8 以前,处理日期和时间是一件令人头疼的事。那时的开发者们,不得不依赖功能有限且设计不佳的 java.util.Date 和 java.util.Calendar 类,在处理时区、格式化和线程安全等问题时常常陷入困境。
为了更好地理解 java.time API 带来的巨大进步,我们有必要先了解一下那个“黑暗时代”的工具。
java.util.Date
java.util.Date
是 Java 世界中最古老的时间类,但它的设计却充满了缺陷。
名字误导性强: 尽管叫
Date
,但它不仅包含日期,也包含时间,内部以自 1970 年 1 月 1 日 00:00:00 GMT 以来的毫秒数来存储。设计缺陷:
Date
类是可变的,这意味着当你将一个Date
对象传递给方法时,该方法可能会修改它,导致意外的副作用。它也 不是线程安全 的,在多线程环境下使用会引发问题。方法混乱: 许多方法(如
getYear()
和getMonth()
)都已 废弃(Deprecated),因为它们的设计糟糕且不直观。例如getMonth()
返回的值是 0-11,而不是我们习惯的 1-12。
java.util.Calendar
为了解决 Date
类的许多缺陷,Sun 公司推出了 java.util.Calendar
。它是一个抽象类,提供了更丰富的日期和时间操作,比如获取年、月、日等字段,以及进行日期的加减运算。但是,Calendar
的出现并没有彻底解决问题,反而带来了新的烦恼。
复杂难用:它的 API 设计得非常复杂,操作起来冗长且不直观。例如,想要表示 2023 年 12 月 25 日,你必须先创建一个实例,然后调用
set()
方法分别设置年份、月份和日期,并且要记住月份仍然是从 0 开始计数的。时区处理:尽管
Calendar
引入了时区的概念,但其 时区处理机制依旧不友好,很容易在复杂的应用中埋下隐患。
JDBC 在史前时代的时间处理
在 Java 8 之前,JDBC 驱动程序通过 java.sql
包与 java.util.Date
和 java.util.Calendar
紧密协作。
类型映射:JDBC 定义了
java.sql.Date
、java.sql.Time
和java.sql.Timestamp
三个子类,它们都继承自java.util.Date
。java.sql.Date
对应数据库的DATE
类型(只存日期)。java.sql.Time
对应数据库的TIME
类型(只存时间)。java.sql.Timestamp
对应数据库的TIMESTAMP
类型(存日期和时间,精确到毫秒)。
代码示例:开发者必须手动在这些类型之间进行转换,以确保与数据库的兼容性。例如,从
java.util.Date
转换到java.sql.Timestamp
:new java.sql.Timestamp(myDate.getTime())
。这带来了额外的转换工作和潜在的出错风险。
总而言之,Java 8 之前的时间处理充满了陷阱:API 不一致、缺乏线程安全、时区处理复杂,这些都是当时 Java 开发者的长期痛点。
Time API 出现之后:现代化的纪元
核心思想和设计
新的 Time API 汲取了业界优秀实践,如 Joda-Time 库,并引入了以下核心设计理念:
不可变性:
java.time
包下的所有核心类(如LocalDateTime
,ZonedDateTime
)都是不可变的。一旦创建之后该对象的任何属性都不可修改,所有修改操作都会返回一个新的实例,这从根本上解决了多线程环境下的安全问题。API 直观:命名清晰,例如
LocalDate
仅表示日期,LocalTime
仅表示时间。方法名也更符合直觉,如plusDays()
、minusWeeks()
。时区明确:
OffsetDateTime
和ZonedDateTime
类明确地包含了时区或时差信息,彻底解决了时区相关的混淆问题。
在这里需要引入夏令时的概念:
夏令时(Daylight Saving Time, DST)是一种为了节约能源而人为调整时间的制度。
简单来说,夏令时就是在一年中某些月份,将时间拨快一小时,通常是在春季。到了秋季,再将时间拨回正常,也就是冬令时。
夏令时主要目的是为了充分利用夏季的日光,减少照明用电。因为在夏天,天亮得早,如果将时钟拨快,人们就可以早起早睡,日出而作、日落而息,从而减少对电力的需求。
夏令时期间,时区与UTC(世界协调时间)的偏移量会改变。例如,纽约在冬令时是UTC-5,但在夏令时会变为UTC-4。这使得处理跨时区和跨季节的日期和时间变得复杂。
当夏令时开始时,时钟会“跳过”一个小时,例如从2:00直接跳到3:00,这导致了该小时内的时间点(如2:30)不存在。当夏令时结束时,时钟会“重复”一个小时,例如从3:00跳回2:00,这导致该小时内的时间点(如2:30)出现了两次。
Java 8 引入的 java.time
包通过 ZoneId
和 ZonedDateTime
等类很好地解决了这些问题。这些类内置了夏令时的规则,能够自动处理时间的调整,避免了手动计算带来的错误。
如果你只关心一个固定的时间偏移量(比如数据库中的 +08:00),那么 ZoneOffset
是合适的。
JDBC 的变革
随着 Java 8 的普及,JDBC 4.2 规范正式支持了 java.time
包下的类,PostgreSQL 的 JDBC 驱动也迅速跟进,这带来了巨大的改变。
自动映射:现在,你可以直接使用
PreparedStatement
的setObject()
方法来设置LocalDateTime
、LocalDate
等对象,而不需要手动转换。驱动会自动处理这些 Java 类型和数据库类型之间的映射。更清晰的类型对应:
java.time.LocalDate
↔DATE
java.time.LocalTime
↔TIME
java.time.LocalDateTime
↔TIMESTAMP WITHOUT TIME ZONE
java.time.ZonedDateTime
↔TIMESTAMP WITH TIME ZONE
代码简化:你可以直接从
ResultSet
中获取java.time
对象,而不再需要先获取Timestamp
再进行转换。例如:rs.getObject("created_at", LocalDateTime.class)
。
总结
Java Time API 的出现彻底终结了 Date
和 Calendar
时代的混乱。它以不可变性、直观性和明确的时区处理,为开发者带来了前所未有的便利和安全。而现代 JDBC 驱动的升级则让这种便利无缝地延伸到了数据库操作中,使得 Java 与 PostgreSQL 的时间数据交互变得简单、高效且不易出错。因此,在任何新项目中,使用 java.time
都是处理时间和日期的唯一正确选择。
关于PostgreSQL中时间相关的数据类型可以参考官方文档:https://www.postgresql.org/docs/current/datatype-datetime.html
注:在MySQL中还有一个datetime
数据类型,但它不包含时区信息,存储格式为 YYYY-MM-DD HH:MM:SS
。它能表示从 1000-01-01 00:00:00
到 9999-12-31 23:59:59
的时间范围。因为它不带时区,所以它适合存储不关心时区或需要保留原始日期时间值(例如,事件发生时间、生日)的数据。
实战应用
虽然在上面的描述中,Java8的Time API看似很完善,但实际将它与JDBC结合起来使用时,还是有不少坑需要踩的。主要在于JDBC有时会隐式地转换时区信息。
在数据库中存储时间时,通常有以下几种方案。本文将使用时间戳1755770400000
(也就是2025-08-21 18:00:00.000 +0800
)演示:
将时间存储为long值(BIGINT)
这种方案是将时间戳(通常是毫秒级)作为一个 long 值直接存储在数据库中,对应PostgreSQL的 BIGINT 类型。
优点:
简单:存储和读取都非常直接,就是存取一个数字,没有复杂的类型转换问题,易于理解和操作。
性能高:读写操作非常快,因为只是简单的数字存取。
跨语言、跨平台兼容性好: 这种存储方式不依赖于特定的数据库类型或编程语言,只要能处理64位整数,就可以轻松地在不同系统间交换数据。
跨时区:由于其写入值不会经过JDBC转换,这个数值永远指UTC时间。在数据库中存储的是一个原始的毫秒值,所以不存在时区转换的困扰,完全由你的应用代码来控制时间的展示和转换。
缺点:
可读性差:数据库中的
1672531200000
这样的数字对人来说无法直接看出具体的日期和时间,你需要额外进行转换才能理解它代表的具体时间。依赖应用层: 所有的时区转换、格式化等操作都必须在应用代码中完成。如果你的应用需要处理多个时区,或者不同语言环境,这会增加代码的复杂性。
功能受限:你无法直接使用PostgreSQL强大的日期时间函数,如
EXTRACT()
,DATE_TRUNC()
等,来对时间进行聚合、分组或提取年份、月份等信息。维护困难:如果需要对时间数据进行修改或查询,必须依赖应用程序代码。
演示代码
import java.sql.Connection
import java.sql.DriverManager
import java.text.SimpleDateFormat
import java.util.*
private lateinit var connection: Connection
private val now = 1755770400000L
fun main() {
init()
insertTime("UTC")
insertTime("Asia/Shanghai")
insertTime("America/New_York")
readTime("UTC")
readTime("Asia/Shanghai")
readTime("America/New_York")
}
fun init() {
Class.forName("org.postgresql.Driver")
connection = DriverManager.getConnection(
"jdbc:postgresql://localhost:5432/Test",
"Test",
"Test123.."
)
connection.createStatement().use {
it.execute("DROP TABLE time_test;")
it.execute(
"""
CREATE TABLE IF NOT EXISTS time_test(
name VARCHAR(64) PRIMARY KEY,
time BIGINT
);
""".trimIndent()
)
}
}
fun insertTime(time: String) {
TimeZone.setDefault(TimeZone.getTimeZone(time))
connection.prepareStatement("INSERT INTO time_test VALUES(?, ?)").use {
it.setString(1, "write at $time")
it.setLong(2, now)
it.executeUpdate()
}
}
fun readTime(time: String) {
println("read time for timezone: $time")
TimeZone.setDefault(TimeZone.getTimeZone(time))
val simpleDateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
connection.prepareStatement("SELECT * FROM time_test;").use { statement ->
statement.executeQuery().use { set ->
while (set.next()) {
val name = set.getString(1)
val readTime = set.getLong(2)
println("$name: ${simpleDateFormat.format(readTime)}")
}
}
}
println()
}
数据库中的数据
运行输出
read time for timezone: UTC
write at UTC: 2025-08-21 10:00:00
write at Asia/Shanghai: 2025-08-21 10:00:00
write at America/New_York: 2025-08-21 10:00:00
read time for timezone: Asia/Shanghai
write at UTC: 2025-08-21 18:00:00
write at Asia/Shanghai: 2025-08-21 18:00:00
write at America/New_York: 2025-08-21 18:00:00
read time for timezone: America/New_York
write at UTC: 2025-08-21 06:00:00
write at Asia/Shanghai: 2025-08-21 06:00:00
write at America/New_York: 2025-08-21 06:00:00
可以看到,对于一个固定的时间,不管在什么时区执行insert语句,其在数据库中存储的值都是1755770400000
,读取出来也是1755770400000
。
如果想要将其输出为人类可读的时间,可以使用SimpleDateFormat进行格式化,它会将对应的UTC时间转化为当前时区的时间并输出。
例如UTC时间2025-08-21 10:00:00
就是上海时间2025-08-21 18:00:00
,也是美国时间2025-08-21 06:00:00
。
将时间存储为TIMESTAMP
这种方案使用PostgreSQL的 TIMESTAMP WITHOUT TIME ZONE 类型,通常在Java中对应 LocalDateTime。
优点
可读性好: 数据库中存储的是人类可读的日期时间格式,例如
2023-01-01 08:00:00
,非常直观。可利用数据库函数:你可以充分利用PostgreSQL丰富的日期时间函数进行各种复杂的查询和分析,这在数据统计和报表生成时非常有用。
符合特定业务场景:适用于不关心具体时区,或者需要用到的所有时间都基于某个特定时区的业务场景。如果你非常确定你所负责的业务不涉及到跨时区的内容,那么你可以放心大胆地使用TIMESTAMP。
缺点
潜在的时区问题: TIMESTAMP 类型不存储时区信息。当数据库与应用服务器的时区不一致时,或者当你的业务需要处理多个时区(如国际化应用)时,很容易出现数据不一致或错误。
例如,你在中国存储了一个
2023-01-01 08:00:00
,但一个在美国的服务器读取这个时间为LocalDateTime时,它仍然会认为是2023-01-01 08:00:00
,而中国的早上八点和美国的早上八点显然不是同一个时间。
依赖JVM时区设置: 数据的存入和取出都依赖于JVM的时区设置。如果你没有显式地设置时区,它会使用操作系统的默认时区,这可能导致在不同机器上运行应用时行为不一致。
演示代码
import java.sql.Connection
import java.sql.DriverManager
import java.sql.Timestamp
import java.text.SimpleDateFormat
import java.time.Instant
import java.time.LocalDateTime
import java.util.*
private lateinit var connection: Connection
private val instant = Instant.ofEpochMilli(1755770400000)
private val localDateTime = LocalDateTime.of(2025, 8, 21, 18, 0, 0)
fun main() {
init()
insertTime("UTC")
insertTime("Asia/Shanghai")
insertTime("America/New_York")
readTime("UTC")
readTime("Asia/Shanghai")
readTime("America/New_York")
}
fun init() {
Class.forName("org.postgresql.Driver")
connection = DriverManager.getConnection(
"jdbc:postgresql://localhost:5432/Test",
"Test",
"Test123.."
)
connection.createStatement().use {
it.execute("DROP TABLE time_test;")
it.execute(
"""
CREATE TABLE IF NOT EXISTS time_test(
name VARCHAR(64) PRIMARY KEY,
time1 TIMESTAMP,
time2 TIMESTAMP,
time3 TIMESTAMP
);
""".trimIndent()
)
}
}
fun insertTime(time: String) {
TimeZone.setDefault(TimeZone.getTimeZone(time))
connection.createStatement().execute("SET TIMEZONE='$time';")
connection.prepareStatement("INSERT INTO time_test VALUES(?, ?, ?, ?)").use {
it.setString(1, "write at $time")
it.setTimestamp(2, Timestamp.from(instant))
it.setTimestamp(3, Timestamp.valueOf(localDateTime))
it.setObject(4, localDateTime)
it.executeUpdate()
}
}
fun readTime(time: String) {
println("read time by timezone: $time")
TimeZone.setDefault(TimeZone.getTimeZone(time))
val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
connection.createStatement().execute("SET TIMEZONE='$time';")
connection.prepareStatement("SELECT * FROM time_test;").use { statement ->
statement.executeQuery().use { set ->
while (set.next()) {
val timeName = set.getString("name")
val timestamp = set.getTimestamp("time2")
val localDateTime1 = set.getObject("time3", LocalDateTime::class.java)
val localDateTime2 = timestamp.toLocalDateTime()
println("\t$timeName: ")
println("\t\ttime: $timestamp")
println("\t\ttimestamp: ${timestamp.time}")
println("\t\tformatTime: ${dateFormat.format(timestamp.time)}")
println("\t\tLocalDateTime1: $localDateTime1")
println("\t\tLocalDateTime2: $localDateTime2")
println()
}
}
}
}
数据库中的数据
客户端时区为Asia/Shanghai
这里可以看到一个有趣的现象:time2和time3由于都是存入localDateTime对象的,所以其时间2025-08-21 18:00:00.000
,这没有问题。
但time1这里却不一致,UTC存入的是2025-08-21 10:00:00.000
而上海时间存入的是2025-08-21 18:00:00.000
,纽约时间为2025-08-21 06:00:00.000
。
由此可以判断出来,当使用it.setTimestamp(2, Timestamp.from(instant))
存入时间时,会先把Instant对象转化为当前时区对应的一个LocalDateTime,然后再存入数据库。
在PostgreSQL中,TIMESTAMP对应的TimeAPI实例为LocalDateTime,这意味着如果要将Instant存入TIMESTAMP数据类型,则会丢弃掉时区信息。所以在time1这里,存入Instant时会自动将其转换为当前时区的时间并存入数据库。
在这里有一个常见的误区需要明白:Java8的Time API中,Instant代表的是一个绝对确定的时间点(确切的来说是UTC时间),而LocalDateTime仅代表一个本地时间。例如:
当一个Instant时间为
2025-08-21 10:00:00
时,我们知道它指代的是上海时间2025-08-21 18:00:00
或者纽约时间2025-08-21 06:00:00
。当一个LocalDateTime为
2025-08-21 18:00:00
时,必须要指定“它是一个上海时间”,才能将其正确的转换为Instant时间2025-08-21 10:00:00
。
你也可以简单的认为Instant就是一个带有时区的时间信息,只不过它的时区永远固定为UTC时间。所以Instant在任何时候都可以指代一个精确的时间。而LocalDateTime则不带有时间信息,若想要让LocalDateTime能够指代一个精确的时间,则必须为它指定一个时区。
运行输出
read time by timezone: UTC
write at UTC:
time: 2025-08-21 18:00:00.0
timestamp: 1755799200000
formatTime: 2025-08-21 18:00:00
LocalDateTime1: 2025-08-21T18:00
LocalDateTime2: 2025-08-21T18:00
write at Asia/Shanghai:
time: 2025-08-21 18:00:00.0
timestamp: 1755799200000
formatTime: 2025-08-21 18:00:00
LocalDateTime1: 2025-08-21T18:00
LocalDateTime2: 2025-08-21T18:00
write at America/New_York:
time: 2025-08-21 18:00:00.0
timestamp: 1755799200000
formatTime: 2025-08-21 18:00:00
LocalDateTime1: 2025-08-21T18:00
LocalDateTime2: 2025-08-21T18:00
read time by timezone: Asia/Shanghai
write at UTC:
time: 2025-08-21 18:00:00.0
timestamp: 1755770400000
formatTime: 2025-08-21 18:00:00
LocalDateTime1: 2025-08-21T18:00
LocalDateTime2: 2025-08-21T18:00
write at Asia/Shanghai:
time: 2025-08-21 18:00:00.0
timestamp: 1755770400000
formatTime: 2025-08-21 18:00:00
LocalDateTime1: 2025-08-21T18:00
LocalDateTime2: 2025-08-21T18:00
write at America/New_York:
time: 2025-08-21 18:00:00.0
timestamp: 1755770400000
formatTime: 2025-08-21 18:00:00
LocalDateTime1: 2025-08-21T18:00
LocalDateTime2: 2025-08-21T18:00
read time by timezone: America/New_York
write at UTC:
time: 2025-08-21 18:00:00.0
timestamp: 1755813600000
formatTime: 2025-08-21 18:00:00
LocalDateTime1: 2025-08-21T18:00
LocalDateTime2: 2025-08-21T18:00
write at Asia/Shanghai:
time: 2025-08-21 18:00:00.0
timestamp: 1755813600000
formatTime: 2025-08-21 18:00:00
LocalDateTime1: 2025-08-21T18:00
LocalDateTime2: 2025-08-21T18:00
write at America/New_York:
time: 2025-08-21 18:00:00.0
timestamp: 1755813600000
formatTime: 2025-08-21 18:00:00
LocalDateTime1: 2025-08-21T18:00
LocalDateTime2: 2025-08-21T18:00
可以看到,不论写入时的时区是什么、读取时的时区是什么,从PostgreSQL中读取时间为LocalDateTime时,返回值永远是2025-08-21T18:00
。
在时区无关的业务中,这没什么问题,因为我们存入的时间确实是LocalDateTime.of(2025, 8, 21, 18, 0, 0)
。但如果你的业务是时间有关的,那你可得小心了:有bug!
UTC时间2025-08-21 18:00
(1755799200000
)、上海时间2025-08-21 18:00
(1755770400000
)、纽约时间2025-08-21 18:00
(1755813600000
)其实是三个不同的时间。
当然,如果你的业务需要跨时区逻辑,但你又非得用TIMESTAMP不可,也不是没有解决办法:在业务层约定一个具体的时区(例如Asia/Shanghai或者UTC),然后再强制要求所有程序在编写代码往PostgreSQL中存储数据时,必须先把时间转换为那个指定的特定时区再存储。
然而,这种方法非常容易出错,而且也不适合团队开发,否则你就需要把“所有数据库时间操作都以UTC时间为准、存入、取出的时间都为UTC时间,业务逻辑中要用到时请手动根据业务时区进行更改”写入员工培训手册了。
真正方便好用的还得是接下来将要介绍的TIMESTAMP WITH TIME ZONE。
将时间存储为TIMESTAMPTZ
这种方案使用PostgreSQL的 TIMESTAMP WITH TIME ZONE 类型,通常在Java中对应OffsetDateTime。
优点
时区安全: TIMESTAMPTZ 会将你存入的时间自动转换为 UTC(协调世界时) 存储,并在查询时根据客户端会话的时区设置(
SET TIMEZONE
)将其转换回来。这保证了无论客户端在哪个时区,数据库中存储的都是一个统一的、不含时区偏移的基准时间。数据一致性: 即使你的应用跨越多个时区,数据也能保持一致。这对于国际化应用或分布式系统来说至关重要。
可利用数据库函数: 和 TIMESTAMP 一样,你可以使用PostgreSQL强大的日期时间函数进行查询和分析。
缺点
JDBC时区依赖: 尽管 TIMESTAMPTZ 解决了数据库层面的时区问题,使用OffsetDateTime是最佳实践,因为它可以明确地携带时区信息。但从JDBC中获取到的OffsetDateTime默认指向的时区为UTC时区,需要在业务层中进行一些转换才能变成当前需要的时区。
稍复杂: 相比于简单的long值或不带时区的TIMESTAMP,这种方案需要对时区有更深入的理解,代码实现上也会稍微复杂一些。
演示代码
import java.sql.Connection
import java.sql.DriverManager
import java.sql.Timestamp
import java.time.Instant
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.*
private lateinit var connection: Connection
private val instant = Instant.ofEpochMilli(1755770400000)
private val localDateTime = LocalDateTime.of(2025, 8, 21, 18, 0, 0)
private val offsetDateTime = OffsetDateTime.of(localDateTime, ZoneOffset.of("+8"))
fun main() {
init()
insertTime("UTC")
insertTime("Asia/Shanghai")
insertTime("America/New_York")
readTime("UTC")
readTime("Asia/Shanghai")
readTime("America/New_York")
}
fun init() {
Class.forName("org.postgresql.Driver")
connection = DriverManager.getConnection(
"jdbc:postgresql://localhost:5432/Test",
"Test",
"Test123.."
)
connection.createStatement().use {
it.execute("DROP TABLE time_test;")
it.execute(
"""
CREATE TABLE IF NOT EXISTS time_test(
name VARCHAR(64) PRIMARY KEY,
time1 TIMESTAMPTZ,
time2 TIMESTAMPTZ,
time3 TIMESTAMPTZ
);
""".trimIndent()
)
}
}
fun insertTime(time: String) {
TimeZone.setDefault(TimeZone.getTimeZone(time))
connection.createStatement().execute("SET TIMEZONE='$time';")
connection.prepareStatement("INSERT INTO time_test VALUES(?, ?, ?, ?)").use {
it.setString(1, "write at $time")
it.setTimestamp(2, Timestamp.from(instant))
it.setObject(3, localDateTime)
it.setObject(4, offsetDateTime)
it.executeUpdate()
}
}
fun readTime(time: String) {
println("read time by timezone: $time")
TimeZone.setDefault(TimeZone.getTimeZone(time))
connection.prepareStatement("SELECT * FROM time_test;").use { statement ->
statement.executeQuery().use { set ->
while (set.next()) {
val timeName = set.getString("name")
val timestamp = set.getTimestamp("time3")
val offsetDateTime = set.getObject("time3", OffsetDateTime::class.java)
val localDateTime = timestamp.toLocalDateTime()
println("\t$timeName: ")
println("\t\ttime: $timestamp")
println("\t\ttimestamp: ${timestamp.time}")
println("\t\toffsetDateTime: $offsetDateTime")
println("\t\tlocalDateTime: $localDateTime")
println()
}
}
}
}
数据库中的数据
客户端时区为Asia/Shanghai
其中time1存储的值为Instant.ofEpochMilli(1755770400000)
,也就是2025-08-21 18:00:00.000 +0800
。可以看到,无论存入数据时的时区设置为什么,这些时间最终全都正确地存储了。
time3存储的值为OffsetDateTime.of(localDateTime, ZoneOffset.of("+8"))
,同样的,这些值也在所有时区设置中全都正确地存储了。
唯一有区别的是这个time2,其中存储的是LocalDateTime.of(2025, 8, 21, 18, 0, 0)
,虽然表面上看上去对于同一个值,其数据库中存储的结果有所不同。但因为UTC时间的2025-08-21 18:00
,就是上海时间的2025-08-22 02:00
、纽约时间的2025-08-21 18:00
,就是上海时间的2025-08-22 06:00
,所以这些值其实是被正确存储了的。
这里关于LocalDateTime的时间转换有一个需要注意的地方:
如果数据库中的时间类型是TIMESTAMP,则LocalDateTime的转换会基于JVM时区,也就是会受到
TimeZone.setDefault(TimeZone.getTimeZone(time))
影响。此时通过execute("SET TIMEZONE='$time';")
设置JDBC客户端时区,不会对存入数据造成影响。如果数据库中的时间类型是TIMESTAMPTZ,则LocalDateTime的转换会基于数据库链接设定的时区,也就是会受到
execute("SET TIMEZONE='$time';")
影响,此时通过TimeZone.setDefault(TimeZone.getTimeZone(time))
设置JVM时区不会对存入数据造成影响。
运行输出
read time by timezone: UTC
write at UTC:
time: 2025-08-21 10:00:00.0
timestamp: 1755770400000
offsetDateTime: 2025-08-21T10:00Z
localDateTime: 2025-08-21T10:00
write at Asia/Shanghai:
time: 2025-08-21 10:00:00.0
timestamp: 1755770400000
offsetDateTime: 2025-08-21T10:00Z
localDateTime: 2025-08-21T10:00
write at America/New_York:
time: 2025-08-21 10:00:00.0
timestamp: 1755770400000
offsetDateTime: 2025-08-21T10:00Z
localDateTime: 2025-08-21T10:00
read time by timezone: Asia/Shanghai
write at UTC:
time: 2025-08-21 18:00:00.0
timestamp: 1755770400000
offsetDateTime: 2025-08-21T10:00Z
localDateTime: 2025-08-21T18:00
write at Asia/Shanghai:
time: 2025-08-21 18:00:00.0
timestamp: 1755770400000
offsetDateTime: 2025-08-21T10:00Z
localDateTime: 2025-08-21T18:00
write at America/New_York:
time: 2025-08-21 18:00:00.0
timestamp: 1755770400000
offsetDateTime: 2025-08-21T10:00Z
localDateTime: 2025-08-21T18:00
read time by timezone: America/New_York
write at UTC:
time: 2025-08-21 06:00:00.0
timestamp: 1755770400000
offsetDateTime: 2025-08-21T10:00Z
localDateTime: 2025-08-21T06:00
write at Asia/Shanghai:
time: 2025-08-21 06:00:00.0
timestamp: 1755770400000
offsetDateTime: 2025-08-21T10:00Z
localDateTime: 2025-08-21T06:00
write at America/New_York:
time: 2025-08-21 06:00:00.0
timestamp: 1755770400000
offsetDateTime: 2025-08-21T10:00Z
localDateTime: 2025-08-21T06:00
通过JDBC读取数据库中的time3数据,可以发现无论JVM时区和JDBC客户端时区是什么,读取出来的时间戳都是1755770400000
,也就是说这个值精准无误地指代最初存入的时间,不受任何时区设置的影响。
读取出来的LocalDateTime则会根据JVM时区设置的不同,解析为不同的时间。例如JVM时区为上海时间时会被解析为2025-08-21T18:00
,当JVM时区为纽约时间时会被解析为2025-08-21T06:00
。
一些细心的读者可能发现了,OffsetDateTime的输出值永远是2025-08-21T10:00Z
,而不是受到当前JVM或JDBC时区的影响而更改,这也符合之前的描述:从JDBC中获取到的OffsetDateTime默认指向的时区为UTC时区。
在实际使用这个OffsetDateTime时,我们通常需要转换一下时区,例如想要显示为上海时间,我们需要通过offsetDateTime.withOffsetSameInstant(ZoneOffset.of("+8"))
将其转换为+8时区,然后再输出。这样虽然麻烦一点,但是它在最大程度上保证了时间的准确性。
总结
我写这篇文章的初衷,是想和大家分享自己在 Java 8 Time API 和 PostgreSQL 时间数据格式处理上的一些心得和踩坑经验。
无论是选择将时间存储为 BIGINT,还是直接使用数据库原生的时间类型,每种方案都有其适用场景。
但最重要的经验是:深入理解数据的存储格式和 JDBC 的时区行为,才能避免那些隐蔽的“坑”。
希望这篇文章能帮助你在开发中避开那些时间数据格式的陷阱,更高效、更安全地处理时间。