Redis底層詳解(八) LRU 算法

一、LRU 算法概述

         1、LRU 概述

         LRU 是 Least Recently Used 的縮寫,即最近最少使用,是內存管理的一種頁面置換算法。算法的核心是:如果一個數據在最近一段時間內沒有被訪問到,那麼它在將來被訪問的可能性也很小。換言之,當內存達到極限時,應該把內存中最久沒有被訪問的數據淘汰掉。
         那麼,如何表示這個最久呢?Redis 在實現上引入了一個 LRU 時鐘來代替 unix 時間戳,每個對象的每次被訪問都會記錄下當前服務器的 LRU 時鐘,然後用服務器的 LRU 時鐘減去對象本身的時鐘,得到的就是這個對象沒有被訪問的時間間隔(也稱空閒時間),空閒時間最大的就是需要淘汰的對象。

         2、LRU 時鐘

#define LRU_BITS 24
#define LRU_CLOCK_MAX ((1<<LRU_BITS)-1)
#define LRU_CLOCK_RESOLUTION 1000

unsigned int getLRUClock(void) {
    return (mstime()/LRU_CLOCK_RESOLUTION) & LRU_CLOCK_MAX;
}

        以上這段代碼的含義是通過當前的 unix 時間戳獲取 LRU 時鐘。unix 時間戳通過接口 mstime 獲取,得到的是從 1970年1月1日早上8點到當前時刻的時間間隔,以毫秒爲單位(mstime底層實現用的是 c 的系統函數 gettimeofday)。
        其中,LRU_BITS 表示 LRU 時鐘的位數;LRU_CLOCK_MAX 爲 LRU 時鐘的最大值;LRU_CLOCK_RESOLUTION 則表示每個 LRU 基本單位對應到自然時鐘的毫秒數,即精度,按照這個宏定義,LRU 時鐘的最小刻度爲 1000 毫秒。

        如圖所示,將自然時鐘和 LRU 時鐘作對比:
        a) 自然時鐘最大值爲 11:59:59,LRU 時鐘最大值爲 LRU_CLOCK_MAX = 2^24 - 1;
        b) 自然時鐘的最小刻度爲 1秒, LRU 時鐘的最小刻度爲 1000 毫秒;
        c) 自然時鐘的一個輪迴是 12小時,LRU 時鐘的一個輪迴是 2^24 * 1000 毫秒(一輪的計算方式是:( 時鐘最大值 + 1 ) * 最小刻度);
        因爲 LRU_CLOCK_MAX 是 2 的冪減 1,即它的二進制表示全是 1,所以這裏的 & 其實是取模的意思。那麼 getLRUClock 函數的含義就是定位到 LRU 時鐘的某個刻度。

二、Redis 中的 LRU 時鐘

         1、Redis 對象
          Redis 中的所有對象定義爲 redisObject 結構體,也正是這些對象採用了 LRU 算法進行內存回收,所以每個對象需要一個成員來用來記錄該對象的最近一次被訪問的時間(即 lru 成員),由於時鐘的最大值只需要 24 個比特位就能表示,所以結構體定義時採用了位域。定義如下:

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

        2、Redis 定時器 
        Redis 中有一個全局的定時器函數 serverCron,用於刷新服務器的 LRU 時鐘,函數大致實現如下:

int serverCron(...) {
    ...
    server.lruclock = getLRUClock();
    ...
}

        其中,server.lruclock 代表服務器的 LRU 時鐘,這個時鐘的刷新頻率由 server.hz 決定,即每秒鐘會調用 server.hz (默認值爲 10)次 serverCron 函數。那麼,服務器每 1 / server.hz 秒就會調用一次定時器函數 serverCron。

        3、Redis 對象的 LRU 時鐘
        每個 Redis 對象的 LRU 時鐘的計算方式由宏 LRU_CLOCK 給出,實現如下:

