Mybatis一级缓存 返回值对象引用

一个线上的诡异问题

同事在排查一个线索问题时,发现一个方法获取的数据里会有几个相同的数据。理论上来说,这个方法里一次数据库查询只会是不会相同的数据的。
代码如下:

/**
一个从数据库中查询并返回列表的方法
 * @param mobile
 * @param group
 * @return
 */
protected List<BzClue> getAllInActionClue(String mobile, String group, String column) {

    BzFusionSource buildInSource = cacheService.getFusionSources().get(FusionSourceEnum.BUILD_IN.getFlag());
    List<BzClue> clues1 = clueMapper.selectList(Wrappers.<BzClue>query().eq("mobile_phone", mobile)
            .eq(column, group)
            .eq("fusion_source_id", buildInSource.getId())
            .in("lead_status", ClueStatusEnum.LEAD_STATUS_LIVE_LIST));

    if (CollectionUtils.isEmpty(clues1)) {
        return Lists.newArrayList();
    }

    List<BzOpportunity> opps = bzOpportunityMapper.selectList(Wrappers.<BzOpportunity>query().eq("contact_phone", mobile)
            .eq(column, group)
            .eq("fusion_source_id", buildInSource.getId())
            .in("stage_name", OpportunityStageNameEnum.STAGE_NAME_LIVE_LIST));

    if (CollectionUtils.isEmpty(opps)) {
        return clues1;
    }

    List<String> leadIds = opps.stream().map(BzOpportunity::getLeadId).collect(Collectors.toList());
    List<BzClue> clues2 = clueMapper.selectList(Wrappers.<BzClue>query().eq("mobile_phone", mobile)
            .eq(column, group)
            .eq("fusion_source_id", buildInSource.getId())
            .in("lead_id", leadIds));

    clues1.addAll(clues2);
    return clues1;
}

按照一般的预期,我们每次调用这个方法,等到的结果都是一样的。可是在多次以相同参数调用方法后,返回结果却不是幂等。难道是数据库的数据更新了?然后查看数据库,并没有发现数据变更。废了很大劲在开发环境断点多次,终于模拟出出错时的场景。
一个遍调用方法时,一切参数变量结果都是正确的,此时clue1是空值,但是clues2是有值的。但是第二次调用时,方法中的clues1变量却是上一次方法调用中的列表对象,里面已经存在上一次调用时add进去的值,导致又再次往同一对象里add了一次clues2的值。

我们知道Java是值传递的,对象传递的是引用的值。这里两次方法调用中的clueMapper.selectList()返回的变量是同一个对象引用,指向堆内存里的同一个对象实例。这个对象实例,在第一次调用getAllInActionClue()后就存在了,并已经往列表里面放入了数据。当第二次时调用时,clues1并非新创建的对象,而是第一次调用时已经被修改过的对象。
那么问题来了,为什么clueMapper.selectList()这个方法返回的值是上一次的引用呢?

产生问题的根源

两次Mybatis查询返回相同的值,马上就能让人联想到缓存,而Mybatis是有一级缓存、二级缓存的。

在这里插入图片描述

MyBatis一级缓存问题,返回的对象引用是旧的。

翻看Mybaits源码文章

查了美团技术团队的《聊聊MyBatis缓存机制》,可以知道

Cache: MyBatis中的Cache接口,提供了和缓存相关的最基本的操作,如下图所示:
在这里插入图片描述

有若干个实现类,使用装饰器模式互相组装,提供丰富的操控缓存的能力,部分实现类如下图所示:

在这里插入图片描述

BaseExecutor成员变量之一的PerpetualCache,是对Cache接口最基本的实现,其实现非常简单,内部持有HashMap,对一级缓存的操作实则是对HashMap的操作。如下代码所示:

public class PerpetualCache implements Cache {
  private String id;
  private Map<Object, Object> cache = new HashMap<Object, Object>();

是的,MyBatis默认的缓存实现,是直接使用HashMap的,里面保存的对象值,只有在执行了insert/delete/dpdate方法后,缓存才会刷新
这时 mybatis的cache不能用,直接都是hashmap!

因为mybatis一级缓存的关系,如果直接对缓存计算会产生问题:

  1. 同一个session内执行多次相同的查询,第二次开始后续的查库是直接返回缓存中的数据(引用而不是变量的副本)
  2. 当第一次查询出结果,将结果放入缓存
  3. 对缓存的引用做计算,直接更新了缓存中的引用
  4. 后续从缓存去取出结果,此时的结果跟数据库存储的数据已经不一致

解决方案

针对此类问题的解决办法:

