评论

收藏

[网络数据] 记mysql-connector-java:8.0.28的bug排查,你可能也踩坑了

网络安全 网络安全 发布于:2022-07-19 21:44 | 阅读数:295 | 评论:0

前言
如标题,最终查明问题是因为 mysql-connector-java:8.0.28 的一个 bug 导致的。但是在真相未浮出之前,整个问题可谓扑朔迷离,博主好久没有排查过如此得劲的 bug ,随着一层层的 debug 深入,真相也随之浮出水面。这个问题属于底层 jdbc 驱动的问题,具有普遍性,可能不知不觉中,你的应用也在线上遭受这个 bug 的摧残,所以,请耐心听我讲完这个故事,然后回去检查下你的应用状态,是否也踩坑了。喜欢直接的可以直接拉到文末结语看结果。
背景
讲故事一般先介绍人物、背景。这里也不列外,先把相关方介绍下。通常,故事情节越丰富越精彩,但是这里博主会考虑篇幅(不讲废话)会把一些与结果走向无关的细节忽略掉,力求叙述完整就好。
      
  • commons-db : 我们内部维护的,一个采用注解驱动的 Spring 生态下的多数据源管理组件。组件给每个 DataSource 预设了些性能优化的默认值,没有全部列出,不过包含了影响问题走向的属性(useLocalSessionState),如下:
Properties defaultProperties = new Properties();
defaultProperties.put("prepStmtCacheSize", 300);
defaultProperties.put("prepStmtCacheSqlLimit", 2048);
defaultProperties.put("useLocalSessionState", true);
defaultProperties.put("cacheResultSetMetadata", true);
defaultProperties.put("elideSetAutoCommits", true);

      
  • java-project : 用来测试组件功能的项目,会作为和出现问题的项目做行为测试对比。spring-boot:2.5.4、mysql-connector-java:8.0.26  
  • store:游戏库项目,正是这个项目发现了问题。spring-boot:2.6.6 、mysql-connector-java:8.0.28  
  • 阿里云 RDS(MySQL): 阿里云 MySQL 默认的隔离级别为 READ_COMMITTED,而 MySQL 默认的隔离级别为 REPEATABLE_READ
说明: java-project 和 store 的 commons-db 版本其实不一样,因为不影响结果。这里假设他们版本一致。
问题
一天,开发反馈,在 store 项目里使用 commons-db 组件时,出现了事务回滚不生效的问题。如下图代码所示:
@Transactional
@DataSource(type = Type.MASTER,value = "developer")
public void addUser(ApolloUser user){
  userRepository.save(user);
  int i = 1/0; //抛异常
}

      
  • 具体表现为:执行 addUser 方法,当 1/0 抛出 RuntimeException 类型异常时,user 对象还是添加成功了。一句话总结就是,【事务回滚不生效了】。

  假设

      
  • 假设1:曾假设过是不是 @Transactional 的 aop 没生效,导致并未开启显式事务。  
  • 假设1不成立,因为在开启了 debug 日志模式后,清晰的输出了事务每个阶段的行为日志,如:
DSC0000.png
      
  • 假设2:考虑到使用了 commons-db , 如果框架层连接管理问题,导致了事务的开启、事务回滚时获取到的连接不一致,也有可能导致这个问题。  
  • 假设2不成立:马上就否了,因为从上面日志上可以看到连接是同一个连接。而且不同连接执行非预期的开启、回滚事务操作应该会抛异常才是。
那么到这里,问题陷入了僵局。不禁沉思,一个看上去人畜无害的代码,一个看上去逻辑清晰的事务日志,为什么会事务回滚失效呢?????
转机
  转机1
随后,我在 java-project 项目里,使用相同的 MySQL 测试了下,发现事务回滚成功了。说明这个问题仅仅影响特定的环境,而且可以通过对比两个项目的差异找到问题,离真相更近了。
转机2
开发那边又传来一个关键的信息,在 store 项目中,当设置隔离级别为 REPEATABLE_READ 时,事务回滚生效了。代码如:
@Transactional(isolation = Isolation.REPEATABLE_READ)
   @DataSource(type = Type.MASTER,value = "developer")
   public void addUser(ApolloUser user){
     userRepository.save(user);
     int i = 1/0;
   }
