理解Redis的內存回收機制

之前看到過一道面試題:Redis的過期策略都有哪些?內存淘汰機制都有哪些?手寫一下LRU代碼實現?筆者結合在工作上遇到的問題學習分析,希望看完這篇文章能對大家有所幫助。

從一次不可描述的故障說起

問題描述:一個依賴於定時器任務的生成的接口列表數據,時而有,時而沒有。

懷疑是Redis過期刪除策略

排查過程長,因爲手動執行定時器,set數據沒有報錯,但是set數據之後不生效。

set沒報錯,但是set完再查的情況下沒數據,開始懷疑Redis的過期刪除策略(準確來說應該是Redis的內存回收機制中的數據淘汰策略觸發內存上限淘汰數據。),導致新加入Redis的數據都被丟棄了。最終發現故障的原因是因爲配置錯了,導致數據寫錯地方,並不是Redis的內存回收機制引起。

通過這次故障後思考總結,如果下一次遇到類似的問題,在懷疑Redis的內存回收之後,如何有效地證明它的正確性?如何快速證明猜測的正確與否?以及什麼情況下懷疑內存回收纔是合理的呢?下一次如果再次遇到類似問題,就能夠更快更準地定位問題的原因。另外,Redis的內存回收機制原理也需要掌握,明白是什麼,爲什麼。

花了點時間查閱資料研究Redis的內存回收機制,並閱讀了內存回收的實現代碼,通過代碼結合理論,給大家分享一下Redis的內存回收機制。

爲什麼需要內存回收?

  • 1、在Redis中,set指令可以指定key的過期時間,當過期時間到達以後,key就失效了;

  • 2、Redis是基於內存操作的,所有的數據都是保存在內存中,一臺機器的內存是有限且很寶貴的。

基於以上兩點,爲了保證Redis能繼續提供可靠的服務,Redis需要一種機制清理掉不常用的、無效的、多餘的數據,失效後的數據需要及時清理,這就需要內存回收了。

Redis的內存回收機制

Redis的內存回收主要分爲過期刪除策略和內存淘汰策略兩部分。

過期刪除策略

刪除達到過期時間的key。

1、定時刪除

對於每一個設置了過期時間的key都會創建一個定時器,一旦到達過期時間就立即刪除。該策略可以立即清除過期的數據,對內存較友好,但是缺點是佔用了大量的CPU資源去處理過期的數據,會影響Redis的吞吐量和響應時間。

2、惰性刪除

當訪問一個key時,才判斷該key是否過期,過期則刪除。該策略能最大限度地節省CPU資源,但是對內存卻十分不友好。有一種極端的情況是可能出現大量的過期key沒有被再次訪問,因此不會被清除,導致佔用了大量的內存。

在計算機科學中,懶惰刪除(英文:lazy deletion)指的是從一個散列表(也稱哈希表)中刪除元素的一種方法。在這個方法中,刪除僅僅是指標記一個元素被刪除,而不是整個清除它。被刪除的位點在插入時被當作空元素,在搜索之時被當作已佔據。

3、定期刪除

每隔一段時間,掃描Redis中過期key字典,並清除部分過期的key。該策略是前兩者的一個折中方案,還可以通過調整定時掃描的時間間隔和每次掃描的限定耗時,在不同情況下使得CPU和內存資源達到最優的平衡效果。

在Redis中,同時使用了定期刪除和惰性刪除。

過期刪除策略原理

爲了大家聽起來不會覺得疑惑,在正式介紹過期刪除策略原理之前,先給大家介紹一點可能會用到的相關Redis基礎知識。

redisDb結構體定義

我們知道,Redis是一個鍵值對數據庫,對於每一個redis數據庫,redis使用一個redisDb的結構體來保存,它的結構如下:

typedef struct redisDb {
        dict *dict;                 /* 數據庫的鍵空間,保存數據庫中的所有鍵值對 */
        dict *expires;              /* 保存所有過期的鍵 */
        dict *blocking_keys;        /* Keys with clients waiting for data (BLPOP)*/
        dict *ready_keys;           /* Blocked keys that received a PUSH */
        dict *watched_keys;         /* WATCHED keys for MULTI/EXEC CAS */
        int id;                     /* 數據庫ID字段,代表不同的數據庫 */
        long long avg_ttl;          /* Average TTL, just for stats */
} redisDb;

從結構定義中我們可以發現,對於每一個Redis數據庫,都會使用一個字典的數據結構來保存每一個鍵值對,dict的結構圖如下:

以上就是過期策略實現時用到比較核心的數據結構。程序=數據結構+算法,介紹完數據結構以後,接下來繼續看看處理的算法是怎樣的。

expires屬性

redisDb定義的第二個屬性是expires,它的類型也是字典,Redis會把所有過期的鍵值對加入到expires,之後再通過定期刪除來清理expires裏面的值。加入expires的場景有:

1、set指定過期時間expire

如果設置key的時候指定了過期時間,Redis會將這個key直接加入到expires字典中,並將超時時間設置到該字典元素。

2、調用expire命令

