使用 Java 在 PostgreSQL 中存储日期和时间

1、简介

在数据库中存储日期(Date)和时间(Time)信息是软件开发中的一项常见任务。由于有许多不同的格式、时区和存储格式,处理日期和时间可能是一项复杂的任务,如果处理不慎,可能会导致许多问题。

本文将带你了解 Java Date / Time API 提供的日期和时间类,以及 PostgreSQL 如何持久化这些类。

2、设置

本文使用 Spring BootSpring Data JPAPostgreSQL 数据库中持久化日期和时间值。

首先,创建一个实体,其中包含 Java Date / Time API 中不同日期和时间类的字段:

@Entity
public class DateTimeValues {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Integer id;

    private Date date;
    private LocalDate localDate;
    private LocalDateTime localDateTime;
    private Instant instant;
    private ZonedDateTime zonedDateTime;
    private LocalTime localTime;
    private OffsetDateTime offsetDateTime;
    private java.sql.Date sqlDate;

    // Getter / Setter 省略
}

此外,还要添加一个默认构造函数,以固定时间初始化所有日期/时间字段:

public DateTimeValues() {
    Clock clock = Clock.fixed(Instant.parse("2024-08-01T14:15:00Z"), ZoneId.of("UTC"));

    this.date = new Date(clock.millis());
    this.localDate = LocalDate.now(clock);
    this.localDateTime = LocalDateTime.now(clock);
    this.zonedDateTime = ZonedDateTime.now(clock);
    this.instant = Instant.now(clock);
    this.localTime = LocalTime.now(clock);
    this.offsetDateTime = OffsetDateTime.now(clock);
    this.sqlDate = java.sql.Date.valueOf(LocalDate.now(clock));
}

如上,我们将固定的时钟(Clock)作为参数传递给创建当前时间的日期或时间对象的方法。注意,我们将时区设置为了 UTC

3. PostgreSQL 类型映射

可以让 Spring Boot 为我们的实体自动生成数据库 Schema。为此,需要配置 Spring Data JPA

spring.jpa.generate-ddl=true

现在,来看看不同类型的映射关系。

3.1、默认的类型映射

无需其他配置,Spring Data JPA 就会为我们创建一个数据库表,并将 Java 类型映射为 PostgreSQL 数据类型:

列名 Java 类型 PostgreSQL 类型
date java.util.Date TIMESTAMP WITHOUT TIME ZONE
local_date LocalDate DATE
local_date_time LocalDateTime TIMESTAMP WITHOUT TIME ZONE
instant Instant TIMESTAMP WITH TIME ZONE
zoned_date_time ZonedDateTime TIMESTAMP WITH TIME ZONE
local_time LocalTime TIMESTAMP TIME ZONE
offset_date_time OffsetDateTime TIMESTAMP WITH TIME ZONE
sql_date java.sql.Date DATE

需要注意的是,PostgreSQL 中有一种 TIME WITH TIME ZONE 数据类型。官方文档 不鼓励使用这种数据类型,因为它是用于传统用途的(向后兼容)。因此,这种数据类型没有默认映射。这样做的主要原因是,在大多数使用情况下,为没有日期的时间设置时区是没有意义的。

3.2、自定义映射

默认情况下,Spring Data JPA 会为我们选择一个合理的数据类型映射。如果我们想更改映射类型,可以使用 @Column 注解来实现:

@Column(columnDefinition = "date")
private LocalDateTime localDateTime;

如上,@Column 注解确保数据库中的列类型是 DATE,而不是不含时区的 TIMESTAMP

此外,还有 @Temporal 注解,用于映射 java.util.Datejava.util.Calendar 的字段:

@Temporal(TemporalType.DATE)
private Date dateAsDate;

@Temporal(TemporalType.TIMESTAMP)
private Date dateAsTimestamp;

@Temporal(TemporalType.TIME)
private Date dateAsTime;

使用 java.util.Date 类主要是为了向后兼容(并不建议在新项目中使用了)。

注意,不能在任何其他类型上使用这个注解。例如,尝试对 LocalDate 字段使用该注解,就会抛出异常:

@Temporal(TemporalType.TIMESTAMP)
private LocalDate localDateAsTS;

异常信息如下:

[org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaConfiguration.class]: 
  TemporalJavaType(javaType=java.time.LocalDate) as
  `jakarta.persistence.TemporalType.TIMESTAMP` not supported

4、PostgreSQL 日期和时间的持久化

PostgreSQL 官方文档 包含有关如何持久化和转换日期和时间值的详细信息。

在内部,日期和时间值自 200011 日(世界协调时,UTC)起以微秒形式保存。它们是一个特定的时间点,所有计算和转换都以此为基础。PostgreSQL 不会保留原始时区信息。

4.1、示例

下面是两个时间戳,我们希望将其作为 TIMESTAMP WITH TIME ZONE(带有时区的时间戳)持久化:

Instant timeUTC = Instant.parse("2024-08-01T14:15:00+00:00");
Instant timeCET = Instant.parse("2024-08-01T14:15:00+01:00");

第一个时间戳 timeUTCUTC 定义。第二个时间戳 timeCETCET (欧洲中部时间)定义(偏移+1 小时)。

