MyBatis缓存
MyBatis提供了一级缓存和二级缓存,并且预留了集成第三方缓存的接口。
从上面MyBatis的包结构可以很容易看出跟缓存相关的类都在cache的package里,其底层是一个Cache的接口,默认的实现类是PerpetualCache,使用一个Map<Object, Object>的哈希Map来缓存数据。此外还有很多的装饰器类,如下图所示:从包名就可以猜测出其功能,这里的缓存基本可以分为三类
- 基本缓存:默认的是PerpetualCache,也可以自定义 RedisCache等
- 淘汰算法缓存:FifoCache,LruCache,WeakCache,SoftCache 定义了当缓存内存不足时,淘汰的算法
- 其他装饰器缓存:BlockingCache等
MyBatis一级缓存
一级缓存也叫本地缓存,MyBatis的一级缓存是在会话(SqlSession)层面进行缓存的。其生命周期也就是Session级别,一旦会话关闭,一级缓存也就不存在了。
MyBatis的一级缓存是默认开启的,不需要任何的配置,如果想要关闭一级缓存,就把localCacheScope设置成STATEMENT。
查看源码可以发现在DefaultSqlSession里有一个Executor属性,在SimpleExecutor/ReuseExecutor/BatchExecutor 的父类BaseExecutor的构造方法里创建了一个PerpetualCache对象用于一级缓存。故而在同一个会话里面(同一个SqlSession),多次执行相同的SQL语句,会直接从PerpetualCache缓存的Map里取到缓存的结果,不会再发送 SQL 到数据库,简单的流程如下图所示
注意:使用一级缓存需要关闭二级缓存,并且将localCacheScope设置成SESSION
<!-- 控制全局缓存(二级缓存) 设置成false则为关闭二级缓存-->
<setting name="cacheEnabled" value="false"/>
<setting name="localCacheScope" value="SESSION"/>
那么MyBatis的一级缓存是以什么为key来判断某两次查询是完全相同的查询? MyBatis构造了一个CacheKey来表示每一个不同的sql
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
BoundSql boundSql = ms.getBoundSql(parameter);
CacheKey key = this.createCacheKey(ms, parameter, rowBounds, boundSql);
return this.query(ms, parameter, rowBounds, resultHandler, key, boundSql);
}
public CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql) {
if (this.closed) {
throw new ExecutorException("Executor was closed.");
} else {
CacheKey cacheKey = new CacheKey();
cacheKey.update(ms.getId());
cacheKey.update(rowBounds.getOffset());
cacheKey.update(rowBounds.getLimit());
cacheKey.update(boundSql.getSql());
List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
....
}
public class CacheKey implements Cloneable, Serializable {
private static final long serialVersionUID = 1146682552656046210L;
public static final CacheKey NULL_CACHE_KEY = new NullCacheKey();
private static final int DEFAULT_MULTIPLYER = 37;
private static final int DEFAULT_HASHCODE = 17;
private final int multiplier;
private int hashcode;
private long checksum;
private int count;
// 8/21/2017 - Sonarlint flags this as needing to be marked transient. While true if content is not serializable, this is not always true and thus should not be marked transient.
private List<Object> updateList;
public CacheKey() {
//得到初始的hashCode和乘数
this.hashcode = DEFAULT_HASHCODE;
this.multiplier = DEFAULT_MULTIPLYER;
this.count = 0;
this.updateList = new ArrayList<>();
}
//每次添加参数,则将其保存在updateList里,然后计算新加参数的hashCode,更新最新的hashCode
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);
}
//比较两个CacheKey是否一致
@Override
public boolean equals(Object object) {
if (this == object) {
return true;
}
if (!(object instanceof CacheKey)) {
return false;
}
final CacheKey cacheKey = (CacheKey) object;
//hashcode,count,checksum都需要相等
if (hashcode != cacheKey.hashcode) {
return false;
}
if (checksum != cacheKey.checksum) {
return false;
}
if (count != cacheKey.count) {
return false;
}
//要求两个CacheKey的updateList里的每个元素都相等
for (int i = 0; i < updateList.size(); i++) {
Object thisObject = updateList.get(i);
Object thatObject = cacheKey.updateList.get(i);
if (!ArrayUtil.equals(thisObject, thatObject)) {
return false;
}
}
return true;
}
从CacheKey的构造可以看出MyBatis认为,如果两次查询,以下条件都完全一样,那么就可以认为它们是完全相同的两次查询:
- 传入的 statementId
- 查询时要求的结果集的分页范围 (rowBounds.offset和rowBounds.limit,这里是逻辑分页);
- 本次次查询要传递给数据库的Sql语句
- sql中的参数值
继续往下就可以看到MyBatis里是如何判断使用一级缓存的
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 (this.closed) {
throw new ExecutorException("Executor was closed.");
} else {
//对于select语句,flushCahe默认为false,如果配置成true,就会去清空localcache一级缓存
if (this.queryStack == 0 && ms.isFlushCacheRequired()) {
this.clearLocalCache();
}
List list;
try {
++this.queryStack;
list = resultHandler == null ? (List)this.localCache.getObject(key) : null;
if (list != null) {
this.handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
} else {
//如果缓存里没有,则去查询数据库
list = this.queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
}
} finally {
--this.queryStack;
}
if (this.queryStack == 0) {
Iterator var8 = this.deferredLoads.iterator();
while(var8.hasNext()) {
BaseExecutor.DeferredLoad deferredLoad = (BaseExecutor.DeferredLoad)var8.next();
deferredLoad.load();
}
this.deferredLoads.clear();
//如果当前mybatis-config.xml配置的localCacheScope是STATEMENT级别,那么也清空缓存,这就是STATEMENT级别的一级缓存无法共享localCache的原因
if (this.configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
this.clearLocalCache();
}
}
return list;
}
}
一级缓存在当前会话执行update(insert,update,delete语句)的时候会调用clearLocalCache()方法清空缓存,但是对于其他会话下的更新不会响应,这就会导致出现数据不一致的问题
public int update(MappedStatement ms, Object parameter) throws SQLException {
ErrorContext.instance().resource(ms.getResource()).activity("executing an update").object(ms.getId());
if (this.closed) {
throw new ExecutorException("Executor was closed.");
} else {
this.clearLocalCache();
return this.doUpdate(ms, parameter);
}
}
总结
- Mybatis一级缓存的生命周期和SqlSession一致
- Mybatis的一级缓存是通过PerpetualCache保存的map来做缓存的,没有更新缓存和缓存过期的机制,也没有做容量上的限定。
- Mybatis的一级缓存最大范围是SqlSession内部,有多个SqlSession或者分布式的环境下,同时操作数据库的话,会引起脏数据
- 可以把一级缓存的默认级别localCacheScope设定为Statement,即不使用一级缓存。
MyBatis二级缓存
二级缓存是用来解决一级缓存不能跨会话共享的问题的,范围是 namespace 级别的,可以被多个SqlSession共享。
MyBatis用了一个装饰器类来存储二级缓存数据,就是CachingExecutor。如果启用了二级缓存,MyBatis在创建Executor对象的时候对Executor进行装饰。
CachingExecutor对于查询请求,会判断二级缓存是否有缓存结果,如果有就直接返回,如果没有交给真正的查询器Executor,比如SimpleExecutor来执行查询,在Executor查询的时候会再去判断一级缓存是否存在,最后会把查询到的结果缓存起来,并且返回给用户 如下图所示
开启二级缓存的方式:
步骤1: 在mybatis-config.xml配置<setting name="cacheEnabled" value="true"/> 默认该参数值是true
步骤2:在Mapper.xml中配置<cache/>标签
#可以配置具体的参数,也可以直接使用一个<cache/>标签
#type 缓存实现类
#size 最多缓存个数,默认是1024
#eviction 回收淘汰策略 默认LRU
#flushInterval自动刷新间隔 没有配置则默认在调用时刷新
#readOnly 默认是false, 只读的缓存会给所有调用者返回相同实例,因此这些对象不能被修改
#而可读写的缓存会(通过序列化)返回缓存对象的拷贝,速度上会慢一些,但是更安全 (设置成true要求对象实
#现序列化接口)
<cache type="org.apache.ibatis.cache.impl.PerpetualCache"
size="1024"
eviction="LRU"
flushInterval="120000"
readOnly="false"/>
如果开启了二级缓存,那么在创建Executor的时候会使用CachingExecutor装饰对应的Executor(装饰器模式)
DefaultSqlSessionFactory:96行
final Executor executor = configuration.newExecutor(tx, execType);
public Executor newExecutor(Transaction transaction, ExecutorType executorType) {
executorType = executorType == null ? defaultExecutorType : executorType;
executorType = executorType == null ? ExecutorType.SIMPLE : executorType;
Executor executor;
//根据ExecutorType创建不同的执行器
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);
}
//如果开启了二级缓存,则使用CachingExecutor装饰Executor
if (cacheEnabled) {
executor = new CachingExecutor(executor);
}
//这里是调用插件的方法来增强executor 后面会详细分析
executor = (Executor) interceptorChain.pluginAll(executor);
return executor;
}
所以最后查询方法会执行CachingExecutor的query方法,代码如下:
@Override
public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
throws SQLException {
//先从MappedStatement中获取在配置解析时得到的cache
//使用了装饰器模式,具体的执行链是SynchronizedCache -> LoggingCache -> SerializedCache -> LruCache -> PerpetualCache。
Cache cache = ms.getCache();
if (cache != null) {
//判断是否需要刷新缓存
flushCacheIfRequired(ms);
//如果在mapper.xml里开启了二级缓存则执行下面的逻辑;否则直接调用原来的executor的query方法
if (ms.isUseCache() && resultHandler == null) {
//用来处理存储过程,暂时不考虑
ensureNoOutParams(ms, boundSql);
@SuppressWarnings("unchecked")
List<E> list = (List<E>) tcm.getObject(cache, key);
//如果缓存里没有,则直接执行执行器的query方法,查询后放入缓存
if (list == null) {
list = delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
tcm.putObject(cache, key, list); // issue #578 and #116
}
return list;
}
}
return delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}
MyBatis的二级缓存是存放在MappedStatement里的,而MappedStatement是通过Configuration里的
Map<String, MappedStatement> mappedStatements 集合根据statement id获取的,由于Configuration是全局单例的,所以相同的statement id 对应的MappedStatement也是唯一且相同的,故MyBatis的二级缓存是可以跨SqlSession的
这里MyBatis的二级缓存就是通过TransactionalCacheManager--tcm来管理的(在CachingExecutor里),获取缓存和添加缓存分别调用了对应的getObject和putObject方法,下面看下tcm的相关源码
public class TransactionalCacheManager {
//缓存查询结果
private final Map<Cache, TransactionalCache> transactionalCaches = new HashMap<>();
public void clear(Cache cache) {
getTransactionalCache(cache).clear();
}
public Object getObject(Cache cache, CacheKey key) {
return getTransactionalCache(cache).getObject(key);
}
public void putObject(Cache cache, CacheKey key, Object value) {
getTransactionalCache(cache).putObject(key, value);
}
private TransactionalCache getTransactionalCache(Cache cache) {
return transactionalCaches.computeIfAbsent(cache, TransactionalCache::new);
}
}
TransactionalCacheManager里持有了一个transactionalCaches的map对象,保存了Cache和用TransactionalCache包装后的Cache的映射关系。这里的TransactionalCache实现了Cache接口,CachingExecutor会默认使用TransactionalCache包装初始生成的Cache,TransactionalCacheManager的getObject和putObject实际是调用了TransactionalCache的getObject和putObject方法,源码如下:
public class TransactionalCache implements Cache {
private static final Log log = LogFactory.getLog(TransactionalCache.class);
private final Cache delegate;
private boolean clearOnCommit;
//保存待添加到缓存的key,value,执行commit的时候才会真正添加到缓存
private final Map<Object, Object> entriesToAddOnCommit;
//保存没有命中的key 用于计算命中率
private final Set<Object> entriesMissedInCache;
public TransactionalCache(Cache delegate) {
this.delegate = delegate;
this.clearOnCommit = false;
this.entriesToAddOnCommit = new HashMap<>();
this.entriesMissedInCache = new HashSet<>();
}
@Override
public Object getObject(Object key) {
//这里直接调用被包装cache的getObject方法获取缓存结果
// issue #116
Object object = delegate.getObject(key);
if (object == null) {
//如果没有缓存则添加到miss集合里
entriesMissedInCache.add(key);
}
// issue #146
if (clearOnCommit) {
return null;
} else {
return object;
}
}
@Override
public void putObject(Object key, Object object) {
//将查询结果的key-value保存到entriesToAddOnCommit集合里
entriesToAddOnCommit.put(key, object);
}
@Override
public void clear() {
clearOnCommit = true;
entriesToAddOnCommit.clear();
}
public void commit() {
if (clearOnCommit) {
delegate.clear();
}
//调用commit的时候执行flushPendingEntries方法,将缓存的key-value真正保存到cache缓存里
flushPendingEntries();
reset();
}
private void flushPendingEntries() {
for (Map.Entry<Object, Object> entry : entriesToAddOnCommit.entrySet()) {
delegate.putObject(entry.getKey(), entry.getValue());
}
for (Object entry : entriesMissedInCache) {
if (!entriesToAddOnCommit.containsKey(entry)) {
delegate.putObject(entry, null);
}
}
}
...
}
那么这里的commit方法是什么时候调用的呢?猜测是在SqlSession执行commit方法的时候
-------DefaultSqlSession的commit方法
@Override
public void commit(boolean force) {
try {
//对于开启了二级缓存的Executor,这里的executor是CachingExecutor
executor.commit(isCommitOrRollbackRequired(force));
dirty = false;
} catch (Exception e) {
throw ExceptionFactory.wrapException("Error committing transaction. Cause: " + e, e);
} finally {
ErrorContext.instance().reset();
}
}
-------CachingExecutor的commit方法
@Override
public void commit(boolean required) throws SQLException {
delegate.commit(required);
//调用了TransactionalCacheManager的commit方法,里面循环所有的TransactionalCache并commit
tcm.commit();
}
public void commit() {
for (TransactionalCache txCache : transactionalCaches.values()) {
txCache.commit();
}
}
上面的源码分析说明了为什么在使用二级缓存的时候需要commit事务才会将查询到的数据写入缓存
如果某些查询方法对数据的实时性要求很高,无法使用二级缓存,我们可以在单个Statement ID上显式关闭二级缓存(默认是true)
<select id="selectPerson" resultMap="personResultMap" useCache="false">
//在二级缓存下,如果执行update数据库更新操作,在更新前会先调用flushCacheIfRequired方法
//然后根据statement 上的 flushCache属性判断是否需要刷新缓存
//对于insert,update,delete语句 flushCache默认值都为true,对于select默认值为false
public int update(MappedStatement ms, Object parameterObject) throws SQLException {
this.flushCacheIfRequired(ms);
return this.delegate.update(ms, parameterObject);
}
private void flushCacheIfRequired(MappedStatement ms) {
Cache cache = ms.getCache();
if (cache != null && ms.isFlushCacheRequired()) {
this.tcm.clear(cache);
}
}
Mybatis的二级缓存一般只推荐在以查询为主的应用中使用,因为频繁的更新会导致缓存清空,那么缓存的意义也就不大了。此外,二级缓存比较适合在单表操作的情形下使用,如果在多个不同的namespace下都操作同一张表,因为二级缓存的范围是namespace,那么一个namespace下的更新无法同步到另外的namespace下,可能会导致出现脏数据
如果想要在多个命名空间中共享相同的缓存配置和实例。可以使用 cache-ref 元素来引用另一个Mapper的缓存。
<cache-ref namespace="com.chenpp.application.data.XXXMapper"/>
除此之外,还可以使用第三方的缓存或者自定义的缓存,比方说redis,ehcache等,使用的时候可以在Mapper.xml里的<cache>标签指定对应的缓存类型
<cache type="org.mybatis.caches.redis.RedisCache" eviction="FIFO" flushInterval="60000" size="512" readOnly="true"/>
如果项目是集群环境,那么推荐使用第三方缓存,比方说redis,可以实现不同集群节点间的缓存共享