1、简介
在系统开发的时候, 如果时区时间的使用错误,常常会造成一些不符合预期的结果。 尤其在一些全球化部署的系统中,需要额外注意,今天主要讲讲在使用时区时间的时候我们需要注意什么, 比如后端是如何处理前端上送的时间的, 还有 new Date() 到底是什么, 和 LocalDateTime.now()有什么区别。 JDBC连接协议的serverTimezone有什么用, 跟mysql服务器本身的时区又是什么有什么关系
2、基础概念
2.1、时区 和 UTC时间
由于时间是由人定义的,而且是根据人们看到太阳的时间去定义什么是早上、什么是晚上, 你老家这里是早上, 但是别的地区那里可能是晚上, 所以为了统一时间标准, 定义了一个全球标准时间, 这个时间也叫UTC时间, 全称叫 协调世界时 (UTC, Coordinated Universal Time)。然后全球其他地区都以它为参照, 去定义你比它快多少小时, 或者说你比它慢多少小时。
然后全球总共被划分为24个时区,而UTC又叫零时区时间, 如果比它大一个1小时就是叫东1区时间,比它小一个小时的时间就叫西1区时间, 像我们的北京时间就叫东八区时间,比它大了8个小时。

在开发时一些系统我们还常常能看到 GMT时间,全称叫格林尼治标准时间"(GMT,基于天文观测), 可以认为与UTC一样,也是标准时间, 但是精度没有UTC高, 在写代码、配服务器、存数据库时,请统一使用 UTC。GMT 更多是历史遗留和民用习惯。
2.2、时间戳
时间戳(Unix Timestamp)表示自 1970-01-01T00:00:00Z 起经过的秒数或毫秒数
- 为什么是1970-01-01T00:00:00Z这个时间? Unix 系统诞生时随意定的,后来成了全球标准
例子
- 时间戳 0 = 1970-01-01 00:00:00 UTC (起点)
- 时间戳 1 = 1970-01-01 00:00:01 UTC (1秒后)
- 时间戳 2 = 1970-01-01 00:00:02 UTC (2秒后)
- 时间戳 86400 = 1970-01-02 00:00:00 UTC (1天后)
像Java里的System.currentTimeMillis() 就是获取的当前UTC时间点 距离 1970-01-01 00:00:00 经过的毫秒数, 底层是OS操作系统内部有一个计数器直接返回即可, 而不是什么先获取当前时间又去减掉1970-01-01 00:00:00计算出来。
2.3、夏令时规则 (DST)
什么是夏令时? (DST - Daylight Saving Time)
夏令时是一种为了节约能源,在夏季将时钟人为拨快 1 小时的制度。
- 春天:时钟拨快 1 小时(少睡 1 小时,例如从 02:00 直接跳到 03:00)。
- 秋天:时钟拨回 1 小时(多睡 1 小时,例如从 02:00 跳回 01:00)。
哪些国家有?
- 有:美国、加拿大、大部分欧洲国家。
- 无 :中国(1991 年后已取消)、日本、印度、韩国等。
2.4、ISO8601标准时间格式(重点)
知道了时区之后, 我们再来讲讲ISO8601时间标准, 它是一种国际标准的日期和时间表示法,目的是为了提供一种清晰、无歧义、全球统一的日期和时间格式,以便于计算机系统处理、数据交换以及跨国交流,消除因不同地区日期格式习惯(如美式 MM/DD/YYYY 与欧式 DD/MM/YYYY)不同而产生的混淆。
完整的ISO时间结构为

多说无益, 直接让我们去看看下面几个时间格式到底表示什么
1、 "2026-03-21T21:37:00Z"
- 可以看到和我们常见的时间 "2026-03-21 21:37:00" 区别就是 多了个T 和最后面多了个Z。
- 这个 T 其实没什么特别意思, 主要是为了区分日期和时间的部分的分隔符,跟空格也是一样的作用,只是因为空格在 URL、某些协议里容易引起歧义,而T 更明确,机器解析也更方便。
- 然后这个Z表示的是时区信息, 用于这个时间是属于哪个地区的时间。 而字母Z就表示的零时区, 也就说这个时间是我们的UTC时间。 你肯定想那表示北京时间的字母是啥, 很遗憾这个并没有。ISO 8601 标准中只有 Z 一个字母(代表 UTC+0),没有为其他时区定义字母。 他们是另外的表示方法,具体看下面
2、 "2026-03-21T21:37:00+08:00"
- 可以看到最后多了个 "+08:00" 表示, 这个意思是这个时间2026-03-21T21:37:00比UTC时间大了8个小时, 2026-03-21T21:37:00也就是我们的东八区时间北京时间。 所以对应的UTC时间就是
2026-03-21T21:37:00 - 08:00 = "2026-03-21T13:37:00Z - 同理如果是 "+02:00"就表达比UTC大2个小时, "+00:00"就表示这就是UTC时间,等同于字母Z。 如果是 "-08:00" 就表示比UTC时间小了8个小时. 比如 "2026-03-21T21:37:00-08:00" 则 对应的UTC时间就是
"2026-03-21T21:37:00" + 08:00 = 2026-03-22T05:37:00
常见的ISO标准时间格式
| 格式 | 示例 |
|---|---|
| 日期 | 2024-03-25 |
| 日期+时间 | 2024-03-25T10:30:00 |
| 含毫秒 | 2024-03-25T10:30:00.123 |
| 含时区偏移 | 2024-03-25T10:30:00+08:00 |
| UTC(Z表示+00:00) | 2024-03-25T10:30:00Z |
| 含时区名称 | 2024-03-25T10:30:00+08:00[Asia/Shanghai] |
像我们日常见到的时间 "2026-03-21 21:37:00" 是没有带时区信息,也不符合ISO标准时间格式, 如果在开发的时候需要额外注意,这个时间来源和目的, 而且一般需要在接口手动使用**@JsonFormat** 或者 @DateTimeFormat 去解析,否则会解析失败,因为Spring Controller接口的时间字段一般默认只支持对ISO标准时间格式或者时间戳进行解析
3、Java里的时间对象
Date:
-
就是一个时间戳,它内部存储的是一个
long值(自 1970-01-01 00:00:00 UTC 以来的毫秒数)。 new Date()等同于调用System.currentTimeMillis(); -
Date的toString() 在不同时区打印会不一样, 会先获取系统时区,然后根据该时区将时间戳转成ISO标准时间进行输出
-
精度 :只精确到毫秒。
-
Java 1.0 就有的类,
Instant
- 也是一个时间戳。只是精度精确到纳秒, 就是的 自"1970-01-01 00:00:00 UTC" 之后的秒数 + 纳秒数
- 类似于
Date的现代版,但更纯粹。与Date区别就是- API更丰富、更新
- 值是不可变的,线程安全的
- 精度更精
- Instant的toString() 打印的是UTC时间,与系统时区无关
LocalDateTime
-
表示没有时区信息的日期和时间,比如"2024-03-25 10:30:00"
-
不能直接转换为时间戳(因为不知道时区,无法计算相对于 UTC 的偏移)。
-
LocalDateTime.now() 获取的时间到底是怎么来的,它的获取逻辑如下
-
↓ 获取当前系统时间戳
-
↓获取JVM默认时区
-
↓ 将该时间戳 转换成 该时区的时间 ,最终得到年月日时分秒,然后去掉时区信息
-
ZoneOffset:
- 就是 时间偏移量", 就是为了表示我们 ISO标准时间的 +08:00 (比 UTC 快 8 小时) 、-05:00 (比 UTC 慢 5 小时)这种时间偏移量
OffsetDateTime
- 就是带"时间偏移量"的时间,
- 组成结构: "2024-03-25 10:30:00+08:00"
- 可以认为 OffsetDateTime =
LocalDateTime(本地日期时间) +ZoneOffset(固定偏移量) - 无法自动处理夏令时
- 假设某地区标准偏移量是 +05:00,夏令时后应该变成 +06:00,但 OffsetDateTime 的偏移量仍然固定是 +05:00,不会自动调整。
代码示例
java
LocalDateTime ldt = LocalDateTime.of(2024, 3, 25, 10, 30);
ZoneOffset offset = ZoneOffset.of("+08:00");
// 组合成 OffsetDateTime
OffsetDateTime odt = ldt.atOffset(offset);
// 结果:2024-03-25T10:30+08:00
ZonedDateTime:
- 可以认为是
LocalDateTime+ZoneId(完整时区,如Asia/Shanghai)。 - 组成结构: "2024-03-25T10:30:00+08:00[Asia/Shanghai]"
- 这个由于知道时区信息(ZoneId), 不仅知道偏移量,还知道夏令时(DST)规则。例如,当时间跨越夏令时切换点时,它能自动调整时间.
JAVA时间对象总结
可以看到,对于各种不同的时间格式字符串, Java定义了各种对象去表现这些时间, 为了方便快速记忆每个时间对象的特点,整理下面表格
| 时间对象 | 代表的时间格式 | 前端上送时默认支持处理的格式 |
|---|---|---|
| Date | 1700000000000 | 时间戳 或者 2024-01-15T10:30:00+08:00 |
| Instant | 1700000000000 | 时间戳 或者 2024-01-15T10:30:00+08:00 |
| LocalDateTime | 2024-01-15T10:30:00 | 2024-01-15T10:30:00 |
| ZonedDateTime | 2024-01-15T10:30:00+08:00[Asia/Shanghai] | 所有ISO标准时间格式 |
| OffsetDateTime | 2024-01-15T10:30:00+08:00 | 2024-01-15T10:30:00+08:00 |
| ZoneOffset | +08:00 | +08:00 |
下面一张图快速快速记忆他们的关系和区别

4、时间处理梳理链路分析
4.1、前端到JAVA
一般从前端到JAVA, 前端先上送时间(可能各种格式时间戳、ISO标准时间),然后后端接收到这个时间字符串后 根据Controller接口的时间对象字段进行反序列化。
其中我们用的比较多的就是Jackson的@JsonFormat 注解去配置如何对时间进行解读。 因为默认是只支持对时间戳或者ISO时间格式做处理,但是我们大部分前端上送的都是 "2024-01-09 15:30:00" 这种不符合的格式,所以需要使用该注解。
接下来会结合@JsonFormat注解的使用重点讲述即这个从前端时间字符串 是如何 映射到Java时间对象的。当然即使该字段没配@JsonFormat注解,整体处理流程也是类似的,只是使用一些默认值去处理而不是用@JsonFormat 注解指定的,大家举一反三即可。
@JsonFormat 注解处理流程
这个注解作用主要配置怎么从时间字符串 和 Java时间对象的转换过程,或者说序列化和反序列化过程. 其中pattern属性表示是什么格式,方便我们去解析处理。 而timezone属性则告诉 Jackson "这个没有时区信息的时间字符串,应该被当作哪个时区来解释"。
- 当然如果你没配置timezone这个属性,也会去取默认值
- 默认值优先级是
- 第一)字段上 @JsonFormat(timezone=)
- 第二) 全局配置 spring.jackson.time-zone
- 第三)JVM 默认时区(-Duser.timezone=...)
- 第四)操作系统时区

