MyBatis缓存模块(二级缓存深入理解)


MyBatis源码学习系列文章目录



前言

在前面介绍二级缓存的时候,我们说到了org.apache.ibatis.cache.decorators.TransactionalCache这个类的注释,对应的内容如下:

The 2nd level cache transactional buffer. This class holds all cache entries that are to be added to the 2nd level cache during a Session. Entries are sent to the cache when commit is called or discarded if the Session is rolled back. Blocking cache support has been added. Therefore any get() that returns a cache miss will be followed by a put() so any lock associated with the key can be released.

在前面我们已经讲解了前半部分。这部分的大意为这个类是二级缓存事务缓冲的抽象。这个类保存一个会话期间需要添加到二级缓存的所有缓存条目。这些条目会在事务提交时提交到二级缓存当中,但如果是事务回滚的话,这些条目会被丢弃,不会提交到二级缓存中。后面关于 Blocking cache的部分其实我们还没有提到,另外在前面我们得出了二级缓存就是MappedStatement中的Cache属性,也就是说它的类型为org.apache.ibatis.cache.Cache,这个我们也知道,但是这个二级缓存究竟是如何实现的呢?以及这个二级缓存是不是线程安全的?如果是线程安全的,又是如何实现的呢?本章我们就从这个Blocking cache深入研究这个二级缓存的真实面目。

二级缓存是事务性的。这意味着,当 SqlSession 完成并提交时,或是完成并回滚,但没有执行 flushCache=true 的 insert/delete/update 语句时,缓存会获得更新。

Blocking cache

在上面的注释当中的Blocking cache究竟说的是什么呢?首先我们不妨看一下MyBatis中的issue:Major bottleneck during cache refresh in Mybatis v3.2.2
在这里插入图片描述
以上的issue是一个用户向MyBatis官方寻求帮助,因为定时刷新缓存,导致二级缓存消失之后,大量的请求打到了数据库,导致很多的请求失败。为什么会出现这种问题呢?他也举了个例子,就是每个不同的线程同时去查同一条数据(符合MyBatis缓存Key原则),但是由于二级缓存当前不存在数据,然后去请求数据库,但是请求数据库大约需要5秒,只要在这5秒时间内所有请求这条数据的都会去请求数据库,导致大量的请求打到了数据库。而数据库并发能力是有限的,这里1000个线程是难以承受的。
在这里插入图片描述
这个人就希望官方能给一些解决方案,一开始官方人员回复说在mapped statement这个对象上去加锁是比较麻烦而且会导致其他问题。为什么他会想到mapped statement呢?其实二级缓存不就是这个上面的属性嘛,而且同一类请求肯定使用的是同一个mapped statement对象。所以他建议在MyBatis外面使用其他的缓存来解决这个问题。然后两个人还就这个在mapped statement中加锁进行了讨论。
在这里插入图片描述
然后直到另一个人提到ehcache中的BlockingCache,他建议要么在外面使用这个,或者自己自定义一个Cache的实现,他这里指的Cache就是MyBatis自己的。然后MyBatis官方就采纳了他的建议了。也就是下面这个类,这个作者emacarron与注释中的@author Eduardo Macarron应该就是同一个人了。
在这里插入图片描述
BTW,这个提建议的人是个日本人,还是挺厉害的。
在这里插入图片描述
那么问题来了,这个BlockingCache是如何起作用的呢?能够保证同时请求同一个key的多个线程只有一个真实请求数据库,而其他的及时感知缓存呢?首先看一下这个类的定义

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

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

在这里,BlockingCache是针对另一个缓存对象(delegate)的包装,同时使用了一个ConcurrentHashMap对象存储了一堆可重入锁(ReentrantLock)。首先是所有线程通过CacheKey来查询目标,所以先看getObject方法

@Override
public Object getObject(Object key) {
    1. 首先根据key获取锁
    acquireLock(key);
    2. 然后查询是不是存在对应key对应的缓存
    Object value = delegate.getObject(key);
    if (value != null) {
        3. 如果存在缓存 则释放锁
        releaseLock(key);
    }
    return value;
}

再看一下acquireLock和releaseLock的方法,其中有一个getLockForKey的方法,这个方法很巧妙的利用putIfAbsent保证通过指定的key在并发的情况下只会返回同一个锁对象(如果没有,则塞一个新的进去作为锁对象)。这里的精髓其实也就在这个getLockForKey方法,获取锁了之后,无非就是加锁了。在上面的案例当中,1000个请求(key是一样的)来了,只有第一个会获得这个锁,其他的都会处于等待了。此时如果获得锁的线程从delegate中得到了值,就会立刻释放锁,接下来的线程就会抢锁、读缓存、释放锁,没有问题。

private ReentrantLock getLockForKey(Object key) {
	首先创建一个ReentrantLock
    ReentrantLock lock = new ReentrantLock();
    尝试存放到Map中,如果已经存在,则返回已经存在的锁(Map中不会覆盖),不存在,则返回null
    ReentrantLock previous = locks.putIfAbsent(key, lock);
    如果返回null,说明使用的就是当前的锁,返回不为null,则应该使用原来的锁
    return previous == null ? lock : previous;
}

private void acquireLock(Object key) {
    1. 获取锁对象
    Lock lock = getLockForKey(key);
    2.1 按照时间上锁
    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 {
        2.2 直接上锁
        lock.lock();
    }
}

private void releaseLock(Object key) {
    ReentrantLock lock = locks.get(key);
    if (lock.isHeldByCurrentThread()) {
        只有锁的持有这才可以释放锁
        lock.unlock();
    }
}

当然一开始肯定从delegate是无法获取到值的,可以看到上面的代码在value != null时才会释放锁,等于null,是不会释放锁的,接下来只有一个线程继续如下的操作,其他999个线程都在等待。这个线程继续查询数据库,然后又会putObject存放到二级缓存中,以下为CachingExecutor中的对应源码

1. 请求一级缓存和数据库
list = delegate.<E>query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
2. 添加二级缓存 其实只是放到缓冲区
tcm.putObject(cache, key, list); // issue #578 and #116

在真正提交事务时才会执行org.apache.ibatis.cache.decorators.TransactionalCache#flushPendingEntries方法,这里会执行putObject方法。最后也会执行到BlockingCache中,然后存放二级缓存,释放锁。

@Override
public void putObject(Object key, Object value) {
    try {
        1. 存放二级缓存
        delegate.putObject(key, value);
    } finally {
        2. 释放锁
        releaseLock(key);
    }
}

当这个线程释放锁之后,接下来其他线程抢锁、查二级缓存就可以共享数据了(即使查询的数据为null这里也缓存了),这样就不会再请求数据库了。可以看出整个过程中只请求了一次数据库了。
如果第一个线程出现了问题,没有提交事务而是回滚事务呢?此时在org.apache.ibatis.cache.decorators.TransactionalCache#entriesMissedInCache属性中记录了对应的key.
在这里插入图片描述
在这里插入图片描述
对应的org.apache.ibatis.cache.decorators.BlockingCache#removeObject中也会释放锁,其他线程再继续接下来的操作。

@Override
public Object removeObject(Object key) {
    // despite of its name, this method is called only to release locks
    releaseLock(key);
    return null;
}

所以现在Blocking cache support has been added. Therefore any get() that returns a cache miss will be followed by a put() so any lock associated with the key can be released.这句话是不是比较容易理解了,因为BlockingCache的存在,即使get返回为null也需要记录,以便在事务提交和回滚的时候无论是putObject还是removeObject都会释放锁,否则会导致死锁问题的。

二级缓存的真实实现

在前面关于二级缓存的章节中我们提到过在解析mapper文件的时候会根据标签创建二级缓存,对应源码为org.apache.ibatis.builder.MapperBuilderAssistant#useNewCache

