Redis源碼剖析以內存淘汰策略(Evict)

Redis做爲一個成熟的數據存儲中間件,它提供了完善的數據管理功能,好比以前咱們提到過的數據過時和今天咱們要講的數據淘汰(evict)策略。在開始介紹Redis數據淘汰策略前,我先拋出幾個問題,幫助你們更深入理解Redis的數據淘汰策略。html

  1. 何爲數據淘汰,Redis有了數據過時策略爲何還要有數據淘汰策略?
  2. 淘汰哪些數據,有什麼樣的數據選取標準?
  3. Redis的數據淘汰策略是如何實現的?

何爲Evict

我先來回答第一個問題,Redis中數據淘汰其實是指的在內存空間不足時,清理掉某些數據以節省內存空間。 雖然Redis已經有了過時的策略,它能夠清理掉有效期外的數據。但想象下這個場景,若是過時的數據被清理以後存儲空間仍是不夠怎麼辦?是否是還能夠再刪除掉一部分數據? 在緩存這種場景下 這個問題的答案是能夠,由於這個數據即使在Redis中找不到,也能夠從被緩存的數據源中找到。因此在某些特定的業務場景下,咱們是能夠丟棄掉Redis中部分舊數據來給新數據騰出空間。git

如何Evict

第二個問題,既然咱們須要有淘汰的機制,大家在具體執行時要選哪些數據淘汰掉?具體策略有不少種,但思路只有一個,那就是總價值最大化。咱們生在一個不公平的世界裏,一樣數據也是,那麼多數據裏必然不是全部數據的價值都是同樣的。因此咱們在淘汰數據時只須要選擇那些低價值的淘汰便可。github

因此問題又來了,哪些數據是低價值的?這裏不得不提到一個貫穿計算機學科的原理局部性原理,這裏能夠明確告訴你,局部性原理在緩存場景有這樣兩種現象,1. 最新的數據下次被訪問的機率越高。 2. 被訪問次數越多的數據下次被訪問的機率越高。 這裏咱們能夠簡單認爲被訪問的機率越高價值越大。基於上述兩種現象,咱們能夠指定出兩種策略 1. 淘汰掉最先未被訪問的數據。2. 淘汰掉訪被訪問頻次最低的數據,這兩種策略分別有個洋氣的英文名LRU(Least Recently Used)和LFU(Least Frequently Used)。redis

Redis中的Evict策略

除了LRU和LFU以外,還能夠隨機淘汰。這就是將數據一視同仁,隨機選取一部分淘汰。實際上Redis實現了以上3中策略,你使用時能夠根據具體的數據配置某個淘汰策略。除了上述三種策略外,Redis還爲由過時時間的數據提供了按TTL淘汰的策略,其實就是淘汰剩餘TTL中最小的數據。另外須要注意的是Redis的淘汰策略能夠配置在全局或者是有過時時間的數據上,因此Redis共計如下8中配置策略。緩存

配置項 具體含義
MAXMEMORY_VOLATILE_LRU 僅在有過時時間的數據上執行LRU
MAXMEMORY_VOLATILE_LFU 僅在有過時時間的數據上執行LFU
MAXMEMORY_VOLATILE_TTL 在有過時時間的數據上按TTL長度淘汰
MAXMEMORY_VOLATILE_RANDOM 僅在有過時時間的數據上隨機淘汰
MAXMEMORY_ALLKEYS_LRU 在全局數據上執行LRU
MAXMEMORY_ALLKEYS_LFU 在全局數據上執行LFU
MAXMEMORY_ALLKEYS_RANDOM 在全局數據上隨機淘汰
MAXMEMORY_NO_EVICTION 不淘汰數,當內存空間滿時插入數據會報錯

源碼剖析

接下來咱們就從源碼來看下Redis是如何實現以上幾種策略的,MAXMEMORY_VOLATILE_和MAXMEMORY_ALLKEYS_策略實現是同樣的,只是做用在不一樣的dict上而已。另外Random的策略也比較簡單,這裏就再也不詳解了,咱們重點看下LRU和LFU。dom

LRU具體實現

