Redis原始碼解析-LRU
LRU 演算法的基本原理
LRU 演算法是指最近最少使用(Least Recently Used,LRU)
從基本原理上來說,LRU 演算法會使用一個連結串列來維護快取中每一個數據的訪問情況,並根據資料的實時訪問,調整資料在連結串列中的位置,然後通過資料在連結串列中的位置,來表示資料是最近剛訪問的,還是已經有一段時間沒有訪問了。
LRU 演算法會把連結串列的頭部和尾部分別設定為 MRU 端和 LRU 端。其中,MRU 是 Most Recently Used 的縮寫,MRU 端表示這裡的資料是剛被訪問的。而 LRU 端則表示,這裡的資料是最近最少訪問的資料。
LRU 演算法的執行,可以分成三種情況來掌握。
-
情況一:當有新資料插入時,LRU 演算法會把該資料插入到連結串列頭部,同時把原來連結串列頭部的資料及其之後的資料,都向尾部移動一位。
-
情況二:當有資料剛被訪問了一次之後,LRU 演算法就會把該資料從它在連結串列中的當前位置,移動到連結串列頭部。同時,把從連結串列頭部到它當前位置的其他資料,都向尾部移動一位。
-
情況三:當連結串列長度無法再容納更多資料時,若再有新資料插入,LRU 演算法就會去除連結串列尾部的資料,這也相當於將資料從快取中淘汰掉。
如果要嚴格按照 LRU 演算法的基本原理來實現的話,需要在程式碼中實現如下內容:
- 要為 Redis 使用最大記憶體時,可容納的所有資料維護一個連結串列;
- 每當有新資料插入或是現有資料被再次訪問時,需要執行多次連結串列操作。
而假設 Redis 儲存的資料比較多的話,那麼,這兩部分的程式碼實現,就既需要額外的記憶體空間來儲存連結串列,還會在訪問資料的過程中,讓 Redis 受到資料移動和連結串列操作的開銷影響,從而就會降低 Redis 訪問效能。
Redis 中近似 LRU 演算法的實現
Redis 配置檔案 redis.conf 中的兩個配置引數有關:
- maxmemory,該配置項設定了 Redis server 可以使用的最大記憶體容量,一旦 server 使用的實際記憶體量超出該閾值時,server 就會根據
- maxmemory-policy 配置項定義的策略,執行記憶體淘汰操作;maxmemory-policy,該配置項設定了 Redis server 的記憶體淘汰策略,主要包括近似 LRU 演算法、LFU 演算法、按 TTL 值淘汰和隨機淘汰等幾種演算法。
Redis中兩種lru策略:allkeys-lru 和 volatile-lru 都會使用近似 LRU 演算法來淘汰資料,它們的區別在於:
採用 allkeys-lru 策略淘汰資料時,它是在所有的鍵值對中篩選將被淘汰的資料;
採用 volatile-lru 策略淘汰資料時,它是在設定了過期時間的鍵值對中篩選將被淘汰的資料。
Redis 對近似 LRU 演算法的實現
- 全域性 LRU 時鐘值的計算:這部分包括,Redis 原始碼為了實現近似 LRU 演算法的效果,是如何計算全域性 LRU 時鐘值的,以用來判斷資料訪問的時效性;
- 鍵值對 LRU 時鐘值的初始化與更新:這部分包括,Redis 原始碼在哪些函式中對每個鍵值對對應的 LRU 時鐘值,進行初始化與更新;
- 近似 LRU 演算法的實際執行:這部分包括,Redis 原始碼具體如何執行近似 LRU 演算法,也就是何時觸發資料淘汰,以及實際淘汰的機制是怎麼實現的。
全域性 LRU 時鐘值的計算
Redis 設計了 LRU 時鐘來記錄資料每次訪問的時間戳。Redis 在原始碼中對於每個鍵值對中的值,會使用一個 redisObject 結構體來儲存指向值的指標。其中除了記錄值的指標以外,它其實還會使用 24 bits 來儲存 LRU 時鐘資訊,對應的是 lru 成員變數。每個鍵值對都會把它最近一次被訪問的時間戳,記錄在 lru 變數當中。
redisOjbect 結構體的定義是在server.h
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS; //記錄LRU資訊,巨集定義LRU_BITS是24 bits
int refcount;
void *ptr;
} robj;
Redis server 使用了一個例項級別的全域性 LRU 時鐘,每個鍵值對的 LRU 時鐘值會根據全域性 LRU 時鐘進行設定。
這個全域性 LRU 時鐘儲存在了 Redis 全域性變數 server 的成員變數 lruclock 中。當 Redis server 啟動後,呼叫 initServerConfig 函式初始化各項引數時,就會對這個全域性 LRU 時鐘 lruclock 進行設定。具體來說,initServerConfig 函式是呼叫 getLRUClock 函式,來設定 lruclock 的值
void initServerConfig(void) {
...
unsigned int lruclock = getLRUClock(); //呼叫getLRUClock函式計算全域性LRU時鐘值
atomicSet(server.lruclock,lruclock);//設定lruclock為剛計算的LRU時鐘值
...
}
全域性 LRU 時鐘值是通過 getLRUClock 函式計算得到的。
getLRUClock 函式是在evict.c檔案中實現的,它會呼叫 mstime 函式(在server.c檔案中)獲得以毫秒為單位計算的 UNIX 時間戳,然後將這個 UNIX 時間戳除以巨集定義 LRU_CLOCK_RESOLUTION。巨集定義 LRU_CLOCK_RESOLUTION 是在 server.h 檔案中定義的,它表示的是以毫秒為單位的 LRU 時鐘精度,也就是以毫秒為單位來表示的 LRU 時鐘最小單位。
因為 LRU_CLOCK_RESOLUTION 的預設值是 1000,所以,LRU 時鐘精度就是 1000 毫秒,也就是 1 秒。如果一個數據前後兩次訪問的時間間隔小於 1 秒,那麼這兩次訪問的時間戳就是一樣的。因為 LRU 時鐘的精度就是 1 秒,它無法區分間隔小於 1 秒的不同時間戳。
getLRUClock 函式執行邏輯:
首先,getLRUClock 函式將獲得的 UNIX 時間戳,除以 LRU_CLOCK_RESOLUTION 後,就得到了以 LRU 時鐘精度來計算的 UNIX 時間戳,也就是當前的 LRU 時鐘值。緊接著,getLRUClock 函式會把 LRU 時鐘值和巨集定義 LRU_CLOCK_MAX 做與運算,其中巨集定義 LRU_CLOCK_MAX 表示的是 LRU 時鐘能表示的最大值。
所以,在預設情況下,全域性 LRU 時鐘值是以 1 秒為精度來計算的 UNIX 時間戳,並且它是在 initServerConfig 函式中進行了初始化。
#define LRU_BITS 24
#define LRU_CLOCK_MAX ((1<<LRU_BITS)-1) //LRU時鐘的最大值
#define LRU_CLOCK_RESOLUTION 1000 //以毫秒為單位的LRU時鐘精度
unsigned int getLRUClock(void) {
return (mstime()/LRU_CLOCK_RESOLUTION) & LRU_CLOCK_MAX;
}
在 Redis server 的執行過程中,全域性 LRU 時鐘值的更新和 Redis server 在事件驅動框架中,定期執行的時間事件所對應的 serverCron 函式有關。
serverCron 函式作為時間事件的回撥函式,本身會按照一定的頻率週期性執行,其頻率值是由 Redis 配置檔案 redis.conf 中的 hz 配置項決定的。hz 配置項的預設值是 10,這表示 serverCron 函式會每 100 毫秒(1 秒 /10 = 100 毫秒)執行一次。在 serverCron 函式中,全域性 LRU 時鐘值就會按照這個函式執行頻率,定期呼叫 getLRUClock 函式進行更新。這樣每個鍵值對就可以從全域性 LRU 時鐘獲取最新的訪問時間戳了。
int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
...
unsigned long lruclock = getLRUClock(); //預設情況下,每100毫秒呼叫getLRUClock函式更新一次全域性LRU時鐘值
atomicSet(server.lruclock,lruclock); //設定lruclock變數
...
}
鍵值對 LRU 時鐘值的初始化與更新
對於一個鍵值對來說,它的 LRU 時鐘值最初是在這個鍵值對被建立的時候,進行初始化設定的,這個初始化操作是在 createObject 函式中呼叫的。createObject 函式實現在object.c檔案當中,當 Redis 要建立一個鍵值對時,就會呼叫這個函式。
而 createObject 函式除了會給 redisObject 結構體分配記憶體空間之外,它還會根據maxmemory_policy 配置項的值,來初始化設定 redisObject 結構體中的 lru 變數。
如果 maxmemory_policy 配置為使用 LFU 策略,那麼 lru 變數值會被初始化設定為 LFU 演算法的計算值。
如果 maxmemory_policy 配置項沒有使用 LFU 策略,那麼,createObject 函式就會呼叫 LRU_CLOCK 函式來設定 lru 變數的值,也就是鍵值對對應的 LRU 時鐘值。
LRU_CLOCK 函式是在 evict.c 檔案中實現的,它的作用就是返回當前的全域性 LRU 時鐘值。因為一個鍵值對一旦被建立,也就相當於有了一次訪問,所以它對應的 LRU 時鐘值就表示了它的訪問時間戳。
robj *createObject(int type, void *ptr) {
robj *o = zmalloc(sizeof(*o));
...
//如果快取替換策略是LFU,那麼將lru變數設定為LFU的計數值
if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
o->lru = (LFUGetTimeInMinutes()<<8) | LFU_INIT_VAL;
} else {
o->lru = LRU_CLOCK(); //否則,呼叫LRU_CLOCK函式獲取LRU時鐘值
}
return o;
}
只要一個鍵值對被訪問了,它的 LRU 時鐘值就會被更新。而當一個鍵值對被訪問時,訪問操作最終都會呼叫 lookupKey 函式。
lookupKey 函式是在db.c檔案中實現的,它會從全域性雜湊表中查詢要訪問的鍵值對。如果該鍵值對存在,那麼 lookupKey 函式就會根據 maxmemory_policy 的配置值,來更新鍵值對的 LRU 時鐘值,也就是它的訪問時間戳。
當 maxmemory_policy 沒有配置為 LFU 策略時,lookupKey 函式就會呼叫 LRU_CLOCK 函式,來獲取當前的全域性 LRU 時鐘值,並將其賦值給鍵值對的 redisObject 結構體中的 lru 變數
robj *lookupKey(redisDb *db, robj *key, int flags) {
dictEntry *de = dictFind(db->dict,key->ptr); //查詢鍵值對
if (de) {
robj *val = dictGetVal(de); 獲取鍵值對對應的redisObject結構體
...
if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
updateLFU(val); //如果使用了LFU策略,更新LFU計數值
} else {
val->lru = LRU_CLOCK(); //否則,呼叫LRU_CLOCK函式獲取全域性LRU時鐘值
}
...
}}
每個鍵值對一旦被訪問,就能獲得最新的訪問時間戳了。
近似 LRU 演算法的實際執行
實現近似 LRU 演算法的目的:為了減少記憶體資源和操作時間上的開銷。
何時觸發演算法執行
近似 LRU 演算法的主要邏輯是在 freeMemoryIfNeeded 函式中實現的,而這個函式本身是在 evict.c 檔案中實現。
freeMemoryIfNeeded 函式是被 freeMemoryIfNeededAndSafe 函式(在 evict.c 檔案中)呼叫,而 freeMemoryIfNeededAndSafe 函式又是被 processCommand 函式( Redis 處理每個命令時都會被呼叫的)所呼叫的。三者的呼叫關係如下:
processCommand 函式在執行的時候,實際上會根據兩個條件來判斷是否呼叫 freeMemoryIfNeededAndSafe 函式。
- 條件一:設定了 maxmemory 配置項為非 0 值。
- 條件二:Lua 指令碼沒有在超時執行。
如果這兩個條件成立,那麼 processCommand 函式就會呼叫 freeMemoryIfNeededAndSafe 函式,如下所示:
...
if (server.maxmemory && !server.lua_timedout) {
int out_of_memory = freeMemoryIfNeededAndSafe() == C_ERR;
...
然後,freeMemoryIfNeededAndSafe 函式還會再次根據兩個條件,來判斷是否呼叫 freeMemoryIfNeeded 函式。
條件一:Lua 指令碼在超時執行。
條件二:Redis server 正在載入資料。
只有在這兩個條件都不成立的情況下,freeMemoryIfNeeded 函式才會被呼叫。
int freeMemoryIfNeededAndSafe(void) {
if (server.lua_timedout || server.loading) return C_OK;
return freeMemoryIfNeeded();
}
一旦 freeMemoryIfNeeded 函式被呼叫了,並且 maxmemory-policy 被設定為了 allkeys-lru 或 volatile-lru,那麼近似 LRU 演算法就開始被觸發執行了
近似 LRU 演算法具體如何執行?
近似 LRU 演算法並沒有使用耗時耗空間的連結串列,而是使用了固定大小的待淘汰資料集合,每次隨機選擇一些 key 加入待淘汰資料集合中。最後,再按照待淘汰集合中 key 的空閒時間長度,刪除空閒時間最長的 key。這樣一來,Redis 就近似實現了 LRU 演算法的效果了。
freeMemoryIfNeeded 函式涉及的基本流程:
首先,freeMemoryIfNeeded 函式會呼叫 getMaxmemoryState 函式,評估當前的記憶體使用情況。getMaxmemoryState 函式是在 evict.c 檔案中實現的,它會判斷當前 Redis server 使用的記憶體容量是否超過了 maxmemory 配置的值。
如果當前記憶體使用量沒有超過 maxmemory,那麼,getMaxmemoryState 函式會返回 C_OK,緊接著,freeMemoryIfNeeded 函式也會直接返回了。
int freeMemoryIfNeeded(void) {
...
if (getMaxmemoryState(&mem_reported,NULL,&mem_tofree,NULL) == C_OK)
return C_OK;
...
}
getMaxmemoryState 函式在評估當前記憶體使用情況的時候,如果發現已用記憶體超出了 maxmemory,它就會計算需要釋放的記憶體量。這個釋放的記憶體大小等於已使用的記憶體量減去 maxmemory。不過,已使用的記憶體量並不包括用於主從複製的複製緩衝區大小,這是 getMaxmemoryState 函式,通過呼叫 freeMemoryGetNotCountedMemory 函式來計算的。
getMaxmemoryState 函式的基本執行邏輯程式碼:
int getMaxmemoryState(size_t *total, size_t *logical, size_t *tofree, float *level) {
...
mem_reported = zmalloc_used_memory(); //計算已使用的記憶體量
...
//將用於主從複製的複製緩衝區大小從已使用記憶體量中扣除
mem_used = mem_reported;
size_t overhead = freeMemoryGetNotCountedMemory();
mem_used = (mem_used > overhead) ? mem_used-overhead : 0;
...
//計算需要釋放的記憶體量
mem_tofree = mem_used - server.maxmemory;
...
}
如果當前 server 使用的記憶體量,的確已經超出 maxmemory 的上限了,那麼 freeMemoryIfNeeded 函式就會執行一個 while 迴圈,來淘汰資料釋放記憶體。
為了淘汰資料,Redis 定義了一個數組 EvictionPoolLRU,用來儲存待淘汰的候選鍵值對。這個陣列的元素型別是 evictionPoolEntry 結構體,該結構體儲存了待淘汰鍵值對的空閒時間 idle、對應的 key 等資訊。以下程式碼展示了 EvictionPoolLRU 陣列和 evictionPoolEntry 結構體,它們都是在 evict.c 檔案中定義的。
static struct evictionPoolEntry *EvictionPoolLRU;
struct evictionPoolEntry {
unsigned long long idle; //待淘汰的鍵值對的空閒時間
sds key; //待淘汰的鍵值對的key
sds cached; //快取的SDS物件
int dbid; //待淘汰鍵值對的key所在的資料庫ID
};
Redis server 在執行 initSever 函式進行初始化時,會呼叫 evictionPoolAlloc 函式(在 evict.c 檔案中)為 EvictionPoolLRU 陣列分配記憶體空間,該陣列的大小由巨集定義 EVPOOL_SIZE(在 evict.c 檔案中)決定,預設是 16 個元素,也就是可以儲存 16 個待淘汰的候選鍵值對。
那麼,freeMemoryIfNeeded 函式在淘汰資料的迴圈流程中,就會更新這個待淘汰的候選鍵值對集合,也就是 EvictionPoolLRU 陣列。
-
更新待淘汰的候選鍵值對集合
首先,freeMemoryIfNeeded 函式會呼叫 evictionPoolPopulate 函式(在 evict.c 檔案中),而 evictionPoolPopulate 函式會先呼叫 dictGetSomeKeys 函式(在 dict.c 檔案中),從待取樣的雜湊表中隨機獲取一定數量的 key。兩點需要注意:
第一點,dictGetSomeKeys 函式取樣的雜湊表,是由 maxmemory_policy 配置項來決定的。如果 maxmemory_policy 配置的是 allkeys_lru,那麼待取樣雜湊表就是 Redis server 的全域性雜湊表,也就是在所有鍵值對中進行取樣;否則,待取樣雜湊表就是儲存著設定了過期時間的 key 的雜湊表。
freeMemoryIfNeeded 函式中對 evictionPoolPopulate 函式的呼叫過程
for (i = 0; i < server.dbnum; i++) { db = server.db+i; //對Redis server上的每一個數據庫都執行 //根據淘汰策略,決定使用全域性雜湊表還是設定了過期時間的key的雜湊表 dict = (server.maxmemory_policy & MAXMEMORY_FLAG_ALLKEYS) ? db->dict : db->expires; if ((keys = dictSize(dict)) != 0) { //將選擇的雜湊表dict傳入evictionPoolPopulate函式,同時將全域性雜湊表也傳給evictionPoolPopulate函式 evictionPoolPopulate(i, dict, db->dict, pool); total_keys += keys; } }
第二點,dictGetSomeKeys 函式取樣的 key 的數量,是由 redis.conf 中的配置項 maxmemory-samples 決定的,該配置項的預設值是 5。下面程式碼就展示了 evictionPoolPopulate 函式對 dictGetSomeKeys 函式的呼叫:
void evictionPoolPopulate(int dbid, dict *sampledict, dict *keydict, struct evictionPoolEntry *pool) { ... dictEntry *samples[server.maxmemory_samples]; //取樣後的集合,大小為maxmemory_samples //將待取樣的雜湊表sampledict、取樣後的集合samples、以及取樣數量maxmemory_samples,作為引數傳給dictGetSomeKeys count = dictGetSomeKeys(sampledict,samples,server.maxmemory_samples); ... }
如此一來,dictGetSomeKeys 函式就能返回取樣的鍵值對集合了。然後,evictionPoolPopulate 函式會根據實際取樣到的鍵值對數量 count,執行一個迴圈。在這個迴圈流程中,evictionPoolPopulate 函式會呼叫 estimateObjectIdleTime 函式,來計算在取樣集合中的每一個鍵值對的空閒時間,如下所示:
for (j = 0; j < count; j++) { ... if (server.maxmemory_policy & MAXMEMORY_FLAG_LRU) { idle = estimateObjectIdleTime(o); } ...
緊接著,evictionPoolPopulate 函式會遍歷待淘汰的候選鍵值對集合,也就是 EvictionPoolLRU 陣列。在遍歷過程中,它會嘗試把取樣的每一個鍵值對插入 EvictionPoolLRU 陣列,這主要取決於以下兩個條件之一:
一是,它能在陣列中找到一個尚未插入鍵值對的空位;
二是,它能在陣列中找到一個空閒時間小於取樣鍵值對空閒時間的鍵值對。
這兩個條件有一個成立的話,evictionPoolPopulate 函式就可以把取樣鍵值對插入 EvictionPoolLRU 陣列。等所有采樣鍵值對都處理完後,evictionPoolPopulate 函式就完成對待淘汰候選鍵值對集合的更新了。接下來,freeMemoryIfNeeded 函式,就可以開始選擇最終被淘汰的鍵值對了。
-
選擇被淘汰的鍵值對並刪除
因為 evictionPoolPopulate 函式已經更新了 EvictionPoolLRU 陣列,而且這個數組裡面的 key,是按照空閒時間從小到大排好序了。所以,freeMemoryIfNeeded 函式會遍歷一次 EvictionPoolLRU 陣列,從陣列的最後一個 key 開始選擇,如果選到的 key 不是空值,那麼就把它作為最終淘汰的 key。
for (k = EVPOOL_SIZE-1; k >= 0; k--) { //從陣列最後一個key開始查詢 if (pool[k].key == NULL) continue; //當前key為空值,則查詢下一個key ... //從全域性雜湊表或是expire雜湊表中,獲取當前key對應的鍵值對;並將當前key從EvictionPoolLRU陣列刪除 //如果當前key對應的鍵值對不為空,選擇當前key為被淘汰的key if (de) { bestkey = dictGetKey(de); break; } else {} //否則,繼續查詢下個key }
最後,一旦選到了被淘汰的 key,freeMemoryIfNeeded 函式就會根據 Redis server 的惰性刪除配置,來執行同步刪除或非同步刪除,如下所示:
if (bestkey) { db = server.db+bestdbid; robj *keyobj = createStringObject(bestkey,sdslen(bestkey)); //將刪除key的資訊傳遞給從庫和AOF檔案 propagateExpire(db,keyobj,server.lazyfree_lazy_eviction); //如果配置了惰性刪除,則進行非同步刪除 if (server.lazyfree_lazy_eviction) dbAsyncDelete(db,keyobj); else //否則進行同步刪除 dbSyncDelete(db,keyobj); }
到這裡,freeMemoryIfNeeded 函式就淘汰了一個 key。而如果此時,釋放的記憶體空間還不夠,也就是說沒有達到前面介紹的待釋放空間,那麼 freeMemoryIfNeeded 函式還會重複執行前面所說的更新待淘汰候選鍵值對集合、選擇最終淘汰 key 的過程,直到滿足待釋放空間的大小要求。