【Redis源碼分析】Redis中的LRU算法實現

做者:張仕華html

LRU是什麼

LRU(least recently used)是一種緩存置換算法。即在緩存有限的狀況下,若是有新的數據須要加載進緩存,則須要將最不可能被繼續訪問的緩存剔除掉。由於緩存是否可能被訪問到無法作預測,因此基於以下假設實現該算法:mysql

若是一個key常常被訪問,那麼該key的idle time應該是最小的。redis

(但這個假設也是基於機率,並非充要條件,很明顯,idle time最小的,甚至都不必定會被再次訪問到)算法

這也就是LRU的實現思路。首先實現一個雙向鏈表,每次有一個key被訪問以後,就把被訪問的key放到鏈表的頭部。當緩存不夠時,直接從尾部逐個摘除。sql

在這種假設下的實現方法很明顯會有一個問題,例如mysql中執行以下一條語句:緩存

select * from table_a;

若是table_a中有大量數據而且讀取以後不會繼續使用,則LRU頭部會被大量的table_a中的數據佔據。這樣會形成熱點數據被逐出緩存從而致使大量的磁盤io函數

mysql innodb的buffer pool使用了一種改進的LRU算法,大意是將LRU鏈表分紅兩部分,一部分爲newlist,一部分爲oldlist,newlist是頭部熱點數據,oldlist是非熱點數據,oldlist默認佔整個list長度的3/8.當初次加載一個page的時候,會首先放入oldlist的頭部,再次訪問時纔會移動到newlist.具體參考以下文章:工具

https://dev.mysql.com/doc/ref...性能

而Redis總體上是一個大的dict,若是實現一個雙向鏈表須要在每一個key上首先增長兩個指針,須要16個字節,而且額外須要一個list結構體去存儲該雙向鏈表的頭尾節點信息。Redis做者認爲這樣實現不只內存佔用太大,並且可能致使性能下降。他認爲既然LRU原本就是基於假設作出的算法,爲何不能模擬實現一個LRU呢。測試

Redis中的實現

首先Redis並無使用雙向鏈表實現一個LRU算法。具體實現方法接下來逐步介紹

首先看一下robj結構體(Redis總體上是一個大的dict,key是一個string,而value都會保存爲一個robj)

typedef struct redisObject {
    ...
    unsigned lru:LRU_BITS; //LRU_BITS爲24bit
    ...
} robj;

咱們看到每一個robj中都有一個24bit長度的lru字段,lru字段裏邊保存的是一個時間戳。看下邊的代碼

robj *lookupKey(redisDb *db, robj *key, int flags) {
    ...
            if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) { //若是配置的是lfu方式,則更新lfu
                updateLFU(val);
            } else {
                val->lru = LRU_CLOCK();//不然按lru方式更新
            }
    ...
}

在Redis的dict中每次按key獲取一個值的時候,都會調用lookupKey函數,若是配置使用了LRU模式,該函數會更新value中的lru字段爲當前秒級別的時間戳(lfu方式後文再描述)。

那麼,雖然記錄了每一個value的時間戳,可是淘汰時總不能挨個遍歷dict中的全部槽,逐個比較lru大小吧。

Redis初始的實現算法很簡單,隨機從dict中取出五個key,淘汰一個lru字段值最小的。(隨機選取的key是個可配置的參數maxmemory-samples,默認值爲5).

在3.0的時候,又改進了一版算法,首先第一次隨機選取的key都會放入一個pool中(pool的大小爲16),pool中的key是按lru大小順序排列的。接下來每次隨機選取的keylru值必須小於pool中最小的lru纔會繼續放入,直到將pool放滿。放滿以後,每次若是有新的key須要放入,須要將pool中lru最大的一個key取出。

淘汰的時候,直接從pool中選取一個lru最小的值而後將其淘汰。

咱們知道Redis執行命令時首先會調用processCommand函數,在processCommand中會進行key的淘汰,代碼以下:

int processCommmand(){
    ...
    if (server.maxmemory && !server.lua_timedout) {
        int out_of_memory = freeMemoryIfNeeded() == C_ERR;//若是開啓了maxmemory的限制,則會調用freeMemoryIfNeeded()函數,該函數中進行緩存的淘汰
    ...
    }
}

能夠看到,lru自己是基於機率的猜想,這個算法也是基於機率的猜想,也就是做者說的模擬lru.那麼效果如何呢?做者作了個實驗,以下圖所示

clipboard.png

