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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章