LRU的本質是淘汰最久沒被訪問的數據,有種實現方式是用鏈表的方式實現,若是數據被訪問了就把它移到鏈表頭部,那麼鏈尾必定是最久未訪問的數據,可是單鏈表的查詢時間複雜度是O(n),因此通常會用hash表來加快查詢數據,好比Java中LinkedHashMap就是這麼實現的。但Redis並無採用這種策略,Redis就是單純記錄了每一個Key最近一次的訪問時間戳,經過時間戳排序的方式來選找出最先的數據,固然若是把全部的數據都排序一遍,未免也太慢了,因此Redis是每次選一批數據,而後從這批數據執行淘汰策略。這樣的好處就是性能高,壞處就是不必定是全局最優,只是達到局部最優。性能

typedef struct redisObject {
    unsigned type:4;  
    unsigned encoding:4;
    unsigned lru:LRU_BITS; 
    int refcount;      
    void *ptr;            
} robj;

LRU信息如何存的? 在以前介紹redisObject的文章中 咱們已提到過了,在redisObject中有個24位的lru字段,這24位保存了數據訪問的時間戳(秒),固然24位沒法保存完整的unix時間戳,不到200天就會有一個輪迴,固然這已經足夠了。學習

robj *lookupKey(redisDb *db, robj *key, int flags) {
    dictEntry *de = dictFind(db->dict,key->ptr);
    if (de) {
        robj *val = dictGetVal(de);
        if (!hasActiveChildProcess() && !(flags & LOOKUP_NOTOUCH)){
            if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
                updateLFU(val);
            } else {
                val->lru = LRU_CLOCK();  // 這裏更新LRU時間戳  
            }
        }
        return val;
    } else {
        return NULL;
    }
}

LFU具體實現

lru這個字段也會被lfu用到,因此你在上面lookupkey中能夠看到在使用lfu策略是也會更新lru。Redis中lfu的出現稍晚一些,是在Redis 4.0才被引入的,因此這裏複用了lru字段。 lru的實現思路只有一種,就是記錄下key被訪問的次數。但實現lru有個問題須要考慮到,雖然LFU是按訪問頻次來淘汰數據,但在Redis中隨時可能有新數據就來,自己老數據可能有更屢次的訪問,新數據當前被訪問次數少,並不意味着將來被訪問的次數會少,若是不考慮到這點,新數據可能一就來就會被淘汰掉,這顯然是不合理的。ui

Redis爲了解決上述問題,將24位被分紅了兩部分,高16位的時間戳(分鐘級),低8位的計數器。每一個新數據計數器初始有必定值,這樣才能保證它能走出新手村,而後計數值會隨着時間推移衰減,這樣能夠保證老的但當前不經常使用的數據纔有機會被淘汰掉,咱們來看下具體實現代碼。this

LFU計數器增加

計數器只有8個二進制位,充其量數到255,怎麼會夠? 固然Redis使用的不是精確計數,而是近似計數。具體實現就是counter機率性增加,counter的值越大增加速度越慢,具體增加邏輯以下:

/* 更新lfu的counter,counter並非一個準確的數值,而是機率增加,counter的數值越大其增加速度越慢
 * 只能反映出某個時間窗口的熱度,沒法反映出具體訪問次數 */
uint8_t LFULogIncr(uint8_t counter) {
    if (counter == 255) return 255;
    double r = (double)rand()/RAND_MAX;
    double baseval = counter - LFU_INIT_VAL; // LFU_INIT_VAL爲5
    if (baseval < 0) baseval = 0;
    double p = 1.0/(baseval*server.lfu_log_factor+1);  // server.lfu_log_factor可配置,默認是10 
    if (r < p) counter++;
    return counter;
}

從代碼邏輯中能夠看出,counter的值越大,增加速度會越慢,因此lfu_log_factor配置較大的狀況下,即使是8位有能夠存儲很大的訪問量。下圖是不一樣lfu_log_factor在不一樣訪問頻次下的增加狀況,圖片來自Redis4.0之基於LFU的熱點key發現機制
在這裏插入圖片描述

LFU計數器衰減

若是說counter一直增加,即使增加速度很慢也有一天會增加到最大值255,最終致使沒法作數據的篩選,因此要給它加一個衰減策略,思路就是counter隨時間增加衰減,具體代碼以下:

/* lfu counter衰減邏輯, lfu_decay_time是指多久counter衰減1,好比lfu_decay_time == 10
 * 表示每10分鐘counter衰減一,但lfu_decay_time爲0時counter不衰減 */
unsigned long LFUDecrAndReturn(robj *o) {
    unsigned long ldt = o->lru >> 8;
    unsigned long counter = o->lru & 255;
    unsigned long num_periods = server.lfu_decay_time ? LFUTimeElapsed(ldt) / server.lfu_decay_time : 0;
    if (num_periods)
        counter = (num_periods > counter) ? 0 : counter - num_periods;
    return counter;
}

