建议收藏,Mybatis缓存体系结构(上)

在 Web 应用中,缓存是必不可少的组件。通常我们都会用 Redis 或 memcached 等缓存中间件,拦截大量奔向数据库的请求,减轻数据库压力。作为一个重要的组件,MyBatis 自然也在内部提供了相应的支持。通过在框架层面增加缓存功能,可减轻数据库的压力,同时又可以提升查询速度,可谓一举两得。MyBatis 缓存结构由一级缓存和二级缓存构成,这两级缓存均是使用 Cache 接口的实现类。因此,我将首先会向大家介绍 Cache 几种实现类的源码,然后再分析一级和二级缓存的实现。
本文主要内容:

Mybatis缓存体系结构
Mybatis跟缓存相关的类都在cache包目录下,在前面的文章中我们也提过,今天才来详细说说。其中有一个顶层接口Cache,并且只有一个默认的实现类PerpetualCache。
下面是Cached的类图:

既然PerpetualCache是默认实现类,那么我们就从他下手。
PerpetualCache
PerpetualCache这个对象会创建,所以这个叫做基础缓存。但是缓存又可以有很多额外的功能,比如说:回收策略、日志记录、定时刷新等等,如果需要的话,就可以在基础缓存上加上这些功能,如果不喜欢就不加。这里是不是想到了一种设计模式-----装饰器设计模式。PerpetualCache 相当于装饰模式中的 ConcreteComponent。
装饰器模式是指在不改变原有对象的基础之上,将功能附加到对象上,提供了比继承更有弹性的替换方案,即扩展原有对象的功能。
除了缓存之外,Mybatis也定义很多的装饰器,同样实现了Cache接口,通过这些装饰器可以额外实现很多功能。
这些缓存是怎么分类的呢?
所有的缓存可以大体归为三类:基本类缓存、淘汰算法缓存、装饰器缓存。
下面把每个缓存进行详细说明和对比:

