Redis 過期淘汰策略

Redis 過期淘汰策略

redis的過期淘汰策略是非常值得去深入了解以及考究的一個問題。很多使用者往往不能深得其意,往往停留在人云亦云的程度,若生產不出事故便劃水就劃過去了,但是當生產數據莫名其妙的消失,或者reids服務崩潰的時候,卻又束手無策。本文嘗試著從淺入深的將redis的過期策略剖析開來,期望幫助作者以及讀者站在一個更加系統(tǒng)化的角度去看待過期策略。

redis作為緩存數據庫,其底層數據結構主要由dict和expires兩個字典構成,其中dict字典負責保存鍵值對,而expires字典則負責保存鍵的過期時間。訪問磁盤空間的成本是訪問緩存的成本高出非常多,所以內存的成本比磁盤空間要大。在實際使用中,緩存的空間往往極為有限,所以為了在為數不多的容量中做到真正的物盡其用,必須要對緩存的容量進行管控。

內存策略

redis通過配置maxmemory來配置最大容量(閾值),當數據占有空間超過所設定值就會觸發(fā)內部的內存淘汰策略(內存釋放)。那么究竟要淘汰哪些數據,才是最符合業(yè)務需求?或者在業(yè)務容忍的范圍內呢?為了解決這個問題,redis提供了可配置的淘汰策略,讓使用者可以配置適合自己業(yè)務場景的淘汰策略,不配置的情況下默認是使用volatile-lru

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

  • allkeys-lru:當內存不足以容納新寫入數據時,在鍵空間中,移除最近最少使用的key。

  • allkeys-random:當內存不足以容納新寫入數據時,在鍵空間中,隨機移除某個key。

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

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

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

如果redis保存的key-value對數量不多(比如數十對),那么當內存超過閾值后,對整個內存空間的所有key進行檢查,也無傷大雅。然而,在實際使用中redis保存的key-value數量遠遠不止于此,如果使用內存超過閾值就逐個去檢查是否符合過期策略嗎?隨意遍歷十萬個key?顯然不是,否則,redis又何以高性能著稱?但是,檢查多少key這個問題確實存在。為了解決該問題,redis的設計者們引入一個配置項maxmemory-samples,稱之為過期檢測樣本,默認值是3,通過它來曲線救國。

過期檢測樣本是如何配合redis來進行數據清理呢?

mem_used內存已經超過maxmemory的設定,對于所有的讀寫請求,都會觸發(fā)redis.c/freeMemoryIfNeeded函數以清理超出的內存。注意這個清理過程是阻塞的,直到清理出足夠的內存空間。所以如果在達到maxmemory并且調用方還在不斷寫入的情況下,可能會反復觸發(fā)主動清理策略,導致請求會有一定的延遲。

清理時會根據用戶配置的maxmemory政策來做適當的清理(一般是LRU或TTL),這里的LRU或TTL策略并不是針對redis的的所有鍵,而是以配置文件中的maxmemory樣本個鍵作為樣本池進行抽樣清理。

redis設計者將該值默認為3,如果增加該值,會提高LRU或TTL的精準度,redis的作者測試的結果是當這個配置為10時已經非常接近全量LRU的精準度了,而且增加maxmemory采樣會導致在主動清理時消耗更多的CPU時間,所以在設置該值必須慎重把控,在業(yè)務的需求以及性能之間做權衡。建議如下:

  • 盡量不要觸發(fā)maxmemory,最好在mem_used內存占用達到maxmemory的一定比例后,需要考慮調大赫茲以加快淘汰,或者進行集群擴容。

  • 如果能夠控制住內存,則可以不用修改maxmemory-samples配置。如果redis本身就作為LRU緩存服務(這種服務一般長時間處于maxmemory狀態(tài),由redis自動做LRU淘汰),可以適當調大maxmemory樣本。

freeMemoryIfNeeded源碼解讀


