1. 程式人生 > >走近原始碼:Redis如何清除過期key

走近原始碼:Redis如何清除過期key

“叮……”,美好的週六就這麼被一陣釘釘訊息吵醒了。 業務組的同學告訴我說很多使用者的帳號今天被強制下線。我們的帳號系統正常的邏輯是使用者登入一次後,token的有效期可以維持一天的時間。現在的問題是使用者大概每10分鐘左右就需要重新登入一次。這種情況一般有兩種原因:1、token生成時出問題。2、驗證token時出現問題。 通過檢查日誌,我發現是驗證token時,Redis中已經沒有對應的token了。並且確定了生成新的token時,set到Redis中的有效期是正確的,那麼就基本可以確定是Redis的問題了。 於是又去檢查了Redis的監控,發現在那段時間Redis由於記憶體佔用過高強制清理了幾次key。但從日誌上來看,這段時間並沒有出現流量暴漲的情況,而且Redis中key的數量也沒有顯著增加。那是什麼原因導致Redis記憶體佔用過高呢?確定了Redis記憶體升高不是我們造成的之後,我們又聯絡了業務組的同學協助他們,他們表示最近確實有上線,並且新上線的功能有使用到Redis。但我仍然感覺很奇怪,為什麼Redis中的key沒有增多,並且沒看到有其他業務的key。經過一番詢問,才瞭解到,業務組同學使用的是這個Redis的db1,而我用的(和剛查的)是db0。這裡確實是我在排查問題時出現了疏忽。 那麼Redis的不同db之間會互相影響嗎?通常情況下,我們使用不同的db進行資料隔離,這沒問題。**但Redis進行清理時,並不是只清理資料量佔用最大的那個db,而是會對所有的db進行清理。**在這之前我並不是很瞭解這方面知識,這裡也只是根據現象進行的猜測。 好奇心驅使我來驗證一下這個想法。於是我決定直接來看Redis的原始碼。清理key相關的程式碼在[evict.c](https://github.com/antirez/redis/blob/unstable/src/evict.c)檔案中。 Redis中會儲存一個“過期key池”,這個池子中存放了一些可能會被清理的key。其中儲存的資料結構如下: ``` c struct evictionPoolEntry { unsigned long long idle; /* Object idle time (inverse frequency for LFU) */ sds key; /* Key name. */ sds cached; /* Cached SDS object for key name. */ int dbid; /* Key DB number. */ }; ``` 其中idle是物件空閒時間,在Reids中,key的過期演算法有兩種:一種是近似LRU,一種是LFU。預設使用的是近似LRU。 ### 近似LRU 在解釋近似LRU之前,先來簡單瞭解一下LRU。當Redis的記憶體佔用超過我們設定的maxmemory時,會把長時間沒有使用的key清理掉。按照LRU演算法,我們需要對所有key(也可以設定成只淘汰有過期時間的key)按照空閒時間進行排序,然後淘汰掉空閒時間最大的那部分資料,使得Redis的記憶體佔用降到一個合理的值。 LRU演算法的缺點是,我們需要維護一個全部(或只有過期時間)key的列表,還要按照最近使用時間排序。這會消耗大量記憶體,並且每次使用key時更新排序也會佔用額外的CPU資源。對於Redis這樣對效能要求很高的系統來說是不被允許的。 因此,Redis採用了一種近似LRU的演算法。當Redis接收到新的寫入命令,而記憶體又不夠時,就會觸發近似LRU演算法來強制清理一些key。具體清理的步驟是,Redis會對key進行取樣,通常是取5個,然後會把過期的key放到我們上面說的“過期池”中,過期池中的key是按照空閒時間來排序的,Redis會優先清理掉空閒時間最長的key,直到記憶體小於maxmemory。 近似LRU演算法的清理效果圖如圖(圖片來自Redis官方文件) ![lru_comparison](https://res.cloudinary.com/dxydgihag/image/upload/v1585453145/Blog/Redis/evict/lru_comparison.png) 這麼說可能不夠清楚,我們直接上程式碼。 ### 原始碼分析 ![lru_call](https://res.cloudinary.com/dxydgihag/image/upload/v1585453153/Blog/Redis/evict/lru_call.png) 上圖展示了程式碼中近似LRU演算法的主要邏輯呼叫路徑。 其中主要邏輯是在`freeMemoryIfNeeded`函式中 首先呼叫`getMaxmemoryState`函式判斷當前記憶體的狀態 ``` c int getMaxmemoryState(size_t *total, size_t *logical, size_t *tofree, float *level) { size_t mem_reported, mem_used, mem_tofree; mem_reported = zmalloc_used_memory(); if (total) *total = mem_reported; int return_ok_asap = !server.maxmemory || mem_reported <= server.maxmemory; if (return_ok_asap && !level) return C_OK; mem_used = mem_reported; size_t overhead = freeMemoryGetNotCountedMemory(); mem_used = (mem_used > overhead) ? mem_used-overhead : 0; if (level) { if (!server.maxmemory) { *level = 0; } else { *level = (float)mem_used / (float)server.maxmemory; } } if (return_ok_asap) return C_OK; if (mem_used <= server.maxmemory) return C_OK; mem_tofree = mem_used - server.maxmemory; if (logical) *logical = mem_used; if (tofree) *tofree = mem_tofree; return C_ERR; } ``` 如果使用記憶體低於maxmemory的話,就返回`C_OK`,否則返回`C_ERR`。另外,這個函式還通過傳遞指標型的引數來返回一些額外的資訊。 - **total**:已使用的位元組總數,無論是`C_OK`還是`C_ERR`都有效。 - **logical**:已使用的記憶體減去slave或AOF緩衝區後的大小,只有返回`C_ERR`時有效。 - **tofree**:需要釋放的記憶體大小,只有返回`C_ERR`時有效。 - **level**:已使用記憶體的比例,通常是0到1之間,當超出記憶體限制時,就大於1。無論是`C_OK`還是`C_ERR`都有效。 判斷完記憶體狀態以後,如果記憶體沒有超過使用限制就會直接返回,否則就繼續向下執行。此時我們已經知道需要釋放多少記憶體空間了,下面就開始進行釋放記憶體的操作了。每次釋放記憶體都會記錄釋放記憶體的大小,直到釋放的記憶體不小於`tofree`。 首先根據`maxmemory_policy`進行判斷,對於不同的清除策略有不同的實現方法,我們來看LRU的具體實現。 ``` c 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; } } ``` 首先是填充“過期池”,這裡遍歷了每一個db(驗證了我最開始的想法),呼叫`evictionPoolPopulate`函式進行填充。 ``` c void evictionPoolPopulate(int dbid, dict *sampledict, dict *keydict, struct evictionPoolEntry *pool) { int j, k, count; dictEntry *samples[server.maxmemory_samples]; count = dictGetSomeKeys(sampledict,samples,server.maxmemory_samples); for (j = 0; j < count; j++) { unsigned long long idle; sds key; robj *o; dictEntry *de; de = samples[j]; key = dictGetKey(de); /* some code */ if (server.maxmemory_policy & MAXMEMORY_FLAG_LRU) { idle = estimateObjectIdleTime(o); } /* some code */ k = 0; while (k < EVPOOL_SIZE && pool[k].key && pool[k].idle < idle) k++; if (k == 0 && pool[EVPOOL_SIZE-1].key != NULL) { continue; } else if (k < EVPOOL_SIZE && pool[k].key == NULL) { } else { if (pool[EVPOOL_SIZE-1].key == NULL) { sds cached = pool[EVPOOL_SIZE-1].cached; memmove(pool+k+1,pool+k, sizeof(pool[0])*(EVPOOL_SIZE-k-1)); pool[k].cached = cached; } else { k--; sds cached = pool[0].cached; /* Save SDS before overwriting. */ if (pool[0].key != pool[0].cached) sdsfree(pool[0].key); memmove(pool,pool+1,sizeof(pool[0])*k); pool[k].cached = cached; } } /* some code */ } } ``` 由於篇幅原因,我截取了部分程式碼,通過這段程式碼我們可以看到,Redis首先是取樣了一部分key,這裡取樣數量maxmemory_samples通常是5,我們也可以自己設定,取樣數量越大,結果就越接近LRU演算法的結果,帶來的影響是效能隨之變差。 取樣之後我們需要獲得每個key的空閒時間,然後將其填充到“過期池”中的指定位置。這裡“過期池”是按照空閒時間從小到大排序的,也就是說,idle大大key排在最右邊。 填充完“過期池”之後,會從後向前獲取到最適合清理的key。 ``` c /* Go backward from best to worst element to evict. */ 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); } /* some code */ if (de) { bestkey = dictGetKey(de); break; } } ``` 找到需要刪除的key後,就需要根據設定清理策略進行同步/非同步清理。 ``` c if (server.lazyfree_lazy_eviction) dbAsyncDelete(db,keyobj); else dbSyncDelete(db,keyobj) ``` 最後記下本次清理的空間大小,用來在迴圈條件判斷是否要繼續清理。 ``` c delta -= (long long) zmalloc_used_memory(); mem_freed += delta; ``` ### 清理策略 最後我們來看一下Redis支援的幾種清理策略 - **noeviction**:不會繼續處理寫請求(DEL可以繼續處理)。 - **allkeys-lru**:對所有key的近似LRU - **volatile-lru**:使用近似LRU演算法淘汰設定了過期時間的key - **allkeys-random**:從所有key中隨機淘汰一些key - **volatile-random**:對所有設定了過期時間的key隨機淘汰 - **volatile-ttl**:淘汰有效期最短的一部分key Redis4.0開始支援了LFU策略,和LRU類似,它分為兩種: - **volatile-lfu**:使用LFU演算法淘汰設定了過期時間的key - **allkeys-lfu**:從全部key中進行淘汰,使用LFU ### 寫在最後 現在我知道了Redis在記憶體達到上限時做了哪些事了。以後出問題時也就不會只檢查自己的db了。 關於這次事故的後續處理,我首先是讓業務同學回滾了程式碼,然後讓他們使用一個單獨的Redis,這樣業務再出現類似問題就不會影響到我們的帳號服務了,整體的影響範圍也會變得更加