如标题,最终查明问题是因为 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 日志模式后,清晰地输出了事务每个阶段的行为日志,如:

假设 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 ()):

发现,相比 RR、RU , 差别就是当隔离级别是 READ_COMMITTED 时,不会再对 session 有更新操作了。到这一步也只是多了一个明确的现象,可以解释知道真相后的行为,并没有触达真相边缘。

分析
上文整了一堆,还没发现真实问题。所以先不做其他测试了,先分析下有何预期后,再针对性去验证。

先来看下普遍的正常的 Spring Transactional 完整的事务回滚的过程,普遍的指的是没有做过特殊参数配置的,一般这些参数也不会配置。

1、在添加了 @Transactional 的方法执行前,会执行事务管理器(DataSourceTransactionManager)的 doBegin 方法创建一个事务,在 doBegin 方法里,会设置 autoCommit = false。会判当前隔离级别是否和用户定义的一致,否则就更新隔离级别。

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(事务回滚生效)

store,mysql-connector-java:8.0.28(事务回滚不生效)

这里稍微介绍下这个参数

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/mysq
找了下 github 的提交记录 commit ,发现,最新版本的又改回了 isAutoCommit () 了,然后 Commit Message 明确说明了这是 8.0.28 版本的 bug,如。

至此,终于真相大白了。

修复
8.0.29 release:https://dev.mysql.com/doc/rel
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 估计会影响一大波人。然后因为只是影响回滚操作,所以这个问题会隐藏的很深,不容易察觉,所谓影响深远。

更多相关文章

  1. 移植 android, touch screen 不能正常工作的问题
  2. Android(安卓)RSA与Java RSA加密不同标准产生问题的解决方法
  3. Android应用如何支持屏幕多尺寸多分辨率问题
  4. android 关于appcompat v7出错问题与解决
  5. 学习android的一些套路(转发之小黑屋总结)
  6. 解决 Android(安卓)中使用ListView和CheckBox批量操作时若干问题
  7. Android(安卓)setTag方法的key问题
  8. Android(安卓)成用户隐私安全问题严重
  9. Android应用如何支持屏幕多尺寸多分辨率问题

随机推荐

  1. 关于SQL2005安装完毕后,没有SQL Server M
  2. sql间歇性出现无法连接和卡死的情况。
  3. CentOS7安装MySQL并设置远程登录
  4. 即使提供了参数,过程也需要参数
  5. 错误:第t列。tgisconstraint不存在
  6. 请问MYSQL 08S01错误怎么解决,谢谢
  7. StringBuilder 拼接sql语句比较快
  8. hibernate中load,get;find,iterator;merg
  9. IFX数据库访问介绍
  10. 完美解决SQLserver 2000“指定的服务并未