叁只仓鼠的个人博客

果然人类,无法理解

Java8 Time API 与 PostgreSQL 时间数据格式

2025-08-21

在现代应用开发中,时间数据的处理是每个应用都绕不开的话题。对于使用 PostgreSQL 的 Java 开发者来说,如何在这两者之间高效、准确地处理时间数据,却是一个不小的技术挑战。

本文将深入探讨 Java 和 PostgreSQL 之间处理时间数据的常见解决方案,并分享一些实战经验,帮助你避开常见的陷阱。

Time API 出现之前:混乱的史前时代

在 Java 8 以前,处理日期和时间是一件令人头疼的事。那时的开发者们,不得不依赖功能有限且设计不佳的 java.util.Datejava.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.Datejava.util.Calendar 紧密协作。

  • 类型映射:JDBC 定义了 java.sql.Datejava.sql.Timejava.sql.Timestamp 三个子类,它们都继承自 java.util.Date

    • java.sql.Date 对应数据库的 DATE 类型(只存日期)。

    • java.sql.Time 对应数据库的 TIME 类型(只存时间)。

    • java.sql.Timestamp 对应数据库的 TIMESTAMP 类型(存日期和时间,精确到毫秒)。

  • 代码示例:开发者必须手动在这些类型之间进行转换,以确保与数据库的兼容性。例如,从 java.util.Date 转换到 java.sql.Timestampnew java.sql.Timestamp(myDate.getTime())。这带来了额外的转换工作和潜在的出错风险。

总而言之,Java 8 之前的时间处理充满了陷阱:API 不一致、缺乏线程安全、时区处理复杂,这些都是当时 Java 开发者的长期痛点。

Time API 出现之后:现代化的纪元

核心思想和设计

新的 Time API 汲取了业界优秀实践,如 Joda-Time 库,并引入了以下核心设计理念:

  1. 不可变性java.time 包下的所有核心类(如 LocalDateTime, ZonedDateTime)都是不可变的。一旦创建之后该对象的任何属性都不可修改,所有修改操作都会返回一个新的实例,这从根本上解决了多线程环境下的安全问题。

  2. API 直观:命名清晰,例如 LocalDate 仅表示日期,LocalTime 仅表示时间。方法名也更符合直觉,如 plusDays()minusWeeks()

  3. 时区明确OffsetDateTimeZonedDateTime 类明确地包含了时区或时差信息,彻底解决了时区相关的混淆问题。

类名

作用与特点

常用场景

LocalDate

仅表示日期,不包含时间或时区信息。不可变且线程安全。

存储生日、法定节假日等只需要日期的信息。

LocalTime

仅表示时间,不包含日期或时区信息。精确到纳秒。

存储营业时间、会议开始时间等只需要时间的信息。

LocalDateTime

结合了 LocalDate 和 LocalTime,表示日期和时间,但不包含时区信息。这是最常用的时间类之一。

存储事件的发生时间(如订单创建时间、系统日志时间),适用于所有操作都在同一个时区内的情况。

Instant

表示时间戳,即从1970年1月1日00:00:00 UTC(世界协调时间)开始到现在的总秒数或纳秒数。

记录事件的精确发生时刻,通常用于在不同系统间传递时间数据,或者用于计算两个时刻之间的时间差。

ZonedDateTime

结合了 LocalDateTime 和时区规则 (ZoneId),表示带时区的完整日期和时间。它能感知夏令时等时区规则变化。

存储跨国会议安排、国际航班起降时间等需要考虑地理时区规则的事件。

OffsetDateTime

结合了 LocalDateTime 和UTC时间偏移量 (ZoneOffset),表示带时差的完整日期和时间。它只关心固定的时差,不关心地理时区规则。

存储数据库时间(如PostgreSQL的TIMESTAMP WITH TIME ZONE),或处理来自外部API(如RESTful API)的固定时差时间戳。

Duration

表示时间跨度,用于衡量两个 Instant 或 LocalTime 之间的差值,以秒和纳秒为单位。

计算任务的执行时长、视频的时长,或两个时间点之间的间隔。

Period

表示以年、月、日为单位的时间跨度。它与 Duration 不同,更适用于人类可读的时间段。

计算一个人的年龄、租房合同的有效期,或两个 LocalDate 之间的年、月、日差值。

ZoneId

表示一个时区标识符,例如 Asia/Shanghai。它定义了时区规则,用于在 LocalDateTime 和 ZonedDateTime 之间进行转换。