尽管我们使用了两个不同的时区来创建时间戳,但 PostgreSQL 仍将它们都存储在 UTC 中。当然,它会将时间戳转换为 UTC。简单地说,TIMESTAMP WITH TIME ZONE 不会存储时区,只会使用偏移量将时间戳转换为 UTC。

因此,当我们从数据库中读取时间戳时,没有任何关于在持久化时使用了哪个时区的信息。

4.2、有时区和无时区的时间戳

PostgreSQL 提供两种 TIMESTAMP 数据类型:TIMESTAMP WITH TIME ZONE(带时区的时间戳)和 TIMESTAMP WITHOUT TIME ZONE(不带时区的时间戳)。两种数据类型的值都以 UTC 保存,类型只影响时间戳的解释方式。下面以 SQL 代码段为例进行说明:

TIMESTAMP '2024-11-22 13:15:00+05'

如上,我们将数据类型定义为 TIMESTAMP,因此 PostgreSQL 忽略时区信息,并将日期字面值处理为与以下相同:

TIMESTAMP '2024-11-22 13:15:00'

如果考虑时区,需要使用 TIMESTAMP WITH TIME ZONE 类型:

TIMESTAMP WITH TIME ZONE '2024-11-22 13:15:00+05'

一般来说,我们应该使用 TIMESTAMP WITH TIME ZONE 数据类型。

5、存储时区信息

如上所述,PostgreSQL 不会存储用于创建日期/时间值的时区信息。如果我们需要这些信息,就需要自己存储时区信息。

DateTimesValues 类中添加一个字段来处理这个问题:

private String zoneId;

我们可以使用这个字段来存储 ZoneId

this.zoneId = ZoneId.systemDefault().getId();

然后,我们就可以在应用中使用 ZoneId 将检索到的日期/时间值转换为该时区的值。不过,需要注意的是,这只是一个 String(字符串类型)的自定义字段,不会影响任何其他日期/时间字段。

6、注意点

在处理日期和时间值时有很多坑。这里说说其中的两个:自定义数据类型映射和时区设置。

6.1、自定义数据类型映射

使用自定义映射时,需要注意。Java 类可能包含时间信息,而这些信息在 PostgreSQL 中转换为日期类型时会被忽略:

@Column(columnDefinition = "date")
private Instant instantAsDate;

如上,我们将包含时间信息的 Instant 映射到 PostgreSQL 中的 DATE

测试如下:

@Test
public void givenJavaInstant_whenPersistedAsSqlDate_thenRetrievedWithoutTime() {
    DateTimeValues dateTimeValues = new DateTimeValues();
    DateTimeValues persisted = dateTimeValueRepository.save(dateTimeValues);
    DateTimeValues fromDatabase = dateTimeValueRepository.findById(persisted.getId()).get();

    Assertions.assertNotEquals(
        dateTimeValues.getInstantAsDate(),
        fromDatabase.getInstantAsDate()
    );

    Assertions.assertEquals(
        dateTimeValues.getInstantAsDate().truncatedTo(ChronoUnit.DAYS),
        fromDatabase.getInstantAsDate()
    );
}

第一个断言证明了持久化日期不等于在 Java 中创建的日期。第二个断言证明持久化的日期不包含时间信息。

原因是 PostgreSQL 数据类型不包含时间或时区信息,因此只有日期部分会被存储。

其他转换也可能出现类似问题,因此强烈建议在处理日期/时间值时了解这些影响。

6.2、时区设置

Java 和 PostgreSQL 都提供了配置时区的方法。

在 Java 中,我们可以在 JVM 参数上设置:

java -Duser.timezone="Europe/Amsterdam" com.baeldung.postgres.datetime

或者,也可以直接在代码中配置时区:

Clock.fixed(Instant.parse("2024-08-01T14:15:00Z"), ZoneId.of("UTC"));

代码中的时区配置优先于 JVM 设置。

可以使用 getAvailableZoneIds() 查找所有可用的时区:

Set<String> zones = ZoneId.getAvailableZoneIds();

在 PostgreSQL 中,我们可以在 postgresql.conf 配置文件中配置系统时区:

timezone = 'Europe/Vienna'

或者,也可以在会话级别进行配置:

SET TIMEZONE TO 'GMT';

可以用如下语句在 PostgreSQL 中查询可用的时区值:

SELECT *
FROM pg_timezone_names;

会话设置优先于服务器设置。

这些设置只影值的解释和显示方式,而不影响值的存储方式。

一般来说,在处理日期和时间时,我们不应依赖数据库或客户端设置。这些设置可能会改变。例如,当我们出差时,操作系统可能会自动切换到另一个时区。因此,好的做法是始终在代码中明确指定时区,而不是依赖于特定的服务器或客户端设置。

7、总结

本文介绍如何使用 Java 在 PostgreSQL 数据库中存储日期和时间值,详细介绍了数据类型如何从 Java 映射到相应的 PostgreSQL 数据类型(日期和时间是以 UTC 保存的),以及处理日期和时间时的一些最佳实践。


Ref:https://www.baeldung.com/java-postgresql-store-date-time