- 前言 -
缓存是 MyBatis 中非常重要的特性。合理使用缓存能够减少数据库 IO,显著提升系统性能。但是在分布式环境下,如果使用不当,则可能会带来数据一致性问题。MyBatis 提供了一级缓存和二级缓存,其中一级缓存基于 SqlSession 实现,而二级缓存基于 Mapper,本文将会详细讲解一级缓存。
- CACHE 缓存 -
MyBatis 跟缓存相关的类都在 Cache 包里面,其中有一个 Cache 接口,只有一个默认的实现类 PerpetualCache,它是用 HashMap 实现的。除此之外,还有很多的装饰器,通过这些装饰器可以额外实现很多的功能:回收策略、日志记录、定时刷新等等。
1、包结构
如图是缓存类所在源码中所处的位置,从包名中我们可以知道 decorators 包中存放的是一些装饰类。
2、查看装饰后的结果
但是无论怎么装饰,经过多少层装饰,最后使用的还是基本的实现类(默认PerpetualCache)。我们 debug 看一下经层层装饰后的结果如图:
CachingExecutor中Debug查看:缓存的层层装饰
我们后面说的一级缓存缓存就是存放到这个 PerpetualCache 里面。
3、对装饰器的分类
- 一级缓存的特点 -
1、一级缓存默认是开启的,而且不能关闭
至于一级缓存为什么不能关闭,MyBatis 核心开发人员做出了解释:
MyBatis的一些关键特性(例如通过<association>和<collection>建立级联映射、避免循环引用(circular references)、加速重复嵌套查询等)都是基于MyBatis一级缓存实现的,而且MyBatis结果集映射相关代码重度依赖CacheKey,所以目前MyBatis不支持关闭一级缓存。
虽然我们不能关闭一级缓存,但是我们可以更改他的作用范围:
MyBatis提供了一个配置参数localCacheScope,用于控制一级缓存的级别,该参数的取值为SESSION、STATEMENT,当指定localCacheScope参数值为SESSION时,缓存对整个SqlSession有效,只有执行DML语句(更新语句)时,缓存才会被清除。当localCacheScope值为STATEMENT时,缓存仅对当前执行的语句有效,当语句执行完毕后,缓存就会被清空。
<settings>
<setting name="localCacheScope" value="STATEMENT"/>
</settings>
能更改一级缓存的作用范围这一点很重要后面我们讲解中会用到这一特性。
2、一级缓存默认是SqlSession级别的
在操作数据库时需要构造 sqlSession 对象,在对象中有一个(内存区域)数据结构(HashMap)用于存储缓存数据。不同的 sqlSession 之间的缓存数据区域(HashMap)是互相不影响的。用一张图来表示一下一级缓存,其中每一个 SqlSession 的内部都会有一个一级缓存对象。
- 实验验证一级缓存的作用范围 -
1、一级缓存同一个会话共享数据
模拟思路:打开一个会话,进行两次查询通过日志查看第二次是否走数据库。
代码如下:
@Test
public void testSession() throws IOException {
try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
OrderMapper orderMapper = sqlSession.getMapper(OrderMapper.class);
List<Order> orders = orderMapper.queryById(620898339119480832L);
System.out.println("第一次查询:" + JSON.toJSONString(orders));
OrderMapper orderMapper2 = sqlSession.getMapper(OrderMapper.class);
List<Order> orders2 = orderMapper2.queryById(620898339119480832L);
System.out.println("第二次查询:" + JSON.toJSONString(orders2));
}
}
日志信息如下:
分析:第一次查询打印了 sql 日志信息,说明是通过数据库获取到数据,第二次也查询到数据但是没有打印日志信息,说明走了缓存。
结论:一级缓存同一个会话共享数据。
2、同一个会话如果有更新操作则缓存清除
模拟思路:打开一个会话,先进行查询,然后进行更新操作,最后再次查询刚才的语句,看是否打印查询数据库的 sql 日志(这些操作都是对一条数据的操作)。
代码如下:
@Test
public void testUpdate() throws IOException {
try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
// 同一个会话 第一次查询
System.out.println("第一次会会话的 第一次查询");
OrderMapper orderMapper = sqlSession.getMapper(OrderMapper.class);
List<Order> orders = orderMapper.queryById(620898339119480832L);
Order order = orders.get(0);
order.setAmount(12L);
// 进行更新
orderMapper.updateByPrimaryKey(order);
// 同一个会话 第二次查询
System.out.println("第一次会会话的 第二次查询");
List<Order> orders2 = orderMapper.queryById(620898339119480832L);
System.out.println(JSON.toJSONString(orders2));
}
}
日志信息如下:
分析:第一次查询打印了 sql 日志,然后进行数据更新,最后进行第二次查询发现仍旧查询数据库,说明缓存已经失效。
结论:同一个会话如果有更新操作则缓存清除。
3、导致脏数据
模拟思路:打开两个会话,第一个会话查询数据库获取数据后 ,接着第二个会话修改数据,最后第一个会话再查询数据,那最后这次查询如果和第一次查询相同,那说明一级缓存会导致脏数据问题。
代码如下:
@Test
public void testDirtyData() throws IOException {
SqlSession sqlSession1 = sqlSessionFactory.openSession(true);
SqlSession sqlSession2 = sqlSessionFactory.openSession(true);
try {
// 同一个会话 第一次查询
OrderMapper orderMapper1 = sqlSession1.getMapper(OrderMapper.class);
OrderMapper orderMapper2 = sqlSession2.getMapper(OrderMapper.class);
List<Order> orders = orderMapper1.queryById(620898339119480832L);
System.out.println("第一次会会话第一次查询的结果" + orders);
Order order1 = getOrder(orders);
System.out.println("=========更新数据======");
orderMapper2.updateByPrimaryKey(order1);
sqlSession2.commit();
List<Order> orders1 = orderMapper1.queryById(620898339119480832L);
System.out.println("第二次查询:"+JSON.toJSONString(orders1));
}catch (Exception e){
sqlSession1.close();
sqlSession2.close();
}
}
日志信息如下:
分析:第一个会话第一次查询 amount 值是1212,第二会话将 amount 更改为666,第一个会话再次查询数据获取的 amount 值为1212,这说明第一个会话的第二次查询命中缓存导致了脏数据问题。
结论:一级缓存在多会话中会导致脏数据。
解决方式:在配置一级缓存作用范围的时候将其设置为 STATEMENT,那么缓存仅对当前执行的语句有效,当语句执行完毕后,缓存就会被清空。
设置方式:
<settings>
<setting name="localCacheScope" value="STATEMENT"/>
</settings>