Skip to content

PostgreSQL 日期类型 ​

在现代 Web 应用开发中,正确处理日期和时间,尤其是涉及不同时区的场景,是一个至关重要的环节。本文将以 PostgreSQL 的 TIMESTAMP WITH TIME ZONE 类型为核心,详细介绍如何在 Java 后端以及前后端数据交互中进行高效、准确的处理。

1. 核心概念:数据库中的时间存储 ​

在开始之前,我们首先要理解 PostgreSQL 是如何处理带时区的时间戳的。

1.1. 数据类型:TIMESTAMP WITH TIME ZONE ​

在 PostgreSQL 中,TIMESTAMP WITH TIME ZONE (或其别名 TIMESTAMPTZ) 是存储时间点的首选类型。它的一个关键特性是:它并不会在数据库中存储时区信息本身。

相反,它的工作机制如下:

  1. 当您插入一个带有时区信息的时间值时(例如 2024-07-08 15:00:00+08:00),PostgreSQL 会根据该时区将其转换为 UTC (Coordinated Universal Time) 时间。
  2. 数据库中实际存储的是这个 UTC 时间戳。
  3. 当您查询这个值时,PostgreSQL 会根据您当前数据库会话的 timezone 设置,将存储的 UTC 时间转换回该时区的时间进行显示。

这种机制确保了所有时间点在数据库中都以一个统一的标准(UTC)存储,从而避免了时区混乱。

1.2. 创建示例表 ​

让我们在 PostgreSQL 中创建一个用于演示的 activity 表,其中包含开始和结束时间字段。

sql
CREATE TABLE activity(
  id BIGINT NOT NULL,
  title VARCHAR(256),
  start_time TIMESTAMP WITH TIME ZONE,
  end_time TIMESTAMP WITH TIME ZONE,
  PRIMARY KEY (id)
);

2. 后端(Java)数据处理 ​

在 Java 后端,我们推荐使用 Java 8 引入的 java.time API 来处理日期和时间,因为它提供了更强大、更清晰的时区支持。

2.1. Java 类型映射关系 ​

PostgreSQL 类型推荐 Java 类型JDBC 默认映射类型
TIMESTAMP WITH TIME ZONEjava.time.OffsetDateTimejava.sql.Timestamp
  • OffsetDateTime: 现代 Java API 的一部分,它精确地表示了一个带有时区偏移量(如 +08:00)的日期和时间,非常适合与 TIMESTAMPTZ 对应。
  • java.sql.Timestamp: 一个遗留的 JDBC 类型,它不直接包含时区信息,在处理时容易引发混淆。

2.2. 存储数据:使用 OffsetDateTime ​

以下示例展示了如何使用 OffsetDateTime 创建一个时间点,并将其存入数据库。

java
package nexus.io.website.service;

import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneId;

import org.junit.Test;

import nexus.io.db.activerecord.Db;
import nexus.io.db.activerecord.Row;
import nexus.io.tio.boot.postgresql.demo.config.DbConfig;
import nexus.io.tio.utils.environment.EnvUtils;
import nexus.io.tio.utils.snowflake.SnowflakeIdUtils;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class ActivityServiceTest {

  @Test
  public void addActivity() {
    // 1. 初始化数据库连接
    EnvUtils.load();
    new DbConfig().config();

    // 2. 获取当前服务器的系统时区
    ZoneId zoneId = ZoneId.systemDefault(); // 例如: Asia/Shanghai

    // 3. 创建一个基于当前时间的 OffsetDateTime 对象
    Instant startInstant = Instant.now();
    OffsetDateTime startTime = startInstant.atZone(zoneId).toOffsetDateTime();

    // 4. 创建一个24小时后的结束时间
    Instant endInstant = startInstant.plus(1, java.time.temporal.ChronoUnit.DAYS);
    OffsetDateTime endTime = endInstant.atZone(zoneId).toOffsetDateTime();

    // 5. 构建记录并保存
    Row row = new Row().set("id", SnowflakeIdUtils.id())
        //
        .set("title", "Test Activity").set("start_time", startTime).set("end_time", endTime);

    boolean save = Db.save("activity", row);
    log.info("save result:{}", save);
    // 底层执行的SQL: 
    // insert into "activity"("id", "title", "start_time", "end_time") values(?, ?, ?, ?)
  }
}

在这个例子中,即使我们的服务器时区是 Asia/Shanghai (+08:00),OffsetDateTime 对象也会被 JDBC 驱动正确解析,并以 UTC 形式存入 PostgreSQL。

2.3. 查询数据:理解 java.sql.Timestamp 的返回类型 ​

当我们从数据库中查询数据时,一个常见的现象是,即使我们期望得到 OffsetDateTime,但 JDBC 驱动默认返回的却是 java.sql.Timestamp。

java
  @Test
  public void listActivity() {
    // 初始化数据库连接...
    
    // 查询所有记录
    List<Row> records = Db.findAll("activity");
    
    // 打印结果
    for (Row row : records) {
      Object startTimeObj = row.get("start_time");
      if (startTimeObj != null) {
        log.info("Key: start_time, Value: {}, Class: {}", startTimeObj, startTimeObj.getClass().getName());
      }
    }
  }

输出结果示例:

Key: start_time, Value: 2025-03-15 08:51:53.685, Class: java.sql.Timestamp

