众所周知的, 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()
public void commit() {
//如果中途发生了异常,则清空
if (clearOnCommit) {
delegate.clear();
}
//将产生的数据真正的写入缓存
//将记录的未命中的数据从结果中读取,并加入缓存中
flushPendingEntries();
//重置状态
reset();
}
public void rollback() {
//清空所有保存的未命中的key
unlockMissedEntries();
//重置状态
reset();
}
有一点疑惑的,它回退并不是简单的把产生数据缓存清空,而且还调用了 unlockMissedEntries();
将执行过程中发现未命中的数据也删除:
private void unlockMissedEntries() {
for (Object entry : entriesMissedInCache) {
delegate.removeObject(entry);
}
}
答案在于 Cache
接口的实现者, BlockingCache
中,增加了数据库的锁,而当回滚的时候,则需要释放锁,这里的操作便是释放锁。
看到这里,我们可以联系数据库中的事务隔离问题,数据库事务存在 脏读,不可重复读,幻读
等问题,而数据库解决这些问题的办法在于通过加锁。对于 MyBatis
的二级缓存,如果在事务的情况下,会不会导致这些事务隔离失效呢?
前面说过, MyBatis
的缓存并不是直接进行提交的,而是当事务真正提交的时候,才写入真正的二级缓存中,这便解决了脏读问题,因为其他事务根本读取不到事务未提交的数据。
解决了脏读问题,接下来看不可重复读:
不可重复的问题也很好解决,因为 MyBatis
存在一级缓存,因此对于同一个事务,必然使用的是同一个 SqlSession
,那么对于相同的 Key
,则会直接命中一级缓存,那么便不会存在不可重复读的问题。
幻读
同样,幻读问题也是在同一个事务中,会命中一级缓存,从而避免了幻读问题。
那是不是说,只要通过 MyBatis
的缓存机制,就可以完全解决脏读问题呢?
答案肯定是否定的,缓存不是一定能命中的, MyBatis
的缓存机制是 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
接口