开源框架是如何使用设计模式的-MyBatis缓存机制之装饰者模式

写在前面

聊一聊MyBatis是如何使用装饰者模式的,顺便回顾下缓存的相关知识,可以看看右侧目录一览内容概述。

装饰者模式

这里就不聊它的概念了,总结下就是套娃。利用组合的方式将装饰器组合进来,增强共同的抽象方法(与代理很类似但是又更灵活)

MyBatis缓存

回忆下传统手艺

  <!-- 先进先出,60秒刷新一次,可存储512个引用,返回对象只读,不同线程中的调用者之间修改会导致冲突 -->
 <cache eviction="FIFO" flushInterval="60000" size="512" readOnly="true"/>

粗略回顾下MyBatis缓存

一级缓存

MyBatis的一级缓存存在于SqlSession的生命周期中,在同一个SqlSession中查询时,MyBatis会把执行的方法和参数通过算法生成缓存的键值,将键值和查询结果存入一个Map对象中。如果同一个SqlSession中执行的方法和参数完全一致,那么通过算法会生成相同键值,当Map缓存对象中已经存在该键值时,则会返回缓存中的对象。

默认开启

二级缓存

MyBatis的二级缓存非常强大,它不同于一级缓存只存在于SqlSession的生命周期中,而是可以理解为存在于SqlSessionFactory的生命周期中。

默认不开启,需要如下配置后开启全局配置,再在对应的Mapper.xml中添加“传统手艺”-标签

<settings>
  <setting name = "cacheEnabled" value="true"/> 
</settings>

 <cache eviction="FIFO" flushInterval="60000" size="512" readOnly="true"/>

另一种开启方式-注解

@CacheNamespace(
  eviction = FifoCache.class,
  flushInterval = 60000,
  size = 512,
  readWrite = true
)
public interface RoleMapper {
  // 接口方法
}
  • eviction(收回策略)
    • LRU(最近最少使用的):移除长时间不使用的对象,这是默认值
    • FIFO(先进先出):按对象进入缓存的顺序来移除它们
    • SOFT(软引用):移除基于垃圾回收器状态和软引用规则的对象
    • WEAK(弱引用):更积极地移除基于垃圾收集器状态和弱引用规则的对象
  • flushInterval(刷新间隔)
  • size(引用数目)
  • readOnly(只读)只读的缓存会给所有调用者返回缓存的相同实例,因此这些对象不能被修改,这提供了很重要的性能优势。可读写的缓存会通过序列化返回缓存对象的拷贝,这种方式会慢一些,但是安全,因此默认是false

集成第三方缓存

MyBatis还支持通过“type”来集成第三方缓存,如下就是集成了Redis缓存,这样就从本地缓存跳跃到了分布式缓存了。

<mapper namespace="xxx.xxx.xxx.mapper.RoleMapper">
  <!-- 集成Redis缓存-->
  <cache type="org.mybatis.caches.redis.RedisCache" />
</mapper>

二级缓存的问题-脏数据

二级缓存虽然能提高应用效率,减轻数据库服务器的压力,但是如果使用不当,很容易产生脏数据

MyBatis的二级缓存是和命名空间绑定的,所以通常情况下每一个Mapper映射文件都拥有自己的二级缓存,不同Mapper的二级缓存互不影响。在常见的数据库操作中,多表联合查询非常常见,由于关系型数据库的设计,使得很多时候需要关联多个表才能获得想要的数据。在关联多表查询时肯定会将查询放到某个命名空间下的映射文件中,这样一个多表的查询就会缓存在该命名空间的二级缓存中。涉及这些表的增删改操作通常不在一个映射文件中,它们的命名空间不同,因此当有数据变化时,多表查询的缓存未必会被清空,这种情况下就会产生脏数据。

基于MyBatis缓存机制结合源码解析装饰器模式

Cache接口:
Cache接口

Cache核心方法:

  • putObject
  • getObject
  • removeObject

DEMO-实战使用MyBatis的装饰者模式

    public static void main(String[] args) {
        final String cacheKey = "cache";
        final Cache cache = new LoggingCache(new BlockingCache(new PerpetualCache(cacheKey)));
        Object cacheValue = cache.getObject(cacheKey);
        if (Objects.isNull(cacheValue)) {
            log.debug("缓存未命中 >>>>>>>>> key:[{}]", cacheKey);
            cache.putObject(cacheKey, "MyCacheValue");
        }

        cacheValue = cache.getObject(cacheKey);
        log.debug("缓存命中 >>>>>>>>> key:[{}],value:[{}]", cacheKey, cacheValue);
    }

