对于缓存功能,相信大家都十分熟悉了。一旦我们发现系统的性能存在瓶颈需要优化时,可能第一时间想到的方式就是加缓存。缓存本质上是一种空间换时间的技术,它将计算结果保存在距离用户更近、或访问效率更高的存储介质中,进而降低请求处理耗时,提升系统性能。
作为一款成熟的开源框架,MyBatis 自然也提供了缓存的功能,它在执行查询语句时首先尝试从缓存获取,避免频繁与数据库交互,大大提升了查询效率。MyBatis 内部有所谓的一级缓存和二级缓存,这个会在后面的章节中详细阐述,本次仅讨论缓存的内部实现。
我们首先来看下 MyBatis 的 Cache
接口,它定义了缓存的基本行为:
/**
* MyBatis缓存接口
*/
public interface Cache {
//获取缓存唯一ID
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;
}
}
可以看到,这个接口定义十分简单,就是对于缓存的基础 CRUD 操作。
我们知道,缓存的本质其实就是一个 Map ,MyBatis 内置了一个最基础的缓存实现 PerpetualCache
,其底层就是使用了一个 HashMap 来维护元素:
/**
* 最基础的缓存实现,本质上就是一个HashMap
*/
public class PerpetualCache implements Cache {
//缓存ID
private final String id;
//底层使用HashMap来维护缓存元素
private final Map<Object, Object> cache = new HashMap<>();
public PerpetualCache(String id) {
this.id = id;
}
@Override
public String getId() {
return id;
}
@Override
public int getSize() {
return cache.size();
}
@Override
public void putObject(Object key, Object value) {
cache.put(key, value);
}
@Override
public Object getObject(Object key) {
return cache.get(key);
}
@Override
public Object removeObject(Object key) {
return cache.remove(key);
}
@Override
public void clear() {
cache.clear();
}
//...省略非必要代码
}
这个缓存的实现看上去平平无奇,任何人都能写得出来。那么现在问题来了,MyBatis 这样一个成熟的 ORM 框架,缓存功能肯定不会如此初级,它势必要为缓存提供各种额外的扩展功能,比如淘汰策略、定时清空功能、防击穿、打印日志等。那么,MyBatis 是如何在缓存的基础实现上,动态扩展这些功能的呢?
想要对一个类进行功能上的扩展,我们第一时间就会想到继承。 没错,通过继承确实可以很方便地在现有的类上增加额外的功能。举个例子:如果我们想要为缓存增加 LRU 淘汰策略,只需要新建一个 LRUCache
实现类,继承 PerpetualCache
,在内部增加 LRU 算法实现即可。同理,如果需要具有打印日志功能的缓存,就要再创建一个LoggingCache
类。这种解决方案看似可以满足需求。
但是在实际的应用场景中,缓存的能力是需要动态排列组合和扩展的。使用过 MyBatis 的同学应该经常会用到如下形式的缓存配置:
<!--开启二级缓存配置-->
<cache eviction="LRU" flushInterval="60000" blocking="true" size="512"/>
这段配置定义了如下的缓存功能:
类似这样的配置,就要求缓存实现类能够动态扩展 LRU、定时清空、阻塞查询等功能。这样一来,如果依然通过继承的方式实现,就需要再创建 LRUScheduledBlockingCache
类。
而且,由于所有功能是动态增加的,事先并不知道客户端会选择哪几个功能,那么就需要提前把所有功能排列组合地实现一遍,如 LRUScheduledCache
、ScheduledBlockingCache
、LRUBlockingCache
…
最大的问题在于,每扩展一个新的功能,就需要把所有已有的缓存再排列组合一遍,最终的结果就是类爆炸。
组合优于继承。
既然通过继承的方式实现缓存功能并不可取,那么 MyBatis 是如何实现缓存的动态扩展的呢?老规矩,设计模式又来了。这里用到的是 Decorator Pattern 装饰器模式。 先来看下装饰器模式的 UML 结构图:
(图片来源:https://refactoring.guru/design-patterns/decorator)
其中包含了以下核心角色:
MyBatis 的缓存实现与装饰器模式的对应关系如下:
装饰器模式角色 | MyBatis 缓存实现 | 具体功能 |
---|---|---|
Component 接口 | Cache 接口 | 定义缓存接口 |
ConcreteComponent 类 | PerpetualCache 类 | 缓存的基础实现,需要被包装的原始对象 |
ConcreteDecorator 类 | LruCache 类、BlockingCache 类、LoggingCache 类… | 具体的缓存装饰器,用于包装 PerpetualCache,以实现缓存功能的动态扩展 |
具体的类结构图可以参考:
注:MyBatis 中并没有为缓存装饰器定义公共的基类,这与标准的装饰器模式不完全一致,但是并不影响具体功能。
装饰器模式最大的作用,就是可以基于已有的组件,动态地扩展新的功能。 例如前面例子中具有多种功能的二级缓存,就可以采用下面这种方式创建:
Cache cache = new ScheduledCache(new BlockingCache(new LruCache(new PerpetualCache())));
这样一来,客户端就可以任意增加自己想要的缓存功能。相较于继承,装饰器模式使得组件在运行期可以根据需要动态的添加功能,甚至对添加的新功能进行自由的组合,具有很强的灵活性与可扩展性。
介绍完了 MyBatis 缓存的设计思想之后,我们一起来看几个比较有意思的缓存装饰器实现。
首先我们来看下 LruCache
。LRU(Least recently used,最近最少使用)可能是最常用的缓存淘汰策略了,它按照访问的顺序将缓存元素排队,当缓存容量达到上限时,会优先将最久未被访问的元素剔除掉。 JDK 中提供了一个集合 LinkedHashMap
,它底层采用链表实现,并且支持按照访问顺序排序,将最近被访问到的元素放在链表头部。MyBatis 就直接使用 LinkedHashMap
来实现了一个简单的 LRU 缓存:
/**
* 基于LRU淘汰算法的缓存装饰器
*/
public class LruCache implements Cache {
private final Cache delegate; //包装底层的缓存实现
private Map<Object, Object> keyMap; //存储所有缓存元素
private Object eldestKey; //记录最早被访问的key,用于淘汰
//...省略非必要代码
public void setSize(final int size) {
//基于LinkedHashMap实现LRU机制
keyMap = new LinkedHashMap<Object, Object>(size, .75F, true) {
private static final long serialVersionUID = 4267176411845948333L;
//重写removeEldestEntry方法,记录eldestKey
@Override
protected boolean removeEldestEntry(Map.Entry<Object, Object> eldest) {
boolean tooBig = size() > size;
if (tooBig) {
eldestKey = eldest.getKey();
}
return tooBig;
}
};
}
@Override
public void putObject(Object key, Object value) {
delegate.putObject(key, value);
//执行缓存淘汰操作
cycleKeyList(key);
}
//...省略非必要代码
//缓存淘汰操作
private void cycleKeyList(Object key) {
keyMap.put(key, key);
if (eldestKey != null) {
delegate.removeObject(eldestKey);
eldestKey = null;
}
}
}
BlockingCache
的作用是:当查询缓存 miss 时,对当前线程加锁,保证同一时刻只有一个线程去 DB 执行查询操作,这样就避免了高并发场景下,缓存失效造成的大量击穿,实现了对数据库的保护。
/**
* 阻塞式缓存装饰器
* 当查询缓存miss时,对当前线程加锁,保证同一时刻只有一个线程去DB执行查询操作。
* 避免了高并发场景下,缓存失效造成的大量击穿实现了对数据库的保护。
*/
public class BlockingCache implements Cache {
private long timeout; //阻塞的超时时间
private final Cache delegate; //包装底层的缓存实现
private final ConcurrentHashMap<Object, CountDownLatch> locks; //缓存key维度的锁
//...省略非必要代码
@Override
public void putObject(Object key, Object value) {
try {
delegate.putObject(key, value);
} finally {
//设置缓存成功后,释放锁
releaseLock(key);
}
}
@Override
public Object getObject(Object key) {
//查询缓存时,先尝试获取锁
acquireLock(key);
Object value = delegate.getObject(key);
if (value != null) {
releaseLock(key);
}
return value;
}
//...省略非必要代码
//加锁操作
private void acquireLock(Object key) {
CountDownLatch newLatch = new CountDownLatch(1);
while (true) {
CountDownLatch latch = locks.putIfAbsent(key, newLatch);
if (latch == null) {
break;
}
try {
if (timeout > 0) {
boolean acquired = latch.await(timeout, TimeUnit.MILLISECONDS);
if (!acquired) {
throw new CacheException(
"Couldn't get a lock in " + timeout + " for the key " + key + " at the cache " + delegate.getId());
}
} else {
latch.await();
}
} catch (InterruptedException e) {
throw new CacheException("Got interrupted while trying to acquire lock for key " + key, e);
}
}
}
//释放锁操作
private void releaseLock(Object key) {
CountDownLatch latch = locks.remove(key);
if (latch == null) {
throw new IllegalStateException("Detected an attempt at releasing unacquired lock. This should never happen.");
}
latch.countDown();
}
public long getTimeout() {
return timeout;
}
public void setTimeout(long timeout) {
this.timeout = timeout;
}
}
但是,对 BlockingCache
缓存的每次操作都会阻塞当前线程,尽管是根据 Cache Key的细粒度锁,但是对性能还是有一定的影响,而且使用不当还存在死锁的风险。因此这个类的作者 Eduardo Macarron 也说了,这是一个简单且低效的版本:
Simple and inefficient version of EhCache’s BlockingCache decorator. It sets a lock over a cache key when the element is not found in cache. This way, other threads will wait until this element is filled instead of hitting the database. By its nature, this implementation can cause deadlock when used incorrectly. @author Eduardo Macarron
ScheduledCache
的作用就是增加了缓存的定时清理功能。这个清理操作是 lazy 模式的,即在每次 get 和 put 操作时,会校验距离上次执行 clear 操作的时间是否已超过 clearInterval。如果超过,则执行一次 clear 。
/**
* 具备定时清理功能的缓存装饰器
*/
public class ScheduledCache implements Cache {
private final Cache delegate; //包装底层的缓存实现
protected long clearInterval; //清理的时间间隔
protected long lastClear; //记录上次清理的时间
//...省略非必要代码
@Override
public int getSize() {
//尝试执行缓存清理操作
clearWhenStale();
return delegate.getSize();
}
@Override
public void putObject(Object key, Object object) {
//尝试执行缓存清理操作
clearWhenStale();
delegate.putObject(key, object);
}
@Override
public Object getObject(Object key) {
//尝试执行缓存清理操作
return clearWhenStale() ? null : delegate.getObject(key);
}
@Override
public Object removeObject(Object key) {
//尝试执行缓存清理操作
clearWhenStale();
return delegate.removeObject(key);
}
//清理缓存
@Override
public void clear() {
lastClear = System.currentTimeMillis();
delegate.clear();
}
//...省略非必要代码
//判断是否到达清理缓存的时间
private boolean clearWhenStale() {
if (System.currentTimeMillis() - lastClear > clearInterval) {
clear();
return true;
}
return false;
}
}
在介绍完了 MyBatis 的缓存功能之后,最后我们来看一下缓存 Key 的设计。MyBatis 涉及到的查询场景十分复杂,具体查询的 SQL 语句、SQL 参数、分页信息等等因素,都会影响到缓存是否命中,使用简单的 String 或者 Long 类型变量作为做为缓存 Key 是肯定是无法满足需求的。那么 MyBatis 中的缓存 Key 是如何设计的呢?
MyBatis 定义了 CacheKey
类,其中封装了所有影响缓存命中的因素,主要包括:
mappedStatment
的 id(Cache id)CacheKey
封装了以上这些信息,并重写了 hashCode()
和 equals()
方法:
/**
* 缓存Key定义
* 内部封装了所有影响缓存命中的因素
*/
public class CacheKey implements Cloneable, Serializable {
//...省略非必要代码
private final int multiplier; //乘积因子
private int hashcode; //hashCode
private long checksum; //校验和
private int count; //影响因素的数量
private List<Object> updateList; //影响因素列表
public CacheKey() {
this.hashcode = DEFAULT_HASHCODE;
this.multiplier = DEFAULT_MULTIPLIER;
this.count = 0;
this.updateList = new ArrayList<>();
}
public CacheKey(Object[] objects) {
this();
updateAll(objects);
}
public int getUpdateCount() {
return updateList.size();
}
//每次增加CacheKey的影响因素,都会重新计算一遍内部的各种校验值
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);
}
public void updateAll(Object[] objects) {
for (Object o : objects) {
update(o);
}
}
//判断两个CacheKey是否相同
@Override
public boolean equals(Object object) {
if (this == object) {
return true;
}
if (!(object instanceof CacheKey)) {
return false;
}
final CacheKey cacheKey = (CacheKey) object;
if ((hashcode != cacheKey.hashcode) || (checksum != cacheKey.checksum) || (count != cacheKey.count)) {
return false;
}
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;
}
@Override
public int hashCode() {
return hashcode;
}
//...省略非必要代码
}
如果两个 CacheKey
的 hashCode()
相等,且 equals()
方法返回 true,则认为是同一个查询操作,可以直接从缓存中获取数据。
本篇详细介绍了 MyBatis 缓存模块的底层原理,包括缓存的基础实现、具备各种扩展功能的缓存装饰器,以及缓存 Key 的设计思想。个人认为,缓存模块中的精髓就是装饰器设计模式的灵活运用,它使得用户在使用缓存时,可以根据不同的需求来灵活地定制化功能。这种设计思想非常值得我们借鉴。