深入理解MyBatis - 二级缓存
前言
在MyBatis的缓存体系中,存在一级与二级缓存。我们在上个章节中讲解了MyBatis一级缓存的实现,同时我们也知道了MyBatis一级缓存是会话级别的缓存,只能在同个会话线程中发挥作用,无法满足应用级别缓存的需求,其作用与意义都不算大。而MyBatis的二级缓存是一个应用级别的缓存,在整体设计上弥补了一级缓存的不足。二级缓存在其设计上,具有良好的扩展性,同时非常灵活,支持第三方外部存储。如果想要设计一个优秀的应用缓存,那么MyBatis的缓存体系设计将会是你非常好的老师。研究MyBatis的二级缓存实现,定会让你受益匪浅。
1、如果设计一个应用级缓存
在进入正题之前,我们先一起来思考一个问题,如何设计好一个应用级别的缓存,或者说,一个应用级的缓存,最基本上需要实现哪些功能?
我相信,绝大多数的Java开发者的职业生涯中都会有面临这个问题的一天。不管在之前有没有思考过这个问题,让我们现在来一起讨论分析下,一个应用级别缓存功能的实现,最起码需要考虑哪些东西以及实现哪些功能。
第一,作为缓存,我们在长期以来对其最直观的理解就是,要快,至少比直接查询数据库要快。在这个要点上,我们就已经面临了两个问题,数据存储在什么地方?并且存储的地方的访问速度要比数据库更快。对于这个问题,我们可以有三个选择,一、磁盘,二、内存,三是第三方软件。如果想用第三方的软件,那么我们在缓存的存储设计上,就需要好好考虑缓存的存储设计的可扩展性,当然这个问题是无法避免的。
第二,不管是存储在磁盘,内存,还是交由第三方软件存储,都对缓存的数据容量存在限制。如果纵容缓存数据的增大,那其实缓存就失去了其意义。那么在缓存的数据容量超过了我们限定的大小时,我们要如何去选择数据的溢出淘汰机制。在计算机领域存在非常出名的两种算法,FIFO(先进先出)和LRU(最近最少使用),几乎在任何的缓存溢出淘汰机制实现中,都可以看到他们的身影。此外,由此问题还能引出数据的过期清理。
第三,作为应用级别的缓存,不可避免的线程安全问题也是重中之重。在多线程下,我们如何保证缓存数据的ACID,这可能会使设计一个应用缓存中最难的一部分。
第四,当我们把数据存储在系统外部或其他第三方软件时,但凡涉及到网络传输或持久化时,还必须将数据序列化。
当我们浅显的分析应用缓存时,以上的四点是必须具备的基本功能。那么MyBatis的二级缓存也会具备上面的四个基本功能,不妨思考下,作为一个成熟的框架,MyBatis的作者会如何设级上述功能的实现,接下来,让我们带着这个问题,走进MyBatis二级缓存的源码。
2、Cache体系
1、Cache体系的设计
在前两章的都出现的BaseExecutor源码中,其中有一个PerpetualCache对象,用来存储以及缓存的数据。我们在上一章中说,PerpetualCache是Cache中最终用来存储数据的实现,其内部只维护了一个HashMap来存储缓存数据,对于一级缓存来说,一个HashMap存储数据可以说是游刃有余,显然这种做法在并不适用于应用级别的缓存。此外,PerpetualCache继承了一个Cache接口,是MyBatis中定义的用来实现缓存的相关操作的顶层接口。作为缓存操作的提供者,实际上Cache中的方法只有几个,我们来看看都有哪些。
public interface Cache {
// 获取ID,对应Mapper的namespace
String getId();
void putObject(Object key, Object value);
Object getObject(Object key);
Object removeObject(Object key);
void clear();
int getSize();
ReadWriteLock riteLock();
}
在这张继承图中,可以看到在MyBatis中关于缓存有具备了哪些功能。
- LonggingCache:用于记录缓存的命中率
- PerpetualCache:用于数据存储
- SynchronizedCache:同步缓存,防止多线程问题,实现方法就是在方法上加上synchronized关键字。现在已经没用了
- ScheduledCache:定时刷新缓存,默认每小时刷新一次缓存
- SoftCache:软引用缓存,使用链表来应用缓存数据,防止垃圾回收,主要是将缓存数据对象转成SoftReference
- WeakCache:弱应用缓存,于SoftCache代码如出一辙,只是将SoftReference换成了WeakReference
- SerializedCache:序列化和反序列化,做法是将对象序列化成二进制,将缩了对象的大小,省内存空间
- BlockingCache:用来阻塞缓存数据的存取
- LruCache:内部使用了一个LinkedHashMap来实现LRU,至于具体怎么做的,我们稍后再分析
- FifoCache:同LruCache一样,用于控制缓存数据的容量,内部使用链表实现
- TransactionalCache:事务缓存,于二级缓存的事务有关,后面再详述
可以看到Cache接口一共有11个实现,各自都有其作用,顾名思义,各自的用途也都很明显。实现也都很简单,其中比较具有分析价值的是LruCache,下面让我们来看看MyBatis作者是怎么实现的。
public class LruCache implements Cache {
private final Cache delegate;
//额外用了一个map才做LRU,但是委托的Cache里面其实也是一个map,这样等于用2倍的内存实现LRU功能
private Map<Object, Object> keyMap;
private Object eldestKey;
public LruCache(Cache delegate) {
this.delegate = delegate;
setSize(1024);
}
@Override
public String getId() {
return delegate.getId();
}
@Override
public int getSize() {
return delegate.getSize();
}
public void setSize(final int size) {
keyMap = new LinkedHashMap<Object, Object>(size, .75F, true) {
private static final long serialVersionUID = 4267176411845948333L;
//核心就是覆盖 LinkedHashMap.removeEldestEntry方法,
//返回true或false告诉 LinkedHashMap要不要删除此最老键值
//LinkedHashMap内部其实就是每次访问或者插入一个元素都会把元素放到链表末尾,
//这样不经常访问的键值肯定就在链表开头啦
@Override
protected boolean removeEldestEntry(Map.Entry<Object, Object> eldest) {
boolean tooBig = size() > size;
if (tooBig) {
//这里没辙了,把eldestKey存入实例变量
eldestKey = eldest.getKey();
}
return tooBig;
}
};
}
@Override
public void putObject(Object key, Object value) {
delegate.putObject(key, value);
//增加新纪录后,判断是否要将最老元素移除
cycleKeyList(key);
}
@Override
public Object getObject(Object key) {
//get的时候调用一下LinkedHashMap.get,让经常访问的值移动到链表末尾
keyMap.get(key); //touch
return delegate.getObject(key);
}
@Override
public Object removeObject(Object key) {
return delegate.removeObject(key);
}
@Override
public void clear() {
delegate.clear();
keyMap.clear();
}
@Override
public ReadWriteLock getReadWriteLock() {
return null;
}
private void cycleKeyList(Object key) {
keyMap.put(key, key);
//keyMap是linkedhashmap,最老的记录已经被移除了,然后这里我们还需要移除被委托的那个cache的记录
if (eldestKey != null) {
delegate.removeObject(eldestKey);
eldestKey = null;
}
}
}
可以看到,在LruCache中,另外维护了一个有序的LinkedHashMap,用于记录缓存key的最新访问顺序,即最新访问的数据总是处于链表的尾部。在类初始化时,创建LinkedHashMap,并将其size默认设置为1024,当LinkedHashMap的长度超过1024时,map对象会移除该key。作者通过重写了removeEldestEntry方法来在移除最老的键值对是获取最少被访问的缓存key。之所以要获取该key,是因为LruCache并不是最终实现数据缓存的类,在其之后,数据最终可能存储在PerpetualCache或者其他第三方实现,当在LruCache中移除该缓存key时,也需要通过委托的cache去删除对应的缓存数据。
此外,FifoCache的实现与LruCache大体一致,只是将LinkedHashMap换成了LinkedList,利用列表来实现缓存数据的FIFO。
2、cache之间的关系
虽然在代码结构上,各种缓存相关功能的实现都很简单和清晰,但是相信会有不少同学会有疑问,各个功能之间是如何协作的呢?如果细心观察的话会发现,在上面LruCache的源码中,存在着一个delegate对象,该对象也是一个Cache对象。不少人看到这里其实已经发现了,缓存体系其实是采用了责任链加上装饰者模式来实现的。在每个Cache的实现类中,除了PerpetualCache,其他的实现类都持有了一个叫做delegate的Cache对象,在各自的方法实现对应的逻辑之后,交由delegate去做后续的逻辑,最终由PerpetualCache去实现数据的缓存。下面列举几个二级缓存关系图
在责任链设计模式中,会为请求或功能的实现创建一个接受者对象的链,通常一个接受者都会包含另外一个接收者的引用,把相同的请求传递给下一个处理对象。通常在对象创建时就需要确定接受处理者的关系链,MyBatis中的Cache链也不另外。在标签中,存在着这样的几个配置项。
<cache type="PERPETUAL" eviction="LRU" flushInterval="60000" size="512" readOnly="false" blocking="false" />
这几个配置项在mapper文件的代码编写阶段就确定,MyBatis在构建mapper文件的MappedStatement对象时就将对应的Cache对象创建好。其对应的代码在XMLMapperBuilder的cacheElement方法中。在确定了参数之后,由CacheBuilder创建cache对象。
public Cache build() {
setDefaultImplementations();
//先new一个base的cache(PerpetualCache)
Cache cache = newBaseCacheInstance(implementation, id);
//设额外属性
setCacheProperties(cache);
if (PerpetualCache.class.equals(cache.getClass())) {
for (Class<? extends Cache> decorator : decorators) {
//装饰者模式一个个包装cache
cache = newCacheDecoratorInstance(decorator, cache);
//又要来一遍设额外属性
setCacheProperties(cache);
}
//最后附加上标准的装饰者
cache = setStandardDecorators(cache);
} else if (!LoggingCache.class.isAssignableFrom(cache.getClass())) {
//如果是custom缓存,且不是日志,要加日志
cache = new LoggingCache(cache);
}
return cache;
}
private void setDefaultImplementations() {
//又是一重保险,如果为null则设默认值,和XMLMapperBuilder.cacheElement以及MapperBuilderAssistant.useNewCache逻辑重复了
if (implementation == null) {
implementation = PerpetualCache.class;
if (decorators.isEmpty()) {
decorators.add(LruCache.class);
}
}
}
//最后附加上标准的装饰者
private Cache setStandardDecorators(Cache cache) {
try {
MetaObject metaCache = SystemMetaObject.forObject(cache);
if (size != null && metaCache.hasSetter("size")) {
metaCache.setValue("size", size);
}
if (clearInterval != null) {
//刷新缓存间隔,怎么刷新呢,用ScheduledCache来刷,还是装饰者模式
cache = new ScheduledCache(cache);
((ScheduledCache) cache).setClearInterval(clearInterval);
}
if (readWrite) {
//如果readOnly=false,可读写的缓存 会返回缓存对象的拷贝(通过序列化) 。这会慢一些,但是安全,因此默认是 false。
cache = new SerializedCache(cache);
}
//日志缓存
cache = new LoggingCache(cache);
//同步缓存, 3.2.6以后这个类已经没用了,考虑到Hazelcast, EhCache已经有锁机制了,所以这个锁就画蛇添足了。
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);
}
}
3、二级缓存的命中条件
在前面我们讲到过,CachingExecutor执行器用于实现二级缓存。CachingExecutor在BaseExecutor通过装饰者模式在原有的逻辑包装上二级缓存相关的逻辑。
@Override
public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
throws SQLException {
Cache cache = ms.getCache();
// 默认情况下是没有开启缓存的(二级缓存).要开启二级缓存,你需要在你的 SQL 映射文件中添加一行: <cache/>
// 简单的说,就是先查CacheKey,查不到再委托给实际的执行器去查
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);
}
return list;
}
}
return delegate.<E> query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}
二级缓存的命中条件与一级缓存一样可以分为运行时相关的参数和配置相关的参数。二级缓存的运行时命中条件与一级缓存只有一个区别,在有在会话提交后才能命中缓存,在CachingExecutor中的TransactionalCacheManager用来实现会话之间的事务缓存管理。而二级缓存的配置参数除了cacheEnable、useCache、flushCache之外,还需要声明缓存空间或缓存引用空间。xml方式和注解方式都有对应的配置如 、@CacheNamespace、和@CacheNamespaceRef等。
4、二级缓存是如何保证事务的
1、为什么要提交之后才能命中缓存
二级缓存时应用级的缓存,必须满足跨线程使用,当多个线程同时读写同一数据时,不可避免的会出现脏读现象。假设现在存在两个线程同时访问同一数据,线程一修改了该数据,同时线程二访问该数据,读到了线程一刚刚修改的数据。在线程二读取完数据之后,线程一出于某些原因回滚了修改的数据,这时便导致了脏读。
如果将流程修改程下图所示,在线程中修改的数据存储在临时存储区中,本线程再读数据时,可以访问到自己修改后的数据。在事务提交之后,再将本线程修改的数据提交到二级缓存和数据库,从而避免脏读现象。
2、事务缓存管理
在CachingExecutor中维护了一个TransactionCacheManager对象,也就是事务缓存管理器,其内部管理着一个Map。这个Map就是我们上面说的临时缓存区,也叫事务暂存区。事务暂存区的键为一个Cache对象,值为TransactionCache对象。
在二级缓存中存储了多个表的缓存数据,假设,我们将每个表的缓存空间成为缓存区,则在事务暂存区中,每一个键值对就对应一个缓存区。而事务暂存区中的缓存,只有当事务提交时,才将暂存区中的所有事务提交。