#define LRU_CLOCK() ((1000/server.hz <= LRU_CLOCK_RESOLUTION) ? server.lruclock : getLRUClock())

        正如上文所提到的,1 / server.hz 代表了 serverCron 這個定時器函數兩次調用之間的最小時間間隔(以秒爲單位),那麼 1000 / server.hz 就是以毫秒爲單位了。如果這個最小時間間隔小於等於 LRU 時鐘的精度,那麼不需要重新計算 LRU時鐘,直接用服務器 LRU時鐘做近似值即可,因爲時間間隔越小,server.lruclock 刷新的越頻繁;相反,當時間間隔很大的時候,server.lruclock 的刷新可能不及時,所以需要用 getLRUClock 重新計算準確的 LRU 時鐘。
        如圖所示,以  server.hz = 10 爲例,sc 代表每次 serverCron 調用的時間結點,兩次調用間隔 100ms,每次調用就會利用 getLRUClock 函數計算一次 LRU 時鐘。由於 LRU時鐘的最小刻度爲 1000ms,所以圖中 LRU_x 和 LRU_y 之間是沒有其它刻度的,那麼所有落在 LRU_x 和 LRU_y 之間計算出來的 LRU時鐘 的值都爲 LRU_x,於是爲了避免重複計算,減少調用系統函數 gettimeofday 的時間,可以用最近一次計算得到的 LRU 時鐘作爲近似值,即 server.lruclock。

        Redis 對象更新 LRU 時鐘的地方有兩個:a) 對象創建時;b) 對象被使用時。
        a) createObject 函數用於創建一個 Redis 對象,代碼實現在 object.c 中:

robj *createObject(int type, void *ptr) {
    robj *o = zmalloc(sizeof(*o));
    o->type = type;
    o->encoding = OBJ_ENCODING_RAW;
    o->ptr = ptr;
    o->refcount = 1;
    o->lru = LRU_CLOCK();
    return o;
}

         這裏調用 LRU_CLOCK() 對 Redis 的對象成員 lru 進行 LRU 時鐘的設置,其中 robj 是 redisObject 的別名,見上文的定義。
        b) lookupKey 不會直接被 redis 命令調用,往往是通過lookupKeyRead()、lookupKeyWrite() 、lookupKeyReadWithFlags() 間接調用的,這個函數的作用是通過傳入的 key 查找對應的 redis 對象,並且會在條件滿足時設置上 LRU 時鐘。爲了便於闡述,這裏簡化了代碼,源碼實現在 db.c 中:

robj *lookupKey(redisDb *db, robj *key, int flags) {
    dictEntry *de = dictFind(db->dict,key->ptr);
    if (de) {
        robj *val = dictGetVal(de);
        ...
        val->lru = LRU_CLOCK();
        ...
        return val;
    } else {
        return NULL;
    }
}

三、Redis 中的 LRU 內存回收 

       1、內存回收策略
       當內存達到極限,就要開始利用回收策略對內存進行回收釋放。回收的配置在 redis.conf 中填寫,如下:

maxmemory 1073741824
maxmemory-policy noeviction
maxmemory-samples 5

        這三個配置項決定了 Redis 內存回收時的機制,maxmemory 指定了內存使用的極限,以字節爲單位。當內存達到極限時,他會嘗試去刪除一些鍵值。刪除的策略由 maxmemory-policy 配置來指定。如果根據指定的策略無法刪除鍵或者策略本身就是 'noeviction',那麼,Redis 會根據命令的類型做出不同的迴應:會給需要更多內存的命令返回一個錯誤,例如 SET、LPUSH 等等;而像 GET 這樣的只讀命令則可以繼續正常運行。
        maxmemory :當你的 Redis 是主 Redis 時 (Redis 採用主從模式時),需要預留一部分系統內存給同步隊列緩存。當然,如果設置的刪除策略 'noeviction',則不需要考慮這個問題。
        maxmemory-policy : 當內存達到 maxmemory 時,採用的回收策略,總共有如下六種:
             a) volatile-lru : 針對設置了過期時間的鍵採用 LRU 算法進行回收;
             b) allkeys-lru : 對所有鍵採用 LRU 算法進行回收;

             c) volatile-random : 針對設置了過期時間的鍵採用隨機回收;
             d) allkeys-random : 對所有鍵隨機回收;
             e) volatile-ttl: 過期時間最近 (TTL 最小) 的鍵進行回收;
             f) noeviction :不進行任何回收,對寫操作返回錯誤;

#define MAXMEMORY_VOLATILE_LRU 0
#define MAXMEMORY_VOLATILE_TTL 1
#define MAXMEMORY_VOLATILE_RANDOM 2
#define MAXMEMORY_ALLKEYS_LRU 3
#define MAXMEMORY_ALLKEYS_RANDOM 4
#define MAXMEMORY_NO_EVICTION 5

        maxmemory-samples :指定了在進行刪除時的鍵的採樣數量。LRU 和 TTL 都是近似算法,所以可以根據參數來進行取捨,到底是要速度還是精確度。默認值一般填 5。10 的話已經非常近似正式的 LRU 算法了,但是會多一些 CPU 消耗;3 的話執行更快,然而不夠精確。

       2、空閒時間
       LRU 算法的執行依據是將空閒時間最大的淘汰掉,每個對象知道自己上次使用的時間,那麼就可以計算出自己空閒了多久,可以通過 estimateObjectIdleTime 接口得出 idletime,實現在 object.c 中:

unsigned long long estimateObjectIdleTime(robj *o) {
    unsigned long long lruclock = LRU_CLOCK();
    if (lruclock >= o->lru) {
        return (lruclock - o->lru) * LRU_CLOCK_RESOLUTION;
    } else {
        return (lruclock + (LRU_CLOCK_MAX - o->lru)) * LRU_CLOCK_RESOLUTION;
    }
}

       由於時鐘是循環的,所以需要考慮服務器當前時鐘和對象本身時鐘的相對大小,從而計算出對象的空閒時間。然後通過對這個空閒時間的排序,就能篩選出空閒時間最長的進行回收了。

       3、LRU 回收流程
       Redis 的數據庫是一個巨大的字典,最上層是由鍵值對組成的。當內存使用超過最大使用數時,就需要採用回收策略進行內存回收。如果回收策略採用 LRU,那麼就會在這個大字典裏面隨機採樣,挑選出空閒時間最大的鍵進行刪除。而回收池會存在於整個服務器的生命週期中,所以它是一個全局變量。
       1) 這個刪除操作發生在每一次處理客戶端命令時。當 server.maxmemory 的值非 0,則檢測是否有需要回收的內存。如果有則執行 2) ;
       2) 隨機從大字典中取出 server.maxmemory_samples 個鍵(實際取到的數量取決於大字典原本的大小),然後用一個長度爲 16 (由宏 MAXMEMORY_EVICTION_POOL_SIZE 指定) 的 evictionPool (回收池)對這幾個鍵進行篩選,篩選出 idletime (空閒時間)最長的鍵,並且按照 idletime 從小到大的順序排列在 evictionPool  中;
       3) 從 evictionPool 池中取出 idletime 最大且在字典中存在的鍵作爲 bestkey 執行刪除,並且從 evictionPool 池中移除;

       以上 evictionPool 扮演的是大頂堆的角色,並且在 Redis 服務器啓動後一直存在。最後,看下 LRU 回收算法的實際執行流程:

#define MAXMEMORY_EVICTION_POOL_SIZE 16
struct evictionPoolEntry {                                                /* a */
    unsigned long long idle;
    sds key;
};
int processCommand(client *c) {
    ...
    if (server.maxmemory) freeMemoryIfNeeded();                           /* b */
    ...
}
int freeMemoryIfNeeded(void) {
    ...
    if (server.maxmemory_policy == MAXMEMORY_ALLKEYS_LRU ||
        server.maxmemory_policy == MAXMEMORY_VOLATILE_LRU) {
        struct evictionPoolEntry *pool = db->eviction_pool;              /* c */
        while(bestkey == NULL) {
            evictionPoolPopulate(dict, db->dict, db->eviction_pool);     /* d */
            for (k = MAXMEMORY_EVICTION_POOL_SIZE-1; k >= 0; k--) {
                if (pool[k].key == NULL) continue;
                de = dictFind(dict,pool[k].key);
                sdsfree(pool[k].key);
                memmove(pool+k,pool+k+1,
                  sizeof(pool[0])*(MAXMEMORY_EVICTION_POOL_SIZE-k-1));
                pool[MAXMEMORY_EVICTION_POOL_SIZE-1].key = NULL;
                pool[MAXMEMORY_EVICTION_POOL_SIZE-1].idle = 0;
                if (de) {
                    bestkey = dictGetKey(de);                            /* e */
                    break;
                } else {
                    continue;
                }
            }
        }
    }
    ...
}

       a) evictionPoolEntry 是回收池中的元素結構體,由一個空閒時間 idle 和 鍵名key 組成;
       b) freeMemoryIfNeeded(...) 接口用於收集 evictionPool 元素並且找出空閒時間最大的鍵並進行釋放;
       c) eviction_pool 是數據庫對象 db 的成員,代表回收池,是 a) 中提到的 evictionPoolEntry 類型的數組,數組長度由宏  MAXMEMORY_EVICTION_POOL_SIZE 指定,默認值爲 16;
       d) evictionPoolPopulate(...) 接口用於隨機採樣數據庫中的鍵,並且逐一和回收池中的鍵的空閒時間進行比較,篩選出空閒時間最大的鍵留在回收池中,這個接口的實現下文會具體講述;
       e) 找出空閒時間最大且存在的鍵,等待執行刪除操作;

       4、回收池更新  
       evictionPoolPopulate 的實現在 server.c, 主要是利用採樣出來的鍵對回收池進行更新篩選,源碼如下:

#define EVICTION_SAMPLES_ARRAY_SIZE 16
void evictionPoolPopulate(dict *sampledict, dict *keydict, struct evictionPoolEntry *pool) {
    int j, k, count;
    dictEntry *_samples[EVICTION_SAMPLES_ARRAY_SIZE];
    dictEntry **samples;

    if (server.maxmemory_samples <= EVICTION_SAMPLES_ARRAY_SIZE) {
        samples = _samples;
    } else {
        samples = zmalloc(sizeof(samples[0])*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);

        if (sampledict != keydict) de = dictFind(keydict, key);
        o = dictGetVal(de);
        idle = estimateObjectIdleTime(o);

        k = 0;
        while (k < MAXMEMORY_EVICTION_POOL_SIZE &&
               pool[k].key &&
               pool[k].idle < idle) k++;
        if (k == 0 && pool[MAXMEMORY_EVICTION_POOL_SIZE-1].key != NULL) {
            continue;                                                           /* a */
        } else if (k < MAXMEMORY_EVICTION_POOL_SIZE && pool[k].key == NULL) {   /* b */
        } else {
            if (pool[MAXMEMORY_EVICTION_POOL_SIZE-1].key == NULL) {             /* c */
                memmove(pool+k+1,pool+k,
                    sizeof(pool[0])*(MAXMEMORY_EVICTION_POOL_SIZE-k-1));
            } else {
                k--;                                                            /* d */
                sdsfree(pool[0].key);
                memmove(pool,pool+1,sizeof(pool[0])*k);
            }
        }
        pool[k].key = sdsdup(key);
        pool[k].idle = idle;
    }
    if (samples != _samples) zfree(samples);
}

       這是 LRU 算法的核心,首先從目標字典中隨機採樣出 server.maxmemory_samples 個鍵,緩存在 samples 數組中,然後一個一個取出來,並且和回收池中的已有的鍵對比空閒時間,從而更新回收池。更新的過程首先,利用遍歷找到每個鍵的實際插入位置 k ,然後,總共涉及四種情況如下:
       a) 回收池已滿,且當前插入的元素的空閒時間最小,則不作任何操作;
       b) 回收池未滿,且將要插入的位置 k 原本沒有鍵,則可直接執行插入操作;
       c) 回收池未滿,且將要插入的位置 k 原本已經有鍵,則將當前第 k 個以後的元素往後挪一個位置,然後執行插入操作;
       d) 回收池已滿,則將當前第 k 個以前的元素往前挪一個位置,然後執行插入操作;
       下圖中的四個子圖分別代表上文提到的四種情況,其中紅色箭頭代表 k 的位置,紅色方塊代表插入的元素:

四、參考資料

https://www.cnblogs.com/nazhizq/p/8494651.html

https://blog.csdn.net/zdy0_2004/article/details/44685615

https://blog.csdn.net/qq_29347295/article/details/79060604

 

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