水煮MyBatis(十五)- 聊聊缓存里的脏数据

167 阅读4分钟

前言

前面章节提到,Mybatis的一级缓存是默认开启的,毕竟在绝大多数情况下,一级缓存对业务系统有着正向作用,所以这个默认设置是非常合理的。当然,这都是针对大部分正常情况而言,下面我们说说不正常的。

不一样的场景

凡是总有特例,现在我们考虑一下这个场景,在同一个事务中,进行两次查询,在第一次查询之后,修改返回的对象。代码如下:

    @Transactional(rollbackFor = Exception.class)
    public void selectDuplicateTransaction(String md5){
        ImageInfo info = imageInfoMapper.byMd5(md5);
        log.info(">>>>>>>>>>>>>>>>>>>=>,{}", info);
        // 修改对象参数
        info.setMd5("000000000000000000000");
        ImageInfo info1  = imageInfoMapper.byMd5(md5);
        log.info(">>>>>>>>>>>>>>>>>>>=>,{}", info1);
    }

这两次输出的对象日志,你觉得会是一样的吗?
下面是相关的日志,可以看出,两次查询条件一样,都是相同的md5字符串,而查到的数据却不一样,这合理吗?

11:25:26.678 logback [main] INFO  c.essay.mybatis.service.ImageService - >>>>>>>>>>>>>>>>>>>=>,ImageInfo(id=1, md5=6e705a7733ac5gbwopmp02, imgUrl=https://fp.test.com/file/XL8iO2No02, status=8, firstJobId=12, createTime=Thu Apr 14 16:37:31 CST 2022, updateTime=Wed May 11 11:12:13 CST 2022)
11:25:26.679 logback [main] INFO  c.essay.mybatis.service.ImageService - >>>>>>>>>>>>>>>>>>>=>,ImageInfo(id=1, md5=000000000000000000000, imgUrl=https://fp.test.com/file/XL8iO2No0, status=8, firstJobId=12, createTime=Thu Apr 14 16:37:31 CST 2022, updateTime=Wed May 11 11:12:13 CST 2022)

分析

如果开启事务,sqlSession的生命周期贯穿事务始终,所以这两次查询用的是同一个sqlSession,第二次查询读取了会话级缓存,最终导致查询出来脏数据。

免脏思路

下面三种方式都可以独立实施,达到避免产生上述脏数据的效果。

YML配置

这是关闭一级缓存的思路
具体配置如下,这个配置默认是session,意思是本地缓存的有效范围辐射同个会话。statement映射是单个方法,也就是说一个Mapper方法执行完成之后,就会调用cache.clear()方法,清除PerpetualCache里缓存的所有数据。这样再进行同样的查询时,就不存在脏数据的问题了。
mybatis.configuration.local-cache-scope: statement
此配置在BaseExecutor.query方法中会用到

  public <E> List<E> query(...) throws SQLException {
    ...
    // 执行查询之后,再清除缓存
    ...
      // 如果配置中定义的是statement,则清除本地缓存【一级缓存】
      if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
        // issue #482
        clearLocalCache();
      }
    ...
    return list;
  }

方法注解

这里提到的是Options注解,在上两个章节反复提到了其使用场景,这里再重复说明一遍。

    @Select("select * from tb_image where md5 = #{md5}")
    @Options(flushCache = Options.FlushCachePolicy.TRUE)
    ImageInfo byMd5(@Param(value = "md5") String md5);

flushCache参数在构建MappedStatement时产生作用,最终会根据此参数来给其flushCacheRequired属性赋值。

      boolean flushCache = !isSelect;
      boolean useCache = isSelect;
      if (options != null) {
        if (FlushCachePolicy.TRUE.equals(options.flushCache())) {
          flushCache = true;
        } else if (FlushCachePolicy.FALSE.equals(options.flushCache())) {
          flushCache = false;
        }
        ...
      }

flushCacheRequired属性的用途主要在三个方面:

  • CachingExecutor里的update方法,更新前清除一级缓存;
  • CachingExecutor里的query方法,查询前清除一级缓存;
  • BaseExecutor里的query方法,查询前清除一级缓存;

这里主要看看 BaseExecutor里的query方法,与local-cache-scope的配置不同的是,这个在查询之前就会发生作用。

  public <E> List<E> query(...) throws SQLException {
    ...
    if (queryStack == 0 && ms.isFlushCacheRequired()) {
      clearLocalCache();
    }
    // 之后执行查询
    ...
    return list;
  }

事务内多个SqlSession

这种方式更直接,从代码层面上,不共享sqlSession。

    @Transactional(rollbackFor = Exception.class)
    public void sqlSessionDuplicateTransaction(String md5){
        // 打开一个sqlSession
        SqlSession sqlSession1 = sqlSessionFactory.openSession();
        ImageInfoMapper mapper = sqlSession1.getMapper(ImageInfoMapper.class);
        ImageInfo info = mapper.byMd5(md5);
        log.info("=>,{}", info);
        info.setMd5("000000000000000000000");
        // 打开第二个sqlSession
        SqlSession sqlSession2 = sqlSessionFactory.openSession();
        ImageInfoMapper mapper2 = sqlSession2.getMapper(ImageInfoMapper.class);
        ImageInfo info2 = mapper2.byMd5(md5);
        log.info("=>,{}", info2);
    }

两次查询分别开启了两个sqlSession,从会话级别上进行了隔离,当然也就不会产生上述的异常了。

二级缓存的脏数据

二级缓存不会有同样的问题,在某个环节直接修改了二级缓存,然后其他查询返回脏数据?

  • 首先,就算开启了二级缓存,前言里提到的那个问题,还是会出现,因为是一级缓存造成的;
  • 然后,事务内的二级缓存,需要等到事务提交的时候,才去更新,所以二级缓存key一定程度上避免脏数据;
  • 其次,跳出单个事务,从系统的角度来看,不同的线程持有了Mapper对象,分别做更新和查询的操作,在CachingExecutor的update方法里,先做了缓存清除,再执行更新。但反事都有万一,如果对此更新方法设置不刷新缓存,那还是会有问题的;
  • 最后,更复杂的一个情况,MapperA里的查询方法是Join MapperB复合查询,MapperB更新以后,不会影响join查询缓存,这样也会产生脏数据。

应对的方法也是多种多样,需要我们开发人员灵活处理,兼顾性能与可用性。