顯式指定某個key的過期時間

3、恢復或修改數據

從Redis持久化文件中恢復文件或者修改key,如果數據中的key已經設置了過期時間,就將這個key加入到expires字典中

以上這些操作都會將過期的key保存到expires。redis會定期從expires字典中清理過期的key。

Redis清理過期key的時機

1、Redis在啓動的時候,會註冊兩種事件,一種是時間事件,另一種是文件事件。(可參考啓動Redis的時候,Redis做了什麼)時間事件主要是Redis處理後臺操作的一類事件,比如客戶端超時、刪除過期key;文件事件是處理請求。

在時間事件中,redis註冊的回調函數是serverCron,在定時任務回調函數中,通過調用databasesCron清理部分過期key。(這是定期刪除的實現。)    

int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData)
{
    …
    /* Handle background operations on Redis databases. */
    databasesCron();
    ...
}

2、每次訪問key的時候,都會調用expireIfNeeded函數判斷key是否過期,如果是,清理key。(這是惰性刪除的實現。)    

robj *lookupKeyRead(redisDb *db, robj *key) {
    robj *val;
    expireIfNeeded(db,key);
    val = lookupKey(db,key);
     ...
    return val;
}

3、每次事件循環執行時,主動清理部分過期key。(這也是惰性刪除的實現。)

void aeMain(aeEventLoop *eventLoop) {
    eventLoop->stop = 0;
    while (!eventLoop->stop) {
        if (eventLoop->beforesleep != NULL)
            eventLoop->beforesleep(eventLoop);
        aeProcessEvents(eventLoop, AE_ALL_EVENTS);
    }
}

void beforeSleep(struct aeEventLoop *eventLoop) {
       ...
       /* Run a fast expire cycle (the called function will return
        - ASAP if a fast cycle is not needed). */
       if (server.active_expire_enabled && server.masterhost == NULL)
           activeExpireCycle(ACTIVE_EXPIRE_CYCLE_FAST);
       ...
   }

過期策略的實現

我們知道,Redis是以單線程運行的,在清理key是不能佔用過多的時間和CPU,需要在儘量不影響正常的服務情況下,進行過期key的清理。過期清理的算法如下:    

1、server.hz配置了serverCron任務的執行週期,默認是10,即CPU空閒時每秒執行十次。
2、每次清理過期key的時間不能超過CPU時間的25%:timelimit = 1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/server.hz/100;
    比如,如果hz=1,一次清理的最大時間爲250ms,hz=10,一次清理的最大時間爲25ms。
3、如果是快速清理模式(在beforeSleep函數調用),則一次清理的最大時間是1ms。
4、依次遍歷所有的DB。
5、從db的過期列表中隨機取20個key,判斷是否過期,如果過期,則清理。
6、如果有5個以上的key過期,則重複步驟5,否則繼續處理下一個db
7、在清理過程中,如果達到CPU的25%時間,退出清理過程。

從實現的算法中可以看出,這只是基於概率的簡單算法,且是隨機的抽取,因此是無法刪除所有的過期key,通過調高hz參數可以提升清理的頻率,過期key可以更及時的被刪除,但hz太高會增加CPU時間的消耗。

刪除key

Redis4.0以前,刪除指令是del,del會直接釋放對象的內存,大部分情況下,這個指令非常快,沒有任何延遲的感覺。但是,如果刪除的key是一個非常大的對象,比如一個包含了千萬元素的hash,那麼刪除操作就會導致單線程卡頓,Redis的響應就慢了。爲了解決這個問題,在Redis4.0版本引入了unlink指令,能對刪除操作進行“懶”處理,將刪除操作丟給後臺線程,由後臺線程來異步回收內存。

實際上,在判斷key需要過期之後,真正刪除key的過程是先廣播expire事件到從庫和AOF文件中,然後在根據redis的配置決定立即刪除還是異步刪除。

如果是立即刪除,Redis會立即釋放key和value佔用的內存空間,否則,Redis會在另一個bio線程中釋放需要延遲刪除的空間。

小結

總的來說,Redis的過期刪除策略是在啓動時註冊了serverCron函數,每一個時間時鐘週期,都會抽取expires字典中的部分key進行清理,從而實現定期刪除。另外,Redis會在訪問key時判斷key是否過期,如果過期了,就刪除,以及每一次Redis訪問事件到來時,beforeSleep都會調用activeExpireCycle函數,在1ms時間內主動清理部分key,這是惰性刪除的實現。

Redis結合了定期刪除和惰性刪除,基本上能很好的處理過期數據的清理,但是實際上還是有點問題的,如果過期key較多,定期刪除漏掉了一部分,而且也沒有及時去查,即沒有走惰性刪除,那麼就會有大量的過期key堆積在內存中,導致redis內存耗盡,當內存耗盡之後,有新的key到來會發生什麼事呢?是直接拋棄還是其他措施呢?有什麼辦法可以接受更多的key?

內存淘汰策略

Redis的內存淘汰策略,是指內存達到maxmemory極限時,使用某種算法來決定清理掉哪些數據,以保證新數據的存入。

