> 《玩轉Redis》系列文章主要講述Redis的基礎及中高級應用。本文是《玩轉Redis》系列第【14】篇,最新系列文章請前往公眾號“zxiaofan”(點我點我)查看,或百度搜索“玩轉Redis zxiaofan”(點我點我)即可。
本文關鍵字:玩轉Redis、Redis數據淘汰策略、8種數據淘汰策略、Redis緩存滿了怎么辦、Redis近似LRU算法、Redis的LFU算法;
往期精選:《玩轉Redis-生產環境如何導入、導出及刪除大量數據》
大綱
- 為什么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】
《玩轉Redis-刪除了兩百萬key,為什么內存依舊未釋放?》
掃碼關注【公眾號@zxiaofan】查閱最新文章。
Life is all about choices!
將來的你一定會感激現在拼命的自己!
【CSDN】【GitHub】【OSCHINA】【掘金】【語雀】

</integer,></bytes>