mybatis-plus缓存bug分析

mybatis-plus缓存bug分析

前段时间在使用mybatis-plus的过程中,发现了一些bug(最新版本均已修复),现在分享一下;

分页缓存bug

这里使用的mybatis-plus版本是3.0.7.1

在项目中有一个场景是需要把数据从数据库全查出来,然后进行一些处理,因为考虑到数据量可能会特别大,就使用了mybatis-plus的分页查询功能,代码如下:

package com.github.joekerouac.mybatis.plus.demo;

import com.baomidou.mybatisplus.core.metadata.IPage;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.Resource;
import java.util.List;

@Service
public class DemoCode {

    @Resource
    private UserRepository userRepository;

    @Transactional
    public void test1() {
        int pageNo = 1;
        int size = 200;
        while (true) {
            IPage<UserEntity> page = userRepository.selectPage(pageNo++, size);
            List<UserEntity> records = page.getRecords();
            for (UserEntity record : records) {
                // do something
            }
            if (page.getCurrent() >= page.getSize()) {
                break;
            }
        }
    }

}

这段代码在开发环境跑的时候没什么问题,可是到了测试环境就开始死循环,数据库、应用程序都快搞爆了,然后就赶紧进行了版本回退,开始找问题,但是试了许久,开发环境就是没有复现,最终在快要放弃准备在测试环境远程debug的时候,发现了一个差异点,那就是开发和测试环境的数据量不同,测试环境有200多条数据,而开发只有40多条,而我这里的分页恰好也是200,有没有可能是这个导致的呢?抱着试一试的态度,将开发环境数据也增加到200以上,结果程序一跑,发现真的是这个导致的,debug的时候发现这里每次查出来的数据都是一样的,所以怀疑是缓存的问题,熟悉mybatis的朋友都知道,mybatis是有二级缓存的,其中一级缓存默认是打开的,我项目中也没有专门关闭,所以这个缓存就是开着的;

注意,这里是有事务注解的,这也是一级缓存能生效的前提;

接下来在缓存处开始debug,核心代码在org.apache.ibatis.executor.BaseExecutor#query(org.apache.ibatis.mapping.MappedStatement, java.lang.Object, org.apache.ibatis.session.RowBounds, org.apache.ibatis.session.ResultHandler, org.apache.ibatis.cache.CacheKey, org.apache.ibatis.mapping.BoundSql)这里,通过debug发现,每次查询时的CacheKey都是一样的,所以最终就走了缓存而不是数据查询,接下来问题就好解决了,我们找到了创建CacheKey的地方org.apache.ibatis.executor.BaseExecutor#createCacheKey,在这里可以发现 ,这里并没有把mybatis-plus的分页参数更新到CacheKey中,想想也是,mybatis-plus是mybatis之上的框架,是基于mybatis的增强工具,mybatis又怎么会把他的分页参数更新进来呢,那不就是倒反天罡嘛;

到这里,问题也就明确了,因为我们只是修改了分页参数,想要查询下一页的数据,但是却因为缓存key没有被正确的更新,导致mybatis错误的命中了缓存,最终把上一次的数据返回了,导致当实际数据大于1页的时候,业务中发生了死循环,因为第二次开始查询的结果都是返回的第一次的结果,永远有下一页;找到了问题,解决也就简单了,只需要在这里将mybatis-plus的分页参数更新进去就行了,不过在我做之前,去看了mybatis-plus仓库,发现他们在3.1.1这个版本已经修复了,对比了下我们项目中使用的3.0.7.1,变更不算大,所以就升级到了该版本,3.1.1版本的解决方案也很简单,就是上边这个思路,在创建CacheKey的时候把分页参数更新了进去,详情可以参考com.baomidou.mybatisplus.core.executor.AbstractBaseExecutor#createCacheKey,这个方法里边将mybatis-plus的分页参数也更新到了CacheKey中,这样当我们在事务中分页查询的时候就不会有错误的缓存了;

演示代码参考: https://github.com/JoeKerouac/mybatis-plus-demo

租户缓存bug

