缓存模块
相信大家都了解并且使用过mybatis的一级缓存和二级缓存,我们先来看看mybatis缓存模块的做了哪些事,然后再去分析它是如何实现的。
1.Mybatis缓存模块的实现是基于HashMap的,通过map来读写数据是缓存的核心功能。
2.我们在开启缓存的时候,可以配置一些额外的功能,如:添加缓存清空策略(LRU,FIFO等)、序列化功能、日志功能、缓存定时清空功能等。
3.上面提到的附加功能可以根据需求自由组合搭配使用,十分灵活。
Mybatis在缓存模块主要做了以上三件事,而第三点提到的附加功能自由搭配是最大的难题。有看过我前面几篇文章的读者应该还记得在日志模块Mybatis使用了动态代理来实现附加功能(也可以说是业务增强),当然也有的读者会想到继承其实就是一种对父类进行业务增强的方式。那么动态代理和继承是否能够满足我们第三点提到的自由组合呢?答案是否定的。
假设有个场景,父类People有个eatFruit()吃水果的抽象方法,而Tom继承了这个方法并简单的实现了EatFruit()。当Tom想吃苹果是,我们需要继承Tom生成一个Apple类,然后调用Apple实例对象中的eatFruit(),这其实没什么,但是Tom可能下次想吃香蕉,那我们又需要继承Tom生成一个Banana类,然后调用Banana实例对象中的eatFruit(),可能下次想吃橙子,那我们又需要继承Tom生成一个Orange类,然后调用Orange实例对象中的eatFruit(),还有可能他想吃苹果和香蕉,那我们又需要继承Tom生成一个AppleBanana类,然后调用AppleBanana实例对象中的eatFruit()。看到这里其实我们的业务都实现了,但是存在很大的问题,每添加一个功能,就需要增加一个类,而且有些功能是前面实现的功能组合。动态代理其实也是一样的,它的增强逻辑也是写死在InvocationHandler实现类的Invoke()方法中(动态可以理解为它是动态生成字节码文件然后实例化代理对象的,并不是说他的业务逻辑是动态组合的)。
用动态代理或者继承的方式扩展多种附加能力的传统方式存在以下问题:这些方式是静态的,用户不能控制增加行为的方式和时机;另外,新功能的存在多种组合,使用继承可能导致大量子类存在。那Mybatis是如何做到缓存模块附加功能自由组合的呢?其实Mybaits缓存模块使用了装饰器模式,接下来我们就先介绍下装饰器模式:
装饰器模式其实可以看做是继承的变形,不需要继承增加子类就能扩展对象的新功能。使用对象的关联关系(组合)代替了继承关系。避免了继承带来的大量子类,更加灵活多变。下面是装饰器的UML:
将上面例子的装饰器模式用上图表现出来,装饰器模式有四个元素:
组件(Component):组件接口定义了全部组件类和装饰器类的方法行为,上图的People
组件实现类(ConcreteComponent):被装饰的原始对象,所有装饰器附加功能都是添加到此类实例对象上,也就是上图的Tom
装饰器抽象类(Decorator):实现Component接口的抽象类,其中有个Component对象,就是被修饰的对象。上图的Decorator
装饰器类(ConcreteComponent):该类定义了对被装饰类的附加功能,上图的Apple等
当Tom想吃三种水果的时候,我们不需要生成新的子类,只需要通过一层一层的装饰,最后Tom就能吃到三种水果,这就避免的继承导致类型体系的快速膨胀。
装饰器与继承相比,它更加的灵活,扩展性更强。将附加功能切分成一个个独立的装饰器,根据需要进行自由的组合,当有新的功能需要添加时,只需要添加新的装饰器,然后通过组合方式添加这个装饰器即可,无需修改原有的代码,遵循开闭原则。
我们在学习IO是学到的带缓存的输入输出流,就是通过装饰器模式实现的。
认识了装饰器模式之后,我们来看看它是怎么在缓存中使用的
在Mybatis的Cache包中,我们可以发现有下图所示的结构,其中decorators就是装饰器所示的包,Cachr就是组件接口,PerpetualCache就是缓存的具体实现,也就是被修饰的类。
我们先进Cache的具体实现中看看,我们会发现,其实缓存就是通过HashMap来存储Key-Value实现的。那么这么多装饰器我们挑选BlokingCache来看看,其他的装饰器其实原理都是一样的就不一一讲解了。
在进行BlokingCache的讲解前我们先说说缓存,我们持久化数据一般是放到数据库中,数据库的数据是放在硬盘的,硬盘的io速度相对于内存来说是比较慢的,并且当我们大量请求同时访问数据库,数据库会有个极限,导致数据库的崩溃。所以我们可以在数据库前面加一层缓存,刚刚我们也看到了缓存其实是一个map,它的数据是在内存中的,我们访问数据可以先从缓存获取,如果缓存中没有,我们才去数据库中查询,这样就减轻了数据库的压力,同时提高了访问的速度。但是这样做也有一定的问题存在。比如说某一时间请求大量缓存中没有的key(不一定同一个key),那么这个时候这些请求都会去请求数据库,就像缓存不存在一样,这就是常说的缓存雪崩,当然还有一些其他的问题。然而我们在多线程的情况下会想到用锁来控制并发,如果我们给缓存加一把锁,的确可以避免上面的问题,但同时会造成性能的急剧下降,说明我们不能简单的给缓存加一把锁,我们必须把锁的的粒度减小,具体该如何操作呢?我们从源码中看看这个让缓存具有阻塞功能的BlockingCache到底做了哪些增强。下面源码截取了主要的一部分进行展示,需要看完整源码的读者可以自行去Mybatis包内查看,并查看其他装饰器的附加功能。
public class BlockingCache implements Cache {
//阻塞的超时时长
private long timeout;
//被装饰的底层对象,一般是PerpetualCache实例对象
private final Cache delegate;
//锁对象集,粒度到key值
private final ConcurrentHashMap<Object, ReentrantLock> locks;
public BlockingCache(Cache delegate) {
this.delegate = delegate;
this.locks = new ConcurrentHashMap<>();
}
@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 ReentrantLock getLockForKey(Object key) {
ReentrantLock lock = new ReentrantLock();
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("Couldn't get a lock in " + timeout + " for the key " + key + " at the cache " + delegate.getId());
}
} catch (InterruptedException e) {
throw new CacheException("Got interrupted while trying to acquire lock for key " + key, e);
}
} else {
lock.lock();
}
}
private void releaseLock(Object key) {
ReentrantLock lock = locks.get(key);
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
这个类有个类型是ConcurrentHashMap的成员locks,它是用来存储每个key所对应的显示锁ReentrantLock,这样大大的减小了锁的粒度,获取不同Key的值的请求就互不干扰了,使用ConcurrentHashMap也是的获取锁这个过程是线程安全的。主要来看看getObject()这个方法,这个方法除了调用被修饰对象的getObject方法,他还到用了两个方法acquireLock和releaseLock,这两个方法的作用是获取锁和释放锁。在acquireLock方法中,首先调用getLockForKey获取锁,getLockForKey方法里先new了一个显示锁,然后尝试放入到locks容器中,如果放入成功说明这个key原来没有锁,则new出来的锁被返回,如果放入失败则说明这个key原来已经有锁了,则拿到容器中的锁返回。然后通过判断是否需要使用超时功能来进行获取锁,获取锁成功后就调用实际的缓存对象的getObject方法,拿到数据后释放锁。释放锁的过程在releaseLock中就十分简单,通过key在容器locks中拿到锁,然后调用unlock方法就释放锁了。以上就是BlockingCache获取缓存值的主要流程,它通过对每个key进行加锁来防止因为缓存中没有对应数据而大量请求去访问数据库,这样大大的减小了锁的粒度,降低了对性能的影响。
除了BlockingCache(阻塞)这个装饰器外,还有LoggingCache(日志能力)、ScheduledCache(定时清空能力)、SerializedCache(序列化能力)、SynchronizedCache(同步控制能力)等功能的装饰器,想了解的读者可以自行阅读源码。
上面图中(红框部分)可以看到缓存的实现类使用的是HashMap来存储缓存数据的,而HashMap其实是线程不安全的容器,那么缓存功能是否存在并发安全问题呢?
Mybatis的缓存分为一级缓存和二级缓存,一级缓存是会话独享的,不会出现多个线程同时操作缓存数据的场景,因此一级缓存不会出现并发安全的问题。二级缓存是可以多个会话共享的缓存,确实会出现并发安全问题,但是Mybatis在初始化二级缓存式,给二级缓存默认加上了SynchronizedCache(同步控制能力,看下图)装饰器的增强,在对共享数据HashMap操作时进行同步控制,所以二级缓存也不会存在并发安全问题。综上所述一级和二级缓存都不存在并发安全问题。
在缓存这个模块中,还有一个比较重要的东西是需要学习的,上面我们说到了Mybatis的缓存机制实际上是用HashMap来存数据的,那么在map中,缓存的key就尤为重要,如果key产生了冲突,那这个查询数据的时候就很有可能拿到错误的数据,所以Mybatis使用CacheKey这个类来封装缓存的key值,CacheKey可以封装多个影响缓存项的因素,判断两个CacheKey是否相同的关键是比较两个CacheKey对象的hash值是否一致,构成CacheKey对象的要素包括:
1.mappedStatement的Id
2.指定查询结果集的分页信息
3.查询所使用的sql语句
4.用户传递给sql语句的实际参数值
我们进入这个类来看看它是如何来保证唯一性的。(CacheKey代码较多,只展示比较核心的部分)
public class CacheKey implements Cloneable, Serializable {
private static final long serialVersionUID = 1146682552656046210L;
public static final CacheKey NULL_CACHE_KEY = new NullCacheKey();
private static final int DEFAULT_MULTIPLYER = 37;
private static final int DEFAULT_HASHCODE = 17;
private final int multiplier;
private int hashcode; //CacheKey的HashCode
private long checksum; //各因素的hashCode和
private int count; //因素的个数
private List<Object> updateList; //存储各个因素对象的容器
public CacheKey() {//对整个key进行初始化
this.hashcode = 17;
this.multiplier = 37;
this.count = 0;
this.updateList = new ArrayList();
}
//每加入一个影响因素,就更新整个key内的hashcode、checksum、count、updateList四个数据
public void update(Object object) {
int baseHashCode = object == null ? 1 : ArrayUtil.hashCode(object);
++this.count;
this.checksum += (long)baseHashCode;
baseHashCode *= this.count;
this.hashcode = this.multiplier * this.hashcode + baseHashCode;
this.updateList.add(object);
}
//equals方法是判断两个CacheKey是否相等的方法。
public boolean equals(Object object) {
if (this == object) {
return true;
} else if (!(object instanceof CacheKey)) {
return false;
} else {
CacheKey cacheKey = (CacheKey)object;
if (this.hashcode != cacheKey.hashcode) {
return false;
} else if (this.checksum != cacheKey.checksum) {
return false;
} else if (this.count != cacheKey.count) {
return false;
} else {
for(int i = 0; i < this.updateList.size(); ++i) {
Object thisObject = this.updateList.get(i);
Object thatObject = cacheKey.updateList.get(i);
if (!ArrayUtil.equals(thisObject, thatObject)) {
return false;
}
}
return true;
}
}
}
public int hashCode() {
return this.hashcode;
}
public String toString() {
//...
}
public CacheKey clone() throws CloneNotSupportedException {
CacheKey clonedCacheKey = (CacheKey)super.clone();
clonedCacheKey.updateList = new ArrayList(this.updateList);
return clonedCacheKey;
}
}
在CacheKey这个类中,主要有update和equals两个方法比较重要,update方法主要是在创建CacheKey的过程中,每加入一个因素都会更新这个CacheKey内部的hashcode、checksum、count、updateList四个数据值,而这四个数据值也是作为判断两个CacheKey对象是否相等的重要因素。那么在equals中是如何判断两个对象是否相等的呢?上图中有详细的过程说明。可以发现判断两个CacheKey对象是否相等的过程是十分严格和复杂的,其实将这些因素封装成一个CacheKey对象,可以提高比较的效率,也能避免出现误判。如果我们在项目中有类似的场景或需求,我们也可以通过这样的方式去实现。
上面也说到了影响CacheKey的因素主要有四个,那么在代码中是怎么体现的?CacheKey的创建过程是怎么样的呢?我们想要看到这个过程,需要去到Mybatis的执行器Execute中去看,关于Execute这部分内容在后面会讲解,这里只说明下CacheKey的创建过程。
图中可以看到影响整个CacheKey的因素有上面说的的四点,并且每次调用update方法都会修改CacheKey内部的值,最后所有因素都加入到CacheKey中后也就得到了最终的一个CacheKey。
以上就是本节内容,谢谢大家的阅读,如有错漏,欢迎评论区指正提出😚!