動手實現 LRU 演算法,以及 Caffeine 和 Redis 中的快取淘汰策略
阿新 • • 發佈:2020-07-15
> 我是風箏,公眾號「古時的風箏」。 文章會收錄在 [JavaNewBee](https://github.com/huzhicheng/JavaNewBee) 中,更有 Java 後端知識圖譜,從小白到大牛要走的路都在裡面。
那天我在 LeetCode 上刷到一道 LRU 快取機制的問題,第 146 題,難度為中等,題目如下。
> 運用你所掌握的資料結構,設計和實現一個 LRU (最近最少使用) 快取機制。它應該支援以下操作: 獲取資料 get 和 寫入資料 put 。
>
> 獲取資料 get(key) - 如果關鍵字 (key) 存在於快取中,則獲取關鍵字的值(總是正數),否則返回 -1。
> 寫入資料 put(key, value) - 如果關鍵字已經存在,則變更其資料值;如果關鍵字不存在,則插入該組「關鍵字/值」。當快取容量達到上限時,它應該在寫入新資料之前刪除最久未使用的資料值,從而為新的資料值留出空間。
LRU 全名 Least Recently Used,意為最近最少使用,注重最近使用的時間,是常用的快取淘汰策略。為了加快訪問速度,快取可以說無處不在,無論是計算機內部的快取,還是 Java 程式中的 JVM 快取,又或者是網站架構中的 Redis 快取。快取雖然好用,但快取內容可不能無限增加,要受儲存空間的約束,當空間不足的時候,只能選擇刪除一部分內容。那刪除哪些內容呢,這就涉及到淘汰策略了,而 LRU 應該是各種快取架構最常用的淘汰策略了。也就是當記憶體不足,新內容進來時,會將最近最少使用的元素刪掉。
我一看這題我熟啊,當初看 `LinkedHashMap`原始碼的時候,原始碼中有註釋提到了它可以用來實現 LRU 快取。原文是這麼寫的。
```
A special {@link #LinkedHashMap(int,float,boolean) constructor} is provided to create a linked hash map whose order of iteration is the order in which its entries were last accessed, from least-recently accessed to most-recently (access-order ). This kind of map is well-suited to building LRU caches.
```
翻譯過來大意如下:
通過一個特殊的建構函式,三個引數的這種,最後一個布林值引數表示是否要維護最近訪問順序,如果是 true 的話會維護最近訪問的順序,如果是 false 的話,只會維護插入順序。保證維護最近最少使用的順序。`LinkedHashMap`這種結構非常適合構造 LRU 快取。
當我看到這段註釋的時候,特意去查了一下用 `LinkedHashMap`實現 LRU 的方法。
```java
public class LRUCache {
private int cacheSize;
private LinkedHashMap linkedHashMap;
public LRUCache(int capacity) {
this.cacheSize = capacity;
linkedHashMap = new LinkedHashMap(capacity,0.75F,true){
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
return size()>cacheSize;
}
};
}
public int get(int key) {
return this.linkedHashMap.getOrDefault(key,-1);
}
public void put(int key, int value) {
this.linkedHashMap.put(key,value);
}
}
```
這是根據這道題的寫法,如果不限定這個題目的話,可以讓 `LRUCache`繼承 `LinkedHashMap`,然後再重寫 `removeEldestEntry`方法即可。
看到沒,就是這麼簡單,`LinkedHashMap`已經完美實現了 LRU,這個方法是在插入鍵值對的時候呼叫的,如果返回 true,就刪除最近最少使用的元素,所以只要判斷 `size()`是否大於 `cacheSize` 即可,`cacheSize`就是快取的最大容量。
提交,順利通過,完美!
![](https://tva1.sinaimg.cn/large/007S8ZIlly1ggngkjh4htj306o06o0so.jpg)
## LRU 簡單實現
你以為這麼簡單就完了嗎,並沒有。當我檢視官方題解的時候,發現裡面是這麼說的。
> 在 `Java` 語言中,同樣有類似的資料結構 `LinkedHashMap`。這些做法都不會符合面試官的要求。
什麼,這麼完美還不符合面試官要求,面試官是什麼要求呢?面試官的要求是考考你 LRU 的原理,讓你自己實現一個。
那咱們就由`LinkedHashMap`介紹一下最基礎的 LRU 實現。簡單概括 `LinkedHashMap`的實現原理就是 `HashMap`+雙向連結串列的結合。
雙向連結串列用來維護元素訪問順序,將最近被訪問(也就是調動 get 方法)的元素放到連結串列尾部,一旦超過快取容量的時候,就從連結串列頭部刪除元素,用雙向連結串列能保證元素移動速度最快,假設訪問了連結串列中的某個元素,只要把這個元素移動連結串列尾部,然後修改這個元素的 prev 和 next 節點的指向即可。
雙向連結串列節點的型別的基本屬性如下:
```java
static class Node {
/**
* 快取 key
*/
private int key;
/**
* 快取值
*/
private int value;
/**
* 當前節點的前驅節點
*/
private Node prev;
/**
* 當前節點的後驅節點
*/
private Node next;
public Node(int key, int value) {
this.key = key;
this.value = value;
}
}
```
`HashMap`用來儲存 key 值對應的節點,為的是快速定位 key 值在連結串列中的位置,我們都知道,這是因為`HashMap`的 get 方法的時間複雜度為 O(1)。而如果不借助 `HashMap`,那這個過程可就慢了。如果要想找一個 key,要從連結串列頭或連結串列尾遍歷才行。
![](https://tva1.sinaimg.cn/large/007S8ZIlly1ggo5q80aw1j31320mste0.jpg)
按上圖的展示, head 是連結串列頭,也是最長時間未被訪問的節點,tail 是最近被訪問的元素,假設快取最大容量是 4 。
### 插入元素
當有新元素被插入,先判斷快取容量是否超過最大值了,如果超過,就將頭節點刪除,然後將頭結點的 next 節點設定為 head,同時刪除 `HashMap`中對應的 key。然後將插入的元素放到連結串列尾部,設定此元素為尾節,並在 `HashMap`中儲存下來。
如果沒超過最大容量,直接插入到尾部。
### 訪問元素
當訪問其中的某個 key 時,先從 `HashMap`中快速找到這個節點。如果這個 key 不是尾節點,那麼就將此節的前驅節點的 next 指向此節點的後驅節點,此節點的後驅節點的 prev 指向此節點的前驅節點。 同時,將這個節點移動到尾部,並將它設定為尾結點。
下面這個動圖,演示了 get key2 時的移動情況。
![](https://tva1.sinaimg.cn/large/007S8ZIlly1ggo7x77d4tg30hs0b44qp.gif)
### 刪除元素
如果是刪除頭節點,則將此節點的後驅節點的 prev 設定為 null,並將它設定為 head,同時,刪除 `HashMap`中此節點的 key。
如果是刪除尾節點,則將此節點的前驅節點的 next 設定為 null,並將它設定為 tail,同時,刪除`HashMap`中此節點的 key。
如果是中間節點,則將此節的前驅節點的 next 指向此節點的後驅節點,此節點的後驅節點的 prev 指向此節點的前驅節點,同時,刪除`HashMap`中此節點的 key。
### 動手實現
思路就是這麼一個思路,有了這個思路我擼起袖子開始寫程式碼,由於自身演算法比較渣,而且又好長時間不刷演算法,所以我的慘痛經歷如下。
![](https://tva1.sinaimg.cn/large/007S8ZIlly1ggo8dql2qlj31qm0ak40h.jpg)
先是執行出錯,後來又解答錯誤,頓時開始懷疑人生,懷疑智商。最後發現,確實是智商問題。
![](https://tva1.sinaimg.cn/large/007S8ZIlly1ggo8ej4bvoj308c08cq2y.jpg)
總歸就是這麼一個意思,你也去寫一遍試試吧,看看效果如何。原題地址:https://leetcode-cn.com/problems/lru-cache/
## 除了 LRU 還有 LFU
還有一種常用的淘汰策略叫做 LFU(Least Frequently Used),最不經常使用。想比於LFU 更加註重訪問頻次。在 LRU 的基礎上增加了訪問頻次。
看下圖,舉個例子來說,假設現在 put 進來一個鍵值對,並且超過了最大的容量,那就要刪除一個鍵值對。假設 key2 是在 5 分鐘之前訪問過一次,而 key1 是在 10 分鐘之前訪問過,以 LRU 的策略來說,就會刪除頭節點,也就是圖中的 key1。但是如果是 LFU 的話,會記錄每個 key 的訪問頻次,雖然 key2 是最近一次訪問晚於 key1,但是它的頻次比 key1 少,那要淘汰一個 key 的話,還是要淘汰 key2 的。只是舉個例子,真正的 LFU 資料結構比 LRU 要複雜。
看 LeetCode 上的難度等級就知道了,LFU 也有一道對應的題目,地址:https://leetcode-cn.com/problems/lfu-cache/,它的難度是困難,而 LRU 的難度是中等。
![](https://tva1.sinaimg.cn/large/007S8ZIlly1ggocxrod4tj318g0pcq8n.jpg)
還有一種 FIFO ,先進先出策略,先進入快取的會先被淘汰,比起上面兩種,它的命中率比較低。
## 優缺點分析
**LRU的優點**:LRU相比於 LFU 而言效能更好一些,因為它演算法相對比較簡單,不需要記錄訪問頻次,可以更好的應對突發流量。
**LRU的缺點**:雖然效能好一些,但是它通過歷史資料來預測未來是侷限的,它會認為最後到來的資料是最可能被再次訪問的,從而給與它最高的優先順序。有些非熱點資料被訪問過後,佔據了高優先順序,它會在快取中佔據相當長的時間,從而造成空間浪費。
**LFU的優點**:LFU根據訪問頻次訪問,在大部分情況下,熱點資料的頻次肯定高於非熱點資料,所以它的命中率非常高。
**LFU的缺點**:LFU 演算法相對比較複雜,效能比 LRU 差。有問題的是下面這種情況,比如前一段時間微博有個熱點話題熱度非常高,就比如那種可以讓微博短時間停止服務的,於是趕緊快取起來,LFU 演算法記錄了其中熱點詞的訪問頻率,可能高達十幾億,而過後很長一段時間,這個話題已經不是熱點了,新的熱點也來了,但是,新熱點話題的熱度沒辦法到達十幾億,也就是說訪問頻次沒有之前的話題高,那之前的熱點就會一直佔據著快取空間,長時間無法被剔除。
針對以上這些問題,現有的快取框架都會做一系列改進。比如 JVM 本地快取 Caffeine,或者分散式快取 Redis。
## Caffeine 中的快取淘汰策略
Caffeine 是一款高效能的 JVM 快取框架,是目前 Spring 5.x 中的預設快取框架,之前版本是用的 Guava Cache。
為了改進上述 LRU 和 LFU 存在的問題,前Google工程師在 TinyLfu的基礎上發明了 W-TinyLFU 快取演算法。Caffine 就是基於此演算法開發的。
Caffeine 因使用 **Window TinyLfu** 回收策略,提供了一個**近乎最佳的命中率**。
TinyLFU維護了近期訪問記錄的頻率資訊,作為一個過濾器,當新記錄來時,只有滿足TinyLFU要求的記錄才可以被插入快取。
TinyLFU藉助了資料流Sketching技術,它可以用小得多的空間存放頻次資訊。TinyLFU採用了一種基於滑動視窗的時間衰減設計機制,藉助於一種簡易的 reset 操作:每次新增一條記錄到Sketch的時候,都會給一個計數器上加 1,當計數器達到一個尺寸 W 的時候,把所有記錄的 Sketch 數值都除以 2,該 reset 操作可以起到衰減的作用 。
W-TinyLFU主要用來解決一些稀疏的突發訪問元素。在一些數目很少但突發訪問量很大的場景下,TinyLFU將無法儲存這類元素,因為它們無法在給定時間內積累到足夠高的頻率。因此 W-TinyLFU 就是結合 LFU 和LRU,前者用來應對大多數場景,而 LRU 用來處理突發流量。
在處理頻次記錄方面,採用 Bloom Filter,對於每個key,用 n 個 byte 每個儲存一個標誌用來判斷 key 是否在集合中。原理就是使用 k 個 hash 函式來將 key 雜湊成一個整數。
在 W-TinyLFU 中使用 Count-Min Sketch 記錄 key 的訪問頻次,而它就是布隆過濾器的一個變種。
![](https://raw.githubusercontent.com/zhouxinghang/resources/master/ZBlog/drawio8.svg?sanitize=true)
## Redis 中的快取淘汰策略
Redis 支援如下 8 中淘汰策略,其中最後兩種 LFU 的是 4.0 版本之後新加的。
noeviction:當記憶體使用超過配置的時候會返回錯誤,不會驅逐任何鍵
allkeys-lru:加入鍵的時候,如果過限,首先通過LRU演算法驅逐最久沒有使用的鍵
volatile-lru:加入鍵的時候如果過限,首先從設定了過期時間的鍵集合中驅逐最久沒有使用的鍵
allkeys-random:加入鍵的時候如果過限,從所有key隨機刪除
volatile-random:加入鍵的時候如果過限,從過期鍵的集合中隨機驅逐
volatile-ttl:從配置了過期時間的鍵中驅逐馬上就要過期的鍵
volatile-lfu:從所有配置了過期時間的鍵中驅逐使用頻率最少的鍵
allkeys-lfu:從所有鍵中驅逐使用頻率最少的鍵
最常用的就是兩種 LRU 和 兩種 LFU 的。
通過在 redis.conf 配置檔案中配置如下配置項,來設定最大容量和採用的快取淘汰策略。
```properties
maxmemory 1024M
maxmemory-policy volatile-lru
```
### Redis 中的 LRU
Redis使用的是近似LRU演算法,它跟常規的LRU演算法還不太一樣,它並不維護佇列,而是隨機取樣法淘汰資料,每次隨機選出5(預設)個key,從裡面淘汰掉最近最少使用的key。
通過配置 `maxmemory-samples`設定隨機取樣大小。
```properties
maxmemory-samples 5
```
LRU 演算法會維護一個淘汰候選池(大小為16),池中的資料根據訪問時間進行排序,第一次隨機選取的key都會放入池中,隨後每次隨機選取的key只有在訪問時間小於池中最小的時間才會放入池中,直到候選池被放滿。當放滿後,如果有新的key需要放入,則將池中最後訪問時間最大(最近被訪問)的移除。當需要淘汰 key 的時候,則直接從池中選取最近訪問時間最小(最久沒被訪問)的 key 淘汰掉即可。
### Redis 中的 LFU
LFU 演算法是 4.0 之後才加入進來的。
上面 LRU 演算法中會按照訪問時間進行淘汰,這個訪問時間是 Redis 中維護的一個 24 位時鐘,也就是當前時間戳,每個 key 所在的物件也維護著一個時鐘欄位,當訪問一個 key 的時候,會拿到當前的全域性時鐘,然後將這個時鐘值賦給這個 key 所在物件維護的時鐘欄位,之後的按時間比較就是根據這個時鐘欄位。
而 LFU 演算法就是利用的這個欄位,24位分成兩部分,前16位還代表時鐘,後8位代表一個計數器。16位的情況下如果還按照秒為單位就會導致不夠用,所以一般這裡以時鐘為單位。而後8位表示當前key物件的訪問頻率,8位只能代表255,但是redis並沒有採用線性上升的方式,而是通過一個複雜的公式,通過配置兩個引數來調整資料的遞增速度。
```properties
lfu-log-factor 10
lfu-decay-time 1
```
在影響因子 lfu-log-factor 為10的情況下,經過1百萬次命中才能達到 255。
本文完。
## 送給你
種一棵樹最好的時間是十年前,其次是現在。送給各位,也送給自己。
> 公眾號「古時的風箏」,Java 開發者,全棧工程師,人稱遲到小王子,bug 殺手,擅長解決問題。
一個兼具深度與廣度的程式設計師鼓勵師,本打算寫詩卻寫起了程式碼的田園碼農!堅持原創乾貨輸出,你可選擇現在就關注我,或者看看歷史文章再關注也不遲。長按二維碼關注,跟我一起變優秀!
![](https://img2020.cnblogs.com/blog/273364/202007/273364-20200713214550082-1803210