server.lfu_decay_time也是可配置的,默認是10 標識每10分鐘counter值減去1。

evict執行過程

evict什麼時候執行

在Redis每次處理命令的時候,都會檢查內存空間,並嘗試去執行evict,由於有些狀況下不須要執行evict,這個能夠從isSafeToPerformEvictions中能夠看出端倪。

static int isSafeToPerformEvictions(void) {
    /* 沒有lua腳本執行超時,也沒有在作數據超時 */
    if (server.lua_timedout || server.loading) return 0;

    /* 只有master才須要作evict */
    if (server.masterhost && server.repl_slave_ignore_maxmemory) return 0;

    /* 當客戶端暫停時,不須要evict,由於數據是不會變化的 */
    if (checkClientPauseTimeoutAndReturnIfPaused()) return 0;

    return 1;
}

evict.c

evict代碼都在evict.c中。裏面包含了每次evict的執行過程。

int performEvictions(void) {
    if (!isSafeToPerformEvictions()) return EVICT_OK;

    int keys_freed = 0;
    size_t mem_reported, mem_tofree;
    long long mem_freed; /* May be negative */
    mstime_t latency, eviction_latency;
    long long delta;
    int slaves = listLength(server.slaves);
    int result = EVICT_FAIL;

    if (getMaxmemoryState(&mem_reported,NULL,&mem_tofree,NULL) == C_OK)
        return EVICT_OK;

    if (server.maxmemory_policy == MAXMEMORY_NO_EVICTION)
        return EVICT_FAIL;  /* We need to free memory, but policy forbids. */

    unsigned long eviction_time_limit_us = evictionTimeLimitUs();

    mem_freed = 0;

    latencyStartMonitor(latency);

    monotime evictionTimer;
    elapsedStart(&evictionTimer);

    while (mem_freed < (long long)mem_tofree) {
        int j, k, i;
        static unsigned int next_db = 0;
        sds bestkey = NULL;
        int bestdbid;
        redisDb *db;
        dict *dict;
        dictEntry *de;

        if (server.maxmemory_policy & (MAXMEMORY_FLAG_LRU|MAXMEMORY_FLAG_LFU) ||
            server.maxmemory_policy == MAXMEMORY_VOLATILE_TTL)
        {
            struct evictionPoolEntry *pool = EvictionPoolLRU;

            while(bestkey == NULL) {
                unsigned long total_keys = 0, keys;

                /* We don't want to make local-db choices when expiring keys,
                 * so to start populate the eviction pool sampling keys from
                 * every DB. 
                 * 先從dict中採樣key並放到pool中 */
                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;
                    }
                }
                if (!total_keys) break; /* No keys to evict. */

                /* 從pool中選擇最適合淘汰的key. */
                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);
                    }

                    /* 從淘汰池中移除. */
                    if (pool[k].key != pool[k].cached)
                        sdsfree(pool[k].key);
                    pool[k].key = NULL;
                    pool[k].idle = 0;

                    /* If the key exists, is our pick. Otherwise it is
                     * a ghost and we need to try the next element. */
                    if (de) {
                        bestkey = dictGetKey(de);
                        break;
                    } else {
                        /* Ghost... Iterate again. */
                    }
                }
            }
        }

        /* volatile-random and allkeys-random 策略 */
        else if (server.maxmemory_policy == MAXMEMORY_ALLKEYS_RANDOM ||
                 server.maxmemory_policy == MAXMEMORY_VOLATILE_RANDOM)
        {
            /* 當隨機淘汰時,咱們用靜態變量next_db來存儲當前執行到哪一個db了*/
            for (i = 0; i < server.dbnum; i++) {
                j = (++next_db) % server.dbnum;
                db = server.db+j;
                dict = (server.maxmemory_policy == MAXMEMORY_ALLKEYS_RANDOM) ?
                        db->dict : db->expires;
                if (dictSize(dict) != 0) {
                    de = dictGetRandomKey(dict);
                    bestkey = dictGetKey(de);
                    bestdbid = j;
                    break;
                }
            }
        }

        /* 從dict中移除被選中的key. */
        if (bestkey) {
            db = server.db+bestdbid;
            robj *keyobj = createStringObject(bestkey,sdslen(bestkey));
            propagateExpire(db,keyobj,server.lazyfree_lazy_eviction);
            /*咱們單獨計算db*Delete()釋放的內存量。實際上,在AOF和副本傳播所需的內存可能大於咱們正在釋放的內存(刪除key)
            ,若是咱們考慮這點的話會很繞。由signalModifiedKey生成的CSC失效消息也是這樣。
            由於AOF和輸出緩衝區內存最終會被釋放,因此咱們只須要關心key空間使用的內存便可。*/
            delta = (long long) zmalloc_used_memory();
            latencyStartMonitor(eviction_latency);
            if (server.lazyfree_lazy_eviction)
                dbAsyncDelete(db,keyobj);
            else
                dbSyncDelete(db,keyobj);
            latencyEndMonitor(eviction_latency);
            latencyAddSampleIfNeeded("eviction-del",eviction_latency);
            delta -= (long long) zmalloc_used_memory();
            mem_freed += delta;
            server.stat_evictedkeys++;
            signalModifiedKey(NULL,db,keyobj);
            notifyKeyspaceEvent(NOTIFY_EVICTED, "evicted",
                keyobj, db->id);
            decrRefCount(keyobj);
            keys_freed++;

            if (keys_freed % 16 == 0) {
                /*當要釋放的內存開始足夠大時,咱們可能會在這裏花費太多時間,不可能足夠快地將數據傳送到副本,所以咱們會在循環中強制傳輸。*/
                if (slaves) flushSlavesOutputBuffers();

                /*一般咱們的中止條件是釋放一個固定的,預先計算的內存量。可是,當咱們*在另外一個線程中刪除對象時,
                最好不時*檢查是否已經達到目標*內存,由於「mem\u freed」量只在dbAsyncDelete()調用中*計算,
                而線程能夠*一直釋放內存。*/
                if (server.lazyfree_lazy_eviction) {
                    if (getMaxmemoryState(NULL,NULL,NULL,NULL) == C_OK) {
                        break;
                    }
                }

                /*一段時間後,儘早退出循環-即便還沒有達到內存限制*。若是咱們忽然須要釋放大量的內存,不要在這裏花太多時間。*/
                if (elapsedUs(evictionTimer) > eviction_time_limit_us) {
                    // We still need to free memory - start eviction timer proc
                    if (!isEvictionProcRunning) {
                        isEvictionProcRunning = 1;
                        aeCreateTimeEvent(server.el, 0,
                                evictionTimeProc, NULL, NULL);
                    }
                    break;
                }
            }
        } else {
            goto cant_free; /* nothing to free... */
        }
    }
    /* at this point, the memory is OK, or we have reached the time limit */
    result = (isEvictionProcRunning) ? EVICT_RUNNING : EVICT_OK;

