閱讀目錄:
概述
最近在看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秒插入新資料到第一個桶,如果調整成10秒觸發,等到觸發刪除這個桶時才過了20秒,跟快取過期時間30秒不一致同樣不合理,不管是1秒還是9秒都會導致提前刪除資料,需要繼續調整觸發時間。
- 如上快取提前刪除不能允許的,但延遲刪除一般是可以接受的,因此可以加入一些冗餘時間來保證不會提前刪除。 這裡調整到15秒觸發,觸發前1秒插入的快取桶正好在30秒後觸發刪除,達到不會提前刪除的目的。
- 如上在觸發前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(); }
程式碼執行步驟:
- 初始化桶加入到連結串列
- 計算快取資料最長過期時間,並作為執行緒休眠的時間。
- 執行緒觸發時刪除最後一個桶並加入新的桶
- 不斷迴圈休眠觸發觸發
- 啟動執行緒
整個桶的資料刪除只需要加一次鎖即可,保證其高效。
獲取、插入、刪除
遍歷整個連結串列,查詢到第一個滿足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.java及github-RotatingMap.java。