前言
前面章节提到,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查询缓存,这样也会产生脏数据。
应对的方法也是多种多样,需要我们开发人员灵活处理,兼顾性能与可用性。