1. 简介

在软件开发中,存储日期和时间信息是常见需求。由于存在多种格式、时区和存储方式,处理日期时间容易踩坑,稍有不慎就会引发各种问题

本文将探讨Java日期/时间API的核心类,以及它们在PostgreSQL中的持久化机制。

2. 环境搭建

我们将使用Spring Boot + Spring Data JPA实现日期时间在PostgreSQL中的存储。先创建实体类:

@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

3. PostgreSQL映射机制

配置Spring Data JPA自动生成数据库表结构:

spring.jpa.generate-ddl=true

3.1. 默认映射规则

Spring Data JPA的默认映射关系:

列名 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 WITHOUT TIME ZONE
offset_date_time OffsetDateTime TIMESTAMP WITH TIME ZONE
sql_date java.sql.Date DATE

⚠️ PostgreSQL虽然有TIME WITH TIME ZONE类型,但官方文档明确不推荐使用(仅用于遗留场景),因此没有默认映射。原因很明确:没有日期的时间带时区在大多数场景下没有意义。

3.2. 自定义映射

默认映射通常合理,但我们可以通过注解覆盖

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

@Column注解强制使用DATE类型而非默认的TIMESTAMP WITHOUT TIME ZONE

对于遗留的java.util.Date,可以使用@Temporal

@Temporal(TemporalType.DATE)
private Date dateAsDate;

@Temporal(TemporalType.TIMESTAMP)
private Date dateAsTimestamp;

@Temporal(TemporalType.TIME)
private Date dateAsTime;

但该注解仅适用于java.util.Datejava.util.Calendar。尝试用于其他类型会报错:

@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");

尽管创建时使用了不同时区(UTC和CET),PostgreSQL都会将它们转换为UTC存储。TIMESTAMP WITH TIME ZONE不存储时区,仅使用偏移量进行转换。

因此从数据库读取时,我们无法获知原始时区信息。

4.2. 两种时间戳类型对比

PostgreSQL提供两种时间戳类型:

  • TIMESTAMP WITH TIME ZONE
  • TIMESTAMP WITHOUT TIME ZONE

两种类型在内部都存储为UTC,区别仅在于解释方式

-- 忽略时区信息
TIMESTAMP '2024-11-22 13:15:00+05'
-- 等同于
TIMESTAMP '2024-11-22 13:15:00'

-- 考虑时区转换
TIMESTAMP WITH TIME ZONE '2024-11-22 13:15:00+05'

✅ 最佳实践:优先使用TIMESTAMP WITH TIME ZONE

5. 存储时区信息

如果业务需要保留原始时区,必须手动存储。在实体类中添加字段:

private String zoneId;

存储时区标识:

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

使用时可用这个字段将数据库时间转换到目标时区。注意这只是普通的String字段,不会影响其他日期时间字段的存储逻辑。

6. 关键注意事项

处理日期时间时容易踩坑,重点关注两个问题:

6.1. 自定义映射陷阱

自定义映射可能导致数据丢失,特别是当Java类型包含时间信息时:

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

测试用例验证:

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

第一个断言证明存储值与原始值不同,第二个断言证明时间信息被截断。因为PostgreSQL的DATE类型不包含时间信息。

6.2. 时区配置陷阱

Java和PostgreSQL都提供时区配置方式:

Java端配置

# JVM级别
java -Duser.timezone="Europe/Amsterdam" com.example.App
// 代码级别(优先级更高)
Clock.fixed(Instant.parse("2024-08-01T14:15:00Z"), ZoneId.of("UTC"));
// 查看可用时区
Set<String> zones = ZoneId.getAvailableZoneIds();

PostgreSQL端配置

-- 配置文件(postgresql.conf)
timezone = 'Europe/Vienna'

-- 会话级别(优先级更高)
SET TIMEZONE TO 'GMT';

-- 查看可用时区
SELECT * FROM pg_timezone_names;

⚠️ 重要原则

  • 这些配置仅影响值的解释和显示,不影响存储方式
  • 永远不要依赖数据库或客户端的默认时区设置
  • 最佳实践:在代码中显式指定时区,避免依赖环境配置

7. 总结

本文深入探讨了Java日期时间类型在PostgreSQL中的存储机制,包括:

  • 默认映射规则和自定义映射方法
  • PostgreSQL内部存储原理(UTC微秒数)
  • 时区处理策略和常见陷阱

核心建议:

  1. 优先使用TIMESTAMP WITH TIME ZONE
  2. 需要原始时区时手动存储
  3. 避免依赖环境时区配置
  4. 自定义映射时注意数据精度问题

完整示例代码可在GitHub获取。


原始标题:Storing Date and Time in PostgreSQL Using Java | Baeldung