这是框架或驱动的问题吗?

不是问题,而是标准的默认行为。 大多数 JDBC 驱动为了保持向后兼容性,会将数据库的 TIMESTAMP 相关类型默认映射为 java.sql.Timestamp。

最佳实践:在应用程序中接收到 java.sql.Timestamp 对象后,应立即将其转换为现代的 OffsetDateTime 或 ZonedDateTime 对象,以便进行更安全、更明确的时区相关操作。

java
Timestamp ts = row.getTimestamp("start_time");
if (ts != null) {
  // 转换为 OffsetDateTime,并假设数据库连接返回的是系统默认时区的时间
  OffsetDateTime startTime = ts.toInstant().atOffset(ZoneOffset.UTC); 
  // 或者转换为特定时区的时间
  // ZonedDateTime zonedStartTime = ts.toInstant().atZone(ZoneId.of("Asia/Shanghai"));
}

新增getOffsetDateTime

OffsetDateTime startTime = row.getOffsetDateTime("start_time");

2.4. 处理常见输入格式 ​

在实际业务中,后端服务经常需要处理来自前端或其他服务的不同格式的时间数据。

场景一:处理字符串(如 2025-03-01 00:28:03) ​

当收到一个不含时区信息的日期字符串时,直接使用 Timestamp.valueOf() 会将其解析为服务器默认时区的时间点。

java
  @Test
  public void testParseString() {
    // 初始化数据库连接...
    
    String timeString = "2025-03-01 00:28:03";
    
    // Timestamp.valueOf() 会使用JVM的默认时区来解析这个字符串
    Timestamp timestamp = Timestamp.valueOf(timeString);
    
    Row row = Row.by("id", SnowflakeIdUtils.id()).set("start_time", timestamp);
    Db.save("activity", row);
  }

注意:这种方式存在歧义,因为字符串本身没有时区信息。如果可能,应要求输入方提供带有时区或偏移量(如 ISO 8601 格式 2025-03-01T00:28:03+08:00)的字符串。

场景二:处理长整型时间戳(如 1743157717000) ​

使用从 Epoch (1970-01-01 00:00:00 UTC) 开始的毫秒数来表示时间,是一种跨平台、无歧义的通用方法。

java
  @Test
  public void testParseLongTimestamp() {
    // 初始化数据库连接...
    
    // 毫秒时间戳 (通常来自前端的 Date.now())
    long timeMillis = 1743157717000L;
    
    // 将毫秒时间戳转换为 Timestamp 对象
    Timestamp timestamp = new Timestamp(timeMillis);
    
    Row row = Row.by("id", SnowflakeIdUtils.id()).set("start_time", timestamp);
    Db.save("activity", row);
  }

这种方式非常可靠,因为它直接代表了一个精确的 UTC 时间点。

3. 前后端参数传递的最佳实践 ​

为了确保时间数据在前端和后端之间传递的准确性,推荐使用 毫秒级时间戳(Long) 作为标准格式。

3.1. 创建接口:前端传递毫秒时间戳 ​

前端通过 API 创建活动时,应将用户选择的日期时间转换为毫秒时间戳后发送给后端。

前端请求体 (JSON):

json
{
  "title": "New Year's Eve Party",
  "start_time": 1735660800000,  // 对应 UTC 2025-01-01 00:00:00
  "end_time": 1735675200000     // 对应 UTC 2025-01-01 04:00:00
}

在 JavaScript 中,可以通过 date.getTime() 或 Date.now() 轻松获取。

后端处理: 后端接收到 long 类型的 start_time 和 end_time 后,可以直接按 2.4. 场景二 的方式将其转换为 Timestamp 或 OffsetDateTime 对象并存入数据库。

3.2. 查询接口:后端返回毫秒时间戳 ​

当后端向前端返回数据时,同样应将数据库中的时间字段转换为毫秒时间戳。

后端响应体 (JSON):

json
{
  "code": 1,
  "ok": true,
  "msg": "Success",
  "data": {
    "id": "399895750549155840",
    "title": "New Year's Eve Party",
    "start_time": 1735660800000,
    "end_time": 1735675200000
  }
}

为什么推荐使用毫秒时间戳?

  • 无歧义:它是一个绝对值,与时区无关,代表了 UTC 时间线上的一个精确点。
  • 易于处理:几乎所有编程语言和平台都原生支持从毫秒时间戳创建日期对象。前端 JavaScript 可以直接使用 new Date(timestamp) 来创建本地时区的日期对象并进行展示。
  • 避免格式化问题:避免了在字符串格式化和解析过程中可能出现的时区错误。

总结 ​

  1. 数据库层:使用 TIMESTAMP WITH TIME ZONE (TIMESTAMPTZ) 类型,它会将所有时间自动转换为 UTC 进行存储,确保数据的一致性。
  2. Java 后端:
    • 优先使用 java.time.OffsetDateTime 或 ZonedDateTime 进行业务逻辑处理。
    • 警惕 JDBC 驱动默认返回 java.sql.Timestamp 的行为,并及时将其转换为现代日期时间对象。
    • 能够灵活处理字符串和长整型等不同格式的时间输入。
  3. 前后端交互:强烈推荐使用 毫秒级时间戳(Long) 作为数据交换格式,它简单、可靠且无歧义,是处理跨时区应用的最佳实践。

MIT Licensed