探索c#之storm的TimeCacheMap

蘑菇先生發表於2015-09-14

閱讀目錄:

  1. 概述
  2. 演算法介紹
  3. 清理執行緒
  4. 獲取、插入、刪除
  5. 總結

概述

最近在看storm,發現其中的TimeCacheMap演算法設計頗為高效,就簡單分享介紹下。
思考一下如果需要一個帶過期淘汰的快取容器,我們通常會使用定時器或執行緒去掃描容器,以便判斷是否過期從而刪除。但這樣效能並不友好,在資料量較大時O(n)檢查是一筆不小的開銷,並且在大量過期資料刪除時需要頻繁對容器加鎖,這會多少會影響到正常的資料讀寫刪除。
Storm設計了一種比較高效的時間快取容器TimeCacheMap,它的演算法可以在某個時間週期內將資料批量刪除,一次批量刪除只需要加一次鎖即可,並且其讀寫刪除複雜度均為O(1)。

演算法介紹

TimeCacheMap把要快取的資料分拆儲存到多個小容器內,這裡稱為桶。另外有個執行緒專門在一定時間內去掃描這些桶,一旦發現過期後就把整個桶的資料給刪除掉。 其中第二步比較關鍵,它並不是傳統意義上的去定時掃描,而是根據過期時間來觸發,比如如果一個桶過期時間10s,那麼這個執行緒就10秒觸發一次把整個桶刪除即可,當然多個桶的觸發策略會有所不同,但思路是同一個。   
為了更詳細的描述,用程式碼和例子介紹如下:

    private LinkedList<Dictionary<K, V>> buckets;
    private readonly object Obj = new object();
    private static readonly int NumBuckets = 3;
    private Thread cleaner;

上面使用了k、v的形式作為快取資料結構,每個Dictionary是一個桶,然後使用連結串列把多個桶儲存起來。Obj是要鎖的物件,NumBuckets是桶的數量,cleaner是清理執行緒。
在快取初始化的時候,會例項三個空桶加入到buckets,清理執行緒開始啟動迴圈檢查,假設過期時間時30秒,桶的數量為3,當有新資料進來時,會全部加入到第一個桶中。

為了刪除效能,清理執行緒會定期把整個桶給刪除掉,一般我們會每次把連結串列中最後一個桶給清理掉,然後再加入一個新桶到連結串列頭部。
這種情況下就不能按照快取過期時間去觸發執行緒清理了,因為有三個桶,如果每30秒觸發執行緒清理掉最後一個桶,那麼第三個桶要等到第90秒才開始清理,很明顯這樣是不合理的。 正確的應該是第30秒開始清理,這時就需要調整執行緒觸發時間,比如調整成10秒,繼續模擬下:

  1. 觸發前1秒插入新資料到第一個桶,如果調整成10秒觸發,等到觸發刪除這個桶時才過了20秒,跟快取過期時間30秒不一致同樣不合理,不管是1秒還是9秒都會導致提前刪除資料,需要繼續調整觸發時間。
  2. 如上快取提前刪除不能允許的,但延遲刪除一般是可以接受的,因此可以加入一些冗餘時間來保證不會提前刪除。 這裡調整到15秒觸發,觸發前1秒插入的快取桶正好在30秒後觸發刪除,達到不會提前刪除的目的。
  3. 如上在觸發前14秒插入資料,那就需要過了30秒+14秒才能刪除。

根據上面的模擬,調整到15秒觸發是一個比較合理的值,因此推出快取最長過期時間的公式為:

expirationSecs * (1 + 1 / (numBuckets-1))

如果過期時間是30秒,其最長刪除時間是:

30*(1+1/(3-1))=30*(1+0.5)=45  

因此其過期時間範圍即為expirationSecs到expirationSecs * (1 + 1 / (numBuckets-1))之間。

清理執行緒

如上演算法的介紹,我們在型別的建構函式中,例項化並啟動清理執行緒:

 public TimeCacheMap(int expirationSecs, int numBuckets, ExpiredCallBack ex)
    {
        if (numBuckets < 2)
            throw new ArgumentException("numBuckets must be >=2");
        this.buckets = new LinkedList<Dictionary<K, V>>();
        for (int i = 0; i < numBuckets; i++)
            buckets.AddFirst(new Dictionary<K, V>());
        var expirationMillis = expirationSecs * 1000;
        var sleepTime = expirationMillis / (numBuckets - 1);
        cleaner = new Thread(() =>
        {
            while (true)
            {
                Dictionary<K, V> dead = null;
                Thread.Sleep(sleepTime);
                lock (Obj)
                {
                    dead = buckets.Last();
                    buckets.RemoveLast();
                    buckets.AddFirst(new Dictionary<K, V>());
                }
                if (ex != null)
                    ex(dead);
            }
        });
        cleaner.IsBackground = true;
        cleaner.Start();
    }

程式碼執行步驟:

  1. 初始化桶加入到連結串列
  2. 計算快取資料最長過期時間,並作為執行緒休眠的時間。
  3. 執行緒觸發時刪除最後一個桶並加入新的桶
  4. 不斷迴圈休眠觸發觸發
  5. 啟動執行緒

整個桶的資料刪除只需要加一次鎖即可,保證其高效。

獲取、插入、刪除

遍歷整個連結串列,查詢到第一個滿足key的立即返回,這需要保證不會有重複key。

   public V Get(K key)
        {
            lock (Obj)
            {
                foreach (var item in buckets)
                {
                    if (item.ContainsKey(key))
                        return item[key];
                }
                return default(V);
            }
        }

在插入時刪除對應的key,保證不會有重複的key出現。

 public void Put(K key, V value)
    {
        lock (Obj)
        {
            foreach (var item in buckets)
            {
                item.Remove(key);
            }
            buckets.First().Add(key, value);
        }
    }

刪除對應的key

    public void Remove(K key)
    {
        lock (Obj)
        {
            foreach (var item in buckets)
            {
                if (item.ContainsKey(key))
                    item.Remove(key);
            }
        }
    }

總結

那些年我們一起追過的快取寫法(三)中有介紹過關於惰性刪除及高效LRU演算法優化快取容器的過期,有興趣的童鞋可以看看。
完整程式碼中有容器Size、ContainsKey的實現,github-TimeCacheMap.c#
在storm中,spout發射的訊息和acker的訊息即儲存在各自的TimeCacheMap裡,如果訊息超時後會自動通知spout的fail方法。 在storm0.8後TimeCacheMap被棄用了,使用的是新的RotatingMap,但設計和實現基本沒變,github-TimeCacheMap.javagithub-RotatingMap.java

相關文章