如代码所示,是不是看到了“装饰者模式”的影子了,在构造函数中疯狂套娃。使用的是MyBatis的API,给基本缓存组件装饰了“日志打印”、“阻塞“的能力。
结果演示:
缓存Demo结果演示
可以看到,LogginCache在读缓存的时候还会打印出缓存命中率。 好了,接下来进入正题,看看其他缓存是怎么实现的吧。以下源码基于MyBatis3.4.5

PerpetualCache

  private final Map<Object, Object> cache = new 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);
  }

这是MyBatis的基础缓存,套娃的基本得有它,它的核心就是个HashMap来作为缓存容器,其实现的Cache接口的几个核心方法也都是委托给了HashMap去做。

FifoCache

一个支持先进先出的缓存策略的MyBatisCache

  private final Cache delegate;
  //维护一个key的双端队列
  private final Deque<Object> keyList;
  private int size;

  public FifoCache(Cache delegate) {
    //通过构造函数,将Cache组合进来,取名”委托“
    this.delegate = delegate;
    this.keyList = new LinkedList<>();
    this.size = 1024;
  }

  @Override
  public void putObject(Object key, Object value) {
    //先走自己的增强
    cycleKeyList(key);
    //真实的写缓存交给”委托“去做
    delegate.putObject(key, value);
  }

  @Override
  public Object getObject(Object key) {
    return delegate.getObject(key);
  }

  @Override
  public Object removeObject(Object key) {
    return delegate.removeObject(key);
  }

  private void cycleKeyList(Object key) {
    //将新写的缓存key添加到双端队列末尾
    keyList.addLast(key);
    // 如果key的大小大于了1024(构造函数中默认赋值1024)则会移除最早添加的缓存
    // 1. 移除自身维护的key队列的队头 2.委托给“委托”去真实删除队头缓存对象
    if (keyList.size() > size) {
      Object oldestKey = keyList.removeFirst();
      delegate.removeObject(oldestKey);
    }
  }

以上就是MyBatis先进先出缓存的实现了,FifoCache维护了key的双端队列,每次写缓存的时候会判断大小如果大于阈值则会先移除队头的key,再委托给组合进来的Cache来删除对应缓存操作,完成“先进先出”的增强(装饰)

LruCache

一个支持LRU(Least Recently Used ,最近最少使用)缓存策略的MyBatisCache

回忆下缓存策略

  • LRU:Least Recently Used,最近最少使用
  • LFU:Least Frequently Used,最近不常被使用

LRU 算法有一个缺点,比如说很久没有使用的一个键值,如果最近被访问了一次,那么即使它是使用次数最少的缓存,它也不会被淘汰;而 LFU 算法解决了偶尔被访问一次之后,数据就不会被淘汰的问题,它是根据总访问次数来淘汰数据的,其核心思想是“如果数据过去被访问多次,那么将来它被访问次数也会比较多”。因此 LFU 可以理解为比 LRU 更加合理的淘汰算法。

回忆下LinkedHashMap的核心机制-LRU

LinkedHashMap相比HashMap多了两个节点,before,after这样就能够维护节点之间的顺序了。

