Redis中的LFU算法

Redis中的LRU算法文中說到,LRU有一個缺陷,在以下狀況下:html

~~~~~A~~~~~A~~~~~A~~~~A~~~~~A~~~~~A~~|
~~B~~B~~B~~B~~B~~B~~B~~B~~B~~B~~B~~B~|
~~~~~~~~~~C~~~~~~~~~C~~~~~~~~~C~~~~~~|
~~~~~D~~~~~~~~~~D~~~~~~~~~D~~~~~~~~~D|

會將數據D誤認爲未來最有可能被訪問到的數據。git

Redis做者曾想改進LRU算法,但發現RedisLRU算法受制於隨機採樣數maxmemory_samples,在maxmemory_samples等於10的狀況下已經很接近於理想的LRU算法性能,也就是說,LRU算法自己已經很難再進一步了。github

因而,將思路回到原點,淘汰算法的本意是保留那些未來最有可能被再次訪問的數據,而LRU算法只是預測最近被訪問的數據未來最有可能被訪問到。咱們能夠轉變思路,採用一種LFU(Least Frequently Used)算法,也就是最頻繁被訪問的數據未來最有可能被訪問到。在上面的狀況中,根據訪問頻繁狀況,能夠肯定保留優先級:B>A>C=D。redis

Redis中的LFU思路

LFU算法中,能夠爲每一個key維護一個計數器。每次key被訪問的時候,計數器增大。計數器越大,能夠約等於訪問越頻繁。算法

上述簡單算法存在兩個問題:c#

  • LRU算法中能夠維護一個雙向鏈表,而後簡單的把被訪問的節點移至鏈表開頭,但在LFU中是不可行的,節點要嚴格按照計數器進行排序,新增節點或者更新節點位置時,時間複雜度可能達到O(N)。
  • 只是簡單的增長計數器的方法並不完美。訪問模式是會頻繁變化的,一段時間內頻繁訪問的key一段時間以後可能會不多被訪問到,只增長計數器並不能體現這種趨勢。

第一個問題很好解決,能夠借鑑LRU實現的經驗,維護一個待淘汰key的pool。第二個問題的解決辦法是,記錄key最後一個被訪問的時間,而後隨着時間推移,下降計數器。app

Redis對象的結構以下:less

typedef struct redisObject {
    unsigned type:4;
    unsigned encoding:4;
    unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
                            * LFU data (least significant 8 bits frequency
                            * and most significant 16 bits access time). */
    int refcount;
    void *ptr;
} robj;

LRU算法中,24 bits的lru是用來記錄LRU time的,在LFU中也可使用這個字段,不過是分紅16 bits與8 bits使用:dom

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

高16 bits用來記錄最近一次計數器下降的時間ldt,單位是分鐘,低8 bits記錄計數器數值counteride

LFU配置

Redis4.0以後爲maxmemory_policy淘汰策略添加了兩個LFU模式:

  • volatile-lfu:對有過時時間的key採用LFU淘汰算法
  • allkeys-lfu:對所有key採用LFU淘汰算法

還有2個配置能夠調整LFU算法:

lfu-log-factor 10
lfu-decay-time 1

lfu-log-factor能夠調整計數器counter的增加速度,lfu-log-factor越大,counter增加的越慢。

lfu-decay-time是一個以分鐘爲單位的數值,能夠調整counter的減小速度

源碼實現

lookupKey中:

robj *lookupKey(redisDb *db, robj *key, int flags) {
    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 &&
            !(flags & LOOKUP_NOTOUCH))
        {
            if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
                updateLFU(val);
            } else {
                val->lru = LRU_CLOCK();
            }
        }
        return val;
    } else {
        return NULL;
    }
}

當採用LFU策略時,updateLFU更新lru

/* Update LFU when an object is accessed.
 * Firstly, decrement the counter if the decrement time is reached.
 * Then logarithmically increment the counter, and update the access time. */
void updateLFU(robj *val) {
    unsigned long counter = LFUDecrAndReturn(val);
    counter = LFULogIncr(counter);
    val->lru = (LFUGetTimeInMinutes()<<8) | counter;
}

下降LFUDecrAndReturn

首先,LFUDecrAndReturncounter進行減小操做:

/* If the object decrement time is reached decrement the LFU counter but
 * do not update LFU fields of the object, we update the access time
 * and counter in an explicit way when the object is really accessed.
 * And we will times halve the counter according to the times of
 * elapsed time than server.lfu_decay_time.
 * Return the object frequency counter.
 *
 * This function is used in order to scan the dataset for the best object
 * to fit: as we check for the candidate, we incrementally decrement the
 * counter of the scanned objects if needed. */
unsigned long LFUDecrAndReturn(robj *o) {
    unsigned long ldt = o->lru >> 8;
    unsigned long counter = o->lru & 255;
    unsigned long num_periods = server.lfu_decay_time ? LFUTimeElapsed(ldt) / server.lfu_decay_time : 0;
    if (num_periods)
        counter = (num_periods > counter) ? 0 : counter - num_periods;
    return counter;
}

函數首先取得高16 bits的最近下降時間ldt與低8 bits的計數器counter,而後根據配置的lfu_decay_time計算應該下降多少。

LFUTimeElapsed用來計算當前時間與ldt的差值:

/* Return the current time in minutes, just taking the least significant
 * 16 bits. The returned time is suitable to be stored as LDT (last decrement
 * time) for the LFU implementation. */
unsigned long LFUGetTimeInMinutes(void) {
    return (server.unixtime/60) & 65535;
}

/* Given an object last access time, compute the minimum number of minutes
 * that elapsed since the last access. Handle overflow (ldt greater than
 * the current 16 bits minutes time) considering the time as wrapping
 * exactly once. */
unsigned long LFUTimeElapsed(unsigned long ldt) {
    unsigned long now = LFUGetTimeInMinutes();
    if (now >= ldt) return now-ldt;
    return 65535-ldt+now;
}

具體是當前時間轉化成分鐘數後取低16 bits,而後計算與ldt的差值now-ldt。當ldt > now時,默認爲過了一個週期(16 bits,最大65535),取值65535-ldt+now

而後用差值與配置lfu_decay_time相除,LFUTimeElapsed(ldt) / server.lfu_decay_time,已過去n個lfu_decay_time,則將counter減小n,counter - num_periods

增加LFULogIncr

增加函數LFULogIncr以下:

/* Logarithmically increment a counter. The greater is the current counter value
 * the less likely is that it gets really implemented. Saturate it at 255. */
uint8_t LFULogIncr(uint8_t counter) {
    if (counter == 255) return 255;
    double r = (double)rand()/RAND_MAX;
    double baseval = counter - LFU_INIT_VAL;
    if (baseval < 0) baseval = 0;
    double p = 1.0/(baseval*server.lfu_log_factor+1);
    if (r < p) counter++;
    return counter;
}

counter並非簡單的訪問一次就+1,而是採用了一個0-1之間的p因子控制增加。counter最大值爲255。取一個0-1之間的隨機數r與p比較,當r<p時,才增長counter,這和比特幣中控制產出的策略相似。p取決於當前counter值與lfu_log_factor因子,counter值與lfu_log_factor因子越大,p越小,r<p的機率也越小,counter增加的機率也就越小。增加狀況以下:

+--------+------------+------------+------------+------------+------------+
| factor | 100 hits   | 1000 hits  | 100K hits  | 1M hits    | 10M hits   |
+--------+------------+------------+------------+------------+------------+
| 0      | 104        | 255        | 255        | 255        | 255        |
+--------+------------+------------+------------+------------+------------+
| 1      | 18         | 49         | 255        | 255        | 255        |
+--------+------------+------------+------------+------------+------------+
| 10     | 10         | 18         | 142        | 255        | 255        |
+--------+------------+------------+------------+------------+------------+
| 100    | 8          | 11         | 49         | 143        | 255        |
+--------+------------+------------+------------+------------+------------+

可見counter增加與訪問次數呈現對數增加的趨勢,隨着訪問次數愈來愈大,counter增加的愈來愈慢。

新生key策略

另一個問題是,當建立新對象的時候,對象的counter若是爲0,很容易就會被淘汰掉,還須要爲新生key設置一個初始countercreateObject:

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;

    /* Set the LRU to the current lruclock (minutes resolution), or
     * alternatively the LFU counter. */
    if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
        o->lru = (LFUGetTimeInMinutes()<<8) | LFU_INIT_VAL;
    } else {
        o->lru = LRU_CLOCK();
    }
    return o;
}

counter會被初始化爲LFU_INIT_VAL,默認5。

pool

pool算法就與LRU算法一致了:

        if (server.maxmemory_policy & (MAXMEMORY_FLAG_LRU|MAXMEMORY_FLAG_LFU) ||
            server.maxmemory_policy == MAXMEMORY_VOLATILE_TTL)

計算idle時有所不一樣:

        } else if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
            /* When we use an LRU policy, we sort the keys by idle time
             * so that we expire keys starting from greater idle time.
             * However when the policy is an LFU one, we have a frequency
             * estimation, and we want to evict keys with lower frequency
             * first. So inside the pool we put objects using the inverted
             * frequency subtracting the actual frequency to the maximum
             * frequency of 255. */
            idle = 255-LFUDecrAndReturn(o);

使用了255-LFUDecrAndReturn(o)當作排序的依據。

參考連接

相關文章
相關標籤/搜索