在解决上边分页缓存问题后不久,又发现一个新问题,那就是在同一个事务中,切换租户上下文会导致错误缓存,因为有了上边的经验,我们直接找到了缓存key创建的地方,经过对比,发现切换租户前后的缓存一模一样,实际上他们的sql应该是不同的(租户不同),但是可以观察到这里的sql是一模一样的,并没有添加租户条件,继续分析mybatis-plus后可以发现,mybatis-plus的租户插件是依赖于分页插件com.baomidou.mybatisplus.extension.plugins.PaginationInterceptor的,在这里拦截修改了sql,为sql添加了租户条件,但是分页拦截器拦截的是StatementHandler的prepare方法,实际上mybatis是先执行的org.apache.ibatis.executor.BaseExecutor#query(org.apache.ibatis.mapping.MappedStatement, java.lang.Object, org.apache.ibatis.session.RowBounds, org.apache.ibatis.session.ResultHandler, org.apache.ibatis.cache.CacheKey, org.apache.ibatis.mapping.BoundSql),缓存逻辑也是在这里处理的,如果当前没有缓存,才会继续执行查询方法,最终调用到StatementHandler的prepare方法,然后被mybatis-plus拦截,动态添加租户条件,所以在生成缓存key时还未到mybatis-plus的分页拦截器,此时sql中并不包含租户,sql的参数中也没有租户,自然缓存key也不会包含租户信息,这就导致了只是切换租户,查询条件不变的情况下,第二次查询错误的命中了第一次查询的缓存;

既然找到了问题,那么也好解决:

  • 方案一:在创建CacheKey的时候定制化处理,将租户信息注入,像分页参数那样;
  • 方案二:提前修改sql,在创建CacheKey之前就把租户信息动态添加到sql中,那就需要修改分页插件的拦截点;

通过查看mybatis-plus源码,发现其在3.4.0中将该问题解决了,解决方案就是采用的上边的方案二,做法如下:

  • 1、之前的拦截点是StatementHandler.prepare方法,上边我们已经分析过了,这个拦截点晚于CacheKey的创建,所以现在将插件的拦截点提前,拦截了org.apache.ibatis.executor.Executor#query(org.apache.ibatis.mapping.MappedStatement, java.lang.Object, org.apache.ibatis.session.RowBounds, org.apache.ibatis.session.ResultHandler)方法;
  • 2、在实现上做了更通用的处理,通过com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor做了拦截点,但是并没有任何实际插件逻辑,而是在内部又将拦截点的调用分发给了com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor接口的实现,无论是租户插件还是分页插件都实现了该接口,最终在生成CacheKey之前将修改同步到了sql中,变更了当前要执行的sql,这样当租户或者分页参数变更后sql也会变更,CacheKey自然也会不同,此时也就无需再hack CacheKey的创建了;同时这样做还有个好处,就是后边如果需要添加其他动态sql修改插件,那么都会在创建CacheKey之前执行,最终保证动态变更的内容会同步到CacheKey中,无需再额外做任何处理即可让mybatis正确的使用缓存;

需要注意的是,我们注册添加com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor的租户实现和分页实现时,一定要在注册分页实现com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor之前注册租户实现com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor,更准确的说,我们注册任何动态修改sql条件的插件实现时,都要注册在分页插件之前,因为为了获取数据总量,在分页插件的com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor#willDoQuery方法中需要执行一个count sql,如果租户插件和其他任何动态修改sql条件的插件在分页插件之后执行,那么这里的count sql执行时最终执行的sql就不会包含租户和其他动态sql条件,获取出来的数据就是错误的(对于租户插件,如果注册顺序错误,那么这里分页插件获取出来的数据量就是所有租户的数据总量);

注意,mybatis-plus升级到3.4.0后,需要将分页插件替换为使用com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor的方式而不是继续使用com.baomidou.mybatisplus.extension.plugins.PaginationInterceptor,否则租户缓存bug还会存在;

联系我

  • 作者微信:JoeKerouac
  • 微信公众号(文章会第一时间更新到公众号,如果搜不出来可能是改名字了,加微信即可=_=|):代码深度研究院
  • GitHub:https://github.com/JoeKerouac
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值