问题现象
一个Spring Boot应用,在更新Spring Boot版本之后,出现了用户无法登录现象,错误提示为登录Token过期,需要重新登录,回退Spring Boot版本后恢复正常。
初步原因分析
经业务初步分析,应用的MySQL数据库中,用户表存放了一个时间戳,用于标识用户登录的有效期,业务会将在该时间之前颁发的Token都视为过期Token不予登录。用户登录信息认证成功后,系统会颁发Token,同时将该时间设置为4个小时后,超过这个时间则需要重新登录刷新Token。
查看数据库中该字段的值无误,但在Spring Boot升级之后,业务日志中该时间为正确时间的14个小时前,初步判断是时区问题。尝试在MySQL连接字符串中指定时区参数serverTimezone=Asia/Shanghai后,问题解决。
Spring Boot原因分析
为什么Spring Boot的版本升级会导致这样的时区问题呢?对比Spring Boot版本对应的MySQL JDBC驱动程序版本,升级前,Spring Boot版本为2.7.4,MySQL JDBC驱动版本为8.0.30;升级后,Spring Boot版本为2.7.10,MySQL JDBC驱动版本为8.0.32。但是通过编写单独的JDBC代码,分别引用这两个版本的驱动,发现获取到的时间戳都没有时区问题。
通过进一步观察代码修改历史,发现该应用在升级Spring Boot版本时,修改过MySQL JDBC驱动程序的groupId和artifactId,原因是从Spring Boot 2.7.5开始,MySQL JDBC驱动版本为8.0.31时,Spring Boot在spring-boot-dependencies中使用了新的MySQL JDBC驱动程序的groupId和artifactId,对比如下:
Spring Boot 2.7.4:
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>${mysql.version}</version>
<exclusions>
<exclusion>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
</exclusion>
</exclusions>
</dependency>
Spring Boot 2.7.5+:
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>${mysql.version}</version>
<exclusions>
<exclusion>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
</exclusion>
</exclusions>
</dependency>
通过分析Maven依赖,应用的另一个依赖中声明了一个原MySQL JDBC驱动程序的版本:
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.18</version>
</dependency>
升级Spring Boot之前,该声明被覆盖为8.0.30版本,而升级后MySQL JDBC驱动程序的groupId和artifactId发生了变更,因此应用中同时存在8.0.18和8.0.32两个版本的MySQL JDBC驱动程序。虽然两个MySQL JDBC驱动程序因为groupId和artifactId不同共存了,但是它们具有相同的包名和类名,因此JVM只会加载其中的一个。通过单独的JDBC代码,引用8.0.18版本的驱动,确认了时区问题是8.0.18版本MySQL JDBC驱动程序的bug。将8.0.18版本的MySQL JDBC驱动程序从Maven依赖中排除之后,问题得到了解决。
MySQL JDBC驱动bug原因分析
MySQL JDBC驱动的这个bug是怎么产生的?
首先我们需要确认MySQL服务器的时区,通过执行以下SQL语句查询MySQL的时区设置:
> show variables like '%time_zone%';
+------------------+--------+
| Variable_name | Value |
+------------------+--------+
| system_time_zone | CST |
| time_zone | SYSTEM |
+------------------+--------+
2 rows in set (0.01 sec)
为什么MySQL有system_time_zone和time_zone两个?参考 https://dev.mysql.com/doc/refman/8.0/en/time-zone-support.html:
-
system_time_zone: MySQL服务(即mysqld)启动的时候读取宿主机的时区,固定下来的值,这个值之后不再改变(除非重启MySQL服务)。
-
time_zone: 实际生效的时区设置,值为SYSTEM意思是与system_time_zone一样。
从MySQL JDBC驱动程序8.0.18的代码,可以看到时区是如何确定的:
com.mysql.cj.protocol.a.NativeProtocol
public void configureTimezone() {
String configuredTimeZoneOnServer = this.serverSession.getServerVariable("time_zone");
if ("SYSTEM".equalsIgnoreCase(configuredTimeZoneOnServer)) {
configuredTimeZoneOnServer = this.serverSession.getServerVariable("system_time_zone");
}
String canonicalTimezone = getPropertySet().getStringProperty(PropertyKey.serverTimezone).getValue();
if (configuredTimeZoneOnServer != null) {
// user can override this with driver properties, so don't detect if that's the case
if (canonicalTimezone == null || StringUtils.isEmptyOrWhitespaceOnly(canonicalTimezone)) {
try {
canonicalTimezone = TimeUtil.getCanonicalTimezone(configuredTimeZoneOnServer, getExceptionInterceptor());
} catch (IllegalArgumentException iae) {
throw ExceptionFactory.createException(WrongArgumentException.class, iae.getMessage(), getExceptionInterceptor());
}
}
}
if (canonicalTimezone != null && canonicalTimezone.length() > 0) {
this.serverSession.setServerTimeZone(TimeZone.getTimeZone(canonicalTimezone));
//
// The Calendar class has the behavior of mapping unknown timezones to 'GMT' instead of throwing an exception, so we must check for this...
//
if (!canonicalTimezone.equalsIgnoreCase("GMT") && this.serverSession.getServerTimeZone().getID().equals("GMT")) {
throw ExceptionFactory.createException(WrongArgumentException.class, Messages.getString("Connection.9", new Object[] { canonicalTimezone }),
getExceptionInterceptor());
}
}
this.serverSession.setDefaultTimeZone(this.serverSession.getServerTimeZone());
}
大致的逻辑可总结为:
-
从MySQL服务器获取time_zone的值
-
如果time_zone的值为SYSTEM,则使用system_time_zone的值
-
如果JDBC连接指定了serverTimezone参数,则使用serverTimezone参数的值
也就是说,在我们的环境中,如果使用8.0.18版驱动且没有指定serverTimezone,则时区会被设置为CST。
作为时区,CST在不同的环境中有着不同的意思:
-
China Standard Time:中国标准时间(UTC +08:00)
-
Central Standard Time (USA) :美国中部时间(UTC−06:00)
-
Central Standard Time (Australia):澳大利亚中部时间(UTC +09:30)
-
Cuba Standard Time:古巴标准时间(UTC−05:00)
在MySQL中,CST并不是一个确定的值,不管我们将宿主机的时区改为中国标准时间(北京时间)还是美国中部时间(如芝加哥),重启MySQL服务之后,system_time_zone的值都是CST。(中间可以通过修改为其他时区如东京时间,并重启MySQL服务来确认修改是否生效)
而在Java中,我们可以看到CST代表的是Central Standard Time (USA) :
System.out.println(TimeZone.getTimeZone("CST").getRawOffset() / 1000 / 60 /60);
-6
这两者的不匹配,导致了bug的产生,MySQL JDBC驱动认为数据库中的时间是一个美国中部时间,并自动转换为中国标准时间,导致在Java应用中读取到的时间变慢了14个小时。该bug在MySQL JDBC驱动8.0.11-8.0.22版本均存在。后续的版本中,其逻辑改为
com.mysql.cj.protocol.a.NativeProtocol
public void configureTimeZone() {
String connectionTimeZone = getPropertySet().getStringProperty(PropertyKey.connectionTimeZone).getValue();
TimeZone selectedTz = null;
if (connectionTimeZone == null || StringUtils.isEmptyOrWhitespaceOnly(connectionTimeZone) || "LOCAL".equals(connectionTimeZone)) {
selectedTz = TimeZone.getDefault();
} else if ("SERVER".equals(connectionTimeZone)) {
// Session time zone will be detected after the first ServerSession.getSessionTimeZone() call.
return;
} else {
selectedTz = TimeZone.getTimeZone(ZoneId.of(connectionTimeZone)); // TODO use ZoneId.of(String zoneId, Map<String, String> aliasMap) for custom abbreviations support
}
this.serverSession.setSessionTimeZone(selectedTz);
if (getPropertySet().getBooleanProperty(PropertyKey.forceConnectionTimeZoneToSession).getValue()) {
// TODO don't send 'SET SESSION time_zone' if time_zone is already equal to the selectedTz (but it requires time zone detection)
StringBuilder query = new StringBuilder("SET SESSION time_zone='");
ZoneId zid = selectedTz.toZoneId().normalized();
if (zid instanceof ZoneOffset) {
String offsetStr = ((ZoneOffset) zid).getId().replace("Z", "+00:00");
query.append(offsetStr);
this.serverSession.getServerVariables().put("time_zone", offsetStr);
} else {
query.append(selectedTz.getID());
this.serverSession.getServerVariables().put("time_zone", selectedTz.getID());
}
query.append("'");
sendCommand(this.commandBuilder.buildComQuery(null, query.toString()), false, 0);
}
}
大致的逻辑可总结为:
-
如果JDBC连接指定了serverTimezone参数,则使用serverTimezone参数的值
-
如果serverTimezone的值为SERVER,则按照MySQL服务的时区处理
-
如果serverTimezone的值为空,则使用Java本地的时区
在不指定serverTimezone参数的情况下,只要Java应用的部署机与MySQL服务部署机时区设置一致,则不会出现该问题。
总结
本文分析了应用更新Spring Boot版本之后出现时区问题的根本原因,其中涉及了因Spring Boot中MySQL JDBC驱动groupId和artifactId变更导致的Maven依赖管理问题、MySQL的CST时区定义不确定且与Java存在差异导致的时区问题,并从MySQL JDBC驱动源代码层面说明了bug导致的根本原因。