從0到1設計一個牛逼的本地緩存系統,直面雙十一架構體系

推薦閱讀:

最近在看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來存儲數據;或者複雜的如redis一樣提供了多種數據類型哈希,列表,集合,有序集合等,底層使用了雙端鏈表,壓縮列表,集合,跳躍表等數據結構;

本地緩存最常見的是直接使用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;

總結

本文大致介紹了要設計一個本地緩存都需要考慮哪些點:數據結構,對象上限,清除策略,過期時間,線程安全,阻塞機制,實用的接口,是否持久化;當然肯定有其他考慮點,歡迎補充。

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