Redis 過時淘汰策略

Redis 過時淘汰策略

redis的過時淘汰策略是很是值得去深刻了解以及考究的一個問題。不少使用者每每不能深得其意,每每停留在人云亦云的程度,若生產不出事故便划水就划過去了,可是當生產數據莫名其妙的消失,或者reids服務崩潰的時候,卻又一籌莫展。本文嘗試着從淺入深的將redis的過時策略剖析開來,指望幫助做者以及讀者站在一個更加系統化的角度去看待過時策略。redis

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

內存策略

redis經過配置maxmemory來配置最大容量(閾值),當數據佔有空間超過所設定值就會觸發內部的內存淘汰策略(內存釋放)。那麼究竟要淘汰哪些數據,纔是最符合業務需求?或者在業務容忍的範圍內呢?爲了解決這個問題,redis提供了可配置的淘汰策略,讓使用者能夠配置適合本身業務場景的淘汰策略,不配置的狀況下默認是使用volatile-lru數據庫

  • noeviction:當內存不足以容納新寫入數據時,新寫入操做會報錯。
  • allkeys-lru:當內存不足以容納新寫入數據時,在鍵空間中,移除最近最少使用的key。
  • allkeys-random:當內存不足以容納新寫入數據時,在鍵空間中,隨機移除某個key。
  • volatile-lru:當內存不足以容納新寫入數據時,在設置了過時時間的鍵空間中,移除最近最少使用的key。
  • volatile-random:當內存不足以容納新寫入數據時,在設置了過時時間的鍵空間中,隨機移除某個key。
  • volatile-ttl:當內存不足以容納新寫入數據時,在設置了過時時間的鍵空間中,有更早過時時間的key優先移除。

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

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

mem_used內存已經超過maxmemory的設定,對於全部的讀寫請求,都會觸發redis.c/freeMemoryIfNeeded函數以清理超出的內存。注意這個清理過程是阻塞的,直到清理出足夠的內存空間。因此若是在達到maxmemory而且調用方還在不斷寫入的狀況下,可能會反覆觸發主動清理策略,致使請求會有必定的延遲。數據結構

清理時會根據用戶配置的maxmemory政策來作適當的清理(通常是LRU或TTL),這裏的LRU或TTL策略並非針對redis的的全部鍵,而是以配置文件中的maxmemory樣本個鍵做爲樣本池進行抽樣清理。app

redis設計者將該值默認爲3,若是增長該值,會提升LRU或TTL的精準度,redis的做者測試的結果是當這個配置爲10時已經很是接近全量LRU的精準度了,並且增長maxmemory採樣會致使在主動清理時消耗更多的CPU時間,因此在設置該值必須慎重把控,在業務的需求以及性能之間作權衡。建議以下:dom

  • 儘可能不要觸發maxmemory,最好在mem_used內存佔用達到maxmemory的必定比例後,須要考慮調大赫茲以加快淘汰,或者進行集羣擴容。
  • 若是可以控制住內存,則能夠不用修改maxmemory-samples配置。若是redis自己就做爲LRU緩存服務(這種服務通常長時間處於maxmemory狀態,由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;
			
			// 一、從哪一個字典中剔除數據
            // 判斷淘汰策略是基於全部的鍵仍是隻是基於設置了過時時間的鍵,
			// 若是是針對全部的鍵,就從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;
			
			// 二、從是否爲隨機策略
			// 是否是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
            }
			
			// 三、判斷是否爲lru算法
			// 是lru策略仍是ttl策略,若是是lru策略就採用lru近似算法
			// 爲了減小運算量,redis的lru算法和expire淘汰算法同樣,都是非最優解,
			// lru算法是在相應的dict中,選擇maxmemory_samples(默認設置是3)份key,挑選其中lru的,進行淘汰
            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
					// 相似排序,循環後找到最近最少使用,將其刪除
                    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));
                // 發佈數據更新消息,主要是AOF 持久化和從機
                propagateExpire(db,keyobj); //將del命令擴散給slaves

				// 注意, propagateExpire() 可能會致使內存的分配,
				// propagateExpire() 提早執行就是由於redis 只計算
				// dbDelete() 釋放的內存大小。假若同時計算dbDelete()
				// 釋放的內存和propagateExpire() 分配空間的大小,與此
				// 同時假設分配空間大於釋放空間,就有可能永遠退不出這個循環。
				// 下面的代碼會同時計算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. */
                 // 將從機回覆空間中的數據及時發送給從機
                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的內存管理機制。ide

輸入圖片說明

從redis的內存管理機制中咱們能夠看到,當使用的內存超過設定的閾值,就會觸發內存清理。那麼必定要等到內存超過閾值才進行內存清理嗎?非要亡羊補牢?redis的設計者顯然是考慮到了這個問題,當redis在使用過程當中,自行去刪除一些過時key,儘可能保證不要觸發超過內存閾值而發生的清理事件。函數

有效時間

  • expire/pexpire key time(以秒/毫秒爲單位)--這是最經常使用的方式(Time To Live 稱爲TTL)
  • setex(String key, int seconds, String value)--字符串獨有的方式

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

  • 除了字符串本身獨有設置過時時間的方法外,其餘方法都須要依靠expire方法來設置時間
  • 若是沒有設置時間,那緩存就是永不過時
  • 若是設置了過時時間,以後又想讓緩存永不過時,使用persist key

過時鍵自動刪除策略

一、定時刪除(主動刪除策略):經過使用定時器(時間事件,採用無序鏈表實現,),定時刪除數據。定時刪除策略能夠保證過時的鍵會盡量快的被刪除了,並釋放過時鍵鎖佔用的內存。

  • 好處:對內存是最友好的。
  • 壞處:它對CPU時間不友好,在過時鍵比較多的狀況下,刪除過時鍵這一行爲會佔用至關一部分CPU時間,在內存不緊張可是CPU很是緊張的狀況下,將CPU應用於刪除和當前任務無關的過時鍵上,無疑會對服務器的響應時間和吞吐量形成影響。

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

  • 好處:對CPU時間友好,永遠只在操做與當前任務有關的鍵。
  • 壞處:可能會在內存中遺留大量的過時鍵而不刪除,形成內存泄漏。

三、按期刪除(主動刪除):按期每隔一段時間執行一段刪除過時鍵操做,經過限制刪除操做的執行時長與頻率來減小刪除操做對CPU時間的影響。除此以外,按期執行也能夠減小過時鍵長期駐留內存的影響,減小內存泄漏的可能。

  • 好處:能夠控制過時刪除的執行頻率
  • 壞處:服務器必須合理設置過時鍵刪除的操做時間以及執行的頻率。

redis的過時鍵刪除策略

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

惰性刪除策略的實現

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

  • 若是過時 --> 刪除
  • 若是非過時 --> 執行命令(expireIfNeeded函數不作動做)

按期刪除策略的實現

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

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

  • 檢查當前庫中的指定個數個key(默認是每一個庫檢查20個key,注意至關於該循環執行20次)
  • 若是當前庫中沒有一個key設置了過時時間,直接執行下一個庫的遍歷
  • 隨機獲取一個設置了過時時間的key,檢查該key是否過時,若是過時,刪除key
  • 判判定期刪除操做是否已經達到指定時長,若已經達到,直接退出按期刪除。

參考資料: