不少 Java 老司机可能都被时间类业务坑过,也有不少互联网大厂犯过这类错误还登上了热搜。
最近我司的一名年轻程序员也跳进了时间戳这类存储、读取、转换的坑里。好在有 AI,有不少老程序员能够帮他迅速定位问题,才不至于产生大麻烦。
所以,接下来,我们就一起来看看,MySQL 官方也承认的 Bug,8.0.22 之前,你的时间可能是错的。
MySQL 8 驱动区参数 我估计,大部分 Java 开发者都抄过这样一段连接串。
jdbc:mysql://localhost:3306/xttblog?useUnicode= true &characterEncoding=utf-8&serverTimezone=Asia/Shanghai&zeroDateTimeBehavior=convertToNull 但这其中的各个参数是什么意思? serverTimezone 又到底做了什么?为什么 MySQL 8 的驱动“逼”着我们必须配置它?那些差 8 小时、差 13 小时、夏令时崩溃的诡异 Bug 又是怎么来的?接下来我们就把这件事彻底讲清楚吧。
配时区的坑 为什么 MySQL 8 的驱动要“逼”着我们配时区?
如果你从 MySQL 5.7 + Connector/J 5.1 升级到 MySQL 8 + Connector/J 8.x,大概率见过这个报错。
java.sql.SQLException: The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized or represents more than one time zone. You must configure either the server or JDBC driver (via the serverTimezone configuration property) to use a more specifc time zone value if you want to utilize time zone support. 注意那个乱码的 'Öйú±ê׼ʱ¼ä' ,它其实是“中国标准时间”被错误解码后的样子,是中文 Windows 上 MySQL 的 system_time_zone 返回值。这个报错几乎是每个国内开发者升级到 8.x 驱动后的“成人礼”,在阿里云开发者社区有大量同款提问记录。
背后的原因是 Connector/J 6.x/8.x 重构了时区处理逻辑,驱动在建立连接时需要明确知道“会话时区是什么”。官方文档的说法是,如果时区检测失败(主要原因是服务器使用了时区缩写),就必须设置显式时区,或在服务器上配置不同的时区。
于是就出现了文章开头那个著名的“教程式”解法,抄来一段 serverTimezone=UTC ,程序能跑了,但某些业务数据却可能悄悄存在差了 8 个小时。
时区相关参数 如果只有一个时间或时区参数也就算了,关键是 mysql 的驱动现在要求的时区参数也太多了吧,有好几个。
connectionTimeZone serverTimezone 和 connectionTimeZone 是同一类参数,也是最核心的参数。
只不过 serverTimezone 是老名字,从 Connector/J 8.0.23 起它就是 connectionTimeZone 的别名,官方已经明确表示“将来可能被废弃”。
connectionTimeZone 接受三种值,如下所示。
具体时区。如 Asia/Shanghai 、 GMT+8 (URL 里要写成 GMT%2B8 ); LOCAL 。假定连接时区与 JVM 默认时区相同(这也是 8.0.23 之后的默认值); SERVER 。让驱动尝试从 MySQL 会话变量 time_zone / system_time_zone 检测时区。 注意,它本身不会修改 MySQL 会话的 time_zone 变量,想改会话时区需要配合下一个参数。
forceConnectionTimeZoneToSession forceConnectionTimeZoneToSession 是一个布尔类型的变量。当设置为 true 时,驱动会强制把 MySQL 会话时区替换为 connectionTimeZone 指定的值。
这个配置会影响 NOW() 、 CURRENT_TIMESTAMP 等数据库端函数的行为,所以它可能既是救命稻草,也可能是事故的来源。
preserveInstants 这是 mysql 8.0.23 新增的一个参数,也是一个非常关键的参数。
它控制驱动是否对 TIMESTAMP 类型做“瞬时时刻(instant)保留”转换,写入时把 JVM 时区的时刻换算成会话时区的字面值,读取时再换算回来。
DATETIME 与 TIMESTAMP 它也是一个布尔类型的变量,默认 true ,是 8.0.23 为修复 5.1 升级 8.0 的行为差异而引入的新机制。
同样的它也有坑需要注意。如果同时设置 connectionTimeZone=LOCAL 、 forceConnectionTimeZoneToSession=false 、 preserveInstants=false ,那么 connectionTimeZone 就彻底失效了,因为官方文档明确说这种组合是无意义的。
useLegacyDatetimeCode 这是 5.1 时代的老参数,估计只有老程序员才记得。
因此它是 Connector/J 5.1 专用的一个参数。默认值也是 true ,使用旧的日期处理代码, false 启用新的时区转换逻辑,大致等价于新驱动的 connectionTimeZone=SERVER&preserveInstants=true 。
MySQL 8 驱动已经移除了这个参数,配置了也会被忽略掉,很多老项目升级后 URL 里还留着它,属于无害的“考古遗迹”,干扰项。
其它时间相关参数 参考链接 https://mysql.net.cn/doc/connector-j/en/connector-j-connp-props-datetime-types-processing.html 上的文档说明。还有不少其它时间、时区相关的参数配置,我就不一一细说了,下面再举两个常用的简单说一下。
zeroDateTimeBehavior:MySQL 允许 0000-00-00 00:00:00 这样的“零日期”,但 Java 无法表示。可选值: EXCEPTION (默认,抛异常)、
CONVERT_TO_NULL (转成 null)、 ROUND (转成 0001-01-01 )。生产环境一般设成 CONVERT_TO_NULL 。 sendFractionalSeconds:是否发送小数秒(毫秒),默认 true ,设成 false 会悄悄截断毫秒,偶尔引发“时间对不上”的灵异问题。 这两个用的不多,如果真有使用还得小心了再小心。
官方承认的 Bug #30962953 是有官方 Release Notes 背书的真官方 Bug。MySQL 官方也在 8.0.23 的更新日志中写道。
从 Connector/J 5.1 升级到 8.0 后,保存再读取 DATETIME 和 TIMESTAMP 的结果有时会变得不同。这是因为 5.1 默认不保留时间瞬时,而 8.0.22 及更早版本会在发送前把时间戳转换到服务器会话时区。本次发布引入了新的时区转换控制机制,通过设置 preserveInstants=false 可恢复 5.1 的默认行为。(Bug #30962953 , Bug #98695 , Bug #30573281 , Bug #95644 )
也就是说, 8.0.0 ~ 8.0.22 是一个“危险区间” 。如果服务端时区配置不当(比如 CST),驱动会自动采用服务端时区做转换,触发上面的 13 小时事故。社区(bugstack、51CTO 等)总结的实践是,5.1 升级 8.x 时务必升到 8.0.23 及以上;永远显式指定 serverTimezone=Asia/Shanghai ,不要依赖自动检测。
除此之外,还有 zeroDateTimeBehavior 枚举改名导致启动即报错等 bug。
比如,把驱动从 5.x 升到 8.x 后,应用直接起不来。
The connection property 'zeroDateTimeBehavior' acceptable values are: 'CONVERT_TO_NULL' , 'EXCEPTION' or 'ROUND' . The value
'convertToNull' is not acceptable. 5.x 时代大家写的都是 zeroDateTimeBehavior=convertToNull ,8.x 把枚举值改成了全大写 CONVERT_TO_NULL ,旧值直接被拒绝。修复就要直接改成 CONVERT_TO_NULL 。
总之,关于时间与时区类的 bug,多的数不胜数。
TIMESTAMP 和 DATETIME 这两个时间类型,可以说是一切混乱的总根源。
TIMESTAMP:表示时间线上的一个“瞬间”。MySQL 内部始终以 UTC 存储,写入时按会话时区换算、读取时按会话时区还原。所以它对会话时区敏感, serverTimezone 和 forceConnectionTimeZoneToSession 主要就是在伺候它。 DATETIME:只是一个“墙钟字面值”,不带时区语义,存什么是什么。MariaDB 官方文档有个很精辟的警告,DATETIME 常被误用来存“时刻”,只要客户端和服务端时区一致就没事,一旦不一致就出事。 两者的区别,上面也有一张图画的很形象了,这里不再展开了。
最佳实践 瞎扯了这么多,都不够实用。
其实,只需要复制下面这个最佳实践的配置就行了。
# 连接串(国内业务) spring.datasource.url=jdbc:mysql: //127.0.0.1:3306/xttblog?\ useUnicode= true &characterEncoding=utf8&useSSL= true &\ serverTimezone=Asia/Shanghai&\ zeroDateTimeBehavior=CONVERT_TO_NULL&\ tinyInt1isBit= false 如果想更细致一点,看下面这 7 句最佳实践概括就行了。
驱动版本建议用 8.0.23+,5.1 的老项目非必要不用动,绕开官方的“危险区间”; serverTimezone 永远显式指定成 Asia/Shanghai 这类 IANA 名称,不要用 CST (歧义)、不要随手写 UTC (8 小时坑)、不要依赖自动检测(夏令时坑); 服务器、MySQL、JDBC 三方的时区配置要互相知晓、保持一致预期,上线前用 SELECT NOW() 和程序当前时间对一遍表; 需要数据库端时间( NOW() )与 Java 端一致时,再考虑 forceConnectionTimeZoneToSession=true ,并清楚它会修改会话时区; 零日期用 CONVERT_TO_NULL ;毫秒别被 sendFractionalSeconds=false 吃掉; JSON 接口的时间还要检查 spring.jackson.time-zone ;ORM 层(Hibernate 6)升级后回归测试时间字段; 存“时刻”用 TIMESTAMP + Instant / OffsetDateTime ,存“字面值”(如用户预约的本地时间)用 DATETIME + LocalDateTime ,不要混用。 结语 时区问题可大可小,本质上可能是一个三方协议问题,操作系统时区、MySQL 会话时区、JVM 时区,外加一个 JDBC 驱动做“翻译”。
不少老司机可能推荐大家使用 long 来存储时间戳,也是一种办法。也或者,时刻用 UTC + 绝对时间类型(PG timestamptz/ MySQL DATETIME(6) 或 BIGINT 毫秒),墙钟时间才用无时区类型;同时,MySQL 的 TIMESTAMP 记住两件事,Y2038 和会话时区转换。