(五)Mybatis-缓存解析
1、概述
Mybatis的缓存大体上分为一级缓存和二级缓存,我们先来说下一级缓存。
2、一级缓存
当我们使用Mybatis对数据库进行一次查询操作的时候,会通过SqlSession来表示一次数据库会话。在每次会话中,可能会对数据库执行相同的SQL查询操作,而我们也知道,对数据库频繁操作是很耗费性能的,因为数据库中的数据是持久化再磁盘上的。Web工程最大的瓶颈就在于对磁盘文件的I/O操作,因为学过计算机的都了解,I/O操作比内存操作速度差了恐怕几个量级。
而为了避免相同sql的多次数据库查询操作,Mybatis提供了一个简单的缓存机制。将每次sql查询的结果缓存起来,下次相同sql执行的时候直接查询缓存。缓存中存在,从缓存中获取后直接返回,缓存中不存在,查询数据库将查询结果放入缓存并返回。我们把这种一次会话级别的缓存称为一级缓存。
2.1 实现
一级缓存在Mybatis中是通过SqlSession中的Executor来维护的,上文我们已经了解过Executor了,这次不再详述了。在BaseExecutor中,维护了一个PerpetualCache的localCache,来实现一级缓存的功能。
2.1.1 首先,我们先来看下PerpetualCache的实现。
PerpetualCache的实现很简单,实现了Mybatis的Cache接口。Mybatis的Cache接口是用于缓存的接口,一般与缓存相关的类都应该实现这个接口。PerpetualCache内部维护了一个HashMap来实现缓存的功能:
public class PerpetualCache implements Cache {
private String id;
private Map<Object, Object> cache = new HashMap<Object, Object>();
}
id是一个名为LocalCache
的字符串,而Map用来存储数据,key也就是接下来会说到的CacheKey,value则是查询到的数据。
2.1.2 然后我们来看下缓存的实现流程,我们从BaseExecutor的query方法看起。
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
BoundSql boundSql = ms.getBoundSql(parameter);
// 获取缓存的key
CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql);
return query(ms, parameter, rowBounds, resultHandler, key, boundSql);
}
这里,比较重要的一点是缓存key的创建,如何确定相同sql的key值是相同的?这里会涉及到用于存储缓存key的CacheKey类。我们先来简单看下CacheKey,再来看下createCacheKey方法。
CacheKey
我们来看下CacheKey内部的实现:
public class CacheKey implements Cloneable, Serializable {
...
public static final CacheKey NULL_CACHE_KEY = new NullCacheKey();
private static final int DEFAULT_MULTIPLYER = 37;
private static final int DEFAULT_HASHCODE = 17;
// 这个是用于hashcode计算时的扩展因子,默认37
private int multiplier;
// 计算hashcode
private int hashcode;
// 生成key的各项参数的默认hashcode的总和
private long checksum;
// 计数
private int count;
// 生成key的各项参数
private List<Object> updateList;
}
CacheKey内部有一些属性,用于生成cacheKey及获取时的校验。由于HashMap的get方法是先判断hashCode再equals进行判断,所以我们可以简单看下CacheKey中对hashcode的处理及equals方法。
private void doUpdate(Object object) {
// 对象默认的hashcode
int baseHashCode = object == null ? 1 : object.hashCode();
// 计数
count++;
// 所有对象的hashcode相加
checksum += baseHashCode;
// 对象的hashcode扩大count倍
baseHashCode *= count;
// 根据扩展因子扩展,然后加上扩大后的对象的hashcode
hashcode = multiplier * hashcode + baseHashCode;
// 添加对象到list中
updateList.add(object);
}
由于生成key的时候最终方法会调用到doUpdate,我们只需看下doUpdate方法,了解它的hashcode是如何生成的即可。我们再来看下equals方法:
public boolean equals(Object object) {
if (this == object) {
return true;
}
if (!(object instanceof CacheKey)) {
return false;
}
final CacheKey cacheKey = (CacheKey) object;
if (hashcode != cacheKey.hashcode) {
return false;
}
if (checksum != cacheKey.checksum) {
return false;
}
if (count != cacheKey.count) {
return false;
}
for (int i = 0; i < updateList.size(); i++) {
Object thisObject = updateList.get(i);
Object thatObject = cacheKey.updateList.get(i);
if (thisObject == null) {
if (thatObject != null) {
return false;
}
} else {
if (!thisObject.equals(thatObject)) {
return false;
}
}
}
return true;
}
equals方法会对CachKey的各个属性进行比较判断,并且会循环判断updateList中的每个元素,通过这种方式来保证key的唯一性。
createCacheKey相关
简单看了CacheKey后,我们再来看下createCacheKey方法,了解一下CacheKey的创建规则。
public CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql) {
if (closed) {
throw new ExecutorException("Executor was closed.");
}
CacheKey cacheKey = new CacheKey();
// 1. MappedStatement的id
cacheKey.update(ms.getId());
// 2. 查询的分页参数 offset和limit
cacheKey.update(Integer.valueOf(rowBounds.getOffset()));
cacheKey.update(Integer.valueOf(rowBounds.getLimit()));
// 3. sql语句
cacheKey.update(boundSql.getSql());
// 4. 传递给JDBC的参数
List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
TypeHandlerRegistry typeHandlerRegistry = ms.getConfiguration().getTypeHandlerRegistry();
// mimic DefaultParameterHandler logic
// 解析参数
for (int i = 0; i < parameterMappings.size(); i++) {
ParameterMapping parameterMapping = parameterMappings.get(i);
if (parameterMapping.getMode() != ParameterMode.OUT) {
Object value;
String propertyName = parameterMapping.getProperty();
if (boundSql.hasAdditionalParameter(propertyName)) {
value = boundSql.getAdditionalParameter(propertyName);
} else if (parameterObject == null) {
value = null;
} else if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {
value = parameterObject;
} else {
MetaObject metaObject = configuration.newMetaObject(parameterObject);
value = metaObject.getValue(propertyName);
}
cacheKey.update(value);
}
}
// 5. 如果mybatis-config配置的environment不为空,取environment的id
if (configuration.getEnvironment() != null) {
// issue #176
cacheKey.update(configuration.getEnvironment().getId());
}
return cacheKey;
}
createCacheKey方法表明了缓存key的生成规则,拿我们来看一下生成cacheKey的一些条件。
- MappedStatement的id。所谓id,即是Mybatis的映射文件中,每个select节点的namespace及名称,有了它,我们才能确定执行的是哪一条sql,我们拿上文的实例来看一下id:
id="com.mapper.IStudentMapper.getAll"
- offset及limit。这里就与Mybatis的分页有关系了,Mybatis的分页功能是通过RowBounds来实现的,而RowBounds则是通过offset和limit属性来实现分页,而这种分页功能是基于查询结果的再过滤,而不是进行数据库的物理分页;
- sql语句。Mybatis的sql语句是通过BoundSql来实现的,这个就比较好理解了,sql语句不一样,那key肯定不会相同;
- 参数。也就是说,调用JDBC的时候,sql语句要一样,传递的参数也要完全一样,这样才是相同的sql。
- environment的id。这里大致说一点:这里配置的id是每个环境的id,可能开发,测试环境等。
针对environment的id,我们看下官网的解释就明白了:
MyBatis 可以配置成适应多种环境,这种机制有助于将 SQL 映射应用于多种数据库之中, 现实情况下有多种理由需要这么做。例如,开发、测试和生产环境需要有不同的配置;或者共享相同 Schema 的多个生产数据库, 想使用相同的 SQL 映射。许多类似的用例。
节选自:配置环境(environments)
query和queryFromDatabase方法
获取到缓存的key之后,接下来的操作就比较简单了。我们接着来看query方法:
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
...
// 如果mapper节点中配置了flushCache=true,就清空缓存
// queryStack 参数应该是用于延迟加载用的
if (queryStack == 0 && ms.isFlushCacheRequired()) {
clearLocalCache();
}
List<E> list;
try {
queryStack++;
// 从缓存localCache中获取
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--;
}
...
return list;
}
这里也说明了一点,如果我们不想从缓存里查询,只想查询数据库,那么只需要配置对应节点的flushCache=true即可了。
<select id="getAll" resultType="Student2" statementType="CALLABLE" flushCache="true">
SELECT * FROM Student
</select>
我们接着来看下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);
// 如果statementType类型是callable,则缓存存储过程的参数
if (ms.getStatementType() == StatementType.CALLABLE) {
localOutputParameterCache.putObject(key, parameter);
}
return list;
}
这里有一个问题,我以前就遇到过:
就是说 我们查询完成之后,localCache直接缓存我们查询的结果,并没有拷贝或者怎么处理,然后这个结果又被直接返回了。但是由于引用的关系,这里就会出现一种情况,就是外部修改了这个结果,缓存中的值也会跟着发生变化。这样的话,可能会出现我们意想不到的结果,所以这里可以注意一下。
2.1.3 我们再简单看下 insert,update,delete方法
我们随便看下这几个方法的实现,可以看到它们底层都是通过调用update方法来实现的,我们来看下BaseExecutor中update方法的实现:
// 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();
return doUpdate(ms, parameter);
}
在update方法中我们可以看到,每次进行insert,update,delete之后,就会进行清空缓存操作。
2.2 如何清除一级缓存或者说不使用一级缓存
其实,我们从query方法的源码中就可以找到解决方式,我们再来看下query源码:
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
...
if (queryStack == 0 && ms.isFlushCacheRequired()) {
clearLocalCache();
}
...
if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
// issue #482
clearLocalCache();
}
}
return list;
}
从这里,我们可以看到两种解决方式:
- 配置映射文件中节点的
flushCache
属性,设置为true
;- 配置mybatis-config.xml中的
localCacheScope
为STATEMENT
;
针对第二种方式可以简单说下:
Mybatis一级缓存的范围有SESSION 和STATEMENT两种,默认是SESSION。我们配置为STATEMENT,这样每次执行完一个对应的Mapper方法后,就会将缓存清空:
<setting name="localCacheScope" value="STATEMENT"/>
2.3 适用场景
- 单从一级缓存来看,它只是对HashMap的操作,并且没有容量的大小限制,所以存在HashMap占用内存太大,导致内存溢出的可能;但一般情况下,每个SqlSession的生命周期很短,并且只要执行相应的update方法,缓存就会被清空,当然我们也可以手动清空缓存,所以正常情况下一般不会出现缓存过大,内存溢出的情况;
- 所以我们在使用一级缓存的时候还是要注意下:对于时效性很高的数据,我们要控制好SqlSession的生存时间,SqlSession的生存时间越长,它其中缓存的数据有可能就越旧,从而造成和真实数据库的误差。对于这种情况,我们可以适时的手动清空缓存;对于特定的查询,我们也可以配置
flushCache
属性,对该条SQL语句不适用一级缓存;
2.4 一级缓存总结
到这里,一级缓存的学习就差不多结束了,我们来总结下,然后开始学习二级缓存。
- Mybatis的一级缓存是SqlSession级别的,而缓存的维护则是通过Executor来实现的,当一次会话结束(比如调用了close方法)后,相应的一级缓存也会被清除;
- 对于一级缓存中的数据,由于引用的关系,如果外部修改了这个结果,那缓存中的值也会跟着发生变化,注意下这种情况;
- 如果不想使用一级缓存,可以配置映射文件中节点的
flushCache
属性为true
或者配置全局文件的localCacheScope
为STATEMENT
;
3、二级缓存
我们现在来开始一下二级缓存。二级缓存是Application级别的缓存,默认是开启的,我们可以通过配置cacheEnabled
参数来关闭二级缓存:
<setting name="cacheEnabled" value="false"/>
Mybatis中的二级缓存适用的是Executor接口的另一个实现类:CachingExecutor
。前文已经学习过如何获取CachingExecutor,现在再来简单看一下:
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);
}
// 配置cacheEnabled
if (cacheEnabled) {
executor = new CachingExecutor(executor);
}
executor = (Executor) interceptorChain.pluginAll(executor);
return executor;
}
在Configuration的newExecutor方法中,我们通过cacheEnabled参数来判断是否开启了二级缓存,如果开启的话,Mybatis就将通过CachingExecutor来完成操作,而CachingExecutor通过适用装饰者模式,在内部包装了一个Executor的实例来进行实际的操作:
// 包装的实际执行器
private Executor delegate;
// 事务缓存数据
private TransactionalCacheManager tcm = new TransactionalCacheManager();
而对于实际用于缓存数据的 TransactionalCacheManager
类,其实底层也是通过HashMap来实现的。其中map的key是每个节点的Cache对象,value是TransactionalCache对象,感兴趣的童鞋可以看下该类:
public class TransactionalCacheManager {
private Map<Cache, TransactionalCache> transactionalCaches = new HashMap<Cache, TransactionalCache>();
}
public class TransactionalCache implements Cache {
private static final Log log = LogFactory.getLog(TransactionalCache.class);
// 包装的缓存对象
private Cache delegate;
private boolean clearOnCommit;
private Map<Object, Object> entriesToAddOnCommit;
private Set<Object> entriesMissedInCache;
}
3.1 二级缓存配置及条件
对整个Application而言,Mybatis二级缓存并不是只有一份,对每个Mapper文件都会有一个<cache>节点,只要配置了这个节点的话,那这个mapper文件就会对应一个Cache对象。
当然,我们也可以对多个Mapper文件公用一个Cache,需要配置一下
<cache-ref>
节点,指定它的namespace
属性;
<cache></cache>
<cache-ref namespace="" ></cache-ref>
当然,如果我们同时配置了cache和cache-ref节点的话,那么Mybatis中cache节点的优先级是高于cache-ref的,所以Mybatis会选择cache节点。
- Mybatis的二级缓存的粒度很细,它可以指定某一条查询语句是否可以使用二级缓存。
- 虽然在Mapper中配置了<cache>,并且为此Mapper分配了Cache对象,这并不表示这个Mapper中的任一条sql语句查到的结果都会放置到Cache对象之中,只有指定了`useCache="true"的<select>节点才会走二级缓存。
<cache></cache>
<select id="getAll" resultType="Student2" useCache="true">
SELECT * FROM Student
</select>
也就是说,如果要使某个<select>节点支持二级缓存,要满足以下三个条件:
- Mybatis开启了二级缓存:
cacheEnabled=true
;- 该select所在的mapper,配置了cache或cache-ref节点;
- 该select节点配置了
useCache=true
属性;
3.1.2 源码分析
接下来,我们来通过源码来查看一下二级缓存:
public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
throws SQLException {
// 获取该mapper对象的cache节点
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);
}
我们来看一下二级缓存的流程:
- 首先,我们通过MappedStatement的
getCache()
方法获取mapper文件的cache节点;- 如果该cache节点不存在,调用BaseExecutor的query方法执行一级缓存相关的操作;如果存在,先根据节点的
flushCache
属性来确定是否清除该节点的二级缓存;
private void flushCacheIfRequired(MappedStatement ms) {
Cache cache = ms.getCache();
if (cache != null && ms.isFlushCacheRequired()) {
tcm.clear(cache);
}
}
- 通过
ensureNoOutParams
方法来确保二级缓存不会存储存储过程相关的查询结果;- 从事务缓存对象tcm中获取数据,这里同样用到了装饰者模式,最终会从TranactionalCache中包装的缓存对象Cache的实例对象中获取数据。
- 如果二级缓存中没有查询到数据,调用BaseExecutor的query方法查询数据,查询到后向二级缓存中缓存一份;
3.2 cache相关
- 由于Mybatis二级缓存的生命周期也就是整个application的生命周期,所以application不结束,二级缓存就会一直在内存中。当然,这里也会出现一级缓存的内存溢出的可能,不过Mybatis在cache节点中增加了许多配置,比如readOnly(只读),eviction(缓存的回收算法),flushInterval(缓存的清理时间间隔),blocking(读取时是否阻塞)等,详细参数可以查看官网:
Mybatis-XML映射文件- 我们也可以通过实现Cache接口,然后配置cache节点的type属性为我们自定义的cache实现。当然,我们也可以使用第三方缓存来实现;
- 所以说,Mybatis的二级缓存有三个选择:
- Mybatis默认的缓存实现;
- 我们自定义的缓存实现;
- 第三方缓存的结合,如Redis等。
3.3 二级缓存的清除
同样,二级缓存,在进行update,insert,delete的时候时可以自动清空的:
public int update(MappedStatement ms, Object parameterObject) throws SQLException {
// 根据flushCache属性来判断是否清空
flushCacheIfRequired(ms);
return delegate.update(ms, parameterObject);
}
当然我们也可以指定flushCache=false
,在更新的时候不清除二级缓存。
3.4 二级缓存的一个问题
在关联查询的时候,Mybatis会有一个小问题。比如说关联查询两张表A与B,他们位于不同的mapper中,有不同的命名空间,我们先在A中进行一次关联查询,然后对B进行了一次update操作,然后再对原先的A的关联查询进行了一次查询,那查询还是原来的结果,这样就有可能导致数据的不同步。这种情况下,我们可以将A,B使用同一份cache来解决这个问题,但这并不是最好的解决方案。
而最理想的解决方案就是:
对于某些表执行了更新(update、delete、insert)操作后,如何去清空跟这些表有关联的查询语句所造成的缓存;这样,就是以很细的粒度管理MyBatis内部的缓存,使得缓存的使用率和准确率都能大大地提升。
所以,使用Mybatis的二级缓存时,最好保证所有的增删改查都在同一个命名空间下;
3.5 二级缓存总结
二级缓存的介绍大致完了,我们来简单总结下二级缓存。
- 二级缓存是Application级别的缓存,默认情况下每个mapper文件会对应一份缓存;
- 二级缓存的粒度很细,可以具体到某条select语句,只需要相应的配置即可;
- 我们可以使用默认的二级缓存,也可以自定义缓存,当然也可以使用第三方的缓存;
- 注意下关联查询的问题,最好在同一个命名空间下进行二级缓存的操作;
3.6 二级缓存和一级缓存的顺序
根据源代码我们也可以很清除的看到,如果同时配置了一级缓存和二级缓存,那Mybatis会先执行二级缓存,再执行一级缓存,最后查询数据库,顺序大致是:
二级缓存 -> 一级缓存 -> 数据库查询
4 总结
到这里,Mybatis的缓存基本上就学习完了。
- Mybatis缓存这里好多地方用到了装饰者模式,我们可以参考学习下;
- Mybatis的缓存还是很灵活的,大部分的配置都可以由我们来选择;
其实缓存这块还有许多东西我们没有分析,比如cache节点的解析,Cache的多个实例如BlockingCache,FifoCache,以及用于自定义及第三软件的LoggingCache等,还有自定义cache的实现,结合第三方软件的实现等。这些等以后有时间了再来学习吧。
本文参考自:
终结篇:MyBatis原理深入解析(三)
Mybatis介绍之缓存
Mybatis - XML映射配置文件
【MyBatis源码解析】MyBatis一二级缓存