前言
什么是MyBatis的一级缓存、二级缓存呢,它们的作用又是什么呢?其实很简单,MYBatis的一级缓存和二级缓存的作用都是为了减少数据库查询,对于相同的DQL语句和相同的查询参数复用之前的查询结果。它们之间的区别在于缓存范围。
一级缓存也称为本地缓存,其缓存范围是SqlSession级别,即使用同一个SqlSession进行相同条件的N次查询,实际只会查询数据库一次(注意,不能在这N次查询之间进行增删改,以及调用close方法,这会让缓存失效),默认开启;而二级缓存范围是全局的,也可以简单理解为接口级别(深究的话,如果使用XML,那就是namespace + 查询语句的id,如果使用注解就是接口的全限定名 + 方法名,通过接口名来隔离不同接口中方法名相同问题,说是接口级别也没毛病),默认关闭。
Mybatis 使用到了两种缓存:本地缓存(local cache)和二级缓存(second level cache)。
每当一个新 session 被创建,MyBatis 就会创建一个与之相关联的本地缓存。任何在 session 执行过的查询结果都会被保存在本地缓存中,所以,当再次执行参数相同的相同查询时,就不需要实际查询数据库了。本地缓存将会在做出修改、事务提交或回滚,以及关闭 session 时清空。
默认情况下,只启用了本地的会话缓存,它仅仅对一个会话中的数据进行缓存。 要启用全局的二级缓存,只需要在你的 SQL 映射文件中添加一行:<cache/>
基本上就是这样。这个简单语句的效果如下:
1.映射语句文件中的所有 select 语句的结果将会被缓存。
2.映射语句文件中的所有 insert、update 和 delete 语句会刷新缓存。
3.缓存会使用最近最少使用算法(LRU, Least Recently Used)算法来清除不需要的缓存。
4.缓存不会定时进行刷新(也就是说,没有刷新间隔)。
5.缓存会保存列表或对象(无论查询方法返回哪种)的 1024 个引用。
6.缓存会被视为读/写缓存,这意味着获取到的对象并不是共享的,可以安全地被调用者修改,而不干扰其他调用者或线程所做的潜在修改。
二级缓存是事务性的。这意味着,当 SqlSession 完成并提交时,或是完成并回滚,但没有执行 flushCache=true 的 insert/delete/update 语句时,缓存会获得更新。
注:@CacheNamespace注解启用二级缓存。下文将从源码角度对MyBatis一级缓存和二级缓存进行分析,对使用方式不做过多讲解,MyBatis在这方面还是很贴心的,有中文版的文档,需要查看的同学请移步官方文档。
一级缓存带来的问题
大家可以想一想一级缓存会引发什么问题呢?
先来看下代码:
/**
* 测试MyBatis一级缓存(本地缓存)
* @author 君战
*/
public static void testLocalCache(){
try (InputStream in = Resources.getResourceAsStream("mybatis-config.xml")){
SqlSessionFactoryBuilder factoryBuilder = new SqlSessionFactoryBuilder();
SqlSessionFactory sessionFactory = factoryBuilder.build(in);
try (SqlSession sqlSession = sessionFactory.openSession(true);
SqlSession sqlSession2 = sessionFactory.openSession(true)){
TUserMapper userMapper = sqlSession.getMapper(TUserMapper.class);
TUserMapper userMapper2 = sqlSession2.getMapper(TUserMapper.class);
// 先删除
userMapper2.delete(1);
// 使用userMapper进行第一次查询
TUserDO tUserDO = userMapper.selectById(1);
System.out.println("t_user表中不存在id = 1的记录:" + (tUserDO == null));
tUserDO = new TUserDO();
tUserDO.setEmail("weibincnwork@163.com");
tUserDO.setName("君战");
tUserDO.setId(1);
// 使用userMapper2插入一条记录
userMapper2.insert(tUserDO);
// 使用userMapper进行第二次查询
tUserDO = userMapper.selectById(1);
System.out.println("因为使用了本地缓存,所以这次查询也是为空 : " +(tUserDO == null));
} catch (Exception e){
throw new RuntimeException(e);
}
} catch (IOException ex){
throw new RuntimeException(ex);
}
}
控制台打印:
t_user表中不存在id = 1的记录:true
因为使用了本地缓存,所以这次查询也是为空 : true
Process finished with exit code 0
和文档描述的一样,两次从同一个SqlSession中查询到的数据是一样的(如果没有使用缓存,那么第二次查询的数据不会为空,因为调用了userMapper2去插入了ID为1的数据)。
我们都知道数据库有四个隔离级别,分别为读未提交(Read uncommitted)、读已提交(Read committed)、可重复读(Repeatable read)、串行化读(Serializable )。
MySQL的默认隔离级别是可重复读。MyBatis这个优化符合可重复读规则,在一个会话中多次读取到的数据一致。但如果把数据库隔离级别设置为已读提交,我们期望每次都能读到数据库最新提交的记录,却发现在数据库中该条记录明明已经更新,但代码读到的还是之前的版本,如下图所示,一脸懵逼。
怎么解决这个问题呢?如果是注解 + SQL方式的话,可以在接口方法上添加org.apache.ibatis.annotations.Options注解(该注解只能加在方法上),然后设置其flushCache属性(flushCache = Options.FlushCachePolicy.TRUE)。
@Options(flushCache = Options.FlushCachePolicy.TRUE)
@Select("SELECT * FROM t_user WHERE ID = #{id}")
TUserDO selectById(@Param("id")int id);
或者在MyBatis配置文件中,在属性中添加:
<setting name="localCacheScope" value="STATEMENT"/>
这两者的效果是一样的。
t_user表中不存在id = 1的记录:true
因为使用了本地缓存,所以这次查询也是为空 : false
Process finished with exit code 0
如果是XML方式的话,将标签的flushCache属性设置为true即可。
<select id="selectByExample" flushCache="true">
<!--your sql-->
</select>
二级缓存带来的问题
前面我们已经说过二级缓存是全局性的,接口级别的。接下来我们就用注解@CacheNamespace启用二级缓存。
/**
* TUserMapper
* @author 君战
* @since 2021/5/20
*/
@CacheNamespace
public interface TUserMapper {
// @Options(flushCache = Options.FlushCachePolicy.TRUE)
@Select("SELECT * FROM t_user WHERE ID = #{id}")
TUserDO selectById(@Param("id")int id);
@Insert("INSERT INTO t_user values(#{id},#{email},#{name})")
int insert(TUserDO userDO);
@Delete("DELETE FROM t_user WHERE id = #{id}")
int delete(int id);
}
启动测试代码:
/**
* 测试MyBatis二级缓存
* @author 君战
*/
private static void testSecondLevelCache(){
try (InputStream in = Resources.getResourceAsStream("mybatis-config.xml")){
SqlSessionFactoryBuilder factoryBuilder = new SqlSessionFactoryBuilder();
SqlSessionFactory sessionFactory = factoryBuilder.build(in);
try (SqlSession sqlSession = sessionFactory.openSession(true);
SqlSession sqlSession2 = sessionFactory.openSession(true)){
TUserMapper userMapper = sqlSession.getMapper(TUserMapper.class);
TUserMapper userMapper2 = sqlSession2.getMapper(TUserMapper.class);
// 使用userMapper进行第一次查询
TUserDO tUserDO = userMapper.selectById(1);
System.out.println(tUserDO);
sqlSession.close();
TUserDO tUserDO2 = userMapper2.selectById(1);
System.out.println(tUserDO2);
} catch (Exception e){
throw new RuntimeException(e);
}
} catch (IOException ex){
throw new RuntimeException(ex);
}
}
查看控制台:
可以看出第二次查询已经命中缓存。
这会带来什么问题呢?假设现在有一个叫TUserMapper2的接口也会去更新t_user表,这时就出现问题了,因为二级缓存是接口级别的,别的接口更新该条数据,不会去清除TUserMapper的缓存,导致TUserMapper读到的都是旧数据。这也是MyBatis默认关闭二级缓存的一个原因。
一级缓存和二级缓存源码分析
之所以要比这两个缓存实现放到一起分析,是因为它们是紧密相连的。
对于每一个DQL是先查询二级缓存(如果启用),再去查询以及缓存。二级缓存实现源码位于CachingExecutor的query方法中。
// org.apache.ibatis.executor.CachingExecutor#query
@Override
public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
throws SQLException {
Cache cache = ms.getCache();
// 如果缓存不为空。如果未启用二级缓存,这个判断就不会成立。
if (cache != null) {
// 如果需要刷新缓存
flushCacheIfRequired(ms);
// 如果启用缓存并且ResultHandler 为空
if (ms.isUseCache() && resultHandler == null) {
ensureNoOutParams(ms, boundSql);
@SuppressWarnings("unchecked")
// 去缓存中获取
List<E> list = (List<E>) tcm.getObject(cache, key);
if (list == null) {// 如果获取到的值为空
// 委派执行。这里使用到的delegate为SimpleExecutor,SimpleExecutor并未实现query方法,调用的是其父类BaseExecutor
// 的query方法。其实就是去一级缓存中查询
list = delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
// 将查询结果保存到二级缓存中。
tcm.putObject(cache, key, list); // issue #578 and #116
}
return list;
}
}
// 未启用二级缓存,直接查询
return delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}
这不就是典型的装饰者模式应用。
再来看下BaseExecutor实现的query方法。
// org.apache.ibatis.executor.BaseExecutor#query
@Override
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
ErrorContext.instance().resource(ms.getResource()).activity("executing a query").object(ms.getId());
if (closed) {
throw new ExecutorException("Executor was closed.");
}
// 一级缓存是无法关闭的。但是我们可以通过配置类似@Options(flushCache = Options.FlushCachePolicy.TRUE)这种方式
// 来强制刷新缓存。如果我们配置了, ms.isFlushCacheRequired()方法就会返回true,然后就会执行clearLocalCache方法。
if (queryStack == 0 && ms.isFlushCacheRequired()) {
clearLocalCache();
}
List<E> list;
try {
queryStack++;
list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;// 去缓存中查询
if (list != null) {
handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
} else {// 缓存获取到的值为空,去数据库查询,那么查询结果是何时保存到一级缓存中的呢?答案就在这个queryFromDatabase中。
list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
}
} finally {
queryStack--;
}
if (queryStack == 0) {
for (DeferredLoad deferredLoad : deferredLoads) {
deferredLoad.load();
}
// issue #601
deferredLoads.clear();
if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
// issue #482
clearLocalCache();
}
}
return list;
}
将从数据库查询到的结果保存到一级缓存,就是在queryFromDatabase方法中完成的。
// org.apache.ibatis.executor.BaseExecutor#queryFromDatabase
private <E> List<E> queryFromDatabase(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
List<E> list;
localCache.putObject(key, EXECUTION_PLACEHOLDER);
try {// doQuery是真正去数据库查询的方法
list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);
} finally {
localCache.removeObject(key);
}
// 将从数据查询到的结果保存到一级缓存中。
localCache.putObject(key, list);
if (ms.getStatementType() == StatementType.CALLABLE) {
localOutputParameterCache.putObject(key, parameter);
}
return list;
}
下图是我根据源码画图的简略流程图,希望能更便于大家理解一级缓存和二级缓存。
总结
通过以上分析,我们可以得知,一级缓存是默认开启的,如果使用的事务隔离级别就是可重复读,其实没什么问题。但如果使用的隔离级别是已提交读,那就需要注意MyBatis的一级缓存,可以通过配置来每次刷新一级缓存或者让一级缓存失效。其实这部分也不用担心,因为现代的大部分应用都是基于Spring + MyBatis,而MyBatis和Spring整合后,MyBatis的一级缓存就失去了效果。
对于一级缓存和二级缓存的源码实现并没有展开分析,只是简单分析了下它们的调用流程,之后我可能会再写一篇博客来详细分析MyBatis的缓存实现。这部分其实挺有意思的,例如缓存类的各种包装,层层嵌套。
附录
DO
@Data
public class TUserDO implements Serializable {
private int id;
private String email;
private String name;
}
DDL
CREATE TABLE `t_user` (
`id` int NOT NULL AUTO_INCREMENT,
`email` varchar(64) NOT NULL,
`name` varchar(32) NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_email` (`email`),
KEY `name` (`name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;