Redis之LRU與LFU


LRU中,系統會根據使用的時間進行排序,內存緊張時會將最久沒有用過的一批數據排除出去。LFU是按照最近的訪問頻率進行排序,它比LRU更加精准地表示了一個key被訪問得熱度。LFU是作者在Redis4.0里引入的一個新的淘汰策略。

在這里我們回顧以下Redis內存不足時的淘汰策略:
noeviction:當內存使用超過配置的時候會返回錯誤,不會驅逐任何鍵
allkeys-lru:加入鍵的時候,如果過限,首先通過LRU算法驅逐最久沒有使用的鍵
volatile-lru:加入鍵的時候如果過限,首先從設置了過期時間的鍵集合中驅逐最久沒有使用的鍵
allkeys-random:加入鍵的時候如果過限,從所有key隨機刪除
volatile-random:加入鍵的時候如果過限,從過期鍵的集合中隨機驅逐
volatile-ttl:從配置了過期時間的鍵中驅逐馬上就要過期的鍵
volatile-lfu:從所有配置了過期時間的鍵中驅逐使用頻率最少的鍵
allkeys-lfu:從所有鍵中驅逐使用頻率最少的鍵

【Redis中LRU的亮點】
我們先看看普通的LRU的思路,比如Java中的實現方式是用HashMap結合雙向鏈表,增、改、訪問(命中)時都將節點移動到隊尾,然后刪除時刪除隊頭的節點。
但Redis的LRU不是一個嚴格的LRU,它在需要淘汰時,只選取有限的key進行對比,排除掉訪問時間最久的元素。
這意味着它不能選擇整個候選元素的最優解,只是局部最優,從redis3.0開始,算法改進為維護一個回收候選key池,這改善了算法的性能,結果更接近於理論LRU算法的結果。
這么做的目的,無非就是降低計算規模,通過概率的手段來近似達到理論的LRU效果,這對於快速響應客戶端的指令以及整體的效率都是有很大益處的。
可以看看理論LRU和這種近似LRU的效果對比:

【LFU的思路】
LRU有一個缺陷。
舉一個極端的場景,比如在一個緩存池里維護一個定期更新的電視節目源,每個源就是一個json數據,如果按照LRU的算法根據json命中(數據更新、查看、插入等)來決定剔除哪些數據,那么在用戶訪問了一段時間之后,排在LRU隊尾的一定是那些觀眾喜歡的熱點電視節目,但這個時候突然來了一批新的電視節目,馬上對這個緩存進行更新,之前排在隊尾的用戶喜歡的節目很可能就會被不斷地擠壓到隊首甚至被剔除掉。
那么看到這里我們會想着記錄key的訪問次數,但是單純的記錄訪問次數有兩個要解決的問題:
>>在LRU算法中可以維護一個雙向鏈表,然后簡單的把被訪問的節點移至鏈表開頭,但在LFU中是不可行的,節點要嚴格按照計數器進行排序,新增節點或者更新節點位置時,時間復雜度可能達到O(N)。
>>只是簡單的增加計數器的方法並不完美。訪問模式是會頻繁變化的,一段時間內頻繁訪問的key一段時間之后可能會很少被訪問到,只增加計數器並不能體現這種趨勢。
所以LFU的思路就是把訪問頻率高的保留,而訪問頻率低,雖然是近期被訪問,但在LFU算法中排除的優先級會比較高,這就跟時間、次數都有關系了。
第一個問題,借鑒LRU的實現經驗,維護了一個待淘汰的key的pool。第二個問題,隨着時間的推移,計數器會減次數。

           16 bits      8 bits
      +----------------+--------+
      + Last decr time | LOG_C  |
      +----------------+--------+

如上所示,既是LFU的關鍵結構,一個24位的字段,前16位存儲了一個按分鍾記的時間低位,我們可以把它理解位最近的時間,右邊的8位記錄了計數器的對數值,反映了訪問頻率,而不是次數。
每一次key被訪問時,logc都會更新,只不過這里由於是存儲的對數值,所以采用的是Mirros概率計數器,保證很多次命中時,8位(最多記錄255)還是能夠承受。
到了內存緊張的時候(maxmemory),LDT的更新才會被觸發,每一個key被命中時,ldt都會更新,同時也對logc進行衰減處理。
最后,隨機挑選若干key,將其中logc最低的排除。后期的優化也是使用了pool來處理。

注意,為了避免新加入的元素會因為logc值過低被立即剔除,默認初始值為5。

【Redis對象頭結構】

#define REDIS_LRU_BITS 24
#define REDIS_LRU_CLOCK_MAX ((1<<REDIS_LRU_BITS)-1) /* Max value of obj->lru */
#define REDIS_LRU_CLOCK_RESOLUTION 1000 /* LRU clock resolution in ms */
typedef struct redisObject {<br>  //存放的對象類型
    unsigned type:4;
    //內容編碼
    unsigned encoding:4;
    //與server.lruclock的時間差值
    unsigned lru:REDIS_LRU_BITS; /* lru time (relative to server.lruclock) */\
    //引用計數算法使用的引用計數器
    int refcount;
    //數據指針
    void *ptr;
} robj;

【LFU相關代碼】

了解了上述logc增加和ldt計數/logc衰減的流程,我們其實不難發現,最關鍵的設計,就是兩點:

1.logc是怎么增加的,這個概率增加如何實現,保證255能夠容納並准確反饋增長的趨勢?

2.在內存緊張時,logc是如何衰減的,跟ldt有什么關系?

至於最后的隨機抽取key,或者直接剔除logc最小的,或者放入pool處理,之前有了解過,這里不再敘述。

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;//獲取分鍾級別的時間戳
}

 【參考】

 《Redis深度歷險 核心原理與應用實踐》

https://www.jianshu.com/p/c8aeb3eee6bc


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM