MyBatis作为目前最常用的ORM数据库访问持久层框架,其本身支持动态SQL存储映射等高级特性也非常优秀,通过Mapper文件采用动态代理模式使SQL与业务代码相解耦,日常开发中使用也非常广泛,本文主要讨论mybatis缓存功能,mybatis缓存本身设计初衷是为了解决同一会话相同查询的效率问题,单机环境下也确实起到了提高查询效率的作用,但是随着业务场景变化以及分布式微服务的出现,其弊端也渐渐显现出来,不同会话间操作数据,关联查询数据采用mybatis缓存时会存在出现脏数据的风险。
1.Mybatis一级缓存是SQLSession级别的,一级缓存的作用域是SQlSession;Mabits一级缓存默认是开启的。 在同一个SqlSession中,执行相同的SQL查询时;第一次会去查询数据库,并写在缓存中,第二次会直接从缓存中取。 在同一次会话中执行两次相同查询中间执行了更新操作的时候,缓存会被清空,第二次相同查询仍然会去查询数据库。
2.Mybatis二级缓存是Mapper级别的,二级缓存的作用域是全局的,多个SQlSession共享的,二级缓存的作用域更大;Mybatis二级缓存默认是没有开启的。 第一次调用mapper下的SQL去查询用户的信息,查询到的信息会存放在该mapper对应的二级缓存区域。 第二次调用namespace下的mapper映射文件中,相同的sql去查询用户信息,会去对应的二级缓存内取结果。
默认情况下,只启用了本地的会话缓存,也就是一级缓存,它仅仅对一个会话中的数据进行缓存。 mybatis一级缓存指的是在应用运行过程中,一次数据库会话中,执行多次相同的查询,会优先查询缓存中的数据,减少数据库查询次数,提高查询效率。
每个SqlSession中持有了Executor,每个Executor中有一个LocalCache。当用户发起查询时,MyBatis根据当前执行的语句生成MappedStatement,在Local Cache进行查询,如果缓存命中的话,直接返回结果给用户,如果缓存没有命中的话,查询数据库,结果写入Local Cache,最后返回结果给用户。
mybatis一级缓存默认是开启的,可根据需要选择级别是session或这statement。开发者只需在MyBatis的配置文件中,添加如下语句,就可以使用一级缓存。共有两个选项,session或者statement,默认是session级别,即在一个MyBatis会话中执行的所有语句,都会共享这一个缓存。一种是statement级别,可以理解为缓存只对当前执行的这一个Statement有效。
<setting name="localCacheScope" value="SESSION"/>
1.在初始化SqlSesion时,会使用Configuration类创建一个全新的Executor,作为DefaultSqlSession构造函数的参数。
// newExecutor 尤其可以注意这里,如果二级缓存开关开启的话,是使用CahingExecutor装饰BaseExecutor的子类
if (cacheEnabled) {
executor = new CachingExecutor(executor);
}
2.SqlSession创建完毕后,根据Statment的不同类型,会进入SqlSession的不同方法中,如果是Select语句的话,最后会执行到SqlSession的selectList,代码如下所示:
@Override
public <E> List<E> selectList(String statement, Object parameter, RowBounds rowBounds) {
MappedStatement ms = configuration.getMappedStatement(statement);
return executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER);
}
3.SqlSession把具体的查询职责委托给了Executor。如果只开启了一级缓存的话,首先会进入BaseExecutor的query方法。代码如下所示:
@Override
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
BoundSql boundSql = ms.getBoundSql(parameter);
CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql);
return query(ms, parameter, rowBounds, resultHandler, key, boundSql);
}
4.在上述代码中,会先根据传入的参数生成CacheKey,进入该方法查看CacheKey是如何生成的,代码如下所示:
CacheKey cacheKey = new CacheKey();
cacheKey.update(ms.getId());
cacheKey.update(rowBounds.getOffset());
cacheKey.update(rowBounds.getLimit());
cacheKey.update(boundSql.getSql());
//后面是update了sql中带的参数
cacheKey.update(value);
在上述的代码中,将MappedStatement的Id、SQL的offset、SQL的limit、SQL本身以及SQL中的参数传入了CacheKey这个类,最终构成CacheKey。以下是这个类的内部结构:
public CacheKey() {
this.hashcode = DEFAULT_HASHCODE;
this.multiplier = DEFAULT_MULTIPLYER;
this.count = 0;
this.updateList = new ArrayList<Object>();
}
首先是成员变量和构造函数,有一个初始的hachcode和乘数,同时维护了一个内部的updatelist。在CacheKey的update方法中,会进行一个hashcode和checksum的计算,同时把传入的参数添加进updatelist中。如下代码所示:
public void update(Object object) {
int baseHashCode = object == null ? 1 : ArrayUtil.hashCode(object);
count++;
checksum += baseHashCode;
baseHashCode *= count;
hashcode = multiplier * hashcode + baseHashCode;
updateList.add(object);
}
除去hashcode、checksum和count的比较外,只要updatelist中的元素一一对应相等,那么就可以认为是CacheKey相等。只要两条SQL的下列五个值相同,即可以认为是相同的SQL。
Statement Id + Offset + Limmit + Sql + Params
5.BaseExecutor的query方法继续往下走,代码如下所示:
list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
if (list != null) {
// 这个主要是处理存储过程用的。
handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
} else {
list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
}
如果查不到的话,就从数据库查,在queryFromDatabase中,会对localcache进行写入。
在query方法执行的最后,会判断一级缓存级别是否是STATEMENT级别,如果是的话,就清空缓存,这也就是STATEMENT级别的一级缓存无法共享localCache的原因。代码如下所示:
if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
clearLocalCache();
}
在源码分析的最后,我们确认一下,如果是insert/delete/update方法,缓存就会刷新的原因。
SqlSession的insert方法和delete方法,都会统一走update的流程,代码如下所示:
@Override
public int insert(String statement, Object parameter) {
return update(statement, parameter);
}
@Override
public int delete(String statement) {
return update(statement, null);
}
update方法也是委托给了Executor执行。BaseExecutor的执行方法如下所示:
@Override
public int update(MappedStatement ms, Object parameter) throws SQLException {
ErrorContext.instance().resource(ms.getResource()).activity("executing an update").object(ms.getId());
if (closed) {
throw new ExecutorException("Executor was closed.");
}
clearLocalCache();
return doUpdate(ms, parameter);
}
每次执行update前都会清空localCache。
1.MyBatis一级缓存的生命周期和SqlSession一致。
2.MyBatis一级缓存内部设计简单,只是一个没有容量限定的HashMap,在缓存的功能性上有所欠缺。
3.MyBatis的一级缓存最大范围是SqlSession内部,有多个SqlSession或者分布式的环境下,数据库写操作会引起脏数据,建议设定缓存级别为Statement。
在上文中提到的一级缓存中,其最大的共享范围就是一个SqlSession内部,如果多个SqlSession之间需要共享缓存,则需要使用到二级缓存。开启二级缓存后,会使用CachingExecutor装饰Executor,进入一级缓存的查询流程前,先在CachingExecutor进行二级缓存的查询,具体的工作流程如下所示。
要正确的使用二级缓存,需完成如下配置的。
1.在MyBatis的配置文件中开启二级缓存。
<setting name="cacheEnabled" value="true"/>
2.在MyBatis的映射XML中配置cache或者 cache-ref 。
cache标签用于声明这个namespace使用二级缓存,并且可以自定义配置。
<cache/>
type:cache使用的类型,默认是PerpetualCache,这在一级缓存中提到过。
eviction: 定义回收的策略,常见的有FIFO,LRU。
flushInterval: 配置一定时间自动刷新缓存,单位是毫秒。
size: 最多缓存对象的个数。
readOnly: 是否只读,若配置可读写,则需要对应的实体类能够序列化。
blocking: 若缓存中找不到对应的key,是否会一直blocking,直到有对应的数据进入缓存。
3.cache-ref代表引用别的命名空间的Cache配置,两个命名空间的操作使用的是同一个Cache。
<cache-ref namespace="mapper.StudentMapper"/>
源码分析从CachingExecutor的query方法展开,源代码走读过程中涉及到的知识点较多,不能一一详细讲解,读者朋友可以自行查询相关资料来学习。
CachingExecutor的query方法,首先会从MappedStatement中获得在配置初始化时赋予的Cache。
Cache cache = ms.getCache();
本质上是装饰器模式的使用,具体的装饰链是:
SynchronizedCache -> LoggingCache -> SerializedCache -> LruCache -> PerpetualCache。
以下是具体这些Cache实现类的介绍,他们的组合为Cache赋予了不同的能力。
SynchronizedCache:同步Cache,实现比较简单,直接使用synchronized修饰方法。
LoggingCache:日志功能,装饰类,用于记录缓存的命中率,如果开启了DEBUG模式,则会输出命中率日志。
SerializedCache:序列化功能,将值序列化后存到缓存中。该功能用于缓存返回一份实例的Copy,用于保存线程安全。
LruCache:采用了Lru算法的Cache实现,移除最近最少使用的Key/Value。
PerpetualCache: 作为为最基础的缓存类,底层实现比较简单,直接使用了HashMap。
1.MyBatis的二级缓存相对于一级缓存来说,实现了SqlSession之间缓存数据的共享,同时粒度更加的细,能够到namespace级别,通过Cache接口实现类不同的组合,对Cache的可控性也更强。
2.MyBatis在多表查询时,极大可能会出现脏数据,有设计上的缺陷,安全使用二级缓存的条件比较苛刻。
3.在分布式环境下,由于默认的MyBatis Cache实现都是基于本地的,分布式环境下必然会出现读取到脏数据,需要使用集中式缓存将MyBatis的Cache接口实现,有一定的开发成本,直接使用Redis、Memcached等分布式缓存可能成本更低,安全性也更高。
测试案例地址:https://gitee.com/rjzhu/opencode/tree/master/mybatis-cache-demo
/**
* MyBatis缓存测试类
*/
public class StudentMapperTest {
private SqlSessionFactory factory;
/**
* 初始化SqlSessionFactory
*/
@Before
public void setUp() throws Exception {
factory = new SqlSessionFactoryBuilder().build(Resources.getResourceAsReader("mybatis-config.xml"));
}
/**
* 查看缓存配置是否生效
*
*
*/
@Test
public void showDefaultCacheConfiguration() {
System.out.println("本地缓存范围: " + factory.getConfiguration().getLocalCacheScope());
System.out.println("二级缓存是否被启用: " + factory.getConfiguration().isCacheEnabled());
}
/**
* MyBatis缓存测试一
* 测试:同一个会话,相同查询连续查询三次
* 结果:第一次查询数据库,二三次查询从缓存读取
* 结论:同一个会话,多次相同查询,只有第一次查询数据库,其他都是缓存中获取,提高了查询效率
*/
@Test
public void testLocalCache() {
SqlSession sqlSession = factory.openSession(true); // 自动提交事务
StudentMapper studentMapper = sqlSession.getMapper(StudentMapper.class);
//第一次查询数据库,二三次查询直接从缓存读取
System.out.println("第一次查询:" + studentMapper.getStudentById(1));
System.out.println("第二次查询:" + studentMapper.getStudentById(1));
System.out.println("第三次查询:" + studentMapper.getStudentById(1));
sqlSession.close();
}
/**
* MyBatis缓存测试二
* 测试:同一个会话,先查询,再新增,再次重复第一次查询
* 结果:第一次与第二次查询都查询数据库,修改操作后执行的相同查询,查询了数据库,一级缓存失效。
* 结论:同一个会话执行更新操作后缓存失效,源码中会清空缓存
*/
@Test
public void testLocalCacheClear() {
SqlSession sqlSession = factory.openSession(true); // 自动提交事务
StudentMapper studentMapper = sqlSession.getMapper(StudentMapper.class);
//第一次与第二次查询都查询数据库,修改操作后执行的相同查询,查询了数据库,一级缓存失效。
System.out.println("第一次查询:" + studentMapper.getStudentById(1));
System.out.println("增加了" + studentMapper.addStudent(StudentEntity.builder().name("明明").age(20).build()) + "个学生");
System.out.println("第二次查询:" + studentMapper.getStudentById(1));
sqlSession.close();
}
/**
* MyBatis缓存测试三
* 测试:同时开启两个会话,会话一连续两次查询,会话二更新操作,会话一再次相同查询,会话二相同查询
* 结果:会话一第一次查询数据库,第二次查询缓存,会话二更新完成,会话一再次相同查询仍然查询缓存(读取脏数据),会话二查询数据库获取最新数据。
* 结论:缓存作用范围是一个会话当中,当其中有会话更新数据,其他会话会读取到脏数据
*/
@Test
public void testLocalCacheScope() {
//开启两个SqlSession,在sqlSession1中查询数据,使一级缓存生效,在sqlSession2中更新数据库
//验证一级缓存只在数据库会话内部共享。
SqlSession sqlSession1 = factory.openSession(true); // 自动提交事务
SqlSession sqlSession2 = factory.openSession(true); // 自动提交事务
StudentMapper studentMapper = sqlSession1.getMapper(StudentMapper.class);
StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class);
System.out.println("studentMapper读取数据: " + studentMapper.getStudentById(1));
System.out.println("studentMapper读取数据: " + studentMapper.getStudentById(1));
System.out.println("studentMapper2更新了" + studentMapper2.updateStudentName("小岑", 1) + "个学生的数据");
System.out.println("studentMapper读取数据: " + studentMapper.getStudentById(1));
System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentById(1));
}
/**
* MyBatis缓存测试四
* 测试:同时开启两个会话,两个会话执行相同的查询
* 结果:两次都是查询数据库
* 结论:缓存作用范围是一个会话当中,不同会话,即使是相同查询,只使用各自的缓存
*/
@Test
public void testCacheWithoutCommitOrClose() {
SqlSession sqlSession1 = factory.openSession(true); // 自动提交事务
SqlSession sqlSession2 = factory.openSession(true); // 自动提交事务
StudentMapper studentMapper = sqlSession1.getMapper(StudentMapper.class);
StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class);
//两次都是从数据库读取,说明需要提交事务,第二次查询才能走缓存
System.out.println("studentMapper读取数据: " + studentMapper.getStudentById(1));
System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentById(1));
}
/**
* MyBatis缓存测试四
* 测试:同时开启两个会话,两个会话执行相同的查询
* 结果:两次都是查询数据库
* 结论:缓存作用范围是一个会话当中,不同会话,即使是相同查询,只使用各自的缓存
*/
@Test
public void testCacheWithCommitOrClose() {
SqlSession sqlSession1 = factory.openSession(true); // 自动提交事务
SqlSession sqlSession2 = factory.openSession(true); // 自动提交事务
StudentMapper studentMapper = sqlSession1.getMapper(StudentMapper.class);
StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class);
//第一次提交以后,第二次走缓存
System.out.println("studentMapper读取数据: " + studentMapper.getStudentById(1));
sqlSession1.close();
System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentById(1));
}
/**
* MyBatis缓存测试五
* 测试:同时开启三个会话,通过接口方式,会话一查询后提交事务,会话二执行相同查询,缓存查询,会话三更新提交事务,会话二查询缓存
* 结果:只有第一次查询数据库,其余都是查询缓存
* 结论:只有提交事务以后,后续相同查询才会查询缓存,否则查询数据库
*/
@Test
public void testCacheWithUpdate() {
SqlSession sqlSession1 = factory.openSession(true); // 自动提交事务
SqlSession sqlSession2 = factory.openSession(true); // 自动提交事务
SqlSession sqlSession3 = factory.openSession(true); // 自动提交事务
StudentMapper studentMapper = sqlSession1.getMapper(StudentMapper.class);
StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class);
StudentMapper studentMapper3 = sqlSession3.getMapper(StudentMapper.class);
System.out.println("studentMapper1读取数据: " + studentMapper.getStudentById(1));
sqlSession1.close();
System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentById(1));
studentMapper3.updateStudentName("方方", 1);
sqlSession3.commit();
System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentById(1));
}
/**
* MyBatis缓存测试六
* 测试:测试关联查询,出现脏数据问题,
* 结论:不同会话之间关联查询的时候,其中会话更新单独更新关联的其中一个表,另一个会话感知不到,在不同的mapper文件中,缓存查询会出现脏数据情况
*/
@Test
public void testCacheWithDiffererntNamespace() {
// 设置自动提交事务
SqlSession sqlSession1 = factory.openSession(true);
SqlSession sqlSession2 = factory.openSession(true);
SqlSession sqlSession3 = factory.openSession(true);
StudentMapper studentMapper1 = sqlSession1.getMapper(StudentMapper.class);
StudentMapper studentMapper2 = sqlSession2.getMapper(StudentMapper.class);
ClassMapper classMapper3 = sqlSession3.getMapper(ClassMapper.class);
System.out.println("studentMapper1读取数据: " + studentMapper1.getStudentByIdWithClassInfo(1));
sqlSession1.close();
System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentByIdWithClassInfo(1));
//更新数据
classMapper3.updateClassName("特色一班", 1);
sqlSession3.commit();
//读取到脏数据,studentMapper2读取数据: StudentEntity(id=1, name=方方, age=16, className=一班)
System.out.println("studentMapper2读取数据: " + studentMapper2.getStudentByIdWithClassInfo(1));
}
}
MyBatis中文网:https://mybatis.net.cn/index.html
MyBatis英文网:https://mybatis.org/mybatis-3/index.html
MyBatis执行流程源码分析:https://blog.csdn.net/m0_37583655/article/details/122115750
聊聊MyBatis缓存机制
mybatis一级缓存和二级缓存的区别是什么