文章目录
引言
对Mybatis一直都没有做实质的记录。 现记录Mybatis的一些实现细节。组成一个系列。
本片文章讲述的是Mybatis是如何无感知的让用户使用到一二级缓存,以及一二级缓存的实现细节和实现原理。
系列文章:Mybatis 实现原理之 JDK动态代理和XML语句执行
结论:通常意义上的Mybatis一级缓存, 指的是SqlSession级别缓存,即在同一个SqlSession下,可以共享的缓存,该缓存默认被开启(可通过每行CRUD标签<flushCache>关闭),依据查询使用的Mapper和方法名、传参等组成查询Key,复用SqlSession的时候可以直接从缓存里面查询;
Mybatis二级缓存,指的是Mapper(namespace)级别的缓存,是全局的,需要指定cacheEnabled
字段(默认为true
)以及每个CRUD的<useCache>字段,以及每个Mapper的<cache>标签。即每个Mapper下相同的查询条件,会直接从缓存中获取结果。
2019年04月10日19:01:37 更新:
关于一级缓存中的SqlSession,它是如何实现全局的SqlSession,又实现一级缓存的效果的呢~ 详见 Mybatis的一级缓存 – 基于SqlSession
SqlSession的生命周期
本博文只谈及Spring环境下的
SqlSession
。
在博文Mybatis 实现原理之 JDK动态代理和XML语句执行中有提到,Spring环境中的SqlSession
实现者是SqlSessionTemplate
, 但同时,它使用到了包装器模式。实际包装的, 依然是DefaultSqlSession
。
包装器中的DefaultSqlSession
在应用程序的运行中, 会频繁的创建和销毁。
Spring管理的SqlSessionTemplate
会贯穿整个Application。
SqlSession的创建 – 动态代理和包装器
Mybatis的Mapper的执行,依托于MapperProxy
这个代理类。在博文Mybatis 实现原理之 JDK动态代理和XML语句执行介绍了Mapper代理的创建和执行过程。
SqlSessionTemplate
的使用非常巧妙,它委托给Spring管理, 生命周期为整个Application。但是实际的执行者并不是它。而是它的一个类元素SqlSessionTemplate#sqlSessionProxy
这个代理类,同样,代理类的生命周期也是贯穿整个Application。它会频繁的创造DefaultSqlSession
用于SQL的执行。
那么, 它是怎么实现SqlSession
创建和事务内复用的呢?
sqlSessionProxy, 顾名思义, 是一个代理类。实际的代理类是SqlSessionTemplate.SqlSessionInterceptor
这个InvocationHandler
。它会根据当前的事务环境重新创建或者复用SqlSession
。创建时序图如下:
此处重点分析下SqlSessionUtils#getSqlSession()
方法
SqlSessionUtils
创建SqlSession
的方式即是SessionFactory#openSession()
(DefaultSqlSessionFactory)。然后它会将创建好的SqlSession
(DefaultSqlSession)放到TransactionSynchronizationManager
中。存储依赖于ThreadLocal,以及当前程序运行的事务环境(基于AOP)。因此:
- 可以很好的做到线程隔离。
- 一个线程下,一个SessionFactory创建的SqlSession在整个线程周期的可传递的事务中复用。
- 自然,跨线程的一级缓存无从谈起,没有事务也没有一级缓存。但是还是有可能用上二级缓存。
- 只支持Spring管理的事务,才会复用SqlSession(启用一级缓存)。
在非Spring环境中,源于Mybatis的各项构造相对麻烦, 一般写法也是通过
DefaultSqlSessionFactory#openSession()
去创建SqlSession
。且一般都是即用即创建。每次创建完毕之后便等待垃圾回收器去回收。不建议全局复用一个SqlSession
。
Mybatis的一级缓存 – 基于SqlSession
Mybatis的一级缓存默认开启,虽然Mybatis的一级缓存是SqlSession
缓存,但是它储存位置并不是在SqlSession
, 而是Executor
中。是一个HashMap(PerpetualCache持有)。
在博文Mybatis 实现原理之 JDK动态代理和XML语句执行有讲到, SqlSession
的CRUD基于Executor
。它的实现类都实现了一级缓存:优先从localCache
获取,获取不到的情况下再从数据库中查询。
具体逻辑可以参考如下代码片段(org.apache.ibatis.executor.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);
}
需要注意的是缓存的Key, 参见#createCacheKey
方法,另参见CacheKey#hashCode()
和CacheKey#equals(Object)
2019年04月10日更新
如上文 SqlSession的创建 – 动态代理和包装器 描述, Spring环境中,SqlSessionTemplate
生命周期贯穿整个Application,而一级缓存又是基于SqlSession、当前线程。 这其中是不是有什么矛盾?
*
实际不矛盾⬇️⬇️⬇️ 此SqlSession并非Spring的SqlSessionTemplate
SqlSessionTemplate
的CRUD依托于SqlSessionTemplate.SqlSessionInterceptor
这个InvocationHandler
(SqlSessionTemplate
本身不执行任何CRUD)。
动态代理会依据当前的线程运行情况依据全局的DefaultSqlSessionFactory
创建或者复用SqlSession
。并会在任意命令之后完毕之后选择关闭创建的SqlSession
。
Mybatis的一级缓存的可行性分析
Spring环境中,对于缓存的使用,最起码的条件就是:
分支条件 | 实现方案 | 是否满足 |
---|---|---|
同SQL Query,同结果 | CacheKey/localCache | 满足 |
数据有Update,不再查询缓存 | 同SqlSession ,所有查询都执行clear() 方法 | 满足 |
只在事务中生效 | 当前线程、当前事务共享SqlSession | 满足 |
不同的事务隔离级别下,缓存生效与否 | 区分不同的传播行为 | 满足 |
理论上的一级缓存,只应该存在于事务之中,且无关具体数据源的事务隔离级别。
Spring环境中, Mybatis的SqlSession
,在开启了事务、同一个线程、同一个SqlSessionFactory
中,就会共享。这就意味着一级缓存也会共享。
Mybatis通过相同的Mapper+Method+Parameter等参数定位具体缓存。
每次的Update(insert/delete)语句,都会导致缓存被清空。
无关数据库事务隔离级别配置, 在Spring默认配置下, Mybatis的一级缓存可行度非常高。
需要注意的事情是,Mybatis的一级缓存只是用的HashMap,未做容量限制。如果查询出来的结果集特别大,最好还是默认关闭一级缓存
Mybatis的一级缓存的作用域 – 当前线程,当前事务
Mybatis的一级缓存是可行的, 如通过源码分析来展示它的作用域。
Spring环境中Mybatis的SqlSession
(一级缓存) 需要基于事务。在事务中才会获取到当前事务、当前线程共享的SqlSession
。
基于当前线程
TransactionSynchronizationManager#doGetResource(Object)
这里的actualKey是SqlSessionFactory
,resources
是一个ThreadLocal。
返回的value是SqlSessionHolder
private static Object doGetResource(Object actualKey) {
Map<Object, Object> map = resources.get();
if (map == null) {
return null;
}
Object value = map.get(actualKey);
// OTHER
return value;
}
基于事务
在获取SqlSession
之后, 当前线程会尝试着将其缓存。
通过 TransactionSynchronizationManager.isSynchronizationActive() 判断是不是要缓存这个SqlSession
它依据一个类元素TheadLocal是否存放了值来判断 true/false。
而这个TheadLocal的set方法, 必须在启用了事务之后才会被调用。
只要启动了事务,且事务隔离级别满足需要。几乎都会缓存SqlSession
见: AbstractPlatformTransactionManager#getTransaction(TransactionDefinition)
在层级调用之后, 在TransactionSynchronizationManager#initSynchronization()里面塞了默认值。
else if (definition.getPropagationBehavior() == TransactionDefinition.PROPAGATION_REQUIRED ||
definition.getPropagationBehavior() == TransactionDefinition.PROPAGATION_REQUIRES_NEW ||
definition.getPropagationBehavior() == TransactionDefinition.PROPAGATION_NESTED)
如上, Spring环境中Mybatis的SqlSession
的缓存, 几乎只基于事务传播类别。而缓存的SqlSession
, 放在ThreadLocal中。自然, 一级缓存作用域也就是 当前线程,当前事务
Mybatis的二级缓存 – 基于Mapper(namespace)
在Mybatis中, 使用到了大量的包装器模式、动态代理。二级缓存也如此。
Mybatis的二级缓存 – 依赖CachingExecutor
在博文Mybatis 实现原理之 JDK动态代理和XML语句执行中有提到,Mybatis的SqlSession的执行, 实质上依赖于Executor
的执行。
Executor
|_BaseExecutor
|_SimpleExecutor
|_CachingExecutor
而二级缓存, 依赖的就是类:org.apache.ibatis.executor.CachingExecutor
。 其获取方式如下Configuration#newExecutor(Transaction, ExecutorType)
:
public Executor newExecutor(Transaction transaction, ExecutorType executorType) {
executorType = executorType == null ? defaultExecutorType : executorType;
executorType = executorType == null ? ExecutorType.SIMPLE : executorType;
Executor executor;
if (ExecutorType.BATCH == executorType) {
executor = new BatchExecutor(this, transaction);
} else if (ExecutorType.REUSE == executorType) {
executor = new ReuseExecutor(this, transaction);
} else {
executor = new SimpleExecutor(this, transaction);
}
if (cacheEnabled) {
executor = new CachingExecutor(executor);
}
executor = (Executor) interceptorChain.pluginAll(executor);
return executor;
}
这里的cacheEnabled字段是默认为True的。
在开启(默认不开启)了二级缓存之后, 会优先从二级缓存里面取。 如下代码片段:
@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);
if (ms.isUseCache() && resultHandler == null) {
ensureNoOutParams(ms, parameterObject, boundSql);
@SuppressWarnings("unchecked")
List<E> list = (List<E>) tcm.getObject(cache, key);
if (list == null) {
list = delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
tcm.putObject(cache, key, list); // issue #578 and #116
}
return list;
}
}
return delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}
如上优先从HashMap中取值, 实现二级缓存。Key还是一级缓存的那个Key.
同时也不难发现, 二级缓存的优先级是高于一级缓存的。
Mybatis的二级缓存 – 如何开启关闭
从之前的代码片段中可以发现, Mybatis的查询默认使用的是CachingExecutor(cacheEnabled
默认 true开启)。 但是它还有三个控件:
- <select> SQL语句XML标签上的
flushCache
字段。默认 false开启。可控制一级缓存和二级缓存的开启(true为关闭) - <select> SQL语句XML标签上的
useCache
字段。默认 true开启。 - XML Mapper的文件上增加<cache></cache>标签。默认 无关闭。
也就是说Mybatis的二级缓存默认是被关闭的。默认添加标签就可以开启。 至于关闭, 从上述四个控件着手即可:
cacheEnabled
全局关闭, 查询都使用SimpleExecutoruseCache
单条关闭,只影响当前SQLflushCache
不建议使用,它的工作原理是在查询缓存前清空缓存。数据还是会缓存,包括一级和二级缓存。- 不要写 <cache></cache>标签
对于上述的二级缓存开启/关闭方法中, 除了 <cache></cache> 之外, 还可以使用
@CacheNamespace
在 Mapper上。
@CacheNamespace
与<cache></cache> 冲突, 只能选一个。
在使用的时候往往发现它不生效, 是因为XML标签只与XML标签搭配。注解只与注解搭配。
如: <cache></cache> 开启即可生效。
或者:@CacheNamespace
必须搭配@Select("select a from b")
。
Mybatis的二级缓存 – 实现源码解析
二级缓存是被存放在
MappedStatement#cache
中(每条SQL语句), 他们持有的是当前Mapper(namespace)的缓存的引用, 也就是说二级缓存是一个namespace级别的缓存。
通过之前的代码片段可以看到, 缓存的获取通过Mybatis的一个工具TransactionalCacheManager
来取出。实际的缓存K/V存放数据结构非常复杂。Key与一级缓存的Key一致, 参见CacheKey
, V层次较深, 也大量使用到了包装器模式, 包装层次为:
TransactionalCache -> SynchronizedCache -> LoggingCache -> SerializedCache -> LruCache -> PerpetualCache -> HashMap
在开启了二级缓存之后, 便会给相应的namespace创建二级缓存Cache, MappedStatement#cache
才会有值。如下代码片段才会走 if 语句(CachingExecutor#query
):
Cache cache = ms.getCache();
if (cache != null) {
// 查缓存
}
这个缓存的创建也是非常繁杂。可参见代码
XMLMapperBuilder#cacheElement(XNode)
(使用注解创建的二级缓存代码类似)。
缓存的创建方法可参见:
CacheBuilder#build()
。 跟二级缓存相关的参数, 如缓存大小、缓存刷新时间等相关的参数, 也可以在此类内被阅读(当然也可看出, Mybatis是多喜欢包装器模式)
Mybatis的二级缓存作用域 – Namespace/跨事务
二级缓存被区分不同的namespace创建之后, 还有一个存储过程。 它是跨事务的。
同时, 因为二级缓存被MappedStatement
持有,MappedStatement
的生命周期为Application, 因此二级缓存的生命周期也延续整个Application。
为什么说二级缓存是Namespace级别的缓存?
Mybatis源码中的查询大致时序:
这里的查缓存,就是CachingExecutor
查MappedStatement
里面的二级缓存。而这个二级缓存,是在装配XML获取到XML的Mapper映射时, 根据<cache></cache>
这个标签所建立的。MappedStatement
持有的,与其对应的namespace是同一个对象。
二级缓存是跨事务的, 在当前事务中只能享受一级缓存, 无法使用到二级缓存。
毕竟在第一次事务中,已经可以直接从以及缓存取值了。
那么,二级缓存是怎么从一级缓存中跑到二级缓存中去的呢?
当一个Spring管理的事务执行完毕, 对应的事务则需要commit
。毕竟实际的SQL执行者还是Executor
,自然是委托给Executor#commit(boolean)
。在对数据库发完毕commit
命令的时候,实现类CachingExecutor
也会对TransactionalCacheManager
执行commit。 也就是把当前事务查询到的缓存,提交到二级缓存里面去。
我理解这么做大致有这么些好处:
1.缓存一致性。只有被提交的事务,它的查询才有意义被提交成为二级缓存。
2.事务中本身已经能查一级缓存,没必要在事务中将没有完毕的事务数据提交到二级缓存(二级缓存可以被其它线程/事务访问)。
Mybatis的二级缓存清理 – update/依赖LRU等
清理策略一:update(
insert / update / delete
语句)操作。
清理策略二:完全依赖这几个缓存类:TransactionalCache -> SynchronizedCache -> LoggingCache -> SerializedCache -> LruCache -> PerpetualCache -> HashMap
。 他们需要是有序的,但是不一定全都有。他们的用处分别是:
类型 | 描述 | 作用 |
---|---|---|
TransactionalCache | 事务中缓存由这个管理,缓存事务管理 | 事务提交时将缓存提交 |
SynchronizedCache | 都有,同步器,在所有方法前加上synchronized | 多线程抢占 |
LoggingCache | 记录日志 | 在DEBUG级别下记录缓存命中率 |
SerializedCache | 序列化到IO | 以ObjectOutputStream存Value |
LruCache | 用LinkedHashMap 存最远最少访问(还有一个FIFO Cache) | 超过配置Size(不是字节大小)后删除最老的Value |
PerpetualCache | 和一级缓存一样, 包装器 | 包装HashMap |
HashMap | 实际存储 | 存<CacheKey, 数据> |
对于更新时候的清理, 则是在所有的update
语句执行的时候,将缓存清理掉。 这个的机理类似于XML里面的flushCache
这个字段。 只不过在<select>
语句中它默认是false, 在<update>/<insert>/<delete>
语句中, 它默认是true。参见:
@Override
public int update(MappedStatement ms, Object parameterObject) throws SQLException {
flushCacheIfRequired(ms);
return delegate.update(ms, parameterObject);
}
private void flushCacheIfRequired(MappedStatement ms) {
Cache cache = ms.getCache();
if (cache != null && ms.isFlushCacheRequired()) {
tcm.clear(cache);
}
}
为什么任何的update SQL都可以清理掉整个namespace的缓存呢?
- 一个namespace(Mapper)下的所有的SQL语句共享一个二级缓存(xml还可以写成多份)。
- SQL语句约等于
MappedStatement
。它们都持有namespace的二级缓存的引用。 - SELECT默认的
flushCache
false, UPDATE默认true。它们操作同一份。 - 一旦有UPDATE执行,二级缓存就清空了。
从上面的描述中也可以得出,不同的namespace(Mapper)下的SQL, 持有的二级缓存是不一致的。也就是说,对于同样的数据库表, 另一个namespace(Mapper)下的UPDATE SQL, 无法清空当前namespace(Mapper)下的二级缓存。
也就是说,同样的表的CRUD, 如果启用了二级缓存, 得写在一块。否则会出现缓存脏读
总结
Mybatis的一级缓存基于SqlSession
, 在一个线程、一次事务上(这样才能获得同一个SqlSession
)才生效。 默认开启, 通过flushCache
这个标签关闭(不建议)。 每次更新语句执行之后就会被清空。
Mybatis的二级缓存基于namespace(Mapper)
。
- 开启比较麻烦
- 需要使用XML里面的
<cache>\</cache>
标签。 - 或者使用
@CacheNamespace
注解。 - 上两者不能共同使用。
@CacheNamespace
注解只能搭配@select
使用。不能搭配XML语句。
- 需要使用XML里面的
- 生命周期很长, 整个Application
- 一个namespace下所有SQL共享。
- 遇见UPDATE语句之后就会被清空。
- 只有在事务提交之后才会被提交到二级缓存中更新原有缓存。
- 不开启事务也可以用。每次Query都会提交到二级缓存。
- 优先级高于一级缓存。
- 使用到二级缓存,需要保证CRUD在一个namespace(Mapper)下。