Redis作為LRUCache的實現

夏周tony發表於2016-11-09

公有云Redis服務:https://www.aliyun.com/product/kvstore?spm=5176.8142029.388261.37.59zzzj

背景

Redis作為目前最流行的KV記憶體資料庫,也實現了自己的LRULatest Recently Used)演算法,在記憶體寫滿的時候,依據其進行資料的淘汰。LRU演算法本身的含義,這裡不做贅述,嚴格的LRU演算法,會優先選擇淘汰最久沒有訪問的資料,這種實現也比較簡單,通常是用一個雙向連結串列+一個雜湊表來實現O(1)的淘汰和更新操作。但是,Redis為了節省記憶體使用,和通常的LRU演算法實現不太一樣,Redis使用了取樣的方法來模擬一個近似LRU演算法。

下面先給一個圖來直觀的感受一下Redis的近似LRU演算法和嚴格LRU演算法的差異,

lru_comparison.png

圖中深灰色和淺灰色的點表示的key數量正好可以寫滿記憶體,綠色的點表示剛寫入的key,淺灰色的點表示被淘汰的key,深灰色的點表示剩餘的沒有被淘汰的key。

在嚴格LRU演算法下,圖的左上部分,最先寫入的一半的key,被順序淘汰掉了,但是在Redis的近似LRU演算法下,圖的左下部分,可能出現很早之前寫入的key,並沒有被淘汰掉,寫入時間更晚的key反而被淘汰了,但是也沒有出現比較極端的剛剛寫入不久的key就被淘汰的情況。

根據Redis作者的說法,如果訪問Redis的模式呈現冪律分佈,即通常說的二八分佈,Redis 2.8的近似LRU演算法和嚴格LRU演算法差異不大,下面我們就來看看這個近似LRU演算法是怎麼實現的。

圖的右半部分是Redis 3.0對於近似LRU演算法的優化,後面我們會寫文章再介紹,同時我們的Redis雲服務核心後續也會merge該優化。

Redis LRU演算法實現

Redis 2.8.19中使用了一個全域性的LRU時鐘,server.lruclock,定義如下,

#define REDIS_LRU_BITS 24
unsigned lruclock:REDIS_LRU_BITS; /* Clock for LRU eviction */

預設的LRU時鐘的解析度是1秒,可以通過改變REDIS_LRU_CLOCK_RESOLUTION巨集的值來改變,Redis會在serverCron()中呼叫updateLRUClock定期的更新LRU時鐘,更新的頻率和hz引數有關,預設為100ms一次,如下,

#define REDIS_LRU_CLOCK_MAX ((1<<REDIS_LRU_BITS)-1) /* Max value of obj->lru */
#define REDIS_LRU_CLOCK_RESOLUTION 1 /* LRU clock resolution in seconds */

void updateLRUClock(void) {
    server.lruclock = (server.unixtime/REDIS_LRU_CLOCK_RESOLUTION) &
                                                REDIS_LRU_CLOCK_MAX;
}

server.unixtime是系統當前的unix時間戳,當lruclock的值超出REDIS_LRU_CLOCK_MAX時,會從頭開始計算,所以在計算一個key的最長沒有訪問時間時,可能key本身儲存的lru訪問時間會比當前的lrulock還要大,這個時候需要計算額外時間,如下,

/* Given an object returns the min number of seconds the object was never
 * requested, using an approximated LRU algorithm. */
unsigned long estimateObjectIdleTime(robj *o) {
    if (server.lruclock >= o->lru) {
        return (server.lruclock - o->lru) * REDIS_LRU_CLOCK_RESOLUTION;
    } else {
        return ((REDIS_LRU_CLOCK_MAX - o->lru) + server.lruclock) *
                    REDIS_LRU_CLOCK_RESOLUTION;
    }
}

這樣計算會不會有問題呢?還是有的,即某個key就是很久很久沒有訪問,lruclock從頭開始後,又超過了該key儲存的lru訪問時間,這個時間是多久呢,在現有的lru時鐘1秒解析度下,24bit可以表示的最長時間大約是194天,所以一個key如果連續194天沒有訪問了,Redis計算該key的idle時間是有誤的,但是這種情況應該非常罕見。

Redis支援的淘汰策略比較多,這裡只涉及和LRU相關的,

  • volatile-lru 設定了過期時間的key參與近似的lru淘汰策略
  • allkeys-lru 所有的key均參與近似的lru淘汰策略

當進行LRU淘汰時,Redis按如下方式進行的,

            ......
            /* volatile-lru and allkeys-lru policy */
            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 = dictGetKey(de);
                    /* When policy is volatile-lru we need an additional 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);
                    o = dictGetVal(de);
                    thisval = estimateObjectIdleTime(o);

                    /* Higher idle time is better candidate for deletion */
                    if (bestkey == NULL || thisval > bestval) {
                        bestkey = thiskey;
                        bestval = thisval;
                    }
                }
            }
            ......

Redis會基於server.maxmemory_samples配置選取固定數目的key,然後比較它們的lru訪問時間,然後淘汰最近最久沒有訪問的key,maxmemory_samples的值越大,Redis的近似LRU演算法就越接近於嚴格LRU演算法,但是相應消耗也變高,對效能有一定影響,樣本值預設為5。

每個key的lru訪問時間更新比較簡單,但是有一點值得注意,為了避免fork子程式後額外的記憶體消耗,當進行bgsaveaof rewrite時,lru訪問時間是不更新的。

robj *lookupKey(redisDb *db, robj *key) {
    dictEntry *de = dictFind(db->dict,key->ptr);
    if (de) {
        robj *val = dictGetVal(de);

        /* Update the access time for the ageing algorithm.
         * Don`t do it if we have a saving child, as this will trigger
         * a copy on write madness. */
        if (server.rdb_child_pid == -1 && server.aof_child_pid == -1)
            val->lru = server.lruclock;
        return val;
    } else {
        return NULL;
    }
}

總結

如果採用雙向連結串列+hash表的方式來實現嚴格的LRU演算法,初步估計每個key要增加額外32個位元組左右的記憶體消耗,當key數量比較多時,還是會帶來相當可觀的記憶體消耗,Redis使用近似的LRU演算法,每個key只需額外24bit的記憶體空間,節省還是相當的大的。後面我們會介紹redis 3.x中對近似LRU演算法的優化,使用盡量少的記憶體,使Redis的LRU演算法更接近於嚴格LRU,敬請期待。


相關文章