缓存实现类源码
PerpetualCache源码
PerpetualCache 是一个具有基本功能的缓存类,内部使用了 HashMap 实现缓存功能。它的源码如下:
public class PerpetualCache implements Cache {

private final String id;
//使用Map作为缓存
private 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();
}
// 存储键值对到 HashMap
@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();
}
//部分代码省略
}
复制代码
上面是 PerpetualCache 的全部代码,也就是所谓的基本缓存,很简单。接下来,我们通过装饰类对该类进行装饰,使其功能变的丰富起来。
LruCache
LruCache,顾名思义,是一种具有 LRU(Least recently used,最近最少使用)算法的缓存实现类。
除此之外,MyBatis 还提供了具有 FIFO 策略的缓存 FifoCache。不过并未提供 LFU (Least Frequently Used ,最近最少使用算法)缓存,也是一种常见的缓存算法 ,如果大家有兴趣,可以自行拓展。
接下来,我们来看一下 LruCache 的实现。
public class LruCache implements Cache {

private final Cache delegate;
private Map<Object, Object> keyMap;
private Object eldestKey;

public LruCache(Cache delegate) {
    this.delegate = delegate;
    setSize(1024);
}

public int getSize() {
    return delegate.getSize();
}

public void setSize(final int size) {
    /*
     * 初始化 keyMap,注意,keyMap 的类型继承自 LinkedHashMap,
     * 并覆盖了 removeEldestEntry 方法
     */
    keyMap = new LinkedHashMap<Object, Object>(size, .75F, true) {
        private static final long serialVersionUID = 4267176411845948333L;

        // 覆盖 LinkedHashMap 的 removeEldestEntry 方法
        @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);
}

@Override
public Object getObject(Object key) {
    // 刷新 key 在 keyMap 中的位置
    keyMap.get(key);
    // 从被装饰类中获取相应缓存项
    return delegate.getObject(key);
}

@Override
public Object removeObject(Object key) {
    // 从被装饰类中移除相应的缓存项
    return delegate.removeObject(key);
}
//清空缓存
@Override
public void clear() {
    delegate.clear();
    keyMap.clear();
}

private void cycleKeyList(Object key) {
    // 存储 key 到 keyMap 中
    keyMap.put(key, key);
    if (eldestKey != null) {
        // 从被装饰类中移除相应的缓存项
        delegate.removeObject(eldestKey);
        eldestKey = null;
    }
}
// 省略部分代码

}
复制代码
从上面代码中可以看出,LruCache 的 keyMap 属性是实现 LRU 策略的关键,该属性类型继承自 LinkedHashMap,并覆盖了 removeEldestEntry 方法。LinkedHashMap 可保持键值对的插入顺序,当插入一个新的键值对时,
LinkedHashMap 内部的 tail 节点会指向最新插入的节点。head 节点则指向第一个被插入的键值对,也就是最久未被访问的那个键值对。默认情况下,LinkedHashMap 仅维护键值对的插入顺序。若要基于 LinkedHashMap 实现 LRU 缓存,还需通过构造方法将 LinkedHashMap 的 accessOrder 属性设为 true,此时 LinkedHashMap 会维护键值对的访问顺序。
比如,上面代码中 getObject 方法中执行了这样一句代码 keyMap.get(key),目的是刷新 key 对应的键值对在 LinkedHashMap 的位置。LinkedHashMap 会将 key 对应的键值对移动到链表的尾部,尾部节点表示最久刚被访问过或者插入的节点。除了需将 accessOrder 设为 true,还需覆盖 removeEldestEntry 方法。LinkedHashMap 在插入新的键值对时会调用该方法,以决定是否在插入新的键值对后,移除老的键值对。
在上面的代码中,当被装饰类的容量超出了 keyMap 的所规定的容量(由构造方法传入)后,keyMap 会移除最长时间未被访问的键,并保存到 eldestKey 中,然后由 cycleKeyList 方法将 eldestKey 传给被装饰类的 removeObject 方法,移除相应的缓存项目。
BlockingCache
BlockingCache 实现了阻塞特性,该特性是基于 Java 重入锁实现的。同一时刻下,BlockingCache 仅允许一个线程访问指定 key 的缓存项,其他线程将会被阻塞住。
下面我们来看一下 BlockingCache 的源码。
public class BlockingCache implements Cache {

private long timeout;
private final Cache delegate;
private final ConcurrentHashMap<Object, ReentrantLock> locks;

public BlockingCache(Cache delegate) {
    this.delegate = delegate;
    this.locks = new ConcurrentHashMap<Object, ReentrantLock>();
}

@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;
}

@Override
public Object removeObject(Object key) {
    // 释放锁
    releaseLock(key);
    return null;
}

private ReentrantLock getLockForKey(Object key) {
    ReentrantLock lock = new ReentrantLock();
    // 存储 <key, Lock> 键值对到 locks 中
    ReentrantLock previous = locks.putIfAbsent(key, lock);
    return previous == null ? lock : previous;
}

private void acquireLock(Object key) {
    Lock lock = getLockForKey(key);
    if (timeout > 0) {
        try {
            // 尝试加锁
            boolean acquired = lock.tryLock(timeout, TimeUnit.MILLISECONDS);
            if (!acquired) {
                throw new CacheException("...");
            }
        } catch (InterruptedException e) {
            throw new CacheException("...");
        }
    } else {
        // 加锁
        lock.lock();
    }
}

private void releaseLock(Object key) {
    // 获取与当前 key 对应的锁
    ReentrantLock lock = locks.get(key);
    if (lock.isHeldByCurrentThread()) {
        // 释放锁
        lock.unlock();
    }
}

// 省略部分代码

}
复制代码
如上,查询缓存时,getObject 方法会先获取与 key 对应的锁,并加锁。若缓存命中,getObject 方法会释放锁,否则将一直锁定。getObject 方法若返回 null,表示缓存未命中。此时 MyBatis 会进行数据库查询,并调用 putObject 方法存储查询结果。同时,putObject 方法会将指定 key 对应的锁进行解锁,这样被阻塞的线程即可恢复运行。
上面的描述有点啰嗦,倒是 BlockingCache 类的注释说到比较简单明了。这里引用一下:

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.