Date对象转换
假设有以下字段
java
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
private Date createTime;
1、反序列化过程(JSON → Java), 从时间字符串 到 Date的 具体处理过程
有请求JSON: { "createTime": "2024-03-25 10:30:00" }
Jackson 解析"2024-03-25 10:30:00"的步骤:
- ↓ 按 pattern 解析为时间数字
- ↓ 发现字符串无时区信息
- ↓ 用 timezone="Asia/Shanghai" 补充 → 10:30:00 +08:00
- ↓ 转换为 UTC 时间→ 02:30:00 UTC
- ↓ 再将UTC 时间转为时间戳 1711330200000
- ↓ 再将时间戳1711330200000 存入 Date内部
2、序列化过程( Java → JSON ), 从Date到时间字符串的具体处理过程
Jackson 将Date序列化的步骤:
- ↓ Date 内部 UTC 毫秒数 是1711330200000
- ↓ 时间戳转换为 UTC 时间→ 02:30:00 UTC
- ↓ 按 timezone="Asia/Shanghai" 换算时区 → 10:30:00 +08:00
- ↓ 按 pattern 格式化
- ↓ 输出 "2024-03-25 10:30:00"
LocalDateTime对象转换
假设有以下字段
java
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
private LocalDateTime createTime;
1、反序列化过程(JSON → Java), 从时间字符串 到 LocalDateTime的 具体处理过程
有请求JSON: { "createTime": "2024-03-25 10:30:00" }
Jackson 解析"2024-03-25 10:30:00"的步骤:
-
↓ 按 pattern 解析为时间数字
-
↓ LocalDateTime 本身无时区概念,timezone 属性直接忽略
-
↓ 字面是什么就存什么, 所以 LocalDateTime = 2024-03-25T10:30:00(无时区)
2、序列化过程( Java → JSON ), 从LocalDateTime 到 时间字符串 的 具体处理过程
Jackson 将LocalDateTime序列化的步骤:
- ↓ 假设有LocalDateTime = 2024-03-25T10:30:00
- ↓ 按 pattern 直接格式化字面值
- ↓ timezone 属性同样忽略,不做时区换算
- ↓ 输出 "2024-03-25 10:30:00"
可以看到与Date最大区别是没什么弯弯绕绕和转换, timezone字段也是不生效的, 你字面传什么就是什么
ZonedDateTime对象转换
假设有以下字段
java
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
private ZonedDateTime createTime;
1、反序列化过程(JSON → Java), 从时间字符串 到 ZonedDateTime的 具体处理过程
情况一:输入字符串自带时区信息
有请求JSON: { "createTime": "2024-03-25T10:30:00+05:30" }
Jackson 解析"2024-03-25T10:30:00+05:30"的步骤:
- ↓ 字符串本身携带时区 +05:30(印度时区)
- ↓ @JsonFormat 的 timezone="Asia/Shanghai" 被忽略
- ↓ 直接按字符串时区解析
- ↓ 获取时间偏移量+05:30对应的时区名称为 [Asia/Kolkata]
- ↓ 最后组装 ZonedDateTime = 2024-03-25T10:30:00+05:30[Asia/Kolkata]
情况二:输入字符串不带时区信息
有请求JSON: { "createTime": "2024-03-25 10:30:00" }
Jackson 解析"2024-03-25 10:30:00"的步骤:
- ↓ 按 pattern 解析字面值,发现无时区信息
- ↓ 用 timezone="Asia/Shanghai" 补充时区
- ↓ 最后组装 ZonedDateTime = 2024-03-25T10:30:00+08:00[Asia/Shanghai]
2、序列化过程( Java → JSON ), 从ZonedDateTime 到 时间字符串 的 具体处理过程
Jackson 将ZonedDateTime序列化的步骤:
- ↓ 假设有 ZonedDateTime = 2024-03-25T10:30:00+08:00[Asia/Shanghai]
- ↓ 如果此时的 注解配的时@JsonFormat(timezone="UTC"), 发现和ZonedDateTime本身的时区信息不一样
- ↓ 就换算到 UTC → 02:30:00 UTC
- ↓ 最后按 pattern 格式化输出 "2024-03-25 02:30:00"
4.2、Java到MYSQL的读取写入
就是写入到MYSQL时, Java时间对象 是怎么转换成 时间字符串发给 mysql的, 然后mysql是怎么解读并存储到列的
还有读取时mysql是怎么从列读取出来变成 时间字符串 再返回给Java反序列化成时间对象的,接下来详细分析。
4.2.1 MYSQL时区体系
1、先搞清楚 MySQL 的时区
执行下面命令可以查看mysql的时区设置
sql
SHOW VARIABLES LIKE '%time_zone%';

system_time_zone: mysql所属的操作系统时区,CST就是东八区
time_zone(关键): MySQL全局/会话的时区偏移量, +00表示UTC零时区, +08表示东八区
2、mysql的时区的作用
-
对当前
时间函数的影响,时间函数获取的是time_zone时区的时间sqlNOW() -- 当前日期时间 CURRENT_TIMESTAMP() -- 同 NOW() SYSDATE() -- 同 NOW() CURDATE() -- 当前日期 CURTIME() -- 当前时间 -
对
TIMESTAMP类型字段的存储会进行转换-
我们知道 TIMESTAMP 内部存储的就是时间戳类型,但是 MySQL 不会直接把时间戳数字给你而是会在读写的时候根据当前的time_zone换成成对应的时间字符串
- 写入时:MySQL 会将当前会话(Session)的时区时间转换为 UTC时间戳 存储。
- 读取时:MySQL 会将存储的 UTC时间戳转换回当前会话的时区时间显示。
-
TIMESTAMP 列的默认值和自动更新 也会受影响
sqlCREATE TABLE t ( id INT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 受时区影响 updated_at TIMESTAMP ON UPDATE CURRENT_TIMESTAMP -- 受时区影响 );
-
-
对 UNIX_TIMESTAMP() 和 FROM_UNIXTIME() 函数的影响
- FROM_UNIXTIME函数: 时间戳转成"当前时区"的时间字符串
- UNIX_TIMESTAMP函数: 把字符串按"当前时区"解读,转成时间戳
-
一些间接影响
- 主从复制时, 主库和从库时区设置不一样时,在statement 复制模式同步下, 如果SQL包含一些时间函数,则同步后的时间会不一样
3、那对MYSQL的其他时间字段DATETIME、DATE、TIME、YEAR是否有影响?
- 不会,这些字段存什么就是什么不会做转换
4.2.2 再看JDBC驱动serverTimezone配置
properties
spring.datasource.url=jdbc:mysql://localhost:3306/db?serverTimezone=Asia/Shanghai
一般我们的JDBC连接协议都会配一个serverTimezone,它的作用主要是 告诉 JDBC 驱动在"翻译"Java时间对象 和 MySQL字符串 之间转换时用哪个时区去解读这个时间。接下来会从读取和写入MYSQL时间的过程详细分析。
4.2.3 读取过程
我们 假设当前条件如下, 模拟从MySQL --》Java的读取流程
- JDBC的
serverTimezone 为 America/New_York(UTC-5)纽约时间 - MySQL 的
time_zone = Asia/Shanghai(UTC+8)上海时间 - 数据库此时存储的值
- DATETIME 列 = "2024-01-15 17:30:00"(上海时间字面值)
- TIMESTAMP 列 = 1705311000ms, (对应的UTC时间为 2024-01-15 09:30:00Z).

