玩轉Redis-8種資料淘汰策略及近似LRU、LFU原理
《玩轉Redis》系列文章主要講述Redis的基礎及中高階應用。本文是《玩轉Redis》系列第【14】篇,最新系列文章請前往檢視,或即可。
本文關鍵字:玩轉Redis、Redis資料淘汰策略、8種資料淘汰策略、Redis快取滿了怎麼辦、Redis近似LRU演算法、Redis的LFU演算法;
往期精選:
大綱
- 為什麼Redis需要資料淘汰機制?
- Redis的8種資料淘汰策略
- Redis的近似LRU演算法
- LRU演算法原理
- 近似LRU演算法原理(approximated LRU algorithm)
- Redis的LFU演算法
- LFU與LRU的區別
- LFU演算法原理
- 小知識
- 為什麼Redis要使用自己的時鐘?
- 如何發現熱點key?
1、為什麼Redis需要資料淘汰機制?
眾所周知,Redis作為知名記憶體型NOSQL,極大提升了程式訪問資料的效能,高效能網際網路應用裡,幾乎都能看到Redis的身影。為了提升系統性能,Redis也從單機版、主從版發展到叢集版、讀寫分離叢集版等等,業界也有諸多著名三方擴充套件庫(如Codis、Twemproxy)。
阿里雲的企業版Redis(Tair)的效能增強型叢集版更是“[豪]無人性”,記憶體容量高達4096 GB 記憶體,支援約61440000 QPS。Tair混合儲存版更是使用記憶體和磁碟同時儲存資料的叢集版Redis例項,最高規格為1024 GB記憶體8192 GB磁碟(16節點)。【援引:https://help.aliyun.com/document_detail/26350.html】
既然Redis這麼牛,那我們就使勁把資料往裡面儲存嗎?
32G DDR4 記憶體條大約 900 元,1TB 的 SSD 硬碟大約 1000 元,價格實在懸殊。此外,即使資料量很大,但常用資料其實相對較少,全放記憶體價效比太低。“二八原則”在這裡也是適用的。
既然記憶體空間有限,為避免記憶體寫滿,就肯定需要進行記憶體資料淘汰了。
- 價效比;
- 記憶體空間有限;
2、Redis的8種資料淘汰策略
redis.conf中可配置Redis的最大記憶體量 maxmemory,如果配置為0,在64位系統下則表示無最大記憶體限制,在32位系統下則表示最大記憶體限制為 3 GB。當實際使用記憶體 mem_used 達到設定的閥值 maxmemory 後,Redis將按照預設的淘汰策略進行資料淘汰。
# redis.conf 最大記憶體配置示例,公眾號 zxiaofan
# 不帶單位則 單位是 位元組<bytes>
maxmemory 1048576
maxmemory 1048576B
maxmemory 1000KB
maxmemory 100MB
maxmemory 1GB
maxmemory 1000K
maxmemory 100M
maxmemory 1G
除了在配置檔案中修改配置,也可以使用 config 命令動態修改maxmemory。
# redis maxmemory 動態設定及檢視命令示例,公眾號 zxiaofan
# 動態修改 maxmemory
config set maxmemory 10GB
# 檢視 maxmemory
config get maxmemory
info memory | grep maxmemory
redis-cli -h 127.0.01 -p 6379 config get maxmemory
接下來我們講講8種資料淘汰策略,Redis 4.0開始,共有8種資料淘汰機制。
淘汰策略名稱 | 策略含義 |
---|---|
noeviction | 預設策略,不淘汰資料;大部分寫命令都將返回錯誤(DEL等少數除外) |
allkeys-lru | 從所有資料中根據 LRU 演算法挑選資料淘汰 |
volatile-lru | 從設定了過期時間的資料中根據 LRU 演算法挑選資料淘汰 |
allkeys-random | 從所有資料中隨機挑選資料淘汰 |
volatile-random | 從設定了過期時間的資料中隨機挑選資料淘汰 |
volatile-ttl | 從設定了過期時間的資料中,挑選越早過期的資料進行刪除 |
allkeys-lfu | 從所有資料中根據 LFU 演算法挑選資料淘汰(4.0及以上版本可用) |
volatile-lfu | 從設定了過期時間的資料中根據 LFU 演算法挑選資料淘汰(4.0及以上版本可用) |
// redis.conf,Redis 6.0.6版本
// 預設策略 是 noeviction,在生產環境建議修改。
# The default is:
#
# maxmemory-policy noeviction
// 線上設定資料淘汰策略 maxmemory-policy
config set maxmemory-policy volatile-lfu
noeviction 涉及的返回錯誤的寫命令包含:
set,setnx,setex,append,incr,decr,rpush,lpush,rpushx,lpushx,linsert,lset,rpoplpush,sadd,sinter,sinterstore,sunion,sunionstore,sdiff,sdiffstore,zadd,zincrby,zunionstore,zinterstore,hset,hsetnx,hmset,hincrby,incrby,decrby,getset,mset,msetnx,exec,sort。
我們可以看到,除 noeviction 比較特殊外,allkeys 開頭的將從所有資料中進行淘汰,volatile 開頭的將從設定了過期時間的資料中進行淘汰。淘汰演算法又核心分為 lru、random、ttl、lfu 幾種。
讓我們用一張圖來概括:
3、Redis的近似LRU演算法
在瞭解Redis近似LRU演算法前,我們先來了解下原生的LRU演算法。
3.1、LRU演算法
LRU(Least Recently Used)最近最少使用。優先淘汰最近未被使用的資料,其核心思想是“如果資料最近被訪問過,那麼將來被訪問的機率也更高”。
LRU底層結構是 hash 表 + 雙向連結串列。hash 表用於保證查詢操作的時間複雜度是O(1),雙向連結串列用於保證節點插入、節點刪除的時間複雜度是O(1)。
為什麼是 雙向連結串列而不是單鏈表呢?單鏈表可以實現頭部插入新節點、尾部刪除舊節點的時間複雜度都是O(1),但是對於中間節點時間複雜度是O(n),因為對於中間節點c,我們需要將該節點c移動到頭部,此時只知道他的下一個節點,要知道其上一個節點需要遍歷整個連結串列,時間複雜度為O(n)。
LRU GET操作:如果節點存在,則將該節點移動到連結串列頭部,並返回節點值;
LRU PUT操作:①節點不存在,則新增節點,並將該節點放到連結串列頭部;②節點存在,則更新節點,並將該節點放到連結串列頭部。
LRU演算法原始碼可參考Leetcode:https://www.programcreek.com/2013/03/leetcode-lru-cache-java/ 。
# LRU 演算法 底層結構 虛擬碼,公眾號 zxiaofan
class Node{
int key;
int value;
Node prev;
Node next;
}
class LRUCache {
Node head;
Node tail;
HashMap<Integer, Node> map = null;
int cap = 0;
public LRUCache(int capacity) {
this.cap = capacity;
this.map = new HashMap<>();
}
public int get(int key) {
}
public void put(int key, int value) {
// 此處程式碼註釋的 move/add to tail,應該是 to head。
}
private void removeNode(Node n){
}
private void offerNode(Node n){
}
}
【LRU快取】【hash+雙向連結串列】結構示意圖
3.2、近似LRU演算法原理(approximated LRU algorithm)
Redis為什麼不使用原生LRU演算法?
- 原生LRU演算法需要 雙向連結串列 來管理資料,需要額外記憶體;
- 資料訪問時涉及資料移動,有效能損耗;
- Redis現有資料結構需要改造;
以上內容反過來就可以回答另一個問題:Redis近似LRU演算法的優勢?
在Redis中,Redis的key的底層結構是 redisObject,redisObject 中 lru:LRU_BITS 欄位用於記錄該key最近一次被訪問時的Redis時鐘 server.lruclock(Redis在處理資料時,都會呼叫lookupKey方法用於更新該key的時鐘)。
不太理解Redis時鐘的同學,可以將其先簡單理解成時間戳(不影響我們理解近似LRU演算法原理),server.lruclock 實際是一個 24bit 的整數,預設是 Unix 時間戳對 2^24 取模的結果,其精度是毫秒。
# Redis的key的底層結構,原始碼位於:server.h,公眾號 zxiaofan
typedef struct redisObject {
unsigned type:4; // 型別
unsigned encoding:4; // 編碼
unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
* LFU data (least significant 8 bits frequency
* and most significant 16 bits access time). */
int refcount; // 引用計數
void *ptr; // 指向儲存實際值的資料結構的指標,資料結構由 type、encoding 決定。
} robj;
server.lruclock 的值是如何更新的呢?
Redis啟動時,initServer 方法中通過 aeCreateTimeEvent 將 serverCron 註冊為時間事件(serverCron 是Redis中最核心的定時處理函式), serverCron 中 則會 觸發 更新Redis時鐘的方法 server.lruclock = getLRUClock() 。
// Redis 核心定時任務 serverCron,原始碼位於sercer.c ,公眾號zxiaofan
int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
...
/* We have just LRU_BITS bits per object for LRU information.
* So we use an (eventually wrapping) LRU clock.
*
* Note that even if the counter wraps it's not a big problem,
* everything will still work but some object will appear younger
* to Redis. However for this to happen a given object should never be
* touched for all the time needed to the counter to wrap, which is
* not likely.
*
* Note that you can change the resolution altering the
* LRU_CLOCK_RESOLUTION define. */
server.lruclock = getLRUClock();
...
}
當 mem_used > maxmemory 時,Redis通過 freeMemoryIfNeeded 方法完成資料淘汰。LRU策略淘汰核心邏輯在 evictionPoolPopulate(淘汰資料集合填充) 方法。
Redis 近似LRU 淘汰策略邏輯:
- 首次淘汰:隨機抽樣選出【最多N個數據】放入【待淘汰資料池 evictionPoolEntry】;
- 資料量N:由 redis.conf 配置的 maxmemory-samples 決定,預設值是5,配置為10將非常接近真實LRU效果,但是更消耗CPU;
- samples:n.樣本;v.抽樣;
- 再次淘汰:隨機抽樣選出【最多N個數據】,只要資料比【待淘汰資料池 evictionPoolEntry】中的【任意一條】資料的 lru 小,則將該資料填充至 【待淘汰資料池】;
- evictionPoolEntry 的容容量是 EVPOOL_SIZE = 16;
- 詳見 原始碼 中 evictionPoolPopulate 方法的註釋;
- 執行淘汰: 挑選【待淘汰資料池】中 lru 最小的一條資料進行淘汰;
Redis為了避免長時間或一直找不到足夠的資料填充【待淘汰資料池】,程式碼裡(dictGetSomeKeys 方法)強制寫死了單次尋找資料的最大次數是 [maxsteps = count*10; ],count 的值其實就是 maxmemory-samples。從這裡我們也可以獲得另一個重要資訊:單次獲取的資料可能達不到 maxmemory-samples 個。此外,如果Redis資料量(所有資料 或 有過期時間 的資料)本身就比 maxmemory-samples 小,那麼 count 值等於 Redis 中資料量個數。
產品、技術思想互通:市面上很多產品也有類似邏輯,列表頁面不強制返回指定的分頁數量,調整為人為滑動,每次返回數量並不固定,後臺按需非同步拉取,對使用者而言是連續滑動且無感的。
//
// 原始碼位於 server.c,公眾號 zxiaofan。
// 1、呼叫lookupCommand()獲取Redis命令,
// 2、檢查命令是否可執行(含執行資料淘汰),
// 3、呼叫 call() 方法執行命令。
int processCommand(client *c) {
...
if (server.maxmemory && !server.lua_timedout) {
int out_of_memory = freeMemoryIfNeededAndSafe() == C_ERR;
...
}
}
// 原始碼位於 evict.c,公眾號 zxiaofan。
/* This is a wrapper for freeMemoryIfNeeded() that only really calls the
* function if right now there are the conditions to do so safely:
*
* - There must be no script in timeout condition.
* - Nor we are loading data right now.
*
*/
int freeMemoryIfNeededAndSafe(void) {
if (server.lua_timedout || server.loading) return C_OK;
return freeMemoryIfNeeded();
}
// 原始碼位於 evict.c
int freeMemoryIfNeeded(void) {
// Redis記憶體釋放核心邏輯程式碼
// 計算使用記憶體大小;
// 判斷配置的資料淘汰策略,按對應的處理方式處理;
void evictionPoolPopulate(int dbid, dict *sampledict, dict *keydict, struct evictionPoolEntry *pool) {
...
}
}
// 【待淘汰資料池 evictionPoolEntry】填充 evictionPoolPopulate
// 原始碼位於 evict.c
/* This is an helper function for freeMemoryIfNeeded(), it is used in order
* to populate the evictionPool with a few entries every time we want to
* expire a key. Keys with idle time smaller than one of the current
* keys are added. Keys are always added if there are free entries.
*
* We insert keys on place in ascending order, so keys with the smaller
* idle time are on the left, and keys with the higher idle time on the
* right. */
void evictionPoolPopulate(int dbid, dict *sampledict, dict *keydict, struct evictionPoolEntry *pool) {
// sampledict :db->dict(從所有資料淘汰時值為 dict) 或 db->expires(從設定了過期時間的資料中淘汰時值為 expires);
// pool : 待淘汰資料池
// 獲取 最多 maxmemory_samples 個數據,用於後續比較淘汰;
count = dictGetSomeKeys(sampledict,samples,server.maxmemory_samples);
}
// // 【待淘汰資料池 evictionPoolEntry】
// 原始碼位於 evict.c
// EVPOOL_SIZE:【待淘汰資料池】存放的資料個數;
// EVPOOL_CACHED_SDS_SIZE:【待淘汰資料池】存放key的最大長度,大於255將單獨申請記憶體空間,長度小於等於255的key將可以複用初始化時申請的記憶體空間;
// evictionPoolEntry 在 evictionPoolAlloc() 初始化,而initServer() 將呼叫evictionPoolAlloc()。
#define EVPOOL_SIZE 16
#define EVPOOL_CACHED_SDS_SIZE 255
struct evictionPoolEntry {
unsigned long long idle; /* key的空閒時間 (LFU訪問頻率的反頻率) */
sds key; /* Key name. */
sds cached; /* Cached SDS object for key name. */
int dbid; /* Key DB number. */
};
static struct evictionPoolEntry *EvictionPoolLRU;
4、Redis的LFU演算法
LFU:Least Frequently Used,使用頻率最少的(最不經常使用的)
- 優先淘汰最近使用的少的資料,其核心思想是“如果一個數據在最近一段時間很少被訪問到,那麼將來被訪問的可能性也很小”。
4.1、LFU與LRU的區別
如果一條資料僅僅是突然被訪問(有可能後續將不再訪問),在 LRU 演算法下,此資料將被定義為熱資料,最晚被淘汰。但實際生產環境下,我們很多時候需要計算的是一段時間下key的訪問頻率,淘汰此時間段內的冷資料。
LFU 演算法相比 LRU,在某些情況下可以提升 資料命中率,使用頻率更多的資料將更容易被保留。
對比項 | 近似LRU演算法 | LFU演算法 |
---|---|---|
最先過期的資料 | 最近未被訪問的 | 最近一段時間訪問的最少的 |
適用場景 | 資料被連續訪問場景 | 資料在一段時間內被連續訪問 |
缺點 | 新增key將佔據快取 | 歷史訪問次數超大的key淘汰速度取決於lfu-decay-time |
4.2、LFU演算法原理
LFU 使用 Morris counter 概率計數器,僅使用幾位元就可以維護 訪問頻率,Morris演算法利用隨機演算法來增加計數,在 Morris 演算法中,計數不是真實的計數,它代表的是實際計數的量級。
LFU資料淘汰策略下,redisObject 的 lru:LRU_BITS 欄位(24位)將分為2部分儲存:
- Ldt:last decrement time,16位,精度分鐘,儲存上一次 LOG_C 更新的時間。
- LOG_C:logarithmic counter,8位,最大255,儲存key被訪問頻率。
注意:
- LOG_C 儲存的是訪問頻率,不是訪問次數;
- LOG_C 訪問頻率隨時間衰減;
- 為什麼 LOG_C 要隨時間衰減?比如在秒殺場景下,熱key被訪問次數很大,如果不隨時間衰減,此部分key將一直存放於記憶體中。
- 新物件 的 LOG_C 值 為 LFU_INIT_VAL = 5,避免剛被建立即被淘汰。
16 bits 8 bits
+----------------+--------+
+ Last decr time | LOG_C |
+----------------+--------+
詳細說明可在原始碼 evict.c 中 搜尋 “LFU (Least Frequently Used) implementation”。
LFU 的核心配置:
- lfu-log-factor:counter 增長對數因子,調整概率計數器 counter 的增長速度,lfu-log-factor值越大 counter 增長越慢;lfu-log-factor 預設10。
- lfu-decay-time:衰變時間週期,調整概率計數器的減少速度,單位分鐘,預設1。
- N 分鐘未訪問,counter 將衰減 N/lfu-decay-time,直至衰減到0;
- 若配置為0:表示每次訪問都將衰減 counter;
counter 的區間是0-255, 其增長與訪問次數呈現對數增長的趨勢,隨著訪問次數越來越大,counter 增長的越來越慢。Redis 官網提供的在 不同 factor 下,不同命中率 時 counter 的值示例如下:
+--------+------------+------------+------------+------------+------------+
| factor | 100 hits | 1000 hits | 100K hits | 1M hits | 10M hits |
+--------+------------+------------+------------+------------+------------+
| 0 | 104 | 255 | 255 | 255 | 255 |
+--------+------------+------------+------------+------------+------------+
| 1 | 18 | 49 | 255 | 255 | 255 |
+--------+------------+------------+------------+------------+------------+
| 10 | 10 | 18 | 142 | 255 | 255 |
+--------+------------+------------+------------+------------+------------+
| 100 | 8 | 11 | 49 | 143 | 255 |
+--------+------------+------------+------------+------------+------------+
不同於 LRU 演算法,LFU 演算法下 Ldt 的值不是在key被訪問時更新,而是在 記憶體達到 maxmemory 時,觸發淘汰策略時更新。
Redis LFU 淘汰策略邏輯:
- 隨機抽樣選出N個數據放入【待淘汰資料池 evictionPoolEntry】;
- 再次淘汰:隨機抽樣選出【最多N個數據】,更新 Ldt 和 counter 的值,只要 counter 比【待淘汰資料池 evictionPoolEntry】中的【任意一條】資料的 counter 小,則將該資料填充至 【待淘汰資料池】;
- evictionPoolEntry 的容容量是 EVPOOL_SIZE = 16;
- 執行淘汰: 挑選【待淘汰資料池】中 counter 最小的一條資料進行淘汰;
在講解近似LRU演算法時,提及“Redis在處理資料時,都會呼叫lookupKey方法用於更新該key的時鐘”,回過頭來看,更為嚴謹的說法是“Redis在處理資料時,都會呼叫lookupKey方法,如果記憶體淘汰策略是 LFU,則會呼叫 ‘updateLFU()’ 方法計算 LFU 模式下的 lru 並更新,否則將更新該key的時鐘 ‘val->lru = LRU_CLOCK()’”.
// 原始碼位於 db.c,公眾號 zxiaofan。
/* Update LFU when an object is accessed.
* Firstly, decrement the counter if the decrement time is reached.
* Then logarithmically increment the counter, and update the access time. */
void updateLFU(robj *val) {
// 首先 根據當前時間 參考 lfu-decay-time 配置 進行一次衰減;
unsigned long counter = LFUDecrAndReturn(val);
// 再參考 lfu_log_factor 配置 進行一次增長;
counter = LFULogIncr(counter);
// 更新 lru;
val->lru = (LFUGetTimeInMinutes()<<8) | counter;
}
Redis 資料淘汰示意圖:
5、小知識
5.1、為什麼Redis要使用自己的時鐘?
- 獲取系統時間戳將呼叫系統底層提供的方法;
- 單執行緒的Redis對效能要求極高,從快取中獲取時間戳將極大提升效能。
5.2、如何發現熱點key?
object freq key 命令支援 獲取 key 的 counter,所以我們可以通過 scan 遍歷所有key,再通過 object freq 獲取counter。
需要注意的是,執行 object freq 的前提是 資料淘汰策略是 LFU。
127.0.0.1:6379> object freq key1
(error) ERR An LFU maxmemory policy is not selected, access frequency not tracked. Please note that when switching between policies at runtime LRU and LFU data will take some time to adjust.
127.0.0.1:6379> config set maxmemory-policy volatile-lfu
OK
127.0.0.1:6379> object freq key1
(integer) 0
127.0.0.1:6379> get key1
"v1"
127.0.0.1:6379> object freq key1
(integer) 1
Redis 4.0.3版本也提供了redis-cli的熱點key功能,執行"./redis-cli --hotkeys"即可獲取熱點key。需要注意的是,hotkeys 本質上是 scan + object freq,所以,如果資料量特別大的情況下,可能耗時較長。
>./redis-cli -p 6378 -a redisPassword--hotkeys
Warning: Using a password with '-a' or '-u' option on the command line interface may not be safe.
# Scanning the entire keyspace to find hot keys as well as
# average sizes per key type. You can use -i 0.1 to sleep 0.1 sec
# per 100 SCAN commands (not usually needed).
[00.00%] Hot key '"key-197804"' found so far with counter 4
[00.00%] Hot key '"key-242392"' found so far with counter 4
[00.00%] Hot key '"key-123994"' found so far with counter 4
[00.00%] Hot key '"key-55821"' ...
...
[03.72%] Hot key '"key1"' found so far with counter 6
-------- summary -------
Sampled 300002 keys in the keyspace!
hot key found with counter: 6 keyname: "key1"
hot key found with counter: 4 keyname: "key-197804"
hot key found with counter: 4 keyname: "key-242392"
hot key found with counter: 4 keyname: "key-123994"
hot key found with counter: 4 keyname: "key-55821"
hot key ...
參考文件:
Using Redis as an LRU cache:https://redis.io/topics/lru-cache;
【玩轉Redis系列文章 近期精選 @zxiaofan】
公眾號搜尋【zxiaofan】查閱最新文章。
Life is all about choices!
將來的你一定會感激現在拼命的自己!
【】【】【】【】【】【】