Redis作為一個成熟的數據存儲中間件,它提供了完善的數據管理功能,比如之前我們提到過的數據過期和今天我們要講的數據淘汰(evict)策略。在開始介紹Redis數據淘汰策略前,我先拋出幾個問題,幫助大家更深刻理解Redis的數據淘汰策略。
- 何為數據淘汰,Redis有了數據過期策略為什么還要有數據淘汰策略?
- 淘汰哪些數據,有什么樣的數據選取標准?
- Redis的數據淘汰策略是如何實現的?
何為Evict
我先來回答第一個問題,Redis中數據淘汰實際上是指的在內存空間不足時,清理掉某些數據以節省內存空間。 雖然Redis已經有了過期的策略,它可以清理掉有效期外的數據。但想象下這個場景,如果過期的數據被清理之后存儲空間還是不夠怎么辦?是不是還可以再刪除掉一部分數據? 在緩存這種場景下 這個問題的答案是可以,因為這個數據即便在Redis中找不到,也可以從被緩存的數據源中找到。所以在某些特定的業務場景下,我們是可以丟棄掉Redis中部分舊數據來給新數據騰出空間。
如何Evict
第二個問題,既然我們需要有淘汰的機制,你們在具體執行時要選哪些數據淘汰掉?具體策略有很多種,但思路只有一個,那就是總價值最大化。我們生在一個不公平的世界里,同樣數據也是,那么多數據里必然不是所有數據的價值都是一樣的。所以我們在淘汰數據時只需要選擇那些低價值的淘汰即可。
所以問題又來了,哪些數據是低價值的?這里不得不提到一個貫穿計算機學科的原理局部性原理,這里可以明確告訴你,局部性原理在緩存場景有這樣兩種現象,1. 最新的數據下次被訪問的概率越高。 2. 被訪問次數越多的數據下次被訪問的概率越高。 這里我們可以簡單認為被訪問的概率越高價值越大。基於上述兩種現象,我們可以指定出兩種策略 1. 淘汰掉最早未被訪問的數據。2. 淘汰掉訪被訪問頻次最低的數據,這兩種策略分別有個洋氣的英文名LRU(Least Recently Used)和LFU(Least Frequently Used)。
Redis中的Evict策略
除了LRU和LFU之外,還可以隨機淘汰。這就是將數據一視同仁,隨機選取一部分淘汰。實際上Redis實現了以上3中策略,你使用時可以根據具體的數據配置某個淘汰策略。除了上述三種策略外,Redis還為由過期時間的數據提供了按TTL淘汰的策略,其實就是淘汰剩余TTL中最小的數據。另外需要注意的是Redis的淘汰策略可以配置在全局或者是有過期時間的數據上,所以Redis共計以下8中配置策略。
配置項 | 具體含義 |
---|---|
MAXMEMORY_VOLATILE_LRU | 僅在有過期時間的數據上執行LRU |
MAXMEMORY_VOLATILE_LFU | 僅在有過期時間的數據上執行LFU |
MAXMEMORY_VOLATILE_TTL | 在有過期時間的數據上按TTL長度淘汰 |
MAXMEMORY_VOLATILE_RANDOM | 僅在有過期時間的數據上隨機淘汰 |
MAXMEMORY_ALLKEYS_LRU | 在全局數據上執行LRU |
MAXMEMORY_ALLKEYS_LFU | 在全局數據上執行LFU |
MAXMEMORY_ALLKEYS_RANDOM | 在全局數據上隨機淘汰 |
MAXMEMORY_NO_EVICTION | 不淘汰數,當內存空間滿時插入數據會報錯 |
源碼剖析
接下來我們就從源碼來看下Redis是如何實現以上幾種策略的,MAXMEMORY_VOLATILE_和MAXMEMORY_ALLKEYS_策略實現是一樣的,只是作用在不同的dict上而已。另外Random的策略也比較簡單,這里就不再詳解了,我們重點看下LRU和LFU。
LRU具體實現
LRU的本質是淘汰最久沒被訪問的數據,有種實現方式是用鏈表的方式實現,如果數據被訪問了就把它移到鏈表頭部,那么鏈尾一定是最久未訪問的數據,但是單鏈表的查詢時間復雜度是O(n),所以一般會用hash表來加快查詢數據,比如Java中LinkedHashMap就是這么實現的。但Redis並沒有采用這種策略,Redis就是單純記錄了每個Key最近一次的訪問時間戳,通過時間戳排序的方式來選找出最早的數據,當然如果把所有的數據都排序一遍,未免也太慢了,所以Redis是每次選一批數據,然后從這批數據執行淘汰策略。這樣的好處就是性能高,壞處就是不一定是全局最優,只是達到局部最優。
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS;
int refcount;
void *ptr;
} robj;
LRU信息如何存的? 在之前介紹redisObject的文章中 我們已提到過了,在redisObject中有個24位的lru字段,這24位保存了數據訪問的時間戳(秒),當然24位無法保存完整的unix時間戳,不到200天就會有一個輪回,當然這已經足夠了。
robj *lookupKey(redisDb *db, robj *key, int flags) {
dictEntry *de = dictFind(db->dict,key->ptr);
if (de) {
robj *val = dictGetVal(de);
if (!hasActiveChildProcess() && !(flags & LOOKUP_NOTOUCH)){
if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
updateLFU(val);
} else {
val->lru = LRU_CLOCK(); // 這里更新LRU時間戳
}
}
return val;
} else {
return NULL;
}
}
LFU具體實現
lru
這個字段也會被lfu用到,所以你在上面lookupkey中可以看到在使用lfu策略是也會更新lru
。Redis中lfu的出現稍晚一些,是在Redis 4.0才被引入的,所以這里復用了lru字段。 lru的實現思路只有一種,就是記錄下key被訪問的次數。但實現lru有個問題需要考慮到,雖然LFU是按訪問頻次來淘汰數據,但在Redis中隨時可能有新數據就來,本身老數據可能有更多次的訪問,新數據當前被訪問次數少,並不意味着未來被訪問的次數會少,如果不考慮到這點,新數據可能一就來就會被淘汰掉,這顯然是不合理的。
Redis為了解決上述問題,將24位被分成了兩部分,高16位的時間戳(分鍾級),低8位的計數器。每個新數據計數器初始有一定值,這樣才能保證它能走出新手村,然后計數值會隨着時間推移衰減,這樣可以保證老的但當前不常用的數據才有機會被淘汰掉,我們來看下具體實現代碼。
LFU計數器增長
計數器只有8個二進制位,充其量數到255,怎么會夠? 當然Redis使用的不是精確計數,而是近似計數。具體實現就是counter概率性增長,counter的值越大增長速度越慢,具體增長邏輯如下:
/* 更新lfu的counter,counter並不是一個准確的數值,而是概率增長,counter的數值越大其增長速度越慢
* 只能反映出某個時間窗口的熱度,無法反映出具體訪問次數 */
uint8_t LFULogIncr(uint8_t counter) {
if (counter == 255) return 255;
double r = (double)rand()/RAND_MAX;
double baseval = counter - LFU_INIT_VAL; // LFU_INIT_VAL為5
if (baseval < 0) baseval = 0;
double p = 1.0/(baseval*server.lfu_log_factor+1); // server.lfu_log_factor可配置,默認是10
if (r < p) counter++;
return counter;
}
從代碼邏輯中可以看出,counter的值越大,增長速度會越慢,所以lfu_log_factor配置較大的情況下,即便是8位有可以存儲很大的訪問量。下圖是不同lfu_log_factor在不同訪問頻次下的增長情況,圖片來自Redis4.0之基於LFU的熱點key發現機制。
LFU計數器衰減
如果說counter一直增長,即便增長速度很慢也有一天會增長到最大值255,最終導致無法做數據的篩選,所以要給它加一個衰減策略,思路就是counter隨時間增長衰減,具體代碼如下:
/* lfu counter衰減邏輯, lfu_decay_time是指多久counter衰減1,比如lfu_decay_time == 10
* 表示每10分鍾counter衰減一,但lfu_decay_time為0時counter不衰減 */
unsigned long LFUDecrAndReturn(robj *o) {
unsigned long ldt = o->lru >> 8;
unsigned long counter = o->lru & 255;
unsigned long num_periods = server.lfu_decay_time ? LFUTimeElapsed(ldt) / server.lfu_decay_time : 0;
if (num_periods)
counter = (num_periods > counter) ? 0 : counter - num_periods;
return counter;
}
server.lfu_decay_time也是可配置的,默認是10 標識每10分鍾counter值減去1。
evict執行過程
evict何時執行
在Redis每次處理命令的時候,都會檢查內存空間,並嘗試去執行evict,因為有些情況下不需要執行evict,這個可以從isSafeToPerformEvictions中可以看出端倪。
static int isSafeToPerformEvictions(void) {
/* 沒有lua腳本執行超時,也沒有在做數據超時 */
if (server.lua_timedout || server.loading) return 0;
/* 只有master才需要做evict */
if (server.masterhost && server.repl_slave_ignore_maxmemory) return 0;
/* 當客戶端暫停時,不需要evict,因為數據是不會變化的 */
if (checkClientPauseTimeoutAndReturnIfPaused()) return 0;
return 1;
}
evict.c
evict代碼都在evict.c中。里面包含了每次evict的執行過程。
int performEvictions(void) {
if (!isSafeToPerformEvictions()) return EVICT_OK;
int keys_freed = 0;
size_t mem_reported, mem_tofree;
long long mem_freed; /* May be negative */
mstime_t latency, eviction_latency;
long long delta;
int slaves = listLength(server.slaves);
int result = EVICT_FAIL;
if (getMaxmemoryState(&mem_reported,NULL,&mem_tofree,NULL) == C_OK)
return EVICT_OK;
if (server.maxmemory_policy == MAXMEMORY_NO_EVICTION)
return EVICT_FAIL; /* We need to free memory, but policy forbids. */
unsigned long eviction_time_limit_us = evictionTimeLimitUs();
mem_freed = 0;
latencyStartMonitor(latency);
monotime evictionTimer;
elapsedStart(&evictionTimer);
while (mem_freed < (long long)mem_tofree) {
int j, k, i;
static unsigned int next_db = 0;
sds bestkey = NULL;
int bestdbid;
redisDb *db;
dict *dict;
dictEntry *de;
if (server.maxmemory_policy & (MAXMEMORY_FLAG_LRU|MAXMEMORY_FLAG_LFU) ||
server.maxmemory_policy == MAXMEMORY_VOLATILE_TTL)
{
struct evictionPoolEntry *pool = EvictionPoolLRU;
while(bestkey == NULL) {
unsigned long total_keys = 0, keys;
/* We don't want to make local-db choices when expiring keys,
* so to start populate the eviction pool sampling keys from
* every DB.
* 先從dict中采樣key並放到pool中 */
for (i = 0; i < server.dbnum; i++) {
db = server.db+i;
dict = (server.maxmemory_policy & MAXMEMORY_FLAG_ALLKEYS) ?
db->dict : db->expires;
if ((keys = dictSize(dict)) != 0) {
evictionPoolPopulate(i, dict, db->dict, pool);
total_keys += keys;
}
}
if (!total_keys) break; /* No keys to evict. */
/* 從pool中選擇最適合淘汰的key. */
for (k = EVPOOL_SIZE-1; k >= 0; k--) {
if (pool[k].key == NULL) continue;
bestdbid = pool[k].dbid;
if (server.maxmemory_policy & MAXMEMORY_FLAG_ALLKEYS) {
de = dictFind(server.db[pool[k].dbid].dict,
pool[k].key);
} else {
de = dictFind(server.db[pool[k].dbid].expires,
pool[k].key);
}
/* 從淘汰池中移除. */
if (pool[k].key != pool[k].cached)
sdsfree(pool[k].key);
pool[k].key = NULL;
pool[k].idle = 0;
/* If the key exists, is our pick. Otherwise it is
* a ghost and we need to try the next element. */
if (de) {
bestkey = dictGetKey(de);
break;
} else {
/* Ghost... Iterate again. */
}
}
}
}
/* volatile-random and allkeys-random 策略 */
else if (server.maxmemory_policy == MAXMEMORY_ALLKEYS_RANDOM ||
server.maxmemory_policy == MAXMEMORY_VOLATILE_RANDOM)
{
/* 當隨機淘汰時,我們用靜態變量next_db來存儲當前執行到哪個db了*/
for (i = 0; i < server.dbnum; i++) {
j = (++next_db) % server.dbnum;
db = server.db+j;
dict = (server.maxmemory_policy == MAXMEMORY_ALLKEYS_RANDOM) ?
db->dict : db->expires;
if (dictSize(dict) != 0) {
de = dictGetRandomKey(dict);
bestkey = dictGetKey(de);
bestdbid = j;
break;
}
}
}
/* 從dict中移除被選中的key. */
if (bestkey) {
db = server.db+bestdbid;
robj *keyobj = createStringObject(bestkey,sdslen(bestkey));
propagateExpire(db,keyobj,server.lazyfree_lazy_eviction);
/*我們單獨計算db*Delete()釋放的內存量。實際上,在AOF和副本傳播所需的內存可能大於我們正在釋放的內存(刪除key)
,如果我們考慮這點的話會很繞。由signalModifiedKey生成的CSC失效消息也是這樣。
因為AOF和輸出緩沖區內存最終會被釋放,所以我們只需要關心key空間使用的內存即可。*/
delta = (long long) zmalloc_used_memory();
latencyStartMonitor(eviction_latency);
if (server.lazyfree_lazy_eviction)
dbAsyncDelete(db,keyobj);
else
dbSyncDelete(db,keyobj);
latencyEndMonitor(eviction_latency);
latencyAddSampleIfNeeded("eviction-del",eviction_latency);
delta -= (long long) zmalloc_used_memory();
mem_freed += delta;
server.stat_evictedkeys++;
signalModifiedKey(NULL,db,keyobj);
notifyKeyspaceEvent(NOTIFY_EVICTED, "evicted",
keyobj, db->id);
decrRefCount(keyobj);
keys_freed++;
if (keys_freed % 16 == 0) {
/*當要釋放的內存開始足夠大時,我們可能會在這里花費太多時間,不可能足夠快地將數據傳送到副本,因此我們會在循環中強制傳輸。*/
if (slaves) flushSlavesOutputBuffers();
/*通常我們的停止條件是釋放一個固定的,預先計算的內存量。但是,當我們*在另一個線程中刪除對象時,
最好不時*檢查是否已經達到目標*內存,因為“mem\u freed”量只在dbAsyncDelete()調用中*計算,
而線程可以*一直釋放內存。*/
if (server.lazyfree_lazy_eviction) {
if (getMaxmemoryState(NULL,NULL,NULL,NULL) == C_OK) {
break;
}
}
/*一段時間后,盡早退出循環-即使尚未達到內存限制*。如果我們突然需要釋放大量的內存,不要在這里花太多時間。*/
if (elapsedUs(evictionTimer) > eviction_time_limit_us) {
// We still need to free memory - start eviction timer proc
if (!isEvictionProcRunning) {
isEvictionProcRunning = 1;
aeCreateTimeEvent(server.el, 0,
evictionTimeProc, NULL, NULL);
}
break;
}
}
} else {
goto cant_free; /* nothing to free... */
}
}
/* at this point, the memory is OK, or we have reached the time limit */
result = (isEvictionProcRunning) ? EVICT_RUNNING : EVICT_OK;
cant_free:
if (result == EVICT_FAIL) {
/* At this point, we have run out of evictable items. It's possible
* that some items are being freed in the lazyfree thread. Perform a
* short wait here if such jobs exist, but don't wait long. */
if (bioPendingJobsOfType(BIO_LAZY_FREE)) {
usleep(eviction_time_limit_us);
if (getMaxmemoryState(NULL,NULL,NULL,NULL) == C_OK) {
result = EVICT_OK;
}
}
}
latencyEndMonitor(latency);
latencyAddSampleIfNeeded("eviction-cycle",latency);
return result;
}
執行的過程可以簡單分為三步,首先按不同的配置策略填充evictionPoolEntry,pool大小默認是16,然后從這16個key中根據具體策略選出最適合被刪掉的key(bestkey),然后執行bestkey的刪除和一些后續邏輯。
總結
可以看出,Redis為了性能,犧牲了LRU和LFU的准確性,只能說是近似LRU和LFU,但在實際使用過程中也完全足夠了,畢竟Redis這么多年也是經歷了無數項目的考驗依舊屹立不倒。Redis的這種設計方案也給我們軟件設計時提供了一條新的思路,犧牲精確度來換取性能。
本文是Redis源碼剖析系列博文,同時也有與之對應的Redis中文注釋版,有想深入學習Redis的同學,歡迎star和關注。
Redis中文注解版倉庫:https://github.com/xindoo/Redis
Redis源碼剖析專欄:https://zxs.io/s/1h
如果覺得本文對你有用,歡迎一鍵三連。