cant_free:
    if (result == EVICT_FAIL) {
        /* At this point, we have run out of evictable items.  It's possible
         * that some items are being freed in the lazyfree thread.  Perform a
         * short wait here if such jobs exist, but don't wait long.  */
        if (bioPendingJobsOfType(BIO_LAZY_FREE)) {
            usleep(eviction_time_limit_us);
            if (getMaxmemoryState(NULL,NULL,NULL,NULL) == C_OK) {
                result = EVICT_OK;
            }
        }
    }

    latencyEndMonitor(latency);
    latencyAddSampleIfNeeded("eviction-cycle",latency);
    return result;
}

執行的過程能夠簡單分爲三步,首先按不一樣的配置策略填充evictionPoolEntry,pool大小默認是16,而後從這16個key中根據具體策略選出最適合被刪掉的key(bestkey),而後執行bestkey的刪除和一些後續邏輯。

總結

能夠看出,Redis爲了性能,犧牲了LRU和LFU的準確性,只能說是近似LRU和LFU,但在實際使用過程當中也徹底足夠了,畢竟Redis這麼多年也是經歷了無數項目的考驗依舊屹立不倒。Redis的這種設計方案也給咱們軟件設計時提供了一條新的思路,犧牲精確度來換取性能

本文是Redis源碼剖析系列博文,同時也有與之對應的Redis中文註釋版,有想深刻學習Redis的同窗,歡迎star和關注。
Redis中文註解版倉庫:https://github.com/xindoo/Redis
Redis源碼剖析專欄:https://zxs.io/s/1h
若是以爲本文對你有用,歡迎一鍵三連

本文來自https://blog.csdn.net/xindoo

相關文章
相關標籤/搜索