---
title: 理清 Java 与 MySQL 中的日期时间类型
date: 2026-02-25
tags: time
description: 本文试图帮助大家搞清楚 Java 里的各种日期时间类型，以及它们和 MySQL 字段类型之间的关系，对于正确处理时区、存储和展示时间非常重要。
---

本文试图帮助大家搞清楚 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 类型的毫秒时间戳。

```java
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 对"时间戳"概念的现代化表达。

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

它内部存储的是：

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

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

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

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

区别在于：
- Instant 精度更高（纳秒级），API 更现代、不可变、线程安全
- Date 是毫秒级，API 混乱，大量方法已废弃

所以，如果你需要存储一个"时刻"（时间点），用 Instant。

### LocalDate

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

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

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

### LocalTime

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

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

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

### LocalDateTime

LocalDateTime = LocalDate（年月日）+ LocalTime（时分秒），但**没有时区信息**。

```java
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（时区），是**带时区的完整日期时间**。

```java
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 可以互相转换：

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

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

把 LocalDateTime 转换为 ZonedDateTime：

```java
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，我们一般就这么设置：

```sql
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）：

```sql
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
// 方式一：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。

（全文完）
