開源框架是如何使用設計模式的-MyBatis快取機制之裝飾者模式

DeepSleeping丶發表於2021-07-26

寫在前面

聊一聊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快取解析-裝飾者設計模式了。歡迎多多交流,希望對你有幫助。原創不易..(沒想到這麼難,本來想總結下,發現一兩次還寫不完,光扣字都扣傻了 哈哈..)

相關文章