在处理跨时区数据时,这是 ZonedDateTime 的核心组成部分。

DateTimeFormatter

格式化和解析日期与时间。它是线程安全的,可以创建自定义的格式化模式。

将日期时间对象格式化为特定字符串,或将字符串解析为日期时间对象,是时间和字符串转换的桥梁。

在这里需要引入夏令时的概念:

夏令时(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 包通过 ZoneIdZonedDateTime 等类很好地解决了这些问题。这些类内置了夏令时的规则,能够自动处理时间的调整,避免了手动计算带来的错误。

如果你只关心一个固定的时间偏移量(比如数据库中的 +08:00),那么 ZoneOffset 是合适的。

JDBC 的变革

随着 Java 8 的普及,JDBC 4.2 规范正式支持了 java.time 包下的类,PostgreSQL 的 JDBC 驱动也迅速跟进,这带来了巨大的改变。

  • 自动映射:现在,你可以直接使用 PreparedStatementsetObject() 方法来设置 LocalDateTimeLocalDate 等对象,而不需要手动转换。驱动会自动处理这些 Java 类型和数据库类型之间的映射。

  • 更清晰的类型对应

    • java.time.LocalDateDATE

    • java.time.LocalTimeTIME

    • java.time.LocalDateTimeTIMESTAMP WITHOUT TIME ZONE

    • java.time.ZonedDateTimeTIMESTAMP WITH TIME ZONE

  • 代码简化:你可以直接从 ResultSet 中获取 java.time 对象,而不再需要先获取 Timestamp 再进行转换。例如:rs.getObject("created_at", LocalDateTime.class)

总结

Java Time API 的出现彻底终结了 DateCalendar 时代的混乱。它以不可变性、直观性和明确的时区处理,为开发者带来了前所未有的便利和安全。而现代 JDBC 驱动的升级则让这种便利无缝地延伸到了数据库操作中,使得 Java 与 PostgreSQL 的时间数据交互变得简单、高效且不易出错。因此,在任何新项目中,使用 java.time 都是处理时间和日期的唯一正确选择

PostgreSQL 数据类型

java.time API 类

描述

TIMESTAMP

LocalDateTime

无时区的本地日期和时间

TIMESTAMPTZ

ZonedDateTime 或 OffsetDateTime

带有时区的绝对时间点

DATE

LocalDate

日期(年、月、日)

TIME

LocalTime

时间(时、分、秒)

INTERVAL

Duration 或 Period

时间间隔或持续时间

关于PostgreSQL中时间相关的数据类型可以参考官方文档:https://www.postgresql.org/docs/current/datatype-datetime.html

注:在MySQL中还有一个datetime数据类型,但它不包含时区信息,存储格式为 YYYY-MM-DD HH:MM:SS。它能表示从 1000-01-01 00:00:009999-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()
}

数据库中的数据

name

time

write at UTC

1755770400000

write at Asia/Shanghai

1755770400000

write at America/New_York

1755770400000

运行输出

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

name

time1

time2

time3

write at UTC

2025-08-21 10:00:00.000

2025-08-21 18:00:00.000

2025-08-21 18:00:00.000

write at Asia/Shanghai

2025-08-21 18:00:00.000

2025-08-21 18:00:00.000

2025-08-21 18:00:00.000

write at America/New_York

2025-08-21 06:00:00.000

2025-08-21 18:00:00.000

2025-08-21 18:00:00.000

这里可以看到一个有趣的现象: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:001755799200000)、上海时间2025-08-21 18:001755770400000)、纽约时间2025-08-21 18:001755813600000)其实是三个不同的时间。

当然,如果你的业务需要跨时区逻辑,但你又非得用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

name

time1

time2

time3

write at UTC

2025-08-21 18:00:00.000 +0800

2025-08-22 02:00:00.000 +0800

2025-08-21 18:00:00.000 +0800

write at Asia/Shanghai

2025-08-21 18:00:00.000 +0800

2025-08-21 18:00:00.000 +0800

2025-08-21 18:00:00.000 +0800

write at America/New_York

2025-08-21 18:00:00.000 +0800

2025-08-22 06:00:00.000 +0800

2025-08-21 18:00:00.000 +0800

其中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 的时区行为,才能避免那些隐蔽的“坑”。

希望这篇文章能帮助你在开发中避开那些时间数据格式的陷阱,更高效、更安全地处理时间。