我用幾個 bit 實現了 LRU,你不好奇嗎?

2021-10-19 12:25:58 字數 3766 閱讀 3935

提到快取,我們肯定都不陌生,由於大部分系統的資料都存在區域性性,即有些資料是經常被使用到的,我們可以將其先快取起來,這樣,一方面能提高系統的吞吐量;另一方面也能降低資料庫等第三方系統的請求壓力。

快取置換,是指當快取滿了之後,這時候再有新的資料需要快取時,需要淘汰掉快取中的乙個條目,給新資料騰出位置。如果乙個快取置換方案設計的不合理,導致我們經常在快取中找不到想要的資料,這時候,需要頻繁進行快取置換,快取的作用很小,甚至是負作用,本來只需要請求一次外部系統,現在還額外增加對快取系統的讀寫。

當需要從快取中淘汰資料時,我們希望能淘汰那些將來不可能再被使用的資料,保留那些將來還會頻繁訪問的資料,但問題是快取並不能預言未來。乙個解決方法就是通過 lru 進行**:最近被頻繁訪問的資料將來被訪問的可能性也越大。

常見的 lru 使用雜湊鍊錶實現,雜湊鍊錶是雙向鍊錶和雜湊表的結合體。

查詢時,利用雜湊表,可以在 o (1) 的複雜度下快速找到某個 key 是否在快取 (鍊錶) 並讀取出值;每次訪問後,會將快取條目移動到煉表頭。這樣,最近一直沒有訪問的資料就會處於鍊錶尾部,發生快取置換時,刪除鍊錶尾部的資料,並將新資料寫入鍊錶頭部。

為什麼使用雙向鍊錶,使用單向鍊錶有什麼問題嗎?使用雙向鍊錶是為了在移動快取資料到表頭的複雜度為 o (1)。移動快取資料在鍊錶中的位置等價於先把節點刪除,再把節點移動到表頭位置,刪除時,我們需要同時知道節點的前驅節點和後驅節點分別是哪個,才能將他們相連。單向鍊錶需要對鍊錶進行遍歷才能獲取前驅節點,顯然不能滿足要求。

上面的 lru 實現用到了乙個雙向鍊錶來記錄資料的最近訪問情況,每個鍊錶節點需要維護乙個前驅指標和乙個後驅指標,當快取量較大時,兩個指標額外占用的記憶體也是不可忽視的。所以,在快取資料庫 redis 中,為了節省記憶體的占用,實現了一種基於取樣的近似 lru 置換演算法。

快取資料依然通過乙個雜湊表管理,通過 key 可以快速找到對應的資料。每個快取資料除了 key-value 之外,額外多儲存乙個最後訪問的時間戳 last_read_time。發生快取置換時,隨機選出 n 個快取資料,淘汰掉其中最久未被訪問的資料。

這種方案,雖然可能每次過濾的不是整個快取中最久未被訪問的資料,但計算簡單,執行效率也是 o (1) 級別的。而且,這個方案能進一步優化,我們每次淘汰時,可能上一次取樣淘汰後剩下的 n-1 個資料中,比下一次取樣得到的 n 個資料的最後一次訪問時間都早,這種情況第一次取樣剩下的那幾個老資料並不會被淘汰掉。

新資料:最後一次訪問時間距離現在較近,last_read_time 值較大

老資料:最後一次訪問時間距離現在較遠,last_read_time 值較小

為了不辜負之前取樣的 「努力」,使演算法能盡量淘汰掉更老的資料,我們可以額外維護乙個大小為 m 的大頂堆,堆中資料按照 last_read_time 的值排序,這樣,堆中最新的資料將會處於堆頂。每次取樣後,我們將取樣得到的資料依次與堆頂資料比較,如果 last_read_time 比堆頂元素小(即取樣的資料更老),我們就把堆頂元素刪除,並將取樣的資料插入堆中;如果比堆頂元素大(即取樣的資料比較新),則不做處理。全部取樣資料比較完成後,我們再淘汰掉堆中最老的一條資料,這樣,我們就能結合」 歷史 「取樣的資料,淘汰掉更老的資料。

在上面兩種實現中,我們對雜湊表都是一筆帶過,但有些場景下,快取很貴,操作快取的成本也很高,需要我們對快取進行更底層的設計,更加合理的利用快取空間。比如 cpu 上的快取,快取很小,可能就只有幾百幾千個快取行,但因離 cpu 很近,造價很高,對快取效能的要求也更高。

