好程式設計師Java培訓分享本地快取如何設計

好程式設計師發表於2020-07-20

  好程式設計師Java 培訓分享本地快取如何設計,最近在看 Mybatis 的原始碼,剛好看到快取這一塊, Mybatis 提供了一級快取和二級快取;一級快取相對來說比較簡單,功能比較齊全的是二級快取,基本上滿足了一個快取該有的功能;當然如果拿來和專門的快取框架如 ehcache 來對比可能稍有差距;本文帶大家來整理一下實現一個本地快取都應該需要考慮哪些東西。

  考慮點

  考慮點主要在資料用何種方式儲存,能儲存多少資料,多餘的資料如何處理等幾個點,下面我們來詳細的介紹每個考慮點,以及該如何去實現;

  1. 資料結構

  首要考慮的就是資料該如何儲存,用什麼資料結構儲存,最簡單的就直接用Map 來儲存資料;或者複雜的如 redis 一樣提供了多種資料型別雜湊,列表,集合,有序集合等,底層使用了雙端連結串列,壓縮列表,集合,跳躍表等資料結構;

  2. 物件上限

  因為是本地快取,記憶體有上限,所以一般都會指定快取物件的數量比如1024 ,當達到某個上限後需要有某種策略去刪除多餘的資料;

  3. 清除策略

  上面說到當達到物件上限之後需要有清除策略,常見的比如有LRU( 最近最少使用 ) FIFO( 先進先出 ) LFU( 最近最不常用 ) SOFT( 軟引用 ) WEAK( 弱引用 ) 等策略;

  4. 過期時間

  除了使用清除策略,一般本地快取也會有一個過期時間設定,比如redis 可以給每個 key 設定一個過期時間,這樣當達到過期時間之後直接刪除,採用清除策略 + 過期時間雙重保證;

  5. 執行緒安全

  像redis 是直接使用單執行緒處理,所以就不存線上程安全問題;而我們現在提供的本地快取往往是可以多個執行緒同時訪問的,所以執行緒安全是不容忽視的問題;並且執行緒安全問題是不應該拋給使用者去保證;

  6. 簡明的介面

  提供一個傻瓜式的對外介面是很有必要的,對使用者來說使用此快取不是一種負擔而是一種享受;提供常用的get put remove clear getSize 方法即可;

  7. 是否持久化

  這個其實不是必須的,是否需要將快取資料持久化看需求;本地快取如ehcache 是支援持久化的,而 guava 是沒有持久化功能的;分散式快取如 redis 是有持久化功能的, memcached 是沒有持久化功能的;

  8. 阻塞機制

  在看Mybatis 原始碼的時候,二級快取提供了一個 blocking 標識,表示當在快取中找不到元素時,它設定對快取鍵的鎖定;這樣其他執行緒將等待此元素被填充,而不是命中資料庫;其實我們使用快取的目的就是因為被快取的資料生成比較費時,比如呼叫對外的介面,查詢資料庫,計算量很大的結果等等;這時候如果多個執行緒同時呼叫 get 方法獲取的結果都為 null ,每個執行緒都去執行一遍費時的計算,其實也是對資源的浪費;比較好的辦法是隻有一個執行緒去執行,其他執行緒等待,計算一次就夠了;但是此功能基本上都交給使用者來處理,很少有本地快取有這種功能;

  如何實現

  以上大致介紹了實現一個本地快取我們都有哪些需要考慮的地方,當然可能還有其他沒有考慮到的點;下面繼續看看關於每個點都應該如何去實現,重點介紹一下思路;

  1. 資料結構

  本地快取最常見的是直接使用Map 來儲存,比如 guava 使用 ConcurrentHashMap ehcache 也是用了 ConcurrentHashMap Mybatis 二級快取使用 HashMap 來儲存:

  Map<Object, Object> cache = new ConcurrentHashMap<Object, Object>()

  Mybatis 使用 HashMap 本身是非執行緒安全的,所以可以看到起內部使用了一個 SynchronizedCache 用來包裝,保證執行緒的安全性;

  當然除了使用Map 來儲存,可能還使用其他資料結構來儲存,比如 redis 使用了雙端連結串列,壓縮列表,整數集合,跳躍表和字典;當然這主要是因為 redis 對外提供的介面很豐富除了雜湊還有列表,集合,有序集合等功能;

  2. 物件上限

  本地快取常見的一個屬性,一般快取都會有一個預設值比如1024 ,在使用者沒有指定的情況下預設指定;當快取的資料達到指定最大值時,需要有相關策略從快取中清除多餘的資料這就涉及到下面要介紹的清除策略;

  3. 清除策略

  配合物件上限之後使用,場景的清除策略如:LRU( 最近最少使用 ) FIFO( 先進先出 ) LFU( 最近最不常用 ) SOFT( 軟引用 ) WEAK( 弱引用 )

  LRU Least Recently Used 的縮寫最近最少使用,移除最長時間不被使用的物件;常見的使用 LinkedHashMap 來實現,也是很多本地快取預設使用的策略;

  FIFO :先進先出,按物件進入快取的順序來移除它們;常見使用佇列 Queue 來實現;

  LFU Least Frequently Used 的縮寫大概也是最近最少使用的意思,和 LRU 有點像;區別點在 LRU 的淘汰規則是基於訪問時間,而 LFU 是基於訪問次數的;可以透過 HashMap 並且記錄訪問次數來實現;

  SOFT :軟引用基於垃圾回收器狀態和軟引用規則移除物件;常見使用 SoftReference 來實現;

  WEAK :弱引用更積極地基於垃圾收集器狀態和弱引用規則移除物件;常見使用 WeakReference 來實現;

  4. 過期時間

  設定過期時間,讓快取資料在指定時間過後自動刪除;常見的過期資料刪除策略有兩種方式:被動刪除和主動刪除;

  被動刪除:每次進行get/put 操作的時候都會檢查一下當前 key 是否已經過期,如果過期則刪除,類似如下程式碼:

  if (System.currentTimeMillis() - lastClear > clearInterval) {

  clear();

  }

  主動刪除:專門有一個job 在後臺定期去檢查資料是否過期,如果過期則刪除,這其實可以有效的處理冷資料;

  5. 執行緒安全

  儘量用執行緒安全的類去儲存資料,比如使用ConcurrentHashMap 代替 HashMap ;或者提供相應的同步處理類,比如 Mybatis 提供了 SynchronizedCache

  public synchronized void putObject(Object key, Object object) {

  ... 省略 ...

  }

  @Override

  public synchronized Object getObject(Object key) {

  ... 省略 ...

  }

  6. 簡明的介面

  提供常用的get put remove clear getSize 方法即可,比如 Mybatis Cache 介面:

  public interface Cache {

  String getId();

  void putObject(Object key, Object value);

  Object getObject(Object key);

  Object removeObject(Object key);

  void clear();

  int getSize();

  ReadWriteLock getReadWriteLock();

  }

  再來看看guava 提供的 Cache 介面,相對來說也是比較簡潔的:

  public interface Cache<K, V> {

  V getIfPresent(@CompatibleWith("K") Object key);

  V get(K key, Callable<? extends V> loader) throws ExecutionException;

  ImmutableMap<K, V> getAllPresent(Iterable<?> keys);

  void put(K key, V value);

  void putAll(Map<? extends K, ? extends V> m);

  void invalidate(@CompatibleWith("K") Object key);

  void invalidateAll(Iterable<?> keys);

  void invalidateAll();

  long size();

  CacheStats stats();

  ConcurrentMap<K, V> asMap();

  void cleanUp();

  }

  7. 是否持久化

  持久化的好處是重啟之後可以再次載入檔案中的資料,這樣就起到類似熱載入的功效;比如ehcache 提供了是否持久化磁碟快取的功能,將快取資料存放在一個 .data 檔案中;

  diskPersistent="false" // 是否持久化磁碟快取

  redis 更是將持久化功能發揮到極致,慢慢的有點像資料庫了;提供了 AOF RDB 兩種持久化方式;當然很多情況下可以配合使用兩種方式;

  8. 阻塞機制

  除了在Mybatis 中看到了 BlockingCache 來實現此功能,之前在看 <<java 併發程式設計實戰 >> 的時候其中有實現一個很完美的快取,大致程式碼如下:

  public class Memoizerl<A, V> implements Computable<A, V> {

  private final Map<A, Future<V>> cache = new ConcurrentHashMap<A, Future<V>>();

  private final Computable<A, V> c;

  public Memoizerl(Computable<A, V> c) {

  this.c = c;

  }

  @Override

  public V compute(A arg) throws InterruptedException, ExecutionException {

  while (true) {

  Future<V> f = cache.get(arg);

  if (f == null) {

  Callable<V> eval = new Callable<V>() {

  @Override

  public V call() throws Exception {

  return c.compute(arg);

  }

  };

  FutureTask<V> ft = new FutureTask<V>(eval);

  f = cache.putIfAbsent(arg, ft);

  if (f == null) {

  f = ft;

  ft.run();

  }

  try {

  return f.get();

  } catch (CancellationException e) {

  cache.remove(arg, f);

  }

  }

  }

  }

  }

  compute 是一個計算很費時的方法,所以這裡把計算的結果快取起來,但是有個問題就是如果兩個執行緒同時進入此方法中怎麼保證只計算一次,這裡最核心的地方在於使用了 ConcurrentHashMap putIfAbsent 方法,同時只會寫入一個 FutureTask

  總結:要設計一個本地快取都需要考慮哪些點:資料結構,物件上限,清除策略,過期時間,執行緒安全,阻塞機制,實用的介面,是否持久化;當然肯定有其他考慮點,歡迎補充。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69913864/viewspace-2705607/,如需轉載,請註明出處,否則將追究法律責任。

相關文章