MyBatis有必要使用缓存吗?为什么?
一般的ORM框架都会提供缓存功能来提升查询效率、减少数据库的压力。跟Hibernate一样,Mybatis也有一级缓存、二级缓存,并预留了集成第三方的缓存接口。
在Mybatis中,与缓存相关的类都在cache包中,其中有一个Cache接口,只有一个默认的实现类PerpetualCache,它是用HashMap实现的。
PerpetualCache这个对象是一定会创建的,所以是基础缓存。但是缓存又可以有很多额外的功能,比如回收策略、日志记录、定时刷新等等,如果需要的话,就可以给基础缓存加上这些功能。
除了基础缓存之外,MyBatis也定义了很多装饰器,同样实现了Cache接口,通过这些装饰器可以额外实现很多功能。
所有缓存可以分为三大类:基本缓存、淘汰算法缓存、装饰器缓存。
类型 | 缓存实现类 | 描述 | 作用 | 装饰条件 |
---|---|---|---|---|
基本缓存 | PerpetualCache | 缓存基本实现类 | 默认是PeretualCache也可以自定义比如RedisCache、EhCache等,具备基本能功能的缓存 | 无 |
淘汰算法缓存 | LruCache | LRU淘汰策略的缓存 | 当缓存达到上限时,删除最少使用的缓存(Laste Recently Use) | eviction="LRU"(默认) |
FifoCache | FIFO策略缓存 | 当缓存达到上限时,删除最先入队的缓存 | evication="FIFO" | |
SoftCache \ WeakCache | 带清理策略的缓存 | 通过JVM的软引用和弱引用来实现缓存,当JVM内存不足时,会自动清理这些缓存。 | evication="SOFT" \ evication="WEAK" | |
装饰器缓存 | LoggingCache | 带日志功能的缓存 | 如可以输出缓存命中率 | 基本 |
SynchronnizedCache | 同步缓存 | 基于synchronized关键字实现,解决并发问题 | 基本 | |
BlockingCache | 阻塞缓存 | 通过在get/put方式中加锁,保证只有一个线程操作缓存,基于java重入锁实现 | bloking=true | |
SerializedCache | 支持序列化的缓存 | 将对象序列化以后存到缓存中,取出时反序列化 | readOnly=false(默认) | |
ScheduledCache | 定时调度的缓存 | 在进行get 、put、remove、getSize等操作前,判断缓存时间是否超过了设置的最长缓存时间(默认一小时),如果是则清空缓存——每隔一段时间清空一次缓存 | flushInterval不为空 | |
TransactionnalCache | 事务缓存 | 在二级缓存中使用,可一次存入多个缓存,移除多个缓存 | 在TransactionalCacheManager中用Map维护对应关系 |
一级缓存也叫本地缓存(Local Cache),MyBatis的一级缓存是在绘画(SqlSession)层进行缓存的。MyBatis一缓存默认是开启的,不需要任何配置(localCacheScope=STATEMENT相当于关闭一级缓存)。
可以在BaseExecutor的query()方法中找到localCacheScope清除逻辑:
if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
// issue #482
clearLocalCache();
}
在MyBatis执行流程里面,涉及到这么多对象,那么缓存PerpetualCache应该放在哪个对象里面去呢?
如果要在同一个会话里共享一级缓存,最好的办法是在SqlSession里创建的,作为SqlSession的一个属性,跟SqlSession生命周期相同,这样就不需要为SqlSession编号、再根据编号查找对应缓存了。
DefaultSqlSession里只有两个对象属性:Configuration和Executor。
Configuration是全局的,不属于SqlSession,所以缓存只可能放在Executor里,因为Executor是一个接口,实际上他是在基本执行器中(SimpleExecutor\ReuseExecutor\BatchExecutor的父类BaseExecutor的构造函数中持有了PerpetualCache)。
在同一个会话里,多次执行相同SQL语句,会直接从内存取到缓存的结果,不要再发送SQL到数据库。但在不同的会话里,即使执行的SQL一样,也不能使用一级缓存(因为跨了Session)。
下边来通过实际例子来验证一级缓存,并通过分析源码了解实现原理。
/**
* 测试一级缓存作用域,是在SqlSession -> Executor -> localCache 中存储
*/
@Test
public void testFirestCache(){
SqlSession sqlSession1 = sqlSessionFactory.openSession();
SqlSession sqlSession2 = sqlSessionFactory.openSession();
try {
//使用相同sqlsession执行查询方法,只打印了1次查询语句
UserMapper userMapper1 = sqlSession1.getMapper(UserMapper.class);
UserMapper userMapper2 = sqlSession1.getMapper(UserMapper.class);
User user1 = userMapper1.byId(1L);
User user2 = userMapper2.byId(1L);
LoggerUtil.printThread("相同Session第1个结果:" + user1.toString());
LoggerUtil.printThread("相同Session第2个结果:" + user2.toString());
LoggerUtil.split();
//使用不同session对象执行查询,会打印出2次查询语句
UserMapper userMapper3 = sqlSession2.getMapper(UserMapper.class);
User user3 = userMapper3.byId(1L);
LoggerUtil.printThread("不同Session结果:" + user3.toString());
}finally {
sqlSession1.close();
sqlSession2.close();
}
}
执行结果:
通过上边的测试代码可以看出,在使用sqlSession1的分别执行的两次查询,只输出一条执行sql语句,表示第二次查询时使用了缓存,未发送sql到数据库。
在sqlSession2执行的相同SQL时新输出了一条SQL表示没有使用缓存。
使用一级缓存的时候,因为缓存不能跨会话共享,不同的会话之间对于相同的数据可能有不一样的缓存。在有多个回话或者分布式环境下,会存在查到过时数据的问题(缓存脏读)。如下别的例子:
/**
* 缓存脏读,当有两个sqlsession同时操作一条数据时,会导致其中一个sqlsession不触发缓存清空,导致其中一个使用旧的缓存数据(已是脏数据)
*/
@Test
public void testCacheDirtyRead(){
SqlSession sqlSession1 = sqlSessionFactory.openSession();
SqlSession sqlSession2 = sqlSessionFactory.openSession();
try {
UserMapper userMapper1 = sqlSession1.getMapper(UserMapper.class);
UserMapper userMapper2 = sqlSession2.getMapper(UserMapper.class);
User user1 = userMapper1.byId(1001L);
LoggerUtil.printThread("session1 第1个结果:" + user1.toString());
LoggerUtil.split();
LoggerUtil.printThread("在session2 中更新数据。");
User newUser = new User(1001L,"测试" + DateUtil.now(),20);
userMapper2.update(newUser);
sqlSession2.commit();
LoggerUtil.printThread("session2 第1个结果(更新数据):" + userMapper2.byId(1001L).toString());
LoggerUtil.split();
//使用session1再次查询,使用缓存,导致使用旧数据
User user3 = userMapper1.byId(1001L);
LoggerUtil.printThread("session1 第2个结果(旧数据):" + user3.toString());
}finally {
sqlSession1.close();
sqlSession2.close();
}
}
执行结果:
可以看到,在session2时已把用户名称修改为最新时间,但因为session1中使用一级缓存,不知道其他session的变更,所以导致缓存脏读。
如果要结果这个问题,就需要用到工作范围更广的二级缓存。
二级缓存是用来解决一级缓存不能跨会话共享问题的,范围是namespace级别的,可以被多个SqlSession共享(只要是同一个接口里的相同方法,都可以共享),生命周期和应用同步。
如果开启了二级缓存,二级缓存应用是工作在一级缓存之前,还是一级缓存之后呢?
作为一个作用范围更广的缓存,它肯定是在SqlSession的外层,否则不可能被多个SqlSession共享。
而一级缓存是在SqlSession内部的,所以肯定是工作在一级缓存之前,也就是只有取不到二级缓存的情况下才到一个会话中去取一级缓存。
二级缓存是在哪里维护的呢?
要夸会话共享的话,SqlSession本身和它里面的BaseExecutor已经满足不了需求了,那我们应该在BaseExecutor之外创建一个对象。
但是二级缓存是不一定开启的。也就是说,开启了二级缓存,就启用这个对象,如果没有,就不用这个对象,我们应用怎么做呢?
实际上MyBatis用了一个装饰器的类来维护,就是CachingExecutor。如果启用了二级缓存,MyBatis在创建Executor的时候会对Executor进行装饰。
CachingExecutor对于查询请求,会判断二级缓存是否有缓存结果,如果有就直接返回,如果没有委派给真正的查询器Executor实现类,比如SimpleExecutor来执行查询,再走到一级缓存的流程。最后会把结果缓存起来,并且返回给用户。
在mybatis-config.xml中配置了(可以不配置,默认是true)
<setting name="cacheEnabled" value="true" />
只要没显示的设置cacheEnabled=false,都会用CachingExecutor装饰基本的执行器(Simple、Reuse、Batch)。
二级缓存的总开关默认是开启的。但是每个Mapper的二级缓存开关是默认关闭的。一个Mapper要用到二级缓存,还要单独打开它自己的开关。
<!-- 在mapper.xml中声明这个namespace使用二级缓存 -->
<cache type="org.apache.ibatis.cache.impl.PerpetualCache"
size="1024" <!--最多缓存对象个数,默认1024-->
eviction="LRU" <!--回收策略-->
flushInterval="120000" <!--自动刷新时间ms,未配置时只有调用时刷新-->
readOnly="false"<!--默认是false(安全),改为true可读写时,对象必须支持序列化-->
/>
mapper.xml配置了<cache>之后,select()会被缓存。update()、delete()、insert()会刷新缓存。
如果二级缓存拿到结果了,就直接返回(最外层判断),否则再到一级缓存,最后到数据库。
如果一个Mapper需要开启二级缓存,但是这个里面的某些查询方法对数据的实时性要求很高,不需要二级缓存,怎么办?
我们可以在单个Statement ID上显示关闭二级缓存(默认是true):
<select id="byId" resultMap="BaseResultMap" useCache="false" >
...
</select>
什么场景适合使用二级缓存?
所以,推荐在一个Mapper里只操作单表的情况使用。
如果让多个namespace共享一个二级缓存,应该怎么做?
<cache-ref namespace="<其他命名空间>" />
cache-ref代表引用别的命名空间的Cache配置,两个命名空间的操作使用的是同一个cache。在关联表比较少,或者按照业务可以对表进行分组的时候可以使用。
注意:这种情况下,多个Mapper操作都会引起缓存刷新,缓存的意义已经不大了。