到这里,然道要怀疑是隔离级别的问题么?显然是不成立的,因为对事务的认知字典里,就没出现过隔离级别影响事务回滚的字条。然后从 java-project 的测试也可以看出,在相同的 RC 隔离级别下,java-project 可以成功。
第一个解决方法
然后终归是向前进了一步了,可以临时用设置隔离级别的办法来解决【事务回滚不生效问题】。不过,不同的隔离级别,对事务锁、并发性能是不一样,这个在调整前必须要有预期。
转机3
事出反常必有妖,本着不信是隔离级别导致的问题,我在 store 项目里将  isolation 设置成 Isolation.READ_UNCOMMITTED ,发现事务回滚也生效了。这也说明了和隔离级别没有直接的关系。然后本着探究【为啥默认的 READ_COMMITTED 导致事务不生效?】的思路排查了下,发现了些问题,如下代码是事务逻辑中的一部分(源码见:DataSourceUtils.prepareConnectionForTransaction()):
DSC0001.png
发现,相比 RR、RU ,差别就是当隔离级别是 READ_COMMITTED 时,不会在对 session 有更新操作了。到这一步也只是多了一个明确的现象,可以解释知道真相后的行为,并没有触达真相边缘。
分析
上文整了一堆,还没发现真实问题。所以先不做其他测试了,先分析下有预期后,在针对性去验证。
先来看下普遍的正常的 Spring Transactional 完整的事务回滚的过程,普遍的指的是没有做过特殊参数配置的,一般这些参数也不会配置。
      
  • 1、在添加了 @Transactional 的方法执行前,会执行事务管理器(DataSourceTransactionManager)的 doBegin 方法创建一个事务,在 doBegin 方法里,会设置 autoCommit = false。会判当前隔离级别是否和用户定义的一致,否则就更新隔离级别。
DSC0002.png
      
  • 2、方法执行失败后,会执行事务管理器(DataSourceTransactionManager)的 doRollback 方法回滚事务。
从 Spring Transactional 的事务日志没看出来问题,创建事务、设置手动提交事务、回滚事务都有日志打印。那么我们就深入到驱动层、或者抓包看,是否这些指令都发到 MySQL Server 了。
定位问题
如分析,在 store 项目中,将断点打在 mysql-connector-java 驱动的 NativeSession.execSQL()方法里,和 MySQL Server 交互的所有指令,最终都会调用这个方法执行。果然发现了问题:
      
  • 事务回滚失败时,事务流程并未执行 SET autocommit=0 指令。
等于说事务回滚失败时,事务一直是自动提交的模式,所以,异常回滚操作并不会回滚已经持久化了的数据。
发现这个问题后,接着定位为什么 Spring 执行了 Set autoCommit=false ,而最终确并未执行的问题,这里再次通过【转机1】的 java-project 项目做单步调试对比,发现一段关键代码(ConnectionImpl.setAutoCommit())两个项目里的代码不一致:
java-project,mysql-connector-java:8.0.26(事务回滚生效)
DSC0003.png
store,mysql-connector-java:8.0.28(事务回滚不生效)
DSC0004.png
这里稍微介绍下这个参数
      
  • useLocalSessionState:维护本地 sessionState ,在需要判断 【事务提交模式】、【隔离级别】设置时,获取本地状态,而不是每次像 MySQL Server 发起询问。
这个参数有助于减少和 MySQL 的交互,可以提升写数据性能。所以在参数性能优化时,被默认设置为 true 了。这里,如果 useLocalSessionState=false,则正好会掩盖这个 bug。
解密
因为在 store,mysql-connector-java:8.0.28 有问题的版本的 isAutocommit() 行为逻辑和 isAutoCommit()不一致,本该调用判断 isAutocommit 返回 true 时,却返回了 false。 最终才导致了 store 在接收到 Spring Transactional 设置 autoCommit=false 的请求时,因为 needsSetOnServer=false ,直接跳过了真正的发起 Set autocommit=0 指令的执行。导致当前事务模式是自动提交模式,所以当事务里有任何增删改操作时,会在执行完后立马 commit 持久化。这时如果异常而发起事务 rollback ,自然不会回滚之前已经自动提交的事务。这个很好的解释了开头贴出的事务日志很完整,但是事务就是回滚不生效的问题。
第二个解决方法
排查到这里,第二个解决问题的方法就出现了,只需要让判断是否需要执行 Set autocommit=0 时的 needsSetOnServer=true 成立就行了。所以,只要对 store 应用做如下两个参数任一参数配置调整,则可以解决问题了。这个方法比第一个方法要合适些:
useLocalSessionState=false
auto-commit=false
                                                                                                                    解释为啥isolation 设置成 Isolation.REPEATABLE_READ 会生效