int freeMemoryIfNeeded(void) {

    size_t mem_used, mem_tofree, mem_freed;

    int slaves = listLength(server.slaves);

    // 計算占用內存大小時,并不計算slave output buffer和aof buffer,

// 因此maxmemory應該比實際內存小,為這兩個buffer留足空間。

    mem_used = zmalloc_used_memory();

    if (slaves) {

        listIter li;

        listNode *ln;

        listRewind(server.slaves,&li);

        while((ln = listNext(&li))) {

            redisClient *slave = listNodeValue(ln);

            unsigned long obuf_bytes = getClientOutputBufferMemoryUsage(slave);

            if (obuf_bytes > mem_used)

                mem_used = 0;

            else

                mem_used -= obuf_bytes;

        }

    }

    if (server.appendonly) {

        mem_used -= sdslen(server.aofbuf);

        mem_used -= sdslen(server.bgrewritebuf);

    }

// 判斷已經使用內存是否超過最大使用內存,如果沒有超過就返回REDIS_OK,

    if (mem_used <= server.maxmemory) return REDIS_OK;

// 當超過了最大使用內存時,就要判斷此時redis到底采用何種內存釋放策略,根據不同的策略,采取不同的清除算法。

// 首先判斷是否是為no-enviction策略,如果是,則返回REDIS_ERR,然后redis就不再接受任何寫命令了。

    if (server.maxmemory_policy == REDIS_MAXMEMORY_NO_EVICTION)

        return REDIS_ERR;

    // 計算需要清理內存大小

    mem_tofree = mem_used - server.maxmemory;

    mem_freed = 0;



    while (mem_freed < mem_tofree) {

        int j, k, keys_freed = 0;

        for (j = 0; j < server.dbnum; j++) {

            long bestval = 0;

            sds bestkey = NULL;

            struct dictEntry *de;

            redisDb *db = server.db+j;

            dict *dict;

// 1、從哪個字典中剔除數據

            // 判斷淘汰策略是基于所有的鍵還是只是基于設置了過期時間的鍵,

// 如果是針對所有的鍵,就從server.db[j].dict中取數據,

// 如果是針對設置了過期時間的鍵,就從server.db[j].expires(記錄過期時間)中取數據。

if (server.maxmemory_policy == REDIS_MAXMEMORY_ALLKEYS_LRU ||

                server.maxmemory_policy == REDIS_MAXMEMORY_ALLKEYS_RANDOM)

            {

                dict = server.db[j].dict;

            } else {

                dict = server.db[j].expires;

            }

            if (dictSize(dict) == 0) continue;

// 2、從是否為隨機策略

// 是不是random策略,包括volatile-random 和allkeys-random,這兩種策略是最簡單的,就是在上面的數據集中隨便去一個鍵,然后刪掉。

            if (server.maxmemory_policy == REDIS_MAXMEMORY_ALLKEYS_RANDOM ||

                server.maxmemory_policy == REDIS_MAXMEMORY_VOLATILE_RANDOM)

            {

                de = dictGetRandomKey(dict);// 從方法名猜出是隨機獲取一個dictEntry

                bestkey = dictGetEntryKey(de);// 得到刪除的key

            }

// 3、判斷是否為lru算法

// 是lru策略還是ttl策略,如果是lru策略就采用lru近似算法

// 為了減少運算量,redis的lru算法和expire淘汰算法一樣,都是非最優(yōu)解,

// lru算法是在相應的dict中,選擇maxmemory_samples(默認設置是3)份key,挑選其中l(wèi)ru的,進行淘汰

            else if (server.maxmemory_policy == REDIS_MAXMEMORY_ALLKEYS_LRU ||

                server.maxmemory_policy == REDIS_MAXMEMORY_VOLATILE_LRU)

            {

                for (k = 0; k < server.maxmemory_samples; k++) {

                    sds thiskey;

                    long thisval;

                    robj *o;

                    de = dictGetRandomKey(dict);

                    thiskey = dictGetEntryKey(de);

                    /* When policy is volatile-lru we need an additonal lookup

                    * to locate the real key, as dict is set to db->expires. */

                    if (server.maxmemory_policy == REDIS_MAXMEMORY_VOLATILE_LRU)

                        de = dictFind(db->dict, thiskey); //因為dict->expires維護的數據結構里并沒有記錄該key的最后訪問時間

                    o = dictGetEntryVal(de);

                    thisval = estimateObjectIdleTime(o);

                    /* Higher idle time is better candidate for deletion */

// 找到那個最合適刪除的key

// 類似排序,循環(huán)后找到最近最少使用,將其刪除

                    if (bestkey == NULL || thisval > bestval) {

                        bestkey = thiskey;

                        bestval = thisval;

                    }

                }

            }

// 如果是ttl策略。

// 取maxmemory_samples個鍵,比較過期時間,

// 從這些鍵中找到最快過期的那個鍵,并將其刪除

            else if (server.maxmemory_policy == REDIS_MAXMEMORY_VOLATILE_TTL) {

                for (k = 0; k < server.maxmemory_samples; k++) {

                    sds thiskey;

                    long thisval;

                    de = dictGetRandomKey(dict);

                    thiskey = dictGetEntryKey(de);

                    thisval = (long) dictGetEntryVal(de);

                    /* Expire sooner (minor expire unix timestamp) is better candidate for deletion */

                    if (bestkey == NULL || thisval < bestval) {

                        bestkey = thiskey;

                        bestval = thisval;

                    }

                }

            }

// 根據不同策略挑選了即將刪除的key之后,進行刪除

            if (bestkey) {

                long long delta;

                robj *keyobj = createStringObject(bestkey,sdslen(bestkey));

                // 發(fā)布數據更新消息,主要是AOF 持久化和從機

                propagateExpire(db,keyobj); //將del命令擴散給slaves

// 注意, propagateExpire() 可能會導致內存的分配,

// propagateExpire() 提前執(zhí)行就是因為redis 只計算

// dbDelete() 釋放的內存大小。倘若同時計算dbDelete()

// 釋放的內存和propagateExpire() 分配空間的大小,與此

// 同時假設分配空間大于釋放空間,就有可能永遠退不出這個循環(huán)。

// 下面的代碼會同時計算dbDelete() 釋放的內存和propagateExpire() 分配空間的大小

                /* We compute the amount of memory freed by dbDelete() alone.

                * It is possible that actually the memory needed to propagate

                * the DEL in AOF and replication link is greater than the one

                * we are freeing removing the key, but we can't account for

                * that otherwise we would never exit the loop.

                *

                * AOF and Output buffer memory will be freed eventually so

                * we only care about memory used by the key space. */

// 只計算dbDelete() 釋放內存的大小

                delta = (long long) zmalloc_used_memory();

                dbDelete(db,keyobj);

                delta -= (long long) zmalloc_used_memory();

                mem_freed += delta;

                server.stat_evictedkeys++;

                decrRefCount(keyobj);

                keys_freed++;

                /* When the memory to free starts to be big enough, we may

                * start spending so much time here that is impossible to

                * deliver data to the slaves fast enough, so we force the

                * transmission here inside the loop. */

                // 將從機回復空間中的數據及時發(fā)送給從機

                if (slaves) flushSlavesOutputBuffers();

            }

        }//在所有的db中遍歷一遍,然后判斷刪除的key釋放的空間是否足夠,未能釋放空間,且此時redis 使用的內存大小依舊超額,失敗返回

        if (!keys_freed) return REDIS_ERR; /* nothing to free... */

    }

    return REDIS_OK;

}

