searchusermenu
  • 发布文章
  • 消息中心
点赞
收藏
评论
分享
原创

Spring Boot版本更新导致时区问题的原因分析

2023-04-24 03:24:45
76
0

问题现象

一个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());
    }

大致的逻辑可总结为:

  1. 从MySQL服务器获取time_zone的值

  2. 如果time_zone的值为SYSTEM,则使用system_time_zone的值

  3. 如果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);
        }
    }
 

大致的逻辑可总结为:

  1. 如果JDBC连接指定了serverTimezone参数,则使用serverTimezone参数的值

  2. 如果serverTimezone的值为SERVER,则按照MySQL服务的时区处理

  3. 如果serverTimezone的值为空,则使用Java本地的时区

在不指定serverTimezone参数的情况下,只要Java应用的部署机与MySQL服务部署机时区设置一致,则不会出现该问题。

总结

本文分析了应用更新Spring Boot版本之后出现时区问题的根本原因,其中涉及了因Spring Boot中MySQL JDBC驱动groupId和artifactId变更导致的Maven依赖管理问题、MySQL的CST时区定义不确定且与Java存在差异导致的时区问题,并从MySQL JDBC驱动源代码层面说明了bug导致的根本原因。

0条评论
0 / 1000
杨济嘉
2文章数
0粉丝数
杨济嘉
2 文章 | 0 粉丝
杨济嘉
2文章数
0粉丝数
杨济嘉
2 文章 | 0 粉丝
原创

Spring Boot版本更新导致时区问题的原因分析

2023-04-24 03:24:45
76
0

问题现象

一个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());
    }

大致的逻辑可总结为:

  1. 从MySQL服务器获取time_zone的值

  2. 如果time_zone的值为SYSTEM,则使用system_time_zone的值

  3. 如果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);
        }
    }
 

大致的逻辑可总结为:

  1. 如果JDBC连接指定了serverTimezone参数,则使用serverTimezone参数的值

  2. 如果serverTimezone的值为SERVER,则按照MySQL服务的时区处理

  3. 如果serverTimezone的值为空,则使用Java本地的时区

在不指定serverTimezone参数的情况下,只要Java应用的部署机与MySQL服务部署机时区设置一致,则不会出现该问题。

总结

本文分析了应用更新Spring Boot版本之后出现时区问题的根本原因,其中涉及了因Spring Boot中MySQL JDBC驱动groupId和artifactId变更导致的Maven依赖管理问题、MySQL的CST时区定义不确定且与Java存在差异导致的时区问题,并从MySQL JDBC驱动源代码层面说明了bug导致的根本原因。

文章来自个人专栏
文章 | 订阅
0条评论
0 / 1000
请输入你的评论
0
0