public Cache useNewCache(Class<? extends Cache> typeClass, Class<? extends Cache> evictionClass, Long flushInterval,
		Integer size, boolean readWrite, boolean blocking, Properties props) {
	Cache cache = new CacheBuilder(currentNamespace)
			.implementation(valueOrDefault(typeClass, PerpetualCache.class))
			.addDecorator(valueOrDefault(evictionClass, LruCache.class))
			.clearInterval(flushInterval)
			.size(size)
			.readWrite(readWrite)
			.blocking(blocking)
			.properties(props)
			.build();
	configuration.addCache(cache);
	currentCache = cache;
	return cache;
}

这里采用的是构造者模式,首先根据标签值或者默认值给CacheBuilder赋值,然后再构造一个二级缓存对象。在默认情况下
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在build开始的时候,默认的情况如下所示:
在这里插入图片描述
1. 设置默认的实现

public Cache build() {
	1. 设置默认的实现
    setDefaultImplementations();

对应的方法如下,无非是设置默认的实现类为PerpetualCache,另外如果装饰缓存列表为空,则添加一个LruCache

private void setDefaultImplementations() {
    if (implementation == null) {
        implementation = PerpetualCache.class;
        if (decorators.isEmpty()) {
            decorators.add(LruCache.class);
        }
    }
}

2. 根据反射创建一个基础缓存

    Cache cache = newBaseCacheInstance(implementation, id);

可以看到这里其实就是根据cacheClass的值创建一个对象,并设置id值

private Cache newBaseCacheInstance(Class<? extends Cache> cacheClass, String id) {
    Constructor<? extends Cache> cacheConstructor = getBaseCacheConstructor(cacheClass);
    try {
        return cacheConstructor.newInstance(id);
    } catch (Exception e) {
        throw new CacheException("Could not instantiate cache implementation (" + cacheClass + "). Cause: " + e, e);
    }
}

private Constructor<? extends Cache> getBaseCacheConstructor(Class<? extends Cache> cacheClass) {
    try {
        return cacheClass.getConstructor(String.class);
    } catch (Exception e) {
        throw new CacheException("Invalid base cache implementation (" + cacheClass + ").  " +
                "Base cache implementations must have a constructor that takes a String id as a parameter.  Cause: " + e, e);
    }
}

在这里插入图片描述
所以一个基础的二级缓存就是PerpetualCache类对象,内部是一个HashMap用于存储数据。

3. 设置缓存参数,以及添加装饰器

    setCacheProperties(cache);
    // issue #352, do not apply decorators to custom caches
    if (PerpetualCache.class.equals(cache.getClass())) {
        for (Class<? extends Cache> decorator : decorators) {
            1. 添加缓存装饰器
            cache = newCacheDecoratorInstance(decorator, cache);
            setCacheProperties(cache);
        }

由于默认参数为空,不具体分析了。而添加装饰器,无非就是遍历装饰器类列表,然后创建对象,并将上面创建的基础缓存类作为构造参数(其实就是delegate),并将新构造的对象返回作为二级缓存的实现。

private Cache newCacheDecoratorInstance(Class<? extends Cache> cacheClass, Cache base) {
    Constructor<? extends Cache> cacheConstructor = getCacheDecoratorConstructor(cacheClass);
    try {
        return cacheConstructor.newInstance(base);
    } catch (Exception e) {
        throw new CacheException("Could not instantiate cache decorator (" + cacheClass + "). Cause: " + e, e);
    }
}

private Constructor<? extends Cache> getCacheDecoratorConstructor(Class<? extends Cache> cacheClass) {
    try {
        return cacheClass.getConstructor(Cache.class);
    } catch (Exception e) {
        throw new CacheException("Invalid cache decorator (" + cacheClass + ").  " +
                "Cache decorators must have a constructor that takes a Cache instance as a parameter.  Cause: " + e, e);
    }
}

在这里插入图片描述
4. 设置标准的装饰器缓存

        cache = setStandardDecorators(cache);
    } else if (!LoggingCache.class.isAssignableFrom(cache.getClass())) {
        cache = new LoggingCache(cache);
    }
    return cache;
}

