整理:Redis中的lru算法實現

Redis中的lru算法實現

發佈於 2019-02-18

目錄

LRU是什麼

mysql innodb的buffer pool使用了一種改進的lru算法:

Redis中的實現

redisObj結構體(保存lru時間戳)

Redis2.8之前的簡單版

Redis3.0 改進版(pool)

測試淘汰效果

LFU算法

算法驗證 LRU vs LFU

參考鏈接


首發於 https://segmentfault.com/a/11...

LRU是什麼

lru(least recently used)是一種緩存置換算法。即在緩存有限的情況下,如果有新的數據需要加載進緩存,則需要將最不可能被繼續訪問的緩存剔除掉。因爲緩存是否可能被訪問到沒法做預測,所以基於如下假設實現該算法:

如果一個key經常被訪問,那麼該key的idle time應該是最小的。

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

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

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

select * from table_a;

如果table_a中有大量數據並且讀取之後不會繼續使用,則lru頭部會被大量的table_a中的數據佔據。這樣會造成熱點數據被逐出緩存從而導致大量的磁盤io

mysql innodbbuffer pool使用了一種改進的lru算法:

大意是將lru鏈表分成兩部分,一部分爲newlist,一部分爲oldlist,

newlist是頭部熱點數據,oldlist是非熱點數據,oldlist默認佔整個list長度的3/8.

當初次加載一個page的時候,會首先放入oldlist的頭部,再次訪問時纔會移動到newlist.

具體參考如下文章:MySQL官方文檔:https://dev.mysql.com/doc/ref...

而Redis整體上是一個大的dict,如果實現一個雙向鏈表需要在每個key上首先增加兩個指針,需要16個字節,並且額外需要一個list結構體去存儲該雙向鏈表的頭尾節點信息。Redis作者認爲這樣實現不僅內存佔用太大,而且可能導致性能降低。他認爲既然lru本來就是基於假設做出的算法,爲什麼不能模擬實現一個lru呢。

Redis中的實現

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

redisObj結構體(保存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大小吧。
 

Redis2.8之前的簡單版

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

 

Redis3.0 改進版(pool)

在3.0的時候,又改進了一版算法

首先第一次隨機選取的key都會放入一個pool中(pool的大小爲16),pool中的key是按lru大小順序排列的。

接下來每次隨機選取的keylru值必須小於pool中最小的lru纔會繼續放入,直到將pool放滿。

放滿之後,每次如果有新的key需要放入,需要將pool中lru最大的一個key取出。

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

總結一下:這個池,基本上維護了一個局部性的LRU池:

1. 隨機挑選key,作爲局部性LRU池

2. 入池要求是池內最久沒使用的key

3. 池子滿了把最新的key擠出去

4. 淘汰時,從池子裏挑一個最久沒使用的key,刪除

測試淘汰效果

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

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

可以看到,lru本身是基於概率的猜測,這個算法也是基於概率的猜測,也就是作者說的模擬lru.那麼效果如何呢?作者做了個實驗,如下圖所示

圖片描述
首先加入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算法,於是作者開始轉換思路。

 

LFU算法

上文介紹了實現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;//獲取分鐘級別的時間戳
}

lfu本質上是一個概率計數器,稱爲morris counter.隨着訪問次數的增加,counter的增加會越來越緩慢。如下是訪問次數與counter值之間的關係

圖片描述

factor即server.lfu_log_facotr配置值,默認爲10.可以看到,一個key訪問一千萬次以後counter值纔會到達255.factor值越小, counter越靈敏【1、訪問的次數越多,counter越大】不過隨着訪問次數的增加,counter的增加會越來越緩慢

除了計數,給時間也增加了一定的權重:

lfu隨着分鐘數對counter做衰減是基於一個原理:過去被大量訪問的key不一定現在仍然會被訪問

2、同樣維護一個訪問時間戳:每分鐘對counter減一】淘汰時計算與當前時間戳的分鐘差

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

【3、淘汰:一樣是pool隨機挑10個可以,淘汰counter最小的key】

LFU 比 LRU 的預測準確率略微高一些!!!

LRU :5%-5.5%之間的miss概率

LFU:%5左右的miss率

算法驗證 LRU vs LFU

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驗證命中率

...
# 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...

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