这是我参与8月更文挑战的第6天,活动详情查看:8月更文挑战
1. 前言
为了提升持久层数据查询的性能,MyBatis提供了一套缓存机制,根据缓存的作用范围可分为:一级缓存和二级缓存。一级缓存默认是开启的,作用域为SqlSession,也称作【回话缓存/本地缓存】。二级缓存默认是关闭的,需要手动开启,作用域为namespace。本篇文章暂且不讨论二级缓存,仅从源码的角度分析一下MyBatis一级缓存的实现原理。
我们已经知道,SqlSession是MyBatis对外提供的,操作数据库的唯一接口。当我们从SqlSessionFactory打开一个新的回话时,一个新的SqlSession实例将被创建。SqlSession内置了一个Executor,它是MyBatis提供的操作数据库的执行器,当我们执行数据库查询时,最终会调用Executor.query()
方法,它在查询数据库前会先判断是否命中一级缓存,如果命中就直接返回,否则才真的发起查询操作。
2. 源码分析
我们直接看Executor.query()
方法,它首先会根据请求参数ParamMap解析出要执行的SQL语句BoundSql,然后创建缓存键CacheKey,然后调用重载方法。
@Override
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
// 根据参数解析出要执行的SQL
BoundSql boundSql = ms.getBoundSql(parameter);
// 创建缓存键
CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql);
// 执行查询
return query(ms, parameter, rowBounds, resultHandler, key, boundSql);
}
因此我们重点看query重载方法,它首先会向ErrorContext报告自己正在做查询,然后判断是否需要清空缓存,如果你在SQL节点配置了flushCache="true"
则不会使用一级缓存。之后就是尝试从一级缓存中获取结果,如果命中缓存则直接返回,否则调用queryFromDatabase
查询数据库,在queryFromDatabase
方法中,会再将查询结果存入一级缓存。
@Override
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
List<E> list;
try {
// 试图从一级缓存中获取数据
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);
}
}
return list;
}
因此,我们重点看localCache对象。
2.1 PerpetualCache
localCache是PerpetualCache类的实例,译为【永久缓存】,因为它不会主动删除缓存,但是会在事务提交或执行更新方法时清空缓存。PerpetualCache是缓存接口Cache的子类,分析子类前,我们应该先看它的接口。
如下是Cache接口,它的职责很简单,就是维护结果集缓存,对缓存提供了【增删查】的API。
public interface Cache {
// 获取缓存唯一标识符
String getId();
// 添加缓存项
void putObject(Object key, Object value);
// 根据缓存键获取缓存
Object getObject(Object key);
// 根据缓存键删除缓存
Object removeObject(Object key);
// 清空缓存数据
void clear();
}
PerpetualCache的实现非常简单, 内部使用一个HashMap容器来维护缓存,Key存放的是CacheKey,Value存放的是结果集,代码就不贴了,如下是它的属性。
public class PerpetualCache implements Cache {
// 缓存唯一表示
private final String id;
// 使用HashMap作为数据缓存的容器
private final Map<Object, Object> cache = new HashMap<>();
}
2.2 CacheKey
CacheKey是MyBatis提供的缓存键,为什么要为缓存键单独写一个类呢?因为MyBatis判断是否命中缓存,条件非常多,并不是一个简单的字符串就可以搞定的,因此才有了CacheKey。
如何判断查询能否命中缓存?有哪些条件需要判断呢?总结如下:
- StatementID要相同,必须执行的是同一个接口的同一个方法。
- RowBounds要相同,查询的数据范围必须一致。
- 执行的SQL语句要相同。
- 预编译的SQL填充的参数要相同。
- 查询的数据源要相同,即EnvironmentID相同。
以上五个条件,必须同时满足,才能命中缓存。而且,像【填充的参数】这类数据是不固定的,因此CacheKey使用一个List
来存放这些条件,如下是它的属性:
- DEFAULT_MULTIPLIER:默认参与哈希值计算的倍数因子。
- DEFAULT_HASHCODE:默认哈希值。
- multiplier:与哈希值计算的倍数因子,默认37。
- hashcode:哈希码,提高equals的效率。
- checksum:校验和,哈希值的和。
- count:updateList元素的个数。
- updateList:条件列表,必须满足所有条件,才能命中缓存。
能否命中缓存,就看CacheKey是否相等了。为了提升equals
方法的性能,避免每次挨个比较updateList,CacheKey使用hashcode
来保存哈希值,哈希值是根据每个条件对象参与计算得出的,MyBatis提供了一套算法,尽可能的让哈希值分散。
调用update
方法可以添加条件对象,源码如下:
public void update(Object object) {
// 计算单个对象的哈希值
int baseHashCode = object == null ? 1 : ArrayUtil.hashCode(object);
// 条件对象个数递增
count++;
// 累加校验和
checksum += baseHashCode;
// 基础哈希值乘以条件数量,使哈希值尽可能分散
baseHashCode *= count;
// 最终哈希值,再乘以质数17,还是为了分散
hashcode = multiplier * hashcode + baseHashCode;
// 添加条件对象到List
updateList.add(object);
}
equals
方法用于判断两个CacheKey是否相等,为了提升性能,会先进行一系列的简单校验,最后才是按个匹配每个条件对象。
@Override
public boolean equals(Object object) {
// 前置一系列校验省略...
// 以上步骤,都是为了提升equals的性能。最终还是比较updateList的每一项
for (int i = 0; i < updateList.size(); i++) {
// 依次比较updateList各项条件
}
return true;
}
如果CacheKey相等,则代表命中缓存。
2.3 创建缓存键
前面已经说过,调用query
方法时,首先会创建CacheKey,根据CacheKey判断能否命中缓存,最后,我们看一下CacheKey的创建过程。
createCacheKey
方法位于BaseExecutor
,CacheKey的创建过程并不复杂,就是实例化一个CacheKey对象,然后将需要匹配的条件调用update
方法保存下来。
上文已经说过判断能否命中缓存的五大条件了,因此它需要MappedStatement获取StatementID、需要parameterObject获取请求参数、需要RowBounds获取分页信息、需要BoundSql获取要执行的SQL。
/**
*
* @param ms 执行的SQL节点封装对象
* @param parameterObject 参数,一般是ParamMap
* @param rowBounds 分页信息
* @param boundSql 绑定的SQL
* @return
*/
@Override
public CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql) {
// 实例化缓存键
CacheKey cacheKey = new CacheKey();
// StatementId要一致,必须调用的是同一个Mapper的同一个方法
cacheKey.update(ms.getId());
// 分页信息,查询的数据范围要一致
cacheKey.update(rowBounds.getOffset());
cacheKey.update(rowBounds.getLimit());
// 执行的SQL要一致,动态SQL的原因,SQL都不一致,肯定不能命中缓存
cacheKey.update(boundSql.getSql());
/**
* 除了上述基本的四项,还要匹配所有的参数。
* 针对同一个查询方法,传入的参数不同,肯定也不能命中缓存。
* 下面是从参数ParamMap中获取对应参数的过程。
*/
cacheKey.update(参数);
// 校验运行环境,查询的数据源不同,也不能命中缓存。
if (configuration.getEnvironment() != null) {
cacheKey.update(configuration.getEnvironment().getId());
}
return cacheKey;
}
CacheKey创建完毕后,就是从PerpetualCache中判断是否已经存在缓存数据了,如果命中缓存就直接取缓存结果,避免查询数据库,提升查询性能。
继续看BaseExecutor的其他代码你会发现,PerpetualCache虽然自己不会主动清理缓存,但是只要执行了update
语句、或者事务提交/回滚都会清空缓存。
3. 总结
一级缓存是基于SqlSession的,当一个会话被打开时,它会同时创建一个Executor,Executor内部持有一个PerpetualCache,PerpetualCache底层使用一个HashMap容器来维护缓存结果集。HashMap中Key存储的是CacheKey,它是MyBatis提供的缓存键,因为判断是否命中缓存涉及的条件非常多,因此CacheKey使用一个List来保存条件对象,只有当所有的条件都匹配时,才能命中缓存。
MyBatis的一级缓存其实还是比较鸡肋的,在多会话的场景下存在脏数据的问题,SessionA读了一遍数据,SessionB修改了该数据,SessionA再去读仍然是旧数据。不过,如果你使用Spring整合MyBatis就不用担心这个问题了。