我們先將這類快取的資料結構抽象成乙個特定長度的陣列,對這個陣列進行快取設計。

為了能滿足快速查詢到某個快取資料,我們依舊可以參考雜湊表的思路,設計乙個雜湊函式,根據 key 快速定位到資料在陣列中的位置。當然,問題也是很明顯的,乙個資料通過雜湊計算後,陣列位置是確定的,所以快取置換時替換的快取資料也是確定的,無法選擇淘汰掉更老的資料。

這個問題在於資料在陣列中位置是唯一確定的,如果允許乙個資料對映到陣列的多個位置,就可以在這多個位置的快取資料中淘汰掉其中比較老的資料了。

這裡我們給出一種方案,在經過雜湊計算出乙個位置 a 後,可以在 a 開始的往後 n 個位置中查詢資料。這 n 個位置的資料組成乙個選擇組。例如快取總容量 100,選擇組大小設定為 8。要查詢 key=「lru」 在快取中的值,經過雜湊後得出在位置 11,那麼,可以在位置【11、12、13、14、15、16、17、18】中依次查詢,直至找到 key 的快取資料。

當有新資料需要快取時,先通過雜湊計算出選擇組的 n 個資料,然後在這 n 個資料中選擇老資料替換成新加的資料。那麼,這個時候該如何選擇呢?

比較容易可以想到的是,可以參考 redis 的實現,每個快取資料記錄下最後訪問的時間戳,置換時,在選擇組中淘汰掉最老的資料即可。但是,這對於」 寸土寸金 「的 cpu 快取來說,額外儲存乙個時間戳,對快取空間的消耗還是有點太 「奢侈」 了。

這裡介紹一種更」 節約 「的模擬 lru 置換方案,每個快取條目拿出 1 個 lru 位 (bit) 來記錄快取的訪問情況。0 代表要被淘汰,當快取被訪問時,將這個 bit 設定為 1,置換時查詢 0 的快取資料替換出去。當選擇組的快取條目全為 1 時,將選擇組中的快取條 lru 位全部重置為 0。

最後再介紹一種更巧妙的模擬 lru 方法。用幾個 bit 來為每個選擇組構造乙個滿二叉樹,如下圖。

樹中的每個節點都是乙個 bit,節點為 0 時表示指向左子節點,1 時表示指向右子節點,初始狀態都為 0,即都指向左邊。

讀取快取時,將改變指向讀取的快取的節點的箭頭指向,比如,讀取 a 時,會將指向 a 的箭頭會被翻轉,結果如下圖。

當然,如果讀取 d 時,整個樹的各節點指向不需要改變。

發生快取置換時,會從根節點開始尋找,順著箭頭方向找到需要淘汰替換的快取條目。在尋找過程中,會將路徑上的節點箭頭全部反轉,0 變成 1,1 變成 0。比如,要寫入新快取 「k」,結果如下。

總結來說,也就是樹的葉子節點指向的快取條目,都是較早被訪問的,應該先被淘汰掉。

思考下,構造 bit-tree 模擬 lru 對選擇組中快取數量有要求嗎?

其實是應該滿足 2^n 的,因為搜尋樹是一顆滿二叉樹,葉子節點的數量是 2^n, 每個葉子節點負責兩個快取資料,所以,快取數?> 據的數量應該是也 2^n,否則可能在置換時,找不到要淘汰的快取資料。

關於LRU演算法用python的簡單實現

記憶體管理的一種頁面置換演算法,對於在記憶體中但又不用的資料塊 記憶體塊 叫做lru,作業系統會根據哪 些資料屬於lru而將其移出記憶體而騰出空間來載入另外的資料。lru指的是最少使用策略 least recently used 在python中我首先是會根據輸入動態生成乙個列表,以下為生成列表的 ...

用C實現7 bit編碼和解碼的演算法

用c實現7 bit編碼和解碼的演算法如下 7 bit編碼 psrc 源字串指標 pdst 目標編碼串指標 nsrclength 源字串長度 返回 目標編碼串長度 int gsmencode7bit const char psrc,unsigned char pdst,int nsrclength 修...

幾個用R實現Data Mining的例子

線性回歸 x 1 10 y x rnorm 10,0,1 fit lm y x summary fit 關聯挖掘 library arules data paste item 1,item 2 item2,item3 sep n 乙個簡單的transaction資料的例子 write data,fi...