除了用户设置的装饰器和默认的装饰器之外,这里还有一些标准的装饰器需要设置。

private Cache setStandardDecorators(Cache cache) {
    try {
        MetaObject metaCache = SystemMetaObject.forObject(cache);
        1. 如果size不为空而且缓存包含有size属性 则设置size属性 在这里是LruCache缓存中的setSize方法
        if (size != null && metaCache.hasSetter("size")) {
            metaCache.setValue("size", size);
        }
        2. 如果clearInterval不为空 则将缓存再套一层ScheduledCache,用于定时清空二级缓存
        if (clearInterval != null) {
            cache = new ScheduledCache(cache);
            ((ScheduledCache) cache).setClearInterval(clearInterval);
        }
        3. 是否需要序列化 默认为true 套一个序列化的缓存
        if (readWrite) {
            cache = new SerializedCache(cache);
        }
        4. 默认添加一个日志缓存 我们常见的Cache Hit Ratio日志就是这个缓存打印的
        cache = new LoggingCache(cache);
        5. 默认添加一个同步缓存
        cache = new SynchronizedCache(cache);
        6. 如果需要阻塞功能 则添加阻塞缓存 默认为false
        if (blocking) {
            cache = new BlockingCache(cache);
        }
        return cache;
    } catch (Exception e) {
        throw new CacheException("Error building standard cache decorators.  Cause: " + e, e);
    }
}

通过以上步骤,一个默认的二级缓存对象构造成功了,可以看到在一个基础缓存PerpetualCache(就是一个Map)外面套上了LruCache、SerializedCache、LoggingCache、SynchronizedCache缓存。而且最后两个是默认一定添加的,不能通过配置取消。
在这里插入图片描述
这里的LoggingCache实现很简单,就是在获取方法的时候统计命中率(其实就是总的命中率,而不是单个key的命中率)

private final Log log;
private final Cache delegate;
protected int requests = 0;
protected int hits = 0;

@Override
public Object getObject(Object key) {
    requests++;
    final Object value = delegate.getObject(key);
    if (value != null) {
        hits++;
    }
    if (log.isDebugEnabled()) {
        log.debug("Cache Hit Ratio [" + getId() + "]: " + getHitRatio());
    }
    return value;
}

而另一个SynchronizedCache实现则更简单,只是对每个方法加上了同步。在这里,我们可以得出一个结论了:二级缓存虽然内部使用的是一个HashMap,但是它是线程安全的。因为这个装饰器保证了所有方法的并发安全性。
在这里插入图片描述
假如我们现在修改了二级缓存的配置如下

<cache size="120" flushInterval="600000" readOnly="true" blocking="true"/>

通过设置flushInterval添加了一个定时刷新的缓存,这个缓存就是记录上一次清缓存的时间,然后下一次调用任何关于缓存的方法的时候比较当前时间是不是超过了设定的间隔时间(单位为毫秒),如果超过了,就会清空缓存,然后执行方法,很明显,getObject直接返回null即可。

private boolean clearWhenStale() {
    if (System.currentTimeMillis() - lastClear > clearInterval) {
        clear();
        return true;
    }
    return false;
}

flushInterval(刷新间隔)属性可以被设置为任意的正整数,设置的值应该是一个以毫秒为单位的合理时间量。 默认情况是不设置,也就是没有刷新间隔,缓存仅仅会在调用语句时刷新。

通过设置readOnly为true关闭了SerializedCache这个序列化缓存,通过设置blocking为true开启了BlockingCache。

readOnly(只读)属性可以被设置为 true 或 false。只读的缓存会给所有调用者返回缓存对象的相同实例。 因此这些对象不能被修改。这就提供了可观的性能提升。而可读写的缓存会(通过序列化)返回缓存对象的拷贝。 速度上会慢一些,但是更安全,因此默认值是 false。