  1. mybatis不支持关闭一级缓存,只能通过一些配置或编码绕过一级缓存,比如每次查询之前都手动清楚缓存
  2. 将缓存级别设置为statement级别(默认session级别),每次执行statement数据都会被清除(这种方式使用的人不多,会不会引入其他新问题也未知,不太推荐使用)
  3. 编码时候禁止直接使用mapper返回的对象,应该复制一个副本
  4. 二级缓存也可有类似的问题

写这段代码的同事的解决方式是,进入方法时,每次都创建一个list,把两个查询的接口都放进去,

      /**
    * 查询进行中的线索
    *
    * @param mobile
    * @param group
    * @return
    */
   protected List<BzClue> getAllInActionClue(String mobile, String group, String column) {

       List<BzClue> list = Lists.newArrayList();

       // 查询进行中的线索
       BzFusionSource buildInSource = cacheService.getFusionSources().get(FusionSourceEnum.BUILD_IN.getFlag());
       List<BzClue> clues1 = clueMapper.selectList(Wrappers.<BzClue>query().eq("mobile_phone", mobile)
               .eq(column, group)
               .eq(FUSION_SOURCE_ID_COLUMN, buildInSource.getId())
               .in("lead_status", ClueStatusEnum.LEAD_STATUS_LIVE_LIST));

       if (CollectionUtils.isNotEmpty(clues1)) {
           list.addAll(clues1);
       }

       // 查询进行中的商机
       List<BzOpportunity> opps = bzOpportunityMapper.selectList(Wrappers.<BzOpportunity>query().eq("contact_phone", mobile)
               .eq(column, group)
               .eq(FUSION_SOURCE_ID_COLUMN, buildInSource.getId())
               .in("stage_name", OpportunityStageNameEnum.STAGE_NAME_LIVE_LIST));


       if (CollectionUtils.isEmpty(opps)) {
           return list;
       }

       // 已转化的线索有可能也还是跟进中
       List<String> leadIds = opps.stream().map(BzOpportunity::getLeadId).collect(Collectors.toList());
       List<BzClue> clues2 = clueMapper.selectList(Wrappers.<BzClue>query().eq("mobile_phone", mobile)
               .eq(column, group)
               .eq(FUSION_SOURCE_ID_COLUMN, buildInSource.getId())
               .in("lead_id", leadIds));

       /**
        *

        *
        *
        */
//        clues1.addAll(clues2);
       list.addAll(clues2);
       return list;
   }

另一个同事可能担心,浅拷贝的问题,直接用一个新的map把Mybaits方法传入对象的引用彻底移除。

        /**
     * 查询进行中的线索
     *
     * @param mobile
     * @param group
     * @return
     */
    protected List<BzClue> getAllInActionClue(String mobile, String group, String column) {

        BzFusionSource buildInSource = cacheService.getFusionSources().get(FusionSourceEnum.BUILD_IN.getFlag());
        List<BzClue> clues1 = clueMapper.selectList(Wrappers.<BzClue>query().eq("mobile_phone", mobile)
                .eq(column, group)
                .eq("fusion_source_id", buildInSource.getId())
                .in("lead_status", ClueStatusEnum.LEAD_STATUS_LIVE_LIST));

        //  无进行中线索也必不会有进行中商机
        if (CollectionUtils.isEmpty(clues1)) {
            return Lists.newArrayList();
        }

        List<BzOpportunity> opps = bzOpportunityMapper.selectList(Wrappers.<BzOpportunity>query().eq("contact_phone", mobile)
                .eq(column, group)
                .eq("fusion_source_id", buildInSource.getId())
                .in("stage_name", OpportunityStageNameEnum.STAGE_NAME_LIVE_LIST));

        if (CollectionUtils.isEmpty(opps)) {
            return clues1;
        }

        // 已转化的线索有可能也还是跟进中
        List<String> leadIds = opps.stream().map(BzOpportunity::getLeadId).collect(Collectors.toList());
        List<BzClue> clues2 = clueMapper.selectList(Wrappers.<BzClue>query().eq("mobile_phone", mobile)
                .eq(column, group)
                .eq("fusion_source_id", buildInSource.getId())
                .in("lead_id", leadIds));

//        clues1.addAll(clues2);
        HashMap<Long,BzClue> buffer = new HashMap<>();
        clues1.forEach(clue->buffer.put(clue.getId(),clue));
        clues2.forEach(clue->buffer.put(clue.getId(),clue));
        return new ArrayList<>(buffer.values());
    }

个人认为,以当前的业务逻辑来说,使用第一种改法就好,因为第一种改法已经没有在直接更新Mybatis查出的对象引用,这要保证这一点,就可以让返回值不变。

至此,一个诡异的bug得以查到原因和解决。

以上内容是自己收集的想法,有不正之处,请大家多多指导。

一级缓存

在这里插入图片描述

二级缓存

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述
引用资料:

美团技术团队 《聊聊MyBatis缓存机制》https://tech.meituan.com/2018/01/19/mybatis-cache.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值