首先加入n個key並順序訪問這n個key,以後加入n/2個key(假設redis中只能保存n個key,因而會有n/2個key被逐出).上圖中淺灰色爲被逐出的key,淡藍色是新增長的key,灰色的爲最近被訪問的key(即不會被lru逐出的key)

左上圖爲理想中的LRU算法,新增長的key和最近被訪問的key都不該該被逐出。

能夠看到,Redis2.8當每次隨機採樣5個key時,新增長的key和最近訪問的key都有必定機率被逐出

Redis3.0增長了pool後效果好一些(右下角的圖)。當Redis3.0增長了pool而且將採樣key增長到10個後,基本等同於理想中的LRU(雖然仍是有一點差距)

若是繼續增長採樣的key或者pool的大小,做者發現很能進一步優化LRU算法,因而做者開始轉換思路。

上文介紹了實現LRU的一種思路,即若是一個key常常被訪問,那麼該key的idle time應該是最小的。

那麼能不能換一種思路呢。若是可以記錄一個key被訪問的次數,那麼常常被訪問的key最有可能再次被訪問到。這也就是lfu(least frequently used),訪問次數最少的最應該被逐出。

lfu的代碼以下:

void updateLFU(robj *val) {
    unsigned long counter = LFUDecrAndReturn(val);//首先計算是否須要將counter衰減
    counter = LFULogIncr(counter);//根據上述返回的counter計算新的counter
    val->lru = (LFUGetTimeInMinutes()<<8) | counter; //robj中的lru字段只有24bits,lfu複用該字段。高16位存儲一個分鐘數級別的時間戳,低8位存儲訪問計數
}
 
unsigned long LFUDecrAndReturn(robj *o) {
    unsigned long ldt = o->lru >> 8;//原來保存的時間戳
    unsigned long counter = o->lru & 255; //原來保存的counter
    unsigned long num_periods = server.lfu_decay_time ? LFUTimeElapsed(ldt) / server.lfu_decay_time : 0;
    //server.lfu_decay_time默認爲1,每通過一分鐘counter衰減1
    if (num_periods)
        counter = (num_periods > counter) ? 0 : counter - num_periods;//若是須要衰減,則計算衰減後的值
    return counter;
}
 
uint8_t LFULogIncr(uint8_t counter) {
    if (counter == 255) return 255;//counter最大隻能存儲到255,到達後再也不增長
    double r = (double)rand()/RAND_MAX;//算一個隨機的小數值
    double baseval = counter - LFU_INIT_VAL;//新加入的key初始counter設置爲LFU_INIT_VAL,爲5.不設置爲0的緣由是防止直接被逐出
    if (baseval < 0) baseval = 0;
    double p = 1.0/(baseval*server.lfu_log_factor+1);//server.lfu_log_facotr默認爲10
    if (r < p) counter++;//能夠看到,counter越大,則p越小,隨機值r小於p的機率就越小。換言之,counter增長起來會愈來愈緩慢
    return counter;
}
 
unsigned long LFUGetTimeInMinutes(void) {
    return (server.unixtime/60) & 65535;//獲取分鐘級別的時間戳
}

LRU本質上是一個機率計數器,稱爲morris counter.隨着訪問次數的增長,counter的增長會愈來愈緩慢。以下是訪問次數與counter值之間的關係

clipboard.png

factor即server.lfu_log_facotr配置值,默認爲10.能夠看到,一個key訪問一千萬次之後counter值纔會到達255.factor值越小, counter越靈敏

lfu隨着分鐘數對counter作衰減是基於一個原理:過去被大量訪問的key不必定如今仍然會被訪問。至關於除了計數,給時間也增長了必定的權重。

淘汰時就很簡單了,仍然是一個pool,隨機選取10個key,counter最小的被淘汰

算法驗證

redis-cli提供了一個參數,能夠驗證LRU算法的效率。主要是經過驗證hits/miss的比率,來判斷淘汰算法是否有效。命中比率高說明確實淘汰了不會被常常訪問的key.具體作法以下:

配置redis LRU算法爲 allkeys-lru

test ~/redis-5.0.0$./src/redis-cli -p 6380
127.0.0.1:6380> config set maxmemory 50m //設置redis最大使用50M內存
OK
127.0.0.1:6380> config get  maxmemory-policy
1) "maxmemory-policy"
2) "noeviction"
127.0.0.1:6380> config set maxmemory-policy allkeys-lru//設置lru算法爲allkeys-lru
OK

執行redis-cli --lru-test驗證命中率

./src/redis-cli -p 6380 --lru-test 1000000//模擬100萬個key

經過info查看使用的內存和被逐出的keys

...
# Memory
used_memory:50001216
...
evicted_keys:115092
...

查看lru-test的輸出

131250 Gets/sec | Hits: 124113 (94.56%) | Misses: 7137 (5.44%)
132250 Gets/sec | Hits: 125091 (94.59%) | Misses: 7159 (5.41%)
131250 Gets/sec | Hits: 124027 (94.50%) | Misses: 7223 (5.50%)
133000 Gets/sec | Hits: 125855 (94.63%) | Misses: 7145 (5.37%)
136250 Gets/sec | Hits: 128882 (94.59%) | Misses: 7368 (5.41%)
139750 Gets/sec | Hits: 132231 (94.62%) | Misses: 7519 (5.38%)
136000 Gets/sec | Hits: 128702 (94.63%) | Misses: 7298 (5.37%)
134500 Gets/sec | Hits: 127374 (94.70%) | Misses: 7126 (5.30%)
134750 Gets/sec | Hits: 127427 (94.57%) | Misses: 7323 (5.43%)
134250 Gets/sec | Hits: 127004 (94.60%) | Misses: 7246 (5.40%)
138500 Gets/sec | Hits: 131019 (94.60%) | Misses: 7481 (5.40%)
130000 Gets/sec | Hits: 122918 (94.55%) | Misses: 7082 (5.45%)
126500 Gets/sec | Hits: 119646 (94.58%) | Misses: 6854 (5.42%)
132750 Gets/sec | Hits: 125672 (94.67%) | Misses: 7078 (5.33%)
136000 Gets/sec | Hits: 128563 (94.53%) | Misses: 7437 (5.47%)
132500 Gets/sec | Hits: 125450 (94.68%) | Misses: 7050 (5.32%)
132250 Gets/sec | Hits: 125234 (94.69%) | Misses: 7016 (5.31%)
133000 Gets/sec | Hits: 125761 (94.56%) | Misses: 7239 (5.44%)
134750 Gets/sec | Hits: 127431 (94.57%) | Misses: 7319 (5.43%)
130750 Gets/sec | Hits: 123707 (94.61%) | Misses: 7043 (5.39%)
133500 Gets/sec | Hits: 126195 (94.53%) | Misses: 7305 (5.47%)

大概有5%-5.5%之間的miss機率。咱們將LRU策略切換爲allkeys-lfu,再次實驗

結果以下:

131250 Gets/sec | Hits: 124480 (94.84%) | Misses: 6770 (5.16%)
134750 Gets/sec | Hits: 127926 (94.94%) | Misses: 6824 (5.06%)
130000 Gets/sec | Hits: 123458 (94.97%) | Misses: 6542 (5.03%)
127750 Gets/sec | Hits: 121231 (94.90%) | Misses: 6519 (5.10%)
130500 Gets/sec | Hits: 123958 (94.99%) | Misses: 6542 (5.01%)
130500 Gets/sec | Hits: 123935 (94.97%) | Misses: 6565 (5.03%)
131250 Gets/sec | Hits: 124622 (94.95%) | Misses: 6628 (5.05%)
131250 Gets/sec | Hits: 124618 (94.95%) | Misses: 6632 (5.05%)
128000 Gets/sec | Hits: 121315 (94.78%) | Misses: 6685 (5.22%)
129000 Gets/sec | Hits: 122585 (95.03%) | Misses: 6415 (4.97%)
132000 Gets/sec | Hits: 125277 (94.91%) | Misses: 6723 (5.09%)
134000 Gets/sec | Hits: 127329 (95.02%) | Misses: 6671 (4.98%)
131750 Gets/sec | Hits: 125258 (95.07%) | Misses: 6492 (4.93%)
136000 Gets/sec | Hits: 129207 (95.01%) | Misses: 6793 (4.99%)
135500 Gets/sec | Hits: 128659 (94.95%) | Misses: 6841 (5.05%)
133750 Gets/sec | Hits: 126995 (94.95%) | Misses: 6755 (5.05%)
131250 Gets/sec | Hits: 124680 (94.99%) | Misses: 6570 (5.01%)
129750 Gets/sec | Hits: 123408 (95.11%) | Misses: 6342 (4.89%)
130500 Gets/sec | Hits: 124043 (95.05%) | Misses: 6457 (4.95%)

%5左右的miss率,在這個測試下,LFU比LRU的預測準確率略微高一些。

在實際生產環境中,不一樣的redis訪問模式須要配置不一樣的LRU策略, 而後能夠經過lru test工具驗證效果。

參考連接

1.http://antirez.com/news/109

2.https://redis.io/topics/lru-c...

相關文章
相關標籤/搜索