所以到这里就结束了吗?并没有,预期是即使 useLocalSessionState=ture ,事务也应该完整。然后别忘了 isAutoCommit() 和 isAutocommit() 的差异。先来看下他们的定义:
public boolean isAutocommit() {
  return (this.statusFlags & 2) != 0;
}
 
public boolean isAutoCommit() {
  return this.autoCommit;
}
原来在  mysql-connector-java:8.0.28 驱动里,使用 statusFlags 状态代替了 autoCommit 的标识(这里先不考究为什么做这个改动),这个解释了
      
  • 转机2:当设置隔离级别为 REPEATABLE_READ 时,事务回滚生效了。是因为当用户定义的隔离级别 RR 和默认的 RC 不一致时,会触发 session 设置新的隔离级别,此时也会将 statusFlags = 0 更新为 statusFlags = 2. 故在调用 isAutocommit() 返回 true ,满足了执行 SET autocommit=0 指令的条件。
这里虽然知道了原因,也确切知道 isAutoCommit() != isAutocommit() ,但是为啥做如此改动确并不清楚。这里具体问题暂且不表,先来复现下问题。
复现问题
既然问题已经大差不差的定位到了,那么按常规排查流程,按预期的问题场景复现下,明确下问题边界。因为还还有可能有其他的影响因素一起导致的问题。在 java-project 项目中,做如下依赖的版本调整
      
  • 升级 spring-boot:2.6.6 版本和 store 保持一致:问题复现了  
  • 保持 spring-boot:2.5.4,调整 mysql-connector-java:8.0.28 :问题也复现了
到这里,基本排除了 Spring Transactional 的嫌疑了。然后将矛头锁定到了 mysql-connector-java:8.0.28 身上。
确认 bug
考虑到从 mysql-connector-java:8.0.26 的 isAutoCommit 更改到了 mysql-connector-java:8.0.28 的 isAutocommit 肯定是有原因的,带着弄清楚代码作者提交这个改动的意图,去翻了下 github。
      
  • https://github.com/mysql/mysql-connector-j
找了下 github 的提交记录  commit ,发现,最新版本的又改回了 isAutoCommit() 了,然后 Commit Message 明确说明了这是 8.0.28 版本的 bug,如。
DSC0005.png
至此,终于真相大白了。
修复

      
  • 8.0.29 release:https://dev.mysql.com/doc/relnotes/connector-j/8.0/en/news-8-0-29.html  
  • A connection did not maintain the correct autocommit state when it was used in a pool with useLocalSessionState=true. (Bug #106435, Bug #33850099)


  最终解决方法
如 8.0.29 release 公告说明,已经修复了 8.0.28 在设置 useLocalSessionState=true 的情况下,autoCommit 状态设置的问题。所以,应用升级到 mysql-connector-java:8.0.29 版本即可
结语
先总结下问题表像为 Spring Transactional【事务回滚不生效,回滚前提交的数据不会回滚】,根本原因是 【mysql-connector-java:8.0.28 版本提交的一个改动 bug ,导致在启用 useLocalSessionState=true 的情况下,autoCommit 状态设置有问题】。
然后因为 spring-boot:2.6.3 ~ 2.6.7 ,这五个版本默认的 MySQL 驱动就是 mysql-connector-java:8.0.28 ,而 useLocalSessionState=true 几乎是 Java JDBC DataSource 里的标配,所以这个 bug 估计会影响一大波人。然后因为只是影响回滚操作,所以这个问题会隐藏的很深,不容易察觉,所谓影响深远。


关注下面的标签,发现更多相似文章