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

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

在本文中,我们了解了如何使用 Java 在 PostgreSQL 数据库中存储日期和时间值。我们了解了数据类型如何从 Java 映射到其对应的 PostgreSQL 数据类型,日期和时间如何以 UTC 存储,并且我们讨论了使用日期和时间的一些最佳实践。

在本教程中,我们将了解Java Date/Time API 提供的日期和时间类以及PostgreSQL如何持久它们。

设置
在本文中,我们将利用带有Spring Data JPA 的Spring Boot应用程序将日期和时间值保存在 PostgreSQL 数据库中。

首先,让我们创建一个实体,其中包含 Java 的日期/时间 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;
    // getters and setters ...
}

此外,我们将添加一个默认构造函数来用固定时间初始化所有日期/时间字段:

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

在这里,我们将固定时钟作为参数传递给创建当前时间的日期或时间对象的方法。值得注意的是,我们将时区设置为 UTC。

PostgreSQL 映射
我们可以让 Spring Boot自动为我们的实体生成数据库模式。为此,我们需要配置 Spring Data JPA:

spring.jpa.generate-ddl=true

现在,让我们看看字段映射是如何工作的。

默认映射
无需进一步配置,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数据类型。官方文档不鼓励使用这种数据类型,因为它是用于遗留目的。因此,这种数据类型没有默认映射。主要原因是,在大多数情况下,没有日期的时间的时区是没有意义的。

自定义映射
默认情况下,Spring Data JPA 会为我们选择合理的数据类型映射。如果我们想更改映射,可以使用@Column注释:

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

这里,@Column注释确保数据库中的列类型为DATE,而不是TIMESTAMP WITHOUT TIME ZONE。

此外,还有@Temporal注释,可以映射java.util.Date和java.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

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

在内部,日期和时间值以微秒为单位保存,自 2000 年 1 月 1 日 UTC 以来。它们是时间中的特定时刻,所有计算和转换均以此为基础。PostgreSQL不会保留原始时区信息。

让我们看一个例子来更好地理解这意味着什么。

示例
以下是我们希望以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");

第一个时间戳timeUTC以 UTC 定义。第二个时间戳timeCET以 CET 定义(偏移量为 +1 小时)。

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

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

带时区和不带时区的时间戳
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数据类型。

存储时区信息
正如我们刚刚了解到的,PostgreSQL 不存储用于创建日期/时间值的时区信息。如果我们有需要此信息的用例,我们需要自己存储时区。让我们在DateTimesValues类中添加一个字段来处理这个问题:

private String zoneId;

我们可以使用此字段来存储ZoneId:

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


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

注意事项
使用日期和时间值时有很多陷阱。让我们讨论其中两个:自定义数据类型映射和时区设置。

自定义映射
使用自定义映射时需要小心。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 数据类型不包含时间或时区信息,因此只有日期部分保留下来。

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

时区设置
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;

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

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

作为一般准则,我们在处理日期和时间时永远不应依赖数据库或客户端设置。这些设置可能会发生变化。例如,当我们旅行时,我们的操作系统可能会自动更改为另一个时区。因此,最好始终在代码中明确指定时区,而不是依赖特定的服务器或客户端设置。