從源碼分析中可以看到redis在使用內存中超過設定的閾值時是如何將清理key-value進行內管管理,其中涉及到redis的存儲結構。開篇就說到redis底層數據結構是由dict以及expires兩個字典構成,通過一張圖可以非常清晰了解到redis中帶過期時間的key-value的存儲結構,可以更加深刻認識到redis的內存管理機制。

輸入圖片說明

從redis的內存管理機制中我們可以看到,當使用的內存超過設定的閾值,就會觸發(fā)內存清理。那么一定要等到內存超過閾值才進行內存清理嗎?非要亡羊補牢?redis的設計者顯然是考慮到了這個問題,當redis在使用過程中,自行去刪除一些過期key,盡量保證不要觸發(fā)超過內存閾值而發(fā)生的清理事件。

有效時間

  • expire/pexpire key time(以秒/毫秒為單位)--這是最常用的方式(Time To Live 稱為TTL)

  • setex(String key, int seconds, String value)--字符串獨有的方式

在使用過期時間時,必要注意如下三點:

  • 除了字符串自己獨有設置過期時間的方法外,其他方法都需要依靠expire方法來設置時間

  • 如果沒有設置時間,那緩存就是永不過期

  • 如果設置了過期時間,之后又想讓緩存永不過期,使用persist key

