Mybatis-Plus“读-批量写-读”数据不一致的问题分享

1,897 阅读9分钟

背景

技术选型与版本: SpringBoot 2.7.6、Mybatis-Plus 3.1.0

技术名词: 快照读、ReadView、Undo log、SqlSession、一级缓存

在日常开发过程中,时常会遇到一个如下场景:

  1. 根据条件x,读取表A,得到多行数据;
  2. 遍历读取到的数据,对条件x以外的字段进行修改,并进行保存;(重点)
  3. 修改后,调用通用方法,传入条件x,重新读取表A的数据,并进行MQ广播;

流程如下图

sequenceDiagram
participant A as 业务代码
participant B as Mybatis
participant C as MQ
note over A,C: 开启事务
note left of A: 第一次查询S1
A->>B: 条件x查询
B->>A: 返回查询结果R1
note left of A: 更新
A->>A: 修改查询结果R1
A->>B: 批量更新
B->>A: 更新成功
note left of A: 第二次查询S2
A->>B: 条件x查询
B->>A: 返回查询结果R2
note left of A: 发送MQ
A->>C: 结果R2发送MQ
note over A,C: 提交事务

以上业务流程,是一个很普通的流程,一眼看去没什么问题,以下是我们期望的流程结果:

  • 第一次条件x查询,得到结果R1;
  • 修改R1后执行更新;
  • 第二次条件x查询,得到结果R2为更新后在当前事务中的最新数据;

但是在我们实际测试中,却发现,对于相同的条件x的前后两次查询S1和S2,得到的居然是一样的结果数据。若按照以上的业务流程,第二次查询后发送MQ,如果R2不是最新值,那么可能导致MQ消费者数据不一致的情况。

下面来看一下伪代码演示。

示例演示

以下伪代码中,为了方便演示,不进行MQ操作,直接使用打印日志代替

3.1.0-updateBatchById.png

可以看到,在执行Mybatis-Plus提供的批量更新方法updateBatchById后,重新读取数据,居然还是更新前的数据,那这到底是怎么回事呢,我们往下分析与追踪。

分析与追踪

在这个案例中,只有查询-更新-查询的代码,数据库使用的是MySQL,ORM框架使用的是Mybatis-Plus,所以可以从以下两方面进行排查

  1. MySQL事务;
  2. MyBatis-Plus;

mysql事务

知识点A:在MySQL中,Innodb基于MVCC思想实现快照读。核心思想是利用事务的ReadView与undo log版本链进行匹配,从而判断当前事务能读取到哪个版本的数据。即,在Innodb的默认隔离级别为可重复读时,对于同一事务下,写操作的结果对下一个读操作是可见的。

详见大佬博客:blog.csdn.net/SIESTA030/a…

回到上面的案例代码,可见,当前代码的“读-写-读”操作,是被包含在同一个事务中,事务管理完全交给Spring事务管理器,且并未对事务传播行为进行控制,所以按照MySQL的知识点,可以确定,第二次“读”操作,如果是正常从MySQL数据库进行读数据,那么得到的数据必然是“写”操作所产生的数据。

但是事实却恰恰相反,第二次“读”操作,无法得到前一次“写”操作产生的数据。由此,我们有理由怀疑,第二次”读“操作,可能存在没有执行MySQL读取操作的情况,为此,我们开启SQL执行日志打印功能,打印日志如下:

mybatis-plus 打印日志.png

从打印的日志中,可以很明显看到,代码是执行”读-写-读“的操作,但是实际上,却只执行的”读-写“的语句,第二次的”读“操作,在代码中并没有报错,且能成功返回数据。

由于我们使用的ORM是Mybatis-Plus,那么我们可以做出以下判断

  1. 第二次”读“操作,Mybatis-Plus或Mybatis没有对MySQL执行select语句;
  2. 第二次”读“操作,Mybatis-Plus或Mybatis从”某个地方“,读取了缓存并返回数据。

接下来我们从Mybatis-Plus来分析。

MyBatis-Plus

知识点B:

  1. Mybatis无论是读操作,还是写操作的,底层都是通过SqlSession接口提供的方法来执行的,并且在默认情况下,同一事务中的多个读写操作,是共享同一个SqlSession实例对象。
  2. Mybatis默认是开启一级缓存的,一级缓存是SqlSession级别的,也就是说,在同一个SqlSession下,连续执行相同的读操作,Mybatis并不会每次都将查询语句发送到MySQL,而是将第一次查询的结果缓存下来,在下一次执行相同的读操作时,会直接查询当前SqlSession下的缓存,若存在,那么直接返回结果的,若不存在,则再与MySQL进行查询交互。
  3. 若两次相同的查询中间存在任何update、commit、rollback操作时,Mybatis会在操作之前,先清除当前SqlSession中的缓存数据。

”读“操作源码

BaseExecutor-query.png

”读“操作源码

BaseExecutor-queryFromDatabase.png

”写“操作源码

BaseExecutor-update.png

基于以上Mybatis的”读“、”写“操作的源码,现在回过头看下我们的业务伪代码,我们可以对”读-写-读“操作,进行简单分析:

  1. ”读“操作,当前SqlSession缓存中,没有数据,需要与数据库进行”读“交互,读取完成后进行SqlSession级别缓存。
  2. ”写“操作,对当前SqlSession中的缓存进行清空操作,再与数据库进行”写“交互。
  3. ”读“操作,当前SqlSession中的缓存数据已经被前面”写“操作清空,此时进行”读“操作,需要需要与数据库进行”读“交互,得到”写“操作后的最新数据。

What??这样分析下来,还是与我们伪代码得到的结果不一致,敢情是分析了个寂寞??

别着急,我们重新看一下我们的伪代码。

图片1.png

在这段代码中,”读-写-读“操作,分别调用了demoUserRepository.listByIds()、demoUserRepository.updateBatchById()这两个方法,而这两个方法,并不是Mybatis框架提供的方法,而是MyBatis-Plus框架提供的,接下来分别看一下这两个方法。

listByIds()

图片2.png

可以看出listByIds()方法中,实际是直接调用了Mapper代理对象的方法,与常规的直接调用Mapper代理对象的方法并无区别。

updateBatchById()

3.png

4.png

但是在updateBatchById()方法中,却调用了sqlSessionBatch()的方法,该方法得到一个SqlSession实例对象,而后直接for循环调用该SqlSession对象的update()方法。

还记得前面知识点中提到过, ”Mybatis在默认情况下,同一事务中的多个读写操作,是共享同一个SqlSession实例对象“ 这句话吗??那么在此处出现的sqlSessionBatch()方法,并且得到一个SqlSession实例对象,并进行使用,又是为什么呢??我们继续跟进sqlSessionBatch()方法。

6.png

7.png

8.png

9.png

最终,我们跟进到了openSessionFromDataSource()方法,可以看到,updateBatchById()方法,为了执行批量更新的操作,重新构建了一个执行器类型为ExecutorType.BATCH的SqlSession实例对象。

也就是说,尽管前面的”读“操作使用的是SqlSession实例对象A,但是在updateBatchById()”写“操作时,却重新构建了一个SqlSession实例对象B,并使用该对象直接执行update语句,此时,”Mybatis在默认情况下,同一事务中的多个读写操作,是共享同一个SqlSession实例对象“ 这个默认情况,由于Mybatis-Plus的封装,直接被打破,导致将同时存在两个SqlSession实例对象A和B。

为了验证以上的结论,我们进行以下断点调试:

首先是第一次”读“操作,如下:

10.png

此时的SqlSession实例对象引用为DefaultSqlSession@10338。

接下来是”写“操作,如下:

11.png

此时的SqlSession实例对象引用为DefaultSqlSession@10344。

通过以上的断点调试,我们可以证明,在实例的伪代码中,”读-写“操作,确实产生了两个不同的SqlSession实例对象。

那么,以上的证明,又与我们讨论的第二次”读“操作得到数据不一致,有什么关系呢??

别急,我们接着看第二次”读“操作的断点调试结果:

12.png

从断点调试结果可以看到,第二次”读“操作,使用的SqlSession实例对象,与第一次”读“操作使用的SqlSession实例对象,是同一个对象,对象引用为DefaultSqlSession@10338。

到此,结合前面《知识点B》中第2点,我们即可瞬间推断出,为何第二次”读“操作,没有与MySQL进行读交互,却能获取到数据,并且得到的数据,并不是前面”写“操作所产生的数据,即

在”读-写-读“顺序中的,”写“操作使用Mybatis-Plus封装的updateBatchById()方法,使用了与前后”读“操作不同SqlSession对象实例,”写“操作无法清除第一次”读“操作所属的SqlSession中的缓存,而第二次”读“操作,却又使用了与第一次”读“操作相同的SqlSession,导致第二次”读“操作,直接从SqlSession缓存中直接获取第一次”读“操作得到的数据。

知识点:关于为何”Mybatis在默认情况下,同一事务中的多个读写操作,共享同一个SqlSession实例对象“的问题,是因为Mybatis在对Mapper接口方法生成动态代理对象的时候,将SqlSession作为构造参数传递进代理对象。详见org.apache.ibatis.binding.MapperProxy类。

结论与方案

SqlSession

从以上演示案例与源码简单分析可以得知,造成案例中第二次”读“操作数据有误问题的原因,主要在于”写“操作使用的是MyBatis-Plus框架封装的”批量写“操作,导致出现不同SqlSession的问题,那么在SqlSession层面,我们有以下的解决方案:

  1. 不使用Mybatis-Plus封装的updateBatchById()方法,使用循环调用Mybatis提供update方法,使”读-写“操作使用同一个SqlSession。
  2. 当使用Mybatis-Plus封装的updateBatchById()方法前,对之前”读“操作的SqlSession进行commit操作,使其清空缓存中的数据,这样在下次”读“操作时,将会直接从数据库读取数据。

事务

以上的演示案例,除了从SqlSession层面解决,也可以从事务层面来解决这个问题。针对以上案例中的第二次”读“操作,我们可以将该操作,与当前事务分离,在当前事务提交后,再进行”读“操作,此时的读操作,将单独生成一个新的SqlSession,并直接从数据库读取数据。代码如下:

14.png

该方案做法是在当前事务中注入一个同步事务回调事件,在当前事务执行完commit后,再重新从数据库读取数据。

*注意,该方案没有返回值,若对第二次“读”操作的结果需要进一步处理,需要将处理过程包含进afterCommit()方法内

跟进

按理来说,Mybatis-Plus这样成熟与活跃的框架,本不应该出现本次案例updateBatchById()方法的“Bug”。由于我们当前案例使用的Mybatis-Plus版本为3.1.0,最新的Mybatis-Plus版本已更新到3.5.1,为此,我们直接翻一下Mybatis-Plus 3.5.1版本的源码看一下是否解决了该问题。

由于Mybatis-Plus 3.5.1使用了大量Java的新语法,源码存在部分差异,我们直接看核心源码:

15.png

可以看到,Mybatis-Plus 3.5.1版本已经修复了该“Bug”,且带上了注释,改修复方案,与上面我们提出的SqlSession方案也是一致的。具体实现,请自行翻阅源码,此处不再赘述。

结语

知其然,必须知其所以然。