Redis的內存淘汰機制

  • noeviction: 當內存不足以容納新寫入數據時,新寫入操作會報錯。

  • allkeys-lru:當內存不足以容納新寫入數據時,在鍵空間(server.db[i].dict)中,移除最近最少使用的 key(這個是最常用的)。

  • allkeys-random:當內存不足以容納新寫入數據時,在鍵空間(server.db[i].dict)中,隨機移除某個 key。

  • volatile-lru:當內存不足以容納新寫入數據時,在設置了過期時間的鍵空間(server.db[i].expires)中,移除最近最少使用的 key。

  • volatile-random:當內存不足以容納新寫入數據時,在設置了過期時間的鍵空間(server.db[i].expires)中,隨機移除某個 key。

  • volatile-ttl:當內存不足以容納新寫入數據時,在設置了過期時間的鍵空間(server.db[i].expires)中,有更早過期時間的 key 優先移除。

在配置文件中,通過maxmemory-policy可以配置要使用哪一個淘汰機制。

什麼時候會進行淘汰?

Redis會在每一次處理命令的時候(processCommand函數調用freeMemoryIfNeeded)判斷當前redis是否達到了內存的最大限制,如果達到限制,則使用對應的算法去處理需要刪除的key。僞代碼如下:    

int processCommand(client *c)
{
    ...
    if (server.maxmemory) {
        int retval = freeMemoryIfNeeded();  
    }
    ...
}

LRU實現原理

在淘汰key時,Redis默認最常用的是LRU算法(Latest Recently Used)。Redis通過在每一個redisObject保存lru屬性來保存key最近的訪問時間,在實現LRU算法時直接讀取key的lru屬性。

具體實現時,Redis遍歷每一個db,從每一個db中隨機抽取一批樣本key,默認是3個key,再從這3個key中,刪除最近最少使用的key。實現僞代碼如下:    

keys = getSomeKeys(dict, sample)
key = findSmallestIdle(keys)
remove(key)

3這個數字是配置文件中的maxmeory-samples字段,也是可以可以設置採樣的大小,如果設置爲10,那麼效果會更好,不過也會耗費更多的CPU資源。

以上就是Redis內存回收機制的原理介紹,瞭解了上面的原理介紹後,回到一開始的問題,在懷疑Redis內存回收機制的時候能不能及時判斷故障是不是因爲Redis的內存回收機制導致的呢?

回到問題原點

如何證明故障是不是由內存回收機制引起的?

根據前面分析的內容,如果set沒有報錯,但是不生效,只有兩種情況:

  • 1、設置的過期時間過短,比如,1s?

  • 2、內存超過了最大限制,且設置的是noeviction或者allkeys-random。

因此,在遇到這種情況,首先看set的時候是否加了過期時間,且過期時間是否合理,如果過期時間較短,那麼應該檢查一下設計是否合理。

如果過期時間沒問題,那就需要查看Redis的內存使用率,查看Redis的配置文件或者在Redis中使用info命令查看Redis的狀態,maxmemory屬性查看最大內存值。如果是0,則沒有限制,此時是通過total_system_memory限制,對比used_memory與Redis最大內存,查看內存使用率。

如果當前的內存使用率較大,那麼就需要查看是否有配置最大內存,如果有且內存超了,那麼就可以初步判定是內存回收機制導致key設置不成功,還需要查看內存淘汰算法是否noeviction或者allkeys-random,如果是,則可以確認是redis的內存回收機制導致。如果內存沒有超,或者內存淘汰算法不是上面的兩者,則還需要看看key是否已經過期,通過ttl查看key的存活時間。如果運行了程序,set沒有報錯,則ttl應該馬上更新,否則說明set失敗,如果set失敗了那麼就應該查看操作的程序代碼是否正確了。

總結

Redis對於內存的回收有兩種方式,一種是過期key的回收,另一種是超過redis的最大內存後的內存釋放。

對於第一種情況,Redis會在:

1、每一次訪問的時候判斷key的過期時間是否到達,如果到達,就刪除key

2、redis啓動時會創建一個定時事件,會定期清理部分過期的key,默認是每秒執行十次檢查,每次過期key清理的時間不超過CPU時間的25%,即若hz=1,則一次清理時間最大爲250ms,若hz=10,則一次清理時間最大爲25ms。

對於第二種情況,redis會在每次處理redis命令的時候判斷當前redis是否達到了內存的最大限制,如果達到限制,則使用對應的算法去處理需要刪除的key。

看完這篇文章後,你能回答文章開頭的面試題了嗎?

思考

留下一道思考題,我們知道,Redis是單線程的,單線程的redis還包含了這麼多的任務每一次處理命令的線程都包含:處理命令、清理過期key、處理內存回收這些任務,爲什麼還能這麼快?裏面做了什麼優化?後續再探索這個問題,敬請關注。

原創文章,文筆有限,才疏學淺,文中若有不正之處,萬望告知。

如果本文對你有幫助,請點個贊吧,謝謝^_^

更多精彩內容,請關注個人公衆號。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章