過期鍵自動刪除策略

1、定時刪除(主動刪除策略):通過使用定時器(時間事件,采用無序鏈表實現,),定時刪除數據。定時刪除策略可以保證過期的鍵會盡可能快的被刪除了,并釋放過期鍵鎖占用的內存。

  • 好處:對內存是最友好的。

  • 壞處:它對CPU時間不友好,在過期鍵比較多的情況下,刪除過期鍵這一行為會占用相當一部分CPU時間,在內存不緊張但是CPU非常緊張的情況下,將CPU應用于刪除和當前任務無關的過期鍵上,無疑會對服務器的響應時間和吞吐量造成影響。

2、惰性刪除(被動刪除策略):程序在每次使用到鍵的時候去檢查是否過期,如果過期則刪除并返回空。

  • 好處:對CPU時間友好,永遠只在操作與當前任務有關的鍵。

  • 壞處:可能會在內存中遺留大量的過期鍵而不刪除,造成內存泄漏。

3、定期刪除(主動刪除):定期每隔一段時間執(zhí)行一段刪除過期鍵操作,通過限制刪除操作的執(zhí)行時長與頻率來減少刪除操作對CPU時間的影響。除此之外,定期執(zhí)行也可以減少過期鍵長期駐留內存的影響,減少內存泄漏的可能。

  • 好處:可以控制過期刪除的執(zhí)行頻率

  • 壞處:服務器必須合理設置過期鍵刪除的操作時間以及執(zhí)行的頻率。

redis的過期鍵刪除策略

redis服務器實際上使用的惰性刪除和定期刪除兩種策略:通過配合使用兩種刪除策略,服務器可以很好地合理使用CPU時間和避免浪費內存空間之間取得平衡。

惰性刪除策略的實現

redis提供一個expireIfNeeded函數,所以讀寫數據庫的命令在執(zhí)行之前都必須調用expireIfNeeded函數。(鍵是否存在)

  • 如果過期 --> 刪除

  • 如果非過期 --> 執(zhí)行命令(expireIfNeeded函數不做動作)

定期刪除策略的實現

定期刪除有函數activeExpireCycle函數實現,每當redis服務器調用serverCorn函數時執(zhí)行定期刪除函數。它會在規(guī)定時間內,分多次遍歷服務器中的各個數據庫,并在數據庫的expire字典中隨機檢查一部分鍵的過期時間,并刪除過期鍵。

遍歷數據庫(就是redis.conf中配置的"database"數量,默認為16)

  • 檢查當前庫中的指定個數個key(默認是每個庫檢查20個key,注意相當于該循環(huán)執(zhí)行20次)

  • 如果當前庫中沒有一個key設置了過期時間,直接執(zhí)行下一個庫的遍歷

  • 隨機獲取一個設置了過期時間的key,檢查該key是否過期,如果過期,刪除key

  • 判斷定期刪除操作是否已經達到指定時長,若已經達到,直接退出定期刪除。

參考資料:

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,505評論 6 533
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現死者居然都...
    沈念sama閱讀 98,556評論 3 418
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,463評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,009評論 1 312
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,778評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,218評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,281評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,436評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發(fā)現了一具尸體,經...
    沈念sama閱讀 48,969評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,795評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,993評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,537評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,229評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,659評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,917評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,687評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,990評論 2 374