聊聊 Mybatis 缓存
mybatis 作为国内使用率最高(可能)的 ORM 框架,其实也是有提供缓存功能的~
前言
在正文开始前,我们先来看这样一个问题:
大家都知道 Oracle 的一大特点就是提供了序列功能,目前很多业务中的 id,序号等都采用 DB 序列生成,类似这样:
select seq_xxx.nextval from dual;
复制代码
一般来说,大家都会把 sql 交给 mybatis 去管理,获取序列也不例外。
那么问题就来了:
如果我在同一个事务中,使用 mybatis 去执行两次上面的 sql 语句,得到的值竟然是一样的!实际上,如果你细心的话,还可以发现只有第一次查询序列时去真正执行了 sql,而第二次,便是使用了 mybatis 缓存。
缓存 key
接下来就是缓存的第一大问题,设计缓存 key。
mybatis 为了让缓存真正得只对相同的 sql 生效,设计了一个包含了很多元素的缓存 key。
缓存属性 | 描述 |
---|---|
mapper.id | mapper 方法的全限定名 |
rowbounds-limit | mybatis 自带的假分页-limit 默认为 0 |
rowbounds-offset | mybatis 自带的假分页-offset 默认为 Integer.MAX_VALUE |
sql | 对应的 sql(未填充参数的形式) |
param | 传入的参数 |
envConfiguration | 当前 myabtis 环境配置的 bean Id |
把上面这些属性都拼接到一起,会得到一个类似下面这样的缓存属性:
git.frank.tuning.mapper.UserMapper.findById:0:2147483647:select * from user where id = ?:1:SqlSessionFactoryBean
复制代码
这样看,是不是这个 key 太长了呢?
所以,mybatis 定了一个 CacheKey
类,并利用 hashCode
方法非常巧妙的大大降低了缓存 key 的长度。
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);
}
复制代码
而对应的 equals
方法,则是分别对原始拼接的对象进行比较:
@Override
public boolean equals(Object object) {
...
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;
}
}
...
}
复制代码
一级缓存
简单来说,一级缓存就是对同一个 session/statement 中相同 sql 的缓存。
可以在 org.apache.ibatis.executor.BaseExecutor
中找到真正执行的缓存代码。
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);
}
复制代码
缓存实现
由于一级缓存范围很小,最大程度也只是对同一个 sqlSession 生效。
mybatis 中一级缓存直接使用 HashMap
封装了缓存实现。
public class PerpetualCache implements Cache {
private Map<Object, Object> cache = new HashMap<>();
@Override
public void putObject(Object key, Object value) {...}
@Override
public Object getObject(Object key) {...}
@Override
public Object removeObject(Object key) {...}
@Override
public int getSize() {...}
}
复制代码
加入缓存/删除缓存
一级缓存的加入与删除比较简单,也是对大家对缓存使用的通用做法。
首先,查询操作优先使用缓存查询:
list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
复制代码
如果缓存不存在的话,再进入数据库查询:
list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
复制代码
在 queryFromDatabase
方法内,封装了缓存执行中,双删除,并加入缓存的动作。
localCache.putObject(key, EXECUTION_PLACEHOLDER);
try {
list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);
} finally {
localCache.removeObject(key);
}
localCache.putObject(key, list);
复制代码
在有更新,删除的 sql 执行时,mysql 采用了直接清空全部缓存的策略(简单粗暴,但是有效)。
@Override
public int update(MappedStatement ms, Object parameter) throws SQLException {
...
clearLocalCache();
return doUpdate(ms, parameter);
}
复制代码
二级缓存
二级缓存的缓存 key 与一级缓存使用的是同一个。
二级缓存的缓存范围更广,将 session 级别提升到了 mapper namespace
级别。使得同一个 mapper namespace
下的缓存所有 session 可以共享。
缓存实现
缓存管理:
org.apache.ibatis.cache.TransactionalCacheManager
两阶段提交:
org.apache.ibatis.cache.decorators.TransactionalCache
首先,由于各个缓存的 namespase 是独立的。所以需要由TransactionalCacheManager
来统一维护各个缓存。
public class TransactionalCacheManager {
private final Map<Cache, TransactionalCache> transactionalCaches = new HashMap<>();
public void clear(Cache cache) {...}
public Object getObject(Cache cache, CacheKey key) {...}
public void putObject(Cache cache, CacheKey key, Object value) {...}
}
复制代码
另外,由于 mybatis 二级缓存的范围大大提升,为了避免其他事务通过缓存读到事务还未提交的数据,所以二级缓存需要在事务提交后才能生效。
所以,对应的 TransactionalCacheManager
中也会有 commit
和 rollback
方法。
针对缓存的提交/回滚,mybatis 也是采用了简单粗暴的策略:
首先在 TransactionalCache
中,有三个这样的属性:
private final Cache delegate;
private final Map<Object, Object> entriesToAddOnCommit;
private final Set<Object> entriesMissedInCache;
复制代码
其中,delegate
为真正的缓存实现,entriesToAddOnCommit
为在事务提交后,需要加入至缓存的对象,而entriesMissedInCache
为未命中的缓存 key。
加入缓存
mybatis 在查询时会优先查二级缓存,如果二级缓存为空,再走一级缓存查询,最后再加入缓存。
List<E> list = (List<E>) tcm.getObject(cache, key);
if (list == null) {
list = delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
tcm.putObject(cache, key, list); // issue #578 and #116
}
复制代码
值得注意的是,二级缓存这里的加入缓存并不是真的加入缓存 ~
来看二级缓存的实现:org.apache.ibatis.cache.decorators.TransactionalCache
。
我们刚才调用的 putObject
方法,实际上并没有真正的把缓存对象加入进去,而是放在了一个待提交列表中。
@Override
public void putObject(Object key, Object object) {
entriesToAddOnCommit.put(key, object);
}
复制代码
另外,在查询缓存方法:getObject
中,还同时记录了缓存未命中的 key(entriesMissedInCache
)。
@Override
public Object getObject(Object key) {
Object object = delegate.getObject(key);
if (object == null) {
entriesMissedInCache.add(key);
}
if (clearOnCommit) {
return null;
} else {
return object;
}
}
复制代码
这样,在缓存进行提交时,就可以只保留发生过未命中 key 的记录,而将没有进行过查询的缓存都进行丢弃。
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);
}
}
}
复制代码
删除缓存
在二级缓存的默认配置中,默认情况下在写操作前都会刷新缓存,而读操作则不会。
private void flushCacheIfRequired(MappedStatement ms) {
Cache cache = ms.getCache();
if (cache != null && ms.isFlushCacheRequired()) {
tcm.clear(cache);
}
}
复制代码
如果你需要在某些读操作上也执行刷新缓存(例如获取序列的 select 语句),需要在对应方法上加 flushCache = TRUE
配置:
@Select("select * from user where name = #{name} limit 2")
@Options(flushCache = Options.FlushCachePolicy.TRUE)
List<User> findByName(String name);
复制代码
该配置对一级缓存也同样会生效。
if (queryStack == 0 && ms.isFlushCacheRequired()) {
clearLocalCache();
}
复制代码
总结
mybatis 对 SQL 执行也提供了一个简单的缓存行为。
其中,一级缓存直接使用了 HashMap 来实现,并且不考虑数据的关联关系:当发生写操作后,直接把整个缓存清空。
由于一级缓存在设计上只是为了防止同 session 内重复执行相同 sql ,缓存范围小,全部清空也只是对本 session 内的一级缓存,问题不大。
对二级缓存而言,mybatis 不是默认开启的,缓存的设计上也较一级缓存稍微复杂一点,还提供了 LRU 的淘汰策略。而缓存范围,二级缓存覆盖了同 namespace 下的所有 sql 执行,实现了 sqlSession 间共享。