本文试图帮助大家搞清楚 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 字段之间的转换。这里面最核心的问题就是时区,我们来一层层拆开看。
serverTimezone 从哪来
驱动需要知道 MySQL 服务器的时区,才能正确做转换。有两种方式:
显式指定(推荐):
jdbc:mysql://localhost:3306/db?serverTimezone=Asia/Shanghai
自动检测:驱动连接时会查询 SELECT @@time_zone, @@system_time_zone。但时区缩写有歧义,CST 既可以是 China Standard Time(UTC+8)也可以是 Central Standard Time(UTC-6),Connector/J 8.x 遇到歧义会直接报错。
在 8.x 中,推荐使用 connectionTimeZone=UTC&forceConnectionTimeZoneToSession=true,它会自动把 MySQL 的 session timezone 也设成 UTC,一步到位避免不一致的问题。
无时区类型:直接透传
LocalDate、LocalTime、LocalDateTime 这些不带时区的类型,驱动直接把字面量字符串透传给 MySQL,不做任何时区换算。LocalDate 对应 date,LocalTime 对应 time,LocalDateTime 对应 datetime,非常简单,没什么好说的。
带时区类型:两阶段转换
java.util.Date、Instant、ZonedDateTime 这些带时区信息的类型就复杂一些了。这里说的”带时区”,Date 和 Instant 虽然没有显式的时区字段,但它们本质是 UTC 时间戳,也算带时区。
写入过程分两个阶段。
第一阶段:驱动把 Java 对象转成时间字符串
不管目标字段是 datetime 还是 timestamp,驱动做的事情都一样:先拿到 UTC 时间戳,再按 serverTimezone 转成本地时间字符串,发送给 MySQL。
我们用一个具体例子来看。假设 serverTimezone=Asia/Shanghai(UTC+8),写入同一个时刻——时间戳 0(即 1970-01-01 00:00:00 UTC):
// 方式一:java.util.Date — 内部就是毫秒时间戳,驱动直接按 serverTimezone 转
Date date = new Date(0);
ps.setTimestamp(1, new Timestamp(date.getTime()));
// 方式二:Instant — 同样是时间戳,和 Date 一样处理
Instant instant = Instant.EPOCH;
ps.setObject(1, instant);
// 方式三:ZonedDateTime — 自带时区,驱动先算出 UTC 时间戳,再按 serverTimezone 转
// UTC+0 的 00:00 = 上海的 08:00,和上面两种方式指向同一个时刻
ZonedDateTime zdt = ZonedDateTime.of(1970, 1, 1, 0, 0, 0, 0, ZoneId.of("UTC"));
ps.setObject(1, zdt);
三种方式指向同一个时刻(时间戳 0),驱动最终都转成同一个字符串:时间戳 0 → 上海时间 → “1970-01-01 08:00:00”。区别只是 Date/Instant 本身就是时间戳可以直接转,ZonedDateTime 多一步从自带时区算出 UTC 时间戳。
到这一步为止,datetime 和 timestamp 没有任何区别。
第二阶段:MySQL 收到字符串后的存储
datetime:原样存储,不做任何转换。收到 “1970-01-01 08:00:00” 就存 “1970-01-01 08:00:00”。
timestamp:MySQL 把这个字符串理解为当前 session timezone 下的时间,转成 UTC 存储。如果 session timezone 也是 Asia/Shanghai,那 “1970-01-01 08:00:00” 减 8 小时,内部存的就是 “1970-01-01 00:00:00” UTC,也就是时间戳 0。
读取:反过来走一遍
读取就是写入的逆过程。对于 datetime,MySQL 原样返回字符串,驱动按 serverTimezone 解析;对于 timestamp,MySQL 先把 UTC 值按 session timezone 转换后返回,驱动再按 serverTimezone 解析。两条路殊途同归,都能还原出正确的时间戳。
什么时候会出问题
datetime 怕 serverTimezone 变。比如写入时 serverTimezone=Asia/Shanghai,存了 “1970-01-01 08:00:00”。后来改成 UTC,读取时驱动把 “08:00:00” 当成 UTC 来解析,时间就偏了 8 小时。所以 datetime 要求写入和读取的 serverTimezone 必须一致。
timestamp 怕 serverTimezone 和 session timezone 不一致。如果两者不同,驱动和 MySQL 各用各的时区做转换,虽然写入和读取的错误可能恰好抵消(结果看起来是对的),但中途任何一个配置变了,数据就会错乱。
反过来说,timestamp 有一个 datetime 没有的优势:只要 serverTimezone 和 session timezone 保持一致,它们可以一起变。比如今天都是 UTC,明天都改成 Asia/Shanghai,timestamp 的数据仍然正确,因为它内部存的是 UTC 时间点。
小结
时区问题是实际开发中很容易踩坑的地方,根源在于各层的时区配置不一致。所以建议服务器、JVM( -Duser.timezone=UTC)、数据库统一使用 UTC,在 JDBC URL 中明确指定 serverTimezone=UTC,如果是 MySQL 8.x,指定 connectionTimeZone=UTC&forceConnectionTimeZoneToSession=true。
(全文完)
0 条评论