本文试图帮助大家搞清楚 Java 里的各种日期时间类型,以及它们和 MySQL 字段类型之间的关系,对于正确处理时区、存储和展示时间非常重要。说实话,我也是第一次系统性地去了解这里面的转换逻辑。

时间戳

时间戳(timestamp) 是理解一切日期时间类型的基础。

时间戳是一个整数,表示从 Unix 纪元(1970-01-01 00:00:00 UTC) 到某个时刻经过的秒数(或毫秒数)。它是一个绝对值,与时区无关。不管你的服务器是在哪里,同一时间调用 new Date().getTime() 拿到的是同一个值。

这里我们讨论的是 unix timestamp,它不包含闰秒,单调线性增加,Java 和 MySQL 都采用相同的语义。

比如 【0】 这个时间戳,在全球任意地方都指向同一个瞬间。不同时区的人看到的只是这个瞬间对应的本地时间不同:

  • UTC+0:1970-01-01 00:00:00
  • UTC+8(北京):1970-01-01 08:00:00
  • UTC-5(纽约):1969-12-31 19:00:00

时间戳只有一个值,本地时间有无数种表示。

java.util.Date

java.util.Date 是 Java 最古老的日期类,从 Java 1.0 就存在了。

它的本质极其简单:内部就是一个 long 类型的毫秒时间戳。

public class Date {
    private transient long fastTime; // 毫秒时间戳
}

通过 new Date() 创建的是当前时刻的毫秒时间戳,通过 date.getTime() 可以取出这个 long 值。

重点:java.util.Date 本身不包含任何时区信息。 它只是一个时间戳的包装器。那为什么 date.toString() 会显示本地时间?因为 toString() 方法内部使用了 JVM 默认时区来格式化,这只是”展示”层面的事,与 Date 对象本身无关。

java.util.Date 设计混乱,很多方法已废弃,不推荐在新代码中使用,但由于历史原因,很多老代码和框架仍在使用它(也包括我们😂)。

Java 8 的新日期时间 API

Java 8 引入了 java.time 包,重新设计了日期时间体系。

Instant

Instant 是 Java 8 对”时间戳”概念的现代化表达。

Instant now = Instant.now();
System.out.println(now); // 2026-02-25T00:00:00Z  (Z 表示 UTC)

它内部存储的是:

  • long epochSecond:从 Unix 纪元起的秒数
  • int nanos:纳秒偏移量

Instant 和 java.util.Date 本质是同一种东西:都是时间戳,都与时区无关,都表示一个全球唯一的时间点。两者可以互相转换:

// Date -> Instant
Instant instant = new Date().toInstant();

// Instant -> Date
Date date = Date.from(Instant.now());

区别在于:

  • Instant 精度更高(纳秒级),API 更现代、不可变、线程安全
  • Date 是毫秒级,API 混乱,大量方法已废弃

所以,如果你需要存储一个”时刻”(时间点),用 Instant。

LocalDate

LocalDate 只有年月日,没有时分秒,也没有时区。

LocalDate today = LocalDate.now();
System.out.println(today); // 2026-02-25

适合表示”纯日期”场景:生日、节假日、有效期等。

LocalTime

LocalTime 只有时分秒,没有年月日,也没有时区。

LocalTime time = LocalTime.of(10, 30, 0);
System.out.println(time); // 10:30:00

适合表示”纯时刻”场景:营业时间、闹钟时间、每天的固定时刻等。

LocalDateTime

LocalDateTime = LocalDate(年月日)+ LocalTime(时分秒),但没有时区信息

LocalDateTime ldt = LocalDateTime.of(2026, 2, 25, 10, 30, 0);
System.out.println(ldt); // 2026-02-25T10:30:00

2026-02-25 10:30:00 这个字符串本身是模糊的——它没有说明是哪个时区的 10:30。北京的 10:30 和纽约的 10:30 是完全不同的两个时刻。

LocalDateTime 就像一张没有时区的时间标签,适合表示”日历上的时间”,比如”会议安排在 2026-02-25 10:30”,而不关心时区。

ZonedDateTime

LocalDate,LocalTime 和 LocalDateTime 都带有 “Local”,所以是不带时区的,要带时区信息就需要使用这个 ZonedDateTime。

ZonedDateTime = LocalDateTime + ZoneId(时区),是带时区的完整日期时间

ZoneId shanghai = ZoneId.of("Asia/Shanghai");
ZonedDateTime zdt = ZonedDateTime.of(2026, 2, 25, 10, 30, 0, 0, shanghai);
System.out.println(zdt); // 2026-02-25T10:30:00+08:00[Asia/Shanghai]

ZonedDateTime 既包含日历时间,也携带时区,因此能唯一确定一个时间点。它和 Instant 可以互相转换:

// ZonedDateTime -> Instant
Instant instant = zdt.toInstant();

// Instant -> ZonedDateTime
ZonedDateTime zdt2 = instant.atZone(ZoneId.of("Asia/Shanghai"));

把 LocalDateTime 转换为 ZonedDateTime:

ZonedDateTime zdt = ldt.atZone(ZoneId.of("Asia/Shanghai"));

各类型一览

  • Instant:精确时间点(时间戳),固定 UTC,用于记录事件发生的时刻
  • LocalDate:仅年月日,无时区,用于生日、节假日等纯日期场景
  • LocalTime:仅时分秒,无时区,用于营业时间等纯时刻场景
  • LocalDateTime:年月日时分秒,无时区,用于不关心时区的日历时间
  • ZonedDateTime:年月日时分秒 + 时区,用于需要明确时区的业务场景

MySQL 的 datetime 和 timestamp

MySQL 有两种主要的时间字段类型,它们的区别经常被误解。

datetime

  • 存储格式:直接存储字面量 YYYY-MM-DD HH:MM:SS,不做任何时区转换。
  • 范围:1000-01-01 00:00:00 到 9999-12-31 23:59:59
  • 特点:你存什么进去,取出来就是什么,完全不受时区影响

datetime 支持自动设置当前时间,通常我们的数据表都会有两列时间字段 create_time 和 update_time,我们一般就这么设置:

CREATE TABLE t (
    id          INT PRIMARY KEY,
    create_time datetime(3) DEFAULT CURRENT_TIMESTAMP(3),
    update_time datetime(3) DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
);

其中 (3) 表示保留 3 位毫秒精度,如果不设置,那么精度就是秒。

timestamp

  • 存储格式:内部存储 UTC 时间戳(4 字节整数存储秒级部分,如果需要表示更高精度,使用额外字节)
  • 范围:1970-01-01 00:00:01 UTC 到 2038-01-19 03:14:07 UTC(2038 问题)
  • 特点:写入时 MySQL 会把当前时区的时间转成 UTC 存储,读取时再把 UTC 转成当前时区展示
  • 自动时间:同样支持 DEFAULT CURRENT_TIMESTAMP 和 ON UPDATE CURRENT_TIMESTAMP

举个例子,假设 MySQL 服务器时区是 Asia/Shanghai(UTC+8):

INSERT INTO t (dt, ts) VALUES ('2026-02-25 10:30:00', '2026-02-25 10:30:00');

如果将 MySQL 服务器时区改为 UTC+0 后再查询:

  • dt 字段:仍然显示 2026-02-25 10:30:00(不变)
  • ts 字段:显示 2026-02-25 02:30:00(减了 8 小时,因为内部存的是 UTC)

Java 类型与 MySQL 字段的映射

实际开发中,JDBC 驱动(MySQL Connector/J)负责 Java 类型和 MySQL 字段之间的转换。

Connector/J 如何获知 MySQL 的时区

Connector/J 需要知道 MySQL 服务器的时区,才能正确地在 Java 时间对象与 MySQL 日期时间字符串之间做转换。

获取方式有两种。第一种是在 JDBC URL 中显式指定

jdbc:mysql://localhost:3306/db?serverTimezone=Asia/Shanghai

第二种是让驱动自动检测:连接建立时,Connector/J 会向 MySQL 查询时区变量:

SELECT @@time_zone, @@system_time_zone

@@time_zone 是 MySQL 当前的全局/会话时区,默认值是 SYSTEM,表示使用操作系统时区;@@system_time_zone 则是操作系统时区,值如 UTC、CST 等。

自动检测的问题在于时区缩写有歧义。CST 在不同系统上可能是 China Standard Time(UTC+8),也可能是 Central Standard Time(UTC-6)。Connector/J 8.x 遇到无法确认的缩写时会直接报错,要求显式指定 serverTimezone。因此实践中建议在 JDBC URL 中明确指定。

在 8.x 中,serverTimezone 已逐步被 connectionTimeZone 取代。推荐使用 connectionTimeZone=UTC&forceConnectionTimeZoneToSession=true,它会自动将 MySQL 的 session timezone 也设置为 UTC(通过一行命令 SET time_zone = ‘UTC’)。这样就避免了使用 serverTimezone 时,serverTimezone 和 MySQL session timezone 不一致的问题。

Connector/J 如何做时区转换

对于 datetime,时区转换完全发生在驱动侧。对于 timestamp,MySQL 服务器会参与 UTC 与 session timezone 之间的转换。

写入时:驱动将 Java 时间对象按 serverTimezone 换算成本地日历时间字符串(如 “2026-02-25 10:30:00”),通过 MySQL 协议发送给服务器。对于 datetime 字段,MySQL 直接存储这个字符串;对于 timestamp 字段,MySQL 再将这个字符串按自身的 session timezone 转成 UTC 存储。

读取时:MySQL 返回日历时间字符串。对于 timestamp 字段,MySQL 会先把存储的 UTC 值按自身 session timezone 转换后再返回。驱动收到字符串后,按 serverTimezone 解析,构造 Java 时间对象。

这里有一个关键点:serverTimezone 必须与 MySQL 实际的 session timezone 保持一致。如果两者不同,驱动和 MySQL 各用各的时区做转换,最终结果就会错位,下面我会举一些例子来说明。

无时区 Java 类型的映射

LocalDateTime、LocalDate、LocalTime 与 MySQL 的对应关系:

  • LocalDate 对应 date
  • LocalTime 对应 time
  • LocalDateTime 对应 datetime

这类映射最简单,驱动直接读写字面量字符串,不涉及时区换算。

带时区 Java 类型写入 datetime

datetime 字段本身不存储时区。当用带时区的 Java 类型写入时,Connector/J 会先将时间换算为 serverTimezone 所指定的本地时间,再写入字面量字符串。这里”带时区的 Java 类型”包括三种:java.util.Date(内部是毫秒时间戳)、Instant、ZonedDateTime。

举例,serverTimezone 设置为 UTC,用 ZonedDateTime 写入一个上海时间 2026-02-25 08:00:00,驱动会先转换成 UTC 时间 “2026-02-25 00:00:00” 字符串,发送给 MySQL 以后,MySQL 直接存储。

读取时,MySQL 读取到 “2026-02-25 00:00:00”,直接返回给驱动这个字符串。驱动拿到字符串 “2026-02-25 00:00:00”,驱动再加上 serverTimezone 信息(UTC),最终得到 UTC 时间 2026-02-25 00:00:00。写入和读取最终一致。

java.util.Date 和 Instant 同理,我们再举个例子,这次我们设置 serverTimezone=Asia/Shanghai,我们写入 0 这个时间戳,驱动首先会将时间戳 0 转换为上海时间 “1970-01-01 08:00:00” 字符串发送给 MySQL,MySQL 直接存储 “1970-01-01 08:00:00”。

读取时,MySQL 读取到 “1970-01-01 08:00:00” 字符串返回给驱动,驱动再加上 serverTimezone 信息,得到上海时间 1970-01-01 08:00:00。写入和读取最终一致。

我们很容易发现,对于 datetime 类型的数据,转换只发生在驱动侧,MySQL 是原样存储的,所以我们需要确保写入和读取用的 serverTimezone 是一致的。因为 datetime 本身不保存时区语义,一旦 serverTimezone 改变,历史数据的“解释方式”也会改变。

带时区 Java 类型写入 timestamp

timestamp 字段内部存储 UTC 时间戳,与带时区类型的语义天然吻合。

写入时,驱动将 java.util.Date、Instant 或 ZonedDateTime 对应的 UTC 毫秒值换算为 serverTimezone 本地时间字符串,发给 MySQL,MySQL 再按自身 session timezone 转成 UTC 存储。

举个复杂并且明显配置错误的例子,serverTimezone 设置为 UTC+4,MySQL session timezone 设置为 UTC+8,我们写入时间戳 0:

写入时,首先驱动侧将 0 转换为 UTC+4 时间: 1970-01-01 04:00:00,发送给 MySQL,MySQL 根据自己的 session timezone 转换成 UTC 时间,因为它是 UTC+8,所以要减掉 8 小时,得到: 1969-12-31 20:00:00,最终存储的就是这个时间。

读取时,MySQL 先根据自己的 session timezone 设置将 1969-12-31 20:00:00 转换为 1970-01-01 04:00:00 返回给驱动,驱动再加上时区信息,最终得到 1970-01-01 04:00:00(UTC+4),也就还原了时间戳 0 的那个时刻。

同 datetime 一样,我们需要确保写入和读取使用的 serverTimezone 是一致的,不然可能会错位。当然,我们最好让它和 session timezone 也保持一致。

timestamp 存储的是一个时间点,而不是一个字符串,引申出来另一个特点:datetime 的数据是不支持”迁移”的,你必须使用写入时的 serverTimezone 来读取,不然就会解释错误,而 timestamp 可以支持”迁移”,比如今天我用 serverTimezone=session timezone=UTC 写入,明天我同步调整 serverTimezone=session timezone=UTC+8,是可以工作的。

小结

时区问题是实际开发中很容易踩坑的地方,根源在于各层的时区配置不一致。所以建议服务器、JVM( -Duser.timezone=UTC)、数据库统一使用 UTC,在 JDBC URL 中明确指定 serverTimezone=UTC,如果是 MySQL 8.x,指定 connectionTimeZone=UTC&forceConnectionTimeZoneToSession=true。

(全文完)