我们看看LinkedHashMap的get方法,它内部有LinkedHashMap开启LRU机制的秘密。

    public V get(Object key) {
        Node<K,V> e;
        if ((e = getNode(hash(key), key)) == null)
            return null;
        if (accessOrder)  // 为true则会执行afterNodeAccess(将节点移动到队尾)
            afterNodeAccess(e);
        return e.value;
    }

    void afterNodeAccess(Node<K,V> e) { // move node to last  (官方注释 言简意赅 -> 将节点移动到队尾)
        LinkedHashMap.Entry<K,V> last;
        if (accessOrder && (last = tail) != e) {
            LinkedHashMap.Entry<K,V> p =
                (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
            p.after = null;
            if (b == null)
                head = a;
            else
                b.after = a;
            if (a != null)
                a.before = b;
            else
                last = b;
            if (last == null)
                head = p;
            else {
                p.before = last;
                last.after = p;
            }
            tail = p;
            ++modCount;
        }
    }

那么这个accessOrder变量是怎么维护的呢?看代码

    public LinkedHashMap(int initialCapacity,
                         float loadFactor,
                         boolean accessOrder) {
        super(initialCapacity, loadFactor);
        this.accessOrder = accessOrder;
    }

你会发现,LinkedHashMap有这么一个构造函数,第三个参数便是accessOrder,所以决定是否开启LRU是你在运行时传参决定的!开启后则会在每次读取键值对之后将读取的节点移动至队尾,那么队头就是最近最少使用的了,队尾就是刚刚使用的了,当需要删除最近最少使用的节点的时候,直接删除队头的即可。

回忆下LinkedHashMap的核心方法-removeEldestEntry

LinkedHashMap是一个有顺序的HashMap,它可以使得你的k,v能够按照某种顺序写入和读取,它的核心方法removeEldestEntry功不可没。

在HashMap新增k,v之后会回调一个方法“afterNodeInsertion”,这个方法在HashMap中是一个空实现(俗称钩子方法),它的子类LinkedHashMap重写了它,代码如下。

    void afterNodeInsertion(boolean evict) { // possibly remove eldest     这是官方注释,言简意赅(可能会删除老key)
        LinkedHashMap.Entry<K,V> first;
        //前面的短路方法不管,我们关注removeEldestEntry方法 -> 如果该方法也返回true,则会走方法体中的removeNode方法(删除first节点的元素)。
        // 当开启LinkedHashMap的LRU模式,则队头的元素是“最近最少使用的元素”,因为每次读取k,v后都会将元素调整至队尾,所以队头的元素是“最近最少使用的元素“
        if (evict && (first = head) != null && removeEldestEntry(first)) {
            K key = first.key;
            removeNode(hash(key), key, null, false, true);
        }
    }

进入正题

  private final Cache delegate;
  // 维护一个key和value都是缓存key的map
  private Map<Object, Object> keyMap;
  //最近最少使用的Key
  private Object eldestKey;

  public LruCache(Cache delegate) {
    //通过构造函数,将Cache组合进来,取名”委托“
    this.delegate = delegate;
    //初始化keyMap(重要)
    setSize(1024);
  }

  public void setSize(final int size) {
    // 构造函数第三个参数传递true(accessOrder),如上所述将开启LRU模式
    keyMap = new LinkedHashMap<Object, Object>(size, .75F, true) {
      private static final long serialVersionUID = 4267176411845948333L;
        
      // 重写了LinkedHashMap的方法
      @Override
      protected boolean removeEldestEntry(Map.Entry<Object, Object> eldest) {
        boolean tooBig = size() > size;
        if (tooBig) {
          // 大小超过阈值,将队头(最近最少使用)的key更新至自身维护的"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) {
    keyMap.get(key); // touch
    return delegate.getObject(key);
  }

  @Override
  public Object removeObject(Object key) {
    return delegate.removeObject(key);
  }

  private void cycleKeyList(Object key) {
    // 因为重写了LinkedHashMap的removeEldestEntry方法,如上所述,超过阈值后eldestKey指向的就是最近最少使用的key
    keyMap.put(key, key);
    if (eldestKey != null) {
      // 委托移除最近最少使用的缓存
      delegate.removeObject(eldestKey);
      // 置空
      eldestKey = null;
    }
  }
  

以上就是MyBatis中的LRU缓存的机制了,自身维护了一个LinkedHashMap,开启了LRU机制,重写了removeEldestEntry方法,当大小触发阈值的时候维护最近最少使用的元素key,委托给组合进来的Cache对象移除,整个流程下来就使得被装饰着有了LRU的增强。

SoftCache

一个软引用的MyBatisCache

弱引用

弱引用比强引用稍弱一些。当JVM内存不足时,GC才会回收那些只被软引用指向的对象,从而避免OutOfMemoryError。当GC将只被软引用指向的对象全部回收之后,内存依然不足时,JVM才会抛出OutOfMemoryError。(这一特性非常适合做缓存,毕竟最终数据源在DB,还能保护JVM进程)

  // 维护最近经常使用的缓存数据,该集合会使用强引用指向其中的每个缓存Value,防止被GC回收
  private final Deque<Object> hardLinksToAvoidGarbageCollection;
  //与SortEntry对象关联,用于记录已经被回收的缓存条目
  private final ReferenceQueue<Object> queueOfGarbageCollectedEntries;
  private final Cache delegate;
  //强引用的个数,默认256。即有256个热点数据无法直接被GC回收
  private int numberOfHardLinks;

  public SoftCache(Cache delegate) {
    this.delegate = delegate;
    this.numberOfHardLinks = 256;
    this.hardLinksToAvoidGarbageCollection = new LinkedList<Object>();
    this.queueOfGarbageCollectedEntries = new ReferenceQueue<Object>();
  }

  @Override
  public void putObject(Object key, Object value) {
    // 同步删除已经被GC回收的Value
    removeGarbageCollectedItems();
    delegate.putObject(key, new SoftEntry(key, value, queueOfGarbageCollectedEntries));
  }

  private static class SoftEntry extends SoftReference<Object> {
    private final Object key;
    
    SoftEntry(Object key, Object value, ReferenceQueue<Object> garbageCollectionQueue) {
      // 关联引用队列。
     // 当SoftReference指向的对象被回收的时候,JVM就会将这个SoftReference作为通知,添加到与其关联的引用队列
      super(value, garbageCollectionQueue);
      this.key = key;
    }
  }


  @Override
  public Object getObject(Object key) {
    Object result = null;
    @SuppressWarnings("unchecked") // assumed delegate cache is totally managed by this cache
    SoftReference<Object> softReference = (SoftReference<Object>) delegate.getObject(key);     // 委托获取缓存
    if (softReference != null) {
      result = softReference.get();
      if (result == null) {
        // 重要的一步!判断Value是否为空,为空则表示弱引用指向的对象已经被GC回收了,就需要同步删除该缓存。
        delegate.removeObject(key);
      } else {
        // See #586 (and #335) modifications need more than a read lock 
        // 读取缓存后,维护“强引用”的数据。
        synchronized (hardLinksToAvoidGarbageCollection) {
          hardLinksToAvoidGarbageCollection.addFirst(result);   // 将缓存添加进强引用队列(热点数据)
          if (hardLinksToAvoidGarbageCollection.size() > numberOfHardLinks) {
            hardLinksToAvoidGarbageCollection.removeLast();   // 维护队列个数  
          }
        }
      }
    }
    return result;
  }

  @Override
  public Object removeObject(Object key) {
    removeGarbageCollectedItems();  // 删除被GC回收的Value
    return delegate.removeObject(key);    // 委托删除缓存
  }

  private void removeGarbageCollectedItems() {
    SoftEntry sv;
    // 引用关联的队列如果有值,则说明有被GC回收的Value
    while ((sv = (SoftEntry) queueOfGarbageCollectedEntries.poll()) != null) {
      delegate.removeObject(sv.key);
    }
  }

WeakCache

一个弱引用的MyBatisCache
与弱引用类似(基本相同),不过多介绍了。

弱引用

弱引用比软引用的引用强度还要弱。弱引用可以引用一个对象,但无法阻止这个对象被GC回收,也就是说,在JVM进行垃圾回收的时候,若发现某个对象只有一个弱引用指向它,那么这个对象会被GC立刻回收。(即遇GC比死,存活的时间为两次GC之间)

  // Entry继承的是WeakReference。
  // 其他内容参考弱引用Cache
  private static class WeakEntry extends WeakReference<Object> {
    private final Object key;
    
    private WeakEntry(Object key, Object value, ReferenceQueue<Object> garbageCollectionQueue) {
      super(value, garbageCollectionQueue);
      this.key = key;
    }
  }

LoggingCache

一个支持打印Debug级别的缓存命中率的MyBatisCache

  // 日志打印的log对象
  private final Log log;  
  private final Cache delegate;
  // 请求数
  protected int requests = 0;
  // 缓存命中数
  protected int hits = 0;

    public LoggingCache(Cache delegate) {
    //通过构造函数,将Cache组合进来,取名”委托“
    this.delegate = delegate;
    //log通过缓存id作为表示
    this.log = LogFactory.getLog(getId());
  }

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

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

  @Override
  public Object removeObject(Object key) {
    return delegate.removeObject(key);
  }

  private double getHitRatio() {
    // 计算缓存命中率
    return (double) hits / (double) requests;
  }

LoggingCache使得缓存读取的时候能够有缓存命中率的日志打印,挺实用的增强。

BlockingCache

一个支持阻塞的MyBatisCache

  private long timeout;
  private final Cache delegate;
  //每个key都有自己的ReentrantLock
  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) {
    // despite of its name, this method is called only to release locks
    releaseLock(key);   // 释放锁
    return null;
  }

  private void acquireLock(Object key) {
    Lock lock = getLockForKey(key);     // 获取对应的Lock,没有则新增一把Lock
    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 ReentrantLock getLockForKey(Object key) {
    ReentrantLock lock = new ReentrantLock();
    ReentrantLock previous = locks.putIfAbsent(key, lock);
    return previous == null ? lock : previous;
  }
 
  private void releaseLock(Object key) {
    ReentrantLock lock = locks.get(key);  // 获取Key对应的Lock
    if (lock.isHeldByCurrentThread()) {   // 如果是当前线程持有lock,则释放锁
      lock.unlock();
    }
  }

SynchronizedCache

一个支持同步的MyBatisCache,从名称就能知道实现原理是synchronized关键字

  public SynchronizedCache(Cache delegate) {
    this.delegate = delegate;
  }

    @Override
  public synchronized int getSize() {
    return delegate.getSize();
  }

  @Override
  public synchronized void putObject(Object key, Object object) {
    delegate.putObject(key, object);
  }

  @Override
  public synchronized Object getObject(Object key) {
    return delegate.getObject(key);
  }

  @Override
  public synchronized Object removeObject(Object key) {
    return delegate.removeObject(key);
  }

同步缓存就是给核心方法加上了同步锁,保证了线程安全。

跟随源码看看解析-装饰过程

cacheElement方法解析cache标签

可以看出最底层是PerpetualCache,默认装饰的是LruCache。

如下就是将剩下的装饰器循环装饰的过程了,细节就不追进去了。

以上就是MyBatis对于缓存的装饰者设计模式的实践相关的源码简单追踪了。

跟随源码看看缓存的使用的地方

先随便点击Cache接口的一方法,看看在哪里有使用。很明显,那个BaseExecutor的类就是正儿八经使用的地方。

query方法中很明显表示了先从缓存中获取,如果没有则走DB(还会写缓存)

代码也很简单,就是从DB获取然后写入缓存

总结

笔者先简单描述了装饰者模式,随后回忆了MyBatis的缓存传统手艺-cache标签的使用,以及一级二级缓存,描述了集成第三方缓存(解决JVM缓存的单点问题)。

随后结合源码介绍了MyBatis的Cache接口及其相关的实现类,首先通过Demo言简意赅地表达了装饰者模式的使用以及MyBatisCache装饰者模式使用的效果(LoggingCache)

紧接着笔者介绍了

  • PerpetualCache这个最关键最核心的缓存实现类,它的核心是一个HashMap;
  • FifoCache先进先出淘汰策略的缓存实现类,它的核心是一个维护key的双端队列,添加缓存前先维护这个双端队列,如果size到达阈值则移除队头的元素;
  • LruCache最近最少使用淘汰策略的缓存实现类,它的核心是基于LinkedHashMap实现LRU机制,我们也回忆了LRU以及LinkedHashMap相关的知识点,其关键点就是一个继承了LinkedHashMap的keyMap(KV都是缓存Key),重写了LinkedHashMap的重要方法removeEldestEntry,用于记录最近最少使用的key,在适当时机删除该缓存;
  • SoftCache、WeakCache我们回忆了软引用、弱引用的相关知识,其核心就是对应的Value组件Entry继承了SoftReference、WeakReference;
  • BlockingCache这个阻塞缓存的核心就是大名鼎鼎的ReentrantLock;
  • SynchronizedCache这个缓存顾名思义就是核心方法追加了synchronized的关键字,事实也确实如此。

为什么要使用缓存?走DB的链路上层用缓存抗一抗再正常不过了。 为什么用装饰者模式?这个场景它的核心就是缓存策略有很多,它们互相可以叠加,可以在配置的时候灵活配置,那么就可以通过解析配置后在运行时灵活的“装饰”起来,达到最后的预期效果,挺妙的。
关于多种Cache的核心实现,以及相关的周边技术可以反复琢磨,比如锁的使用、缓存的读写、LinkedHashMap、JVM的GC等等,毕竟这是开源框架的实战代码,这些都是值得我们像骆驼一样反复咀嚼,反复反刍的,至少了解了这一块,后续你真的有类似实战的时候之前可以先参考参考了!

好了,以上就是MyBatis缓存解析-装饰者设计模式了。欢迎多多交流,希望对你有帮助。原创不易..(没想到这么难,本来想总结下,发现一两次还写不完,光扣字都扣傻了 哈哈..)

posted @ 2021-07-26 13:51  程序员deepz  阅读(288)  评论(0编辑  收藏  举报