使用 Java 在 PostgreSQL 中存储日期和时间
1、简介
在数据库中存储日期(Date
)和时间(Time
)信息是软件开发中的一项常见任务。由于有许多不同的格式、时区和存储格式,处理日期和时间可能是一项复杂的任务,如果处理不慎,可能会导致许多问题。
本文将带你了解 Java Date / Time API 提供的日期和时间类,以及 PostgreSQL 如何持久化这些类。
2、设置
本文使用 Spring Boot 和 Spring Data JPA 在 PostgreSQL 数据库中持久化日期和时间值。
首先,创建一个实体,其中包含 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.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
4、PostgreSQL 日期和时间的持久化
PostgreSQL 官方文档 包含有关如何持久化和转换日期和时间值的详细信息。
在内部,日期和时间值自 2000 年 1 月 1 日(世界协调时,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");
第一个时间戳 timeUTC 以 UTC 定义。第二个时间戳 timeCET 以 CET (欧洲中部时间)定义(偏移+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