Mybatis源码阅读之--本地(一级)缓存实现原理分析
前言:
Mybatis为了提升性能,内置了本地缓存(也可以称之为一级缓存),在mybatis-config.xml中可以设置localCacheScope中可以配置本地缓存的作用域,包含两个值session和statement,其中session选项表示本地缓存在整个session都有效,而statement只能在一条语句中有效(这条语句有嵌套查询--nested query/select)。
下面分析一下mybatis本地缓存的实现原理。
本地缓存是在Executor内部构建,Executor包含了三个实现类,SimpleExecutor,BatchExecutor以及CachingExecutor,其中CachingExecutor是开启了二级缓存才会用到的,这里主要是SimpleExecutor和BatchExecutor,他们都实现了BaseExecutor,而BaseExecutor中正是进行了一级缓存的处理。
public abstract class BaseExecutor implements Executor {
protected PerpetualCache localCache; // 一级缓存,实质就是一个HashMap<Object, Object>
protected PerpetualCache localOutputParameterCache; // 出参一级缓存,当statment为callable的时候使用
}
在BaseExecutor中定义了一个PerpetualCache类型的localCache属性,用来保存一级缓存
而PerpetualCache类的主要功能如下:
public class PerpetualCache implements Cache {
private final String id; // 该缓存的id
private final Map<Object, Object> cache = new HashMap<>();
// ...其他一些获取缓存数据、移除缓存数据的方法
}
其中包含了两个属性,id表示缓存的唯一标识,cache是一个HashMap类型的对象,里面存放所有已经缓存的数据
也就是是说Mybatis的一级缓存实质就是一个HashMap。
再回过头看一看BaseExecutor中的一级缓存处理过程(下述中的代码片段都是BaseExecutor类中的,不会再把类加上了):
- select添加缓存
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);
// 根据cachekey执行查询
return query(ms, parameter, rowBounds, resultHandler, key, boundSql);
}
首先创建缓存键key,然后根据key再查询。
下面代码展示了根据key进行查询的逻辑
@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.");
}
// 由于嵌套查询,这里会查询多次
// 第一个查询,并且当前的语句需要刷新缓存,则进行缓存的刷新
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 { // 缓存中没有拿到,则从数据库中拿
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;
}
先看第二个if--如果当前的查询语句设置了清除缓存的属性为true,那么就要把一级缓存清除
当然里面还需要满足queryStack==0的条件,这个条件涉及到了嵌套查询(nested select/query),如果是嵌套查询的最外层查询(第一个查询),才进行缓存的清理动作,否则不进行。这里的queryStack是查询的层级,取决于nested select的层数,例如一个Blog有一个Author,一个Author有一个Account,其中Author和Account都使用了嵌套查询,并且不是延迟加载(fetchType设置),那么Author查询的时候queryStack就会是1,Account查询的时候queryStack为2。针对嵌套查询这里就说这么多,后续会专门写一篇嵌套查询原理的文章,包括非延迟加载以及延迟加载的不同情况的处理方式。
if (queryStack == 0 && ms.isFlushCacheRequired()) {
clearLocalCache();
}
下面代码片段展示了从缓存中取数据的逻辑
list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
``
直接调用localCache的getObject方法,但是需要在resultHandler不为null的情况,因为如果查询数据是传入了ResultHandler,那么会返回null,数据由ResultHandler进行处理。
如果缓存中查到了数据,那么会处理缓存的出参(出参只有在MappedStatement类型为Callable时才会有,其他的STATEMENT/PREPAREDSTATMENT都没有)
如果没有查到数据,那么从数据库中查询
```java
list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
数据查询完了之后执行queryStack--操作。
进入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 {
list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);
} finally {
// 删除占位
localCache.removeObject(key);
}
// 将数据放入到缓存中
localCache.putObject(key, list);
// 对于callable的statement来说,出参也需要缓存,而出参也是放在了入参中
// 因此这里缓存了入参
if (ms.getStatementType() == StatementType.CALLABLE) {
localOutputParameterCache.putObject(key, parameter);
}
return list;
}
主要步骤:
- 先在一级缓存中设置一个占位符,EXECUTION_PLACEHOLDER,
此处代码的作用就是为了防止嵌套查询是查询了相同的数据
举个例子,一个Blog有一个Author,而Author中又嵌套了一个Blog,那么Blog还没有放到缓存中,但是嵌套查询现在查Author,Author中的Blog又是第一个Blog查询的数据,这里放置一个占位符就是为了说明,这个Blog已经在查询了,结果还没出来而已,不要急,等结果出来了再进行配对。 - 执行子类的doQuery方法,查询数据
- 删除缓存占位、将查询出的数据放入到缓存中。
- 如果此查询语句是CALLABLE类型的,那么要把出参也缓存
以上四部做完之后从数据库中查询数据就结束了,其中第一步可能有些人还是很困惑,大家可以执行一些测试看一看。
再次将思路返回到query方法中,
if (queryStack == 0) { // 最外层的查询已经结束
// 所有非延迟的嵌套查询也已经查完了,那么就可以把嵌套查询的结果放入到需要的对象中
for (DeferredLoad deferredLoad : deferredLoads) {
deferredLoad.load();
}
// issue #601
deferredLoads.clear();
if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
// issue #482
clearLocalCache();
}
}
当最外层查询结束时,需要执行一些清理动作:
- 执行所有嵌套查询的连接操作,上面例子中的Blog->Author->Blog,会把author中的Blog设置正确
- 清除嵌套查询
- 如果当前语句的一级缓存作用域是statement的话,要把一级缓存清空
上面的第一步和第二部需要结合ResultSetHandler共同分析,后面分析嵌套查询的时候再做详细的介绍,这里大家心中有个了解即可。
至此,查询过程的缓存处理就已经结束了
下面简单看一下cleanLocalCache方法
public void clearLocalCache() {
if (!closed) {
localCache.clear();
localOutputParameterCache.clear();
}
}
也很简单,就是把localCache和localOutputParameterCache置空。
接下来就分析update(其中insert/update/delete都统称为update)时,一级缓存如何处理:
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();
// 使用子类的doUpdate方法
return doUpdate(ms, parameter);
}
``
先把缓存清空,然后调用子类的doUpdate执行具体的更新操作
另外事务的提交以及回滚都会清空以及缓存,代码如下:
```java
public void commit(boolean required) throws SQLException {
if (closed) {
throw new ExecutorException("Cannot commit, transaction is already closed");
}
clearLocalCache(); // 清除缓存
flushStatements();
if (required) {
transaction.commit();
}
}
public void commit(boolean required) throws SQLException {
if (closed) {
throw new ExecutorException("Cannot commit, transaction is already closed");
}
clearLocalCache(); // 清理缓存
flushStatements();
if (required) {
transaction.commit();
}
}
public void rollback(boolean required) throws SQLException {
if (!closed) {
try {
clearLocalCache(); // 清理缓存
flushStatements(true);
} finally {
if (required) {
transaction.rollback();
}
}
}
}
因此,在一个sqlSession执行了commit或者rollback方法后,一级缓存已经没有了数据,如果再次执行相同的查询操作,那么会重新从数据库中查询。
一级缓存需要注意的事项:
在实际开发中,有可能对查询数据进行一些操作,比如修改一些字段,或者一个列表中删除/添加一些数据,再次执行相同的查询,返回的不会是数据库中的数据,而是经过修改的数据,因此最好不要对Mybatis返回的数据进行修改操作。