这段话的意思是,当指定 key 对应元素不存在于缓存中时,BlockingCache 会根据 lock 进行加锁。此时,其他线程将会进入等待状态,直到与 key 对应的元素被填充到缓存中。而不是让所有线程都去访问数据库。
在上面代码中,removeObject 方法的逻辑很奇怪,仅调用了 releaseLock 方法释放锁,却没有调用被装饰类的 removeObject 方法移除指定缓存项。这样做是为什么呢?大家可以先思考,答案将在分析二级缓存的相关逻辑时分析。
CacheKey
在 MyBatis 中,引入缓存的目的是为提高查询效率,降低数据库压力。既然 MyBatis 引入了缓存,那么大家思考过缓存中的 key 和 value 的值分别是什么吗?大家可能很容易能回答出 value 的内容,不就是 SQL 的查询结果吗。
那 key 是什么呢?是字符串,还是其他什么对象?如果是字符串的话,那么大家首先能想到的是用 SQL 语句作为 key。但这是不对的.
比如:
SELECT * FROM author where id > ?
复制代码
d > 1 和 id > 10 查出来的结果可能是不同的,所以我们不能简单的使用 SQL 语句作为 key。从这里可以看出来,运行时参数将会影响查询结果,因此我们的 key 应该涵盖运行时参数。除此之外呢,如果进行分页查询也会导致查询结果不同,因此 key 也应该涵盖分页参数。综上,我们不能使用简单的 SQL 语句作为 key。应该考虑使用一种复合对象,能涵盖可影响查询结果的因子。在 MyBatis 中,这种复合对象就是 CacheKey。
下面来看一下它的定义。
public class CacheKey implements Cloneable, Serializable {
private static final int DEFAULT_MULTIPLYER = 37;
private static final int DEFAULT_HASHCODE = 17;

// 乘子,默认为37
private final int multiplier;
// CacheKey 的 hashCode,综合了各种影响因子
private int hashcode;
// 校验和
private long checksum;
// 影响因子个数
private int count;
// 影响因子集合
private List<Object> updateList;

public CacheKey() {
    this.hashcode = DEFAULT_HASHCODE;
    this.multiplier = DEFAULT_MULTIPLYER;
    this.count = 0;
    this.updateList = new ArrayList<Object>();
}
// 省略其他方法

}
复制代码
如上,除了 multiplier 是恒定不变的 ,其他变量将在更新操作中被修改。
下面看一下更新操作的代码。
/** 每当执行更新操作时,表示有新的影响因子参与计算 */
public void update(Object object) {
int baseHashCode = object == null ? 1 : ArrayUtil.hashCode(object);
// 自增 count
count++;
// 计算校验和
checksum += baseHashCode;
// 更新 baseHashCode
baseHashCode *= count;

// 计算 hashCode
hashcode = multiplier * hashcode + baseHashCode;

// 保存影响因子
updateList.add(object);

}
复制代码
当不断有新的影响因子参与计算时,hashcode 和 checksum 将会变得愈发复杂和随机。这样可降低冲突率,使 CacheKey 可在缓存中更均匀的分布。CacheKey 最终要作为键存入 HashMap,因此它需要覆盖 equals 和 hashCode 方法。
下面我们来看一下这两个方法的实现。
public boolean equals(Object object) {
// 检测是否为同一个对象
if (this == object) {
return true;
}
// 检测 object 是否为 CacheKey
if (!(object instanceof CacheKey)) {
return false;
}
final CacheKey cacheKey = (CacheKey) object;

// 检测 hashCode 是否相等
if (hashcode != cacheKey.hashcode) {
    return false;
}
// 检测校验和是否相同
if (checksum != cacheKey.checksum) {
    return false;
}
// 检测 coutn 是否相同
if (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;

}

public int hashCode() {
// 返回 hashcode 变量
return hashcode;
}
复制代码
equals 方法的检测逻辑比较严格,对 CacheKey 中多个成员变量进行了检测,已保证两者相等。hashCode 方法比较简单,返回 hashcode 变量即可。
关于 CacheKey 就先分析到这,CacheKey 在一二级缓存中会被用到,接下来还会看到它的身影。
好吧终于把源码缓存实现类的源码说完了。

作者:田维常
链接:https://juejin.cn/post/6907923325805133837
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

posted @ 2020-12-19 19:12  田维常TWC  阅读(101)  评论(0编辑  收藏  举报