缓存
众所周知的,MyBatis
内置了二级缓存,对于一级缓存是默认打开的,而二级缓存需要手动开启。
接下来,我们探索一下MyBatis
的缓存。
首选在官方文档中,我们可以找到MyBatis
的相关配置:
- 全局配置:
cacheEnabled
: 全局的开启或关闭配置文件中所有映射器已经配置的缓存。 默认true
- 全局配置:
localCacheScope
: 设置一级缓存作用域,可以设置为SESSION
和STATEMENT
, 默认为SESSION
,当设置为STATEMENT
之后,一级缓存仅仅会用在STATEMENT
范围 - 映射配置:
useCache
: 是否将返回结果在二级缓存中缓存起来,默认select
为true
- 映射配置:
flushCache
: 语句调用后,是否将本地缓存和二级缓存都清空,默认非select
为true
- 映射配置:
<cache>
: 开启二级缓存,如果是MyBatis
的内置二级缓存,还可以配置:缓存刷新时间,缓存大小,缓存刷新间隔,缓存替换策略等 - 映射配置:
<cache-ref>
: 联合域名空间,使用所指定的域名空间的缓存。
以上便是MyBatis
中,所有有关缓存的配置。
一级缓存
首先看一级缓存,一级缓存的代码主要在BaseExecutor
中:
MyBatis
的一级缓存是通过HashMap
实现的。
@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.");
}
//是否需要刷新缓存
//queryStack的作用应该是防止在多线程的情况下,其他线程同时在查询缓存,而这里执行
//清空操作
//不过这里考虑到了多线程,为什么缓存还用HashMap,是因为觉得并发不够,并且一般很少多线程么
if (queryStack == 0 && ms.isFlushCacheRequired()) {
clearLocalCache();
}
List<E> list;
try {
queryStack++;
//前面说过,当有自定义的`ResultHandler`时,不会使用缓存
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();
//如果配置了一级缓存为`STATEMENT` 则清空缓存
if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
// issue #482
clearLocalCache();
}
}
return list;
}
从以上代码可以分析出来,大概就是首先从缓存中查找,如果找不到再从数据库中查找。
同时,如果缓存范围是STATEMENT
,那么每次执行都会清空本地缓存,那么STATEMENT
的缓存在哪里呢?
需要知道的
Executor
是属于SqlSession
的,而STATMENT
是属于方法的,也就是整个SqlSession
用的是同一个Executor
,而对于方法是每执行一个方法,就会新建一个STATEMENT
,因此我们可以认为,对于作用域为STATEMENT
的一级缓存,相当于关闭了一级缓存
二级缓存
首先看看CacheEnable
:
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 为`true`
if (cacheEnabled) {
//则使用`CachingExecutor`包装生成的`executor`
executor = new CachingExecutor(executor);
}
executor = (Executor) interceptorChain.pluginAll(executor);
return executor;
}
可以看到,这便是前面我们说的第四个包装类Executor
而CachingExecutor
仅仅的作用便是在代理Executor
执行前或执行后进行缓存的处理:
接下来看看CachingExecutor
的具体实现:
CachingExecutor
中包含两个成员:
//包装的类
private final Executor delegate;
//事务缓存管理器
private final TransactionalCacheManager tcm = new TransactionalCacheManager();
因为二级缓存是可以跨Session
的,因此就涉及到事务的提交和回滚。
@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);
//如果配置了useCache以及没有自定义resultHandler
if (ms.isUseCache() && resultHandler == null) {
//判断是否有存储过程
ensureNoOutParams(ms, boundSql);
//查看缓存中是否存在
@SuppressWarnings("unchecked")
List<E> list = (List<E>) tcm.getObject(cache, key);
//不存在则交给`Executor`查找
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
处理事务性缓存的方案非常简单: 首先,使用一个临时缓存保存产生的数据,当commit()
的时候,就将数据真正的写入缓存中,当需要rollback()
则直接clear()
TransactionalCache#commit()
public void commit() {
//如果中途发生了异常,则清空
if (clearOnCommit) {
delegate.clear();
}
//将产生的数据真正的写入缓存
//将记录的未命中的数据从结果中读取,并加入缓存中
flushPendingEntries();
//重置状态
reset();
}
TransactionalCache#rollback()
public void rollback() {
//清空所有保存的未命中的key
unlockMissedEntries();
//重置状态
reset();
}
有一点疑惑的,它回退并不是简单的把产生数据缓存清空,而且还调用了unlockMissedEntries();
将执行过程中发现未命中的数据也删除:
TransactionalCache#unlockMissedEntries()
private void unlockMissedEntries() {
for (Object entry : entriesMissedInCache) {
delegate.removeObject(entry);
}
}
答案在于
Cache
接口的实现者,BlockingCache
中,增加了数据库的锁,而当回滚的时候,则需要释放锁,这里的操作便是释放锁。
看到这里,我们可以联系数据库中的事务隔离问题,数据库事务存在脏读,不可重复读,幻读等问题,而数据库解决这些问题的办法在于通过加锁。对于MyBatis
的二级缓存,如果在事务的情况下,会不会导致这些事务隔离失效呢?
需要知道的便是MyBatis
的缓存使用只有两种方式,取数据(select),清空缓存(update),而数据库的事务一般都是在读取的过程中数据被修改而导致的。而修改了数据就会导致MyBatis
清空缓存,从而达到通过数据库解决事务隔离的问题。
但是,有些情况还是会有事务隔离问题:
复现如下:
- 首先,关闭一级缓存,打开二级缓存。
-
然后调用
select
并提交,使得二级缓存中存在值。 - 从新打开一个新的连接,开启事务,然后
select
,此时并不提交, - 再打开一个新的连接,修改此
select
对应的数据,并提交 - 继续刚刚没有提交的
select
,再次进行select
, - 可以看到两次
select
获取的数据不一样,但是数据库的隔离级别确实不可重复读
究其原因,是因为在两次select
中,第一次select
命中了缓存,所以没有查询数据库,而当其他事务修改了此数据并提交后,此时清空了缓存,因此第二次select
会去查询数据库,此时才会开启数据库的事务,但是两次查询的数据已经不一样了。
上面的问题很难发现,因为我们一般不会关闭一级缓存。
当一级缓存开启的时候,两次select
都会命中一级缓存,因而不会有不可重复读的问题。
这可能也是一级缓存默认开启的原因吧。
看完了缓存的使用方式,接下来看看MyBatis
缓存的真正实现:
MyBatis
的缓存接口为Cache
:
public interface Cache {
String getId();
void putObject(Object key, Object value);
Object getObject(Object key);
Object removeObject(Object key);
void clear();
int getSize();
default ReadWriteLock getReadWriteLock() {
return null;
}
}
可以看到,就是简单的增删查改。
虽然Cache
的接口简单,但是其实现有很多,因为MyBatis
内置了很多不同的Cache
配置。
可以看看Cache
的实现类有如下:
BlockingCache
: 阻塞缓存,当未在缓存中成功查询到数据的时候,会对该数据加锁,然后仅让其中一个连接查询,其他连接等待,查询完毕后直接使用该缓存,防止缓存雪崩
不知道为什么
MyBatis
官网没有他的说明,但是确实可以配置的,配置方式为<cache blocking=true>
FifoCache
: 先进先出策略缓存-
LoggingCache
: 增加日志信息的缓存 -
LruCache
:移除最近最少使用的缓存通过
LinkedHashMap
包装实现 ScheduledCache
: 定时刷新的缓存-
SerializedCache
: 将Value
序列化起来存储的缓存 -
SoftCache
:Value
使用软引用的缓存 -
SynchronizedCache
: 将缓存的所有操作都添加锁(SynchronizedCache
)为什么不用
ConcurrentHashMap
? 为了更好的解耦么? TransactionalCache
: 具有事务性的缓存-
WeakCache
: 使用弱引用的缓存
上面所有的缓存实现,每个所具有的功能都不一样,在配置中可以选着配置功能,在MyBatis
中都将其通过装饰者模式包装起来。
真正的具有缓存功能的是:PerpetualCache
,其内部通过HashMap
实现。
在MyBatis
中,创建包装类的代码如下所示:
private Cache setStandardDecorators(Cache cache) {
try {
MetaObject metaCache = SystemMetaObject.forObject(cache);
//如果设置了缓存大小,则设置缓存的大小
//默认1024
//默认传入的cache是`LruCache`
if (size != null && metaCache.hasSetter("size")) {
metaCache.setValue("size", size);
}
//如果设置了刷新间隔,则包装定时刷新缓存
if (clearInterval != null) {
cache = new ScheduledCache(cache);
((ScheduledCache) cache).setClearInterval(clearInterval);
}
//如果缓存的对象有可能被改写,那么为了安全,会将对象进行序列化
//readOnly =false
if (readWrite) {
cache = new SerializedCache(cache);
}
//添加日志记录
//设置日志级别为`Debug` 即可看到日志信息
cache = new LoggingCache(cache);
//添加锁
cache = new SynchronizedCache(cache);
//是否需要阻塞
if (blocking) {
cache = new BlockingCache(cache);
}
return cache;
} catch (Exception e) {
throw new CacheException("Error building standard cache decorators. Cause: " + e, e);
}
}
从这里我们可以看到MyBatis
的二级缓存默认使用了Serializable
序列化Value
,因此对于MyBaits
的Domain
,我们需要实现Serializable
接口,否则会报错。
在MyBaits
中,还可以自定义实现二级缓存:
<cache type="com.domain.something.MyCustomCache"/>
不过由于一些原因,MyBatis
限制了一些包装类只能用在内置类中:
// issue #352, do not apply decorators to custom caches
//如果是内置类,再进行包装
if (PerpetualCache.class.equals(cache.getClass())) {
for (Class<? extends Cache> decorator : decorators) {
cache = newCacheDecoratorInstance(decorator, cache);
setCacheProperties(cache);
}
cache = setStandardDecorators(cache);
}
//否则,只包装一层`Logging`
else if (!LoggingCache.class.isAssignableFrom(cache.getClass())) {
cache = new LoggingCache(cache);
}
看完MyBaits
的Cache
实现,还有一个问题就是Cache
对应的Key
,MyBatis
是如何判断是同一条SQL
呢?
一般来说,最好的判断方法便是直接看SQL
语句是不是一样,但事实并不是这么简单。在MyBatis
中,将Cache
的Key
使用Cachekey
包装起来:
CacheKey
主要包含5个字段:
private final int multiplier;
private int hashcode;
private long checksum;
private int count;
private List<Object> updateList;
在MyBatis
执行过程中,当遇到影响SQL
的结果的时候,就会同时更新这5个字段:
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);
}
比如在更新的时候:
@Override
public CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql) {
if (closed) {
throw new ExecutorException("Executor was closed.");
}
CacheKey cacheKey = new CacheKey();
//id
//同一个namespace使用同一个cache
cacheKey.update(ms.getId());
//offset
cacheKey.update(rowBounds.getOffset());
//limit
cacheKey.update(rowBounds.getLimit());
//sql
cacheKey.update(boundSql.getSql());
List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
//参数以及具体的值
TypeHandlerRegistry typeHandlerRegistry = ms.getConfiguration().getTypeHandlerRegistry();
// mimic DefaultParameterHandler logic
for (ParameterMapping parameterMapping : parameterMappings) {
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);
}
}
if (configuration.getEnvironment() != null) {
// issue #176
// DataBase 环境添加影响
cacheKey.update(configuration.getEnvironment().getId());
}
return cacheKey;
}
这便是MyBatis
的缓存的大概实现。
总结
简单总结下:
MyBatis
缓存分为一级缓存和二级缓存,其最后的实现都是HashMap
MyBaits
的一级缓存默认范围为Session
,可以修改为STATEMENT
,相当于关闭了缓存(每执行一次方法都会新建一个Statement
)MyBatis
利用3层缓存解决了事务的隔离的问题MyBatis
的缓存可以配置多种功能,其实现是通过装饰者模式实现MyBatis
的二级缓存默认需要使Domain
实现Serializable
接口