从上面可以看到serverTimezone作用主要是Java在收到MYSQL的时间字符串后, 由于该时间字符串没有携带时区信息,但是映射成OffsetDateTime对象和Date对象都要时区信息才能转换, 所以默认就用配置的serverTimezone时区去解读这个时间。而LocalDateTime这种类型本身不需要时区信息,所以直接原样映射即可。
还有一点值得注意是其实MYSQL的TIMESTAMP 列存储的是时间戳, 但是奇怪的是我们去查的时候去看的时候确实一个完整的时间,这是因为,在读取时MYSQL会自动根据当前时区将当前时间戳换算成时间格式字符串。
4.2.4写入过程
我们 假设当前条件如下, 模拟从Java --》MySQL的写入流程
- JDBC的serverTimezone 为 UTC
- MySQL 的 time_zone = Asia/Shanghai(UTC+8)
- 当前Java时间对象的时间是 北京时间 2024-01-15 17:30:00
- 对应的 UTC时间是 2024-01-15 09:30:00
- 对应的时间戳是 1705308600000ms

1、可以看到MYSQL时区的配置主要是负责对TIMESTAMP类型字段进行时间戳和时间字符串的转换, 而其他类型如DATETIME则没有作用,而是客户端发什么,它就存什么
2、JDBC的serverTimezone理解
- 可以看到JDBC连接协议的serverTimezone真正作用 是 在 Java时间戳(绝对时刻) 和 MySQL时间字符串(字面值) 之间互转时 告诉 JDBC 驱动用哪个时区来格式化/解析, 像一个"翻译器", 你给它 输入一个绝对时间点(时间戳), 它给你输出一个该时区对应的时间字符串。 所以对于LocalDateTime这种没有时区信息也就没有绝对时刻的东西就无法完成转换,这JDBC就原样发给MYSQL不做转换。
就像你问:
"北京时间17:30,换成UTC是几点?" → 能算,09:30。
"17:30,换成UTC是几点?" → 不知道,17:30是哪里的?没法算
5、总结
我们整个时间处理链路其实就是如下图

需要注意点就是
1、JDBC连接协议的serverTimezone配置需要和MYSQL服务的时区保持一致,否则可能有一些不符合预期的时间, 更推荐都配成UTC,这样无论哪个地区都能兼容, 甚至避免夏令时等边界问题
2、MYSQL的TIMESTAMP字段类型在处理上与其他所有时间类型字段(DATE、TIME、DATETIME)都不同, 一般业务时间字段推荐用 DATETIME, 需要记录绝对时刻(如日志、审计)用 TIMESTAMP 或直接存 BIGINT 时间戳。
3、MYSQL使用一些时间函数 select now()啥的一定要注意当前MYSQL的time_zone,否则SQL过滤时可能是错误。 比如你用的是DATETIME字段存的是北京时间,但是你myql时区又是UTC则now()出来的是UTC时间, 则 一些什么 select DATETIME字段 > now() 就不符合预期
4、默认Spring只支持处理前端上送的时间戳或者 ISO标准的时间字符串, 这样才能自动进行反序列化到什么Date、LocalDateTime上面, 你用我们日常生活常见的什么 "2024-01-15 22:30:00"格式则需要做一些配置去映射,否则会抛反序列化异常。
5、java的JVM也有默认的时区配置, 默认是继承自操作系统,也可以通过JVM 启动参数:-Duser.timezone去指定, 然后 JAVA里面的一些时间对象就会根据这个值去自动转换。 比如各种 xx.now() 就是获取该时区的当前时间。