在这里插入图片描述

eviction算法

在二级缓存的配置标签当中还有eviction这样一个属性,也是不得不提的一个重点。什么是eviction?所谓的eviction就是缓存的清除策略。使用缓存当然能提高程序的性能,但是同时也需要占用额外的空间,也就是空间换时间。如果不考虑空间的限制,缓存也可能导致可用空间不够进而导致服务的崩溃。因此通过设定一个阈值,当缓存的数量达到这个阈值的时候,就清除多余的缓存。比如上面的配置中我们设置了size为120,那么当缓存的数量达到了120,接下来每加入一个缓存,都要考虑踢出一个缓存了,保证总数在120。在MyBatis中可用的清除策略有:
LRU – 最近最少使用:移除最长时间不被使用的对象。默认实现。对应LruCache类。
FIFO – 先进先出:按对象进入缓存的顺序来移除它们。对应FifoCache类。
SOFT – 软引用:基于垃圾回收器状态和软引用规则移除对象。对应SoftCache类。
WEAK – 弱引用:更积极地基于垃圾收集器状态和弱引用规则移除对象。对应WeakCache类。
通过以下的方式就可以将缓存清除策略更换为FIFO。

cache size="120" flushInterval="600000" readOnly="true" blocking="true" eviction="FIFO"/>

在这里插入图片描述
FIFO的实现非常简单,就是通过一个双向链表记录缓存的key,队尾加入,队首移除即可。

private final Cache delegate;
private final Deque<Object> keyList;
private int size;

public FifoCache(Cache delegate) {
    this.delegate = delegate;
    双向链表
    this.keyList = new LinkedList<Object>();
    默认值为1024 通过setSize修改
    this.size = 1024;
}

@Override
public void putObject(Object key, Object value) {
    cycleKeyList(key);
    delegate.putObject(key, value);
}

private void cycleKeyList(Object key) {
    在队尾加入
    keyList.addLast(key);
    if (keyList.size() > size) {
    	超过了容量 就会在队首移除
        Object oldestKey = keyList.removeFirst();
        delegate.removeObject(oldestKey);
    }
}

SoftCache在保存缓存的时候将对象包装为SoftReference,而WeakCache则是包装为WeakReference。当JVM的内存不够的时候就会回收SoftCache缓存,当JVM进行GC的时候就会回收WeakCache。这两个也是比较简单的。

而关于LRU缓存的实现可以参考博客:MyBatis LRU缓存的实现

总结

MyBatis缓存的实现是基于Map的,从缓存中读写数据是缓存模块的核心基础功能。除了核心功能之外,有很多额外的附加功能,比如:防止缓存击穿(BlockingCache)、缓存清空策略(LruCache、FifoCache、SoftCache、WeakCache)、序列化功能(SerializedCache)、日志功能(LoggingCache)、定时清空功能(ScheduledCache)和多线程安全(SynchronizedCache)。而且这些功能是可以进行任意的组合附加到核心基础功能之上的。这里最大的难题其实就是如何优雅的将这些附加功能添加到基础功能当中,用动态代理或者继承的方式可以扩展类的能力。但它们都是静态的扩展功能,用户不能控制增加行为的方式和动机,如果使用继承,新功能的存在有多种组合,无疑会导致子类数量的暴增,给使用也带来极大的麻烦。而MyBatis缓存模块通过装饰器模式完成了以上的功能,这也是装饰器模式的强大之处。值得学习。装饰器模式在IO流的设计中也有使用到,比如

BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(new FileInputStream(file)));

对网络爬虫的自定义增强,可增强的功能包括:多线程能力、缓存、自动生成报表、黑白名单、random触发等。

最后还有一点在某些情况下也有用,那就是自定义基础缓存的扩展,在Cache这个标签中有一个type的属性,可以修改默认的基础缓存。具体参考官方文档即可:使用自定义缓存

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

lang20150928

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值