[redis 源碼走讀] maxmemory 數據淘汰策略

redis 是內存數據庫,可以通過 redis.conf 配置 maxmemory,限制 redis 內存使用量。當 redis 主庫內存超出限制時,命令處理將會觸發數據淘汰機制,淘汰(key-value)數據,直至當前內存使用量小於限制閾值。

更精彩內容,可以關注我的博客:wenfh2020.com



數據淘汰策略概述

redis.conf

配置 描述
maxmemory <字節> 將內存使用限制設置爲指定的字節數。

redis 申請和回收內存基本上都是通過 zmalloc 接口統一管理的,可以通過接口統計 redis 的內存使用量。當 redis 超出了內存的使用限制 maxmemory,服務在處理命令時會觸發 redis 內部的數據淘汰機制。淘汰目標數據一共有兩種:

  1. 數據庫所有(key-value)數據。
  2. 數據庫所有被設置了過期時間的(key-value)數據。

aof 緩存,主從同步的積壓緩衝區這些數據是不會被淘汰的,也沒有計算在 maxmemory 裏面。

針對這兩種目標數據,它有幾種淘汰策略:

  1. 隨機淘汰。
  2. 先淘汰到期或快到期數據。
  3. 近似 LRU 算法(最近最少使用)
  4. 近似 LFU 算法 (最近使用頻率最少)

關於近似的 lrulfu 淘汰策略,英文好的朋友,可以去看看 antirez 的這兩篇文章: Using Redis as an LRU cacheRandom notes on improving the Redis LRU algorithmredis.conf 也有不少闡述。再結合源碼,基本能理解它們的實現思路。


maxmemory 核心數據淘汰策略在函數 freeMemoryIfNeeded 中,可以仔細閱讀這個函數的源碼。


配置

redis.conf 配置了 maxmemory,可以根據配置採用相應的數據淘汰策略。volatile-xxx 這種類型配置,都是隻淘汰設置了過期時間的數據,allkeys-xxx 淘汰數據庫所有數據。如果 redis 在你的應用場景中,只是作爲緩存,任何數據都可以淘汰,可以設置 allkeys-xxx

配置 描述
noeviction 不要淘汰任何數據,大部分寫操作會返回錯誤。
volatile-random 隨機刪除設置了過期時間的鍵。
allkeys-random 刪除隨機鍵,任何鍵。
volatile-ttl 刪除最接近到期​​時間(較小的TTL)的鍵。
volatile-lru 使用近似的LRU淘汰數據,僅設置過期的鍵。
allkeys-lru 使用近似的LRU算法淘汰長時間沒有使用的鍵。
volatile-lfu 在設置了過期時間的鍵中,使用近似的LFU算法淘汰使用頻率比較低的鍵。
allkeys-lfu 使用近似的LFU算法淘汰整個數據庫的鍵。
#define MAXMEMORY_FLAG_LRU (1<<0)
#define MAXMEMORY_FLAG_LFU (1<<1)
#define MAXMEMORY_FLAG_ALLKEYS (1<<2)

#define MAXMEMORY_VOLATILE_LRU ((0<<8)|MAXMEMORY_FLAG_LRU)
#define MAXMEMORY_VOLATILE_LFU ((1<<8)|MAXMEMORY_FLAG_LFU)
#define MAXMEMORY_VOLATILE_TTL (2<<8)
#define MAXMEMORY_VOLATILE_RANDOM (3<<8)
#define MAXMEMORY_ALLKEYS_LRU ((4<<8)|MAXMEMORY_FLAG_LRU|MAXMEMORY_FLAG_ALLKEYS)
#define MAXMEMORY_ALLKEYS_LFU ((5<<8)|MAXMEMORY_FLAG_LFU|MAXMEMORY_FLAG_ALLKEYS)
#define MAXMEMORY_ALLKEYS_RANDOM ((6<<8)|MAXMEMORY_FLAG_ALLKEYS)
#define MAXMEMORY_NO_EVICTION (7<<8)

數據淘汰時機

在事件循環處理命令時觸發檢查

int processCommand(client *c) {
    ...
    if (server.maxmemory && !server.lua_timedout) {
        int out_of_memory = freeMemoryIfNeededAndSafe() == C_ERR;
        if (server.current_client == NULL) return C_ERR;

        if (out_of_memory &&
            (c->cmd->flags & CMD_DENYOOM ||
             (c->flags & CLIENT_MULTI &&
              c->cmd->proc != execCommand &&
              c->cmd->proc != discardCommand)))
        {
            flagTransaction(c);
            addReply(c, shared.oomerr);
            return C_OK;
        }
    }
    ...
}

int freeMemoryIfNeededAndSafe(void) {
    if (server.lua_timedout || server.loading) return C_OK;
    return freeMemoryIfNeeded();
}

數據淘汰策略

下面從簡單到複雜,說說這幾種策略。


不淘汰數據(noeviction)

超出內存限制,可以淘汰數據,當然也可以不使用淘汰策略淘汰數據,noeviction 配置允許我們這樣做。服務允許讀,但禁止大部分命令,返回 oomerr 錯誤。只有少數寫命令可以執行,例如刪除命令 delhdelunlink 這些能降低內存使用的寫命令

  • 32 位系統,如果沒有設置 maxmemory,系統默認最大值是 3G,過期淘汰策略是:MAXMEMORY_NO_EVICTION

64 位系統不設置 maxmemory,是沒有限制的,Linux 以及其它很多系統通過虛擬內存管理物理內存,進程可以使用超出物理內存大小的內存,只是那個時候,物理內存和磁盤間頻繁地 swap,導致系統性能下降,對於 redis 這種高性能內存數據庫,這不是一個友好的體驗。

void initServer(void) {
    ...
    if (server.arch_bits == 32 && server.maxmemory == 0) {
        serverLog(LL_WARNING,"Warning: 32 bit instance detected but no memory limit set. Setting 3 GB maxmemory limit with 'noeviction' policy now.");
        server.maxmemory = 3072LL*(1024*1024); /* 3 GB */
        server.maxmemory_policy = MAXMEMORY_NO_EVICTION;
    }
    ...
}

  • 服務禁止大部分命令
int processCommand(client *c) {
    ...
    if (server.maxmemory && !server.lua_timedout) {
        // 當內存超出限制,進行回收處理。
        int out_of_memory = freeMemoryIfNeededAndSafe() == C_ERR;
        /* freeMemoryIfNeeded may flush slave output buffers. This may result
         * into a slave, that may be the active client, to be freed. */
        if (server.current_client == NULL) return C_ERR;

        /* It was impossible to free enough memory, and the command the client
         * is trying to execute is denied during OOM conditions or the client
         * is in MULTI/EXEC context? Error. */
        // 內存回收後,還是辦法將內存減少到限制以下,那麼大部分寫命令將會被禁止執行。
        if (out_of_memory &&
            (c->cmd->flags & CMD_DENYOOM ||
             (c->flags & CLIENT_MULTI &&
              c->cmd->proc != execCommand &&
              c->cmd->proc != discardCommand)))
        {
            flagTransaction(c);
            addReply(c, shared.oomerr);
            return C_OK;
        }
    }
    ...
}

int freeMemoryIfNeededAndSafe(void) {
    if (server.lua_timedout || server.loading) return C_OK;
    return freeMemoryIfNeeded();
}

int freeMemoryIfNeeded(void) {
    ...
    if (server.maxmemory_policy == MAXMEMORY_NO_EVICTION)
        goto cant_free; /* We need to free memory, but policy forbids. */
    ...
cant_free:
    ...
    return C_ERR;
}
  • CMD_DENYOOM 命令屬性(use-memory)
int populateCommandTableParseFlags(struct redisCommand *c, char *strflags) {
    ...
    for (int j = 0; j < argc; j++) {
        ...
        } else if (!strcasecmp(flag,"use-memory")) {
            c->flags |= CMD_DENYOOM;
        }
        ...
    }
    ...
}

struct redisCommand redisCommandTable[] = {
    ...

    {"get",getCommand,2,
     "read-only fast @string",
     0,NULL,1,1,1,0,0,0},

    /* Note that we can't flag set as fast, since it may perform an
     * implicit DEL of a large key. */
    {"set",setCommand,-3,
     "write use-memory @string",
     0,NULL,1,1,1,0,0,0},

    {"setnx",setnxCommand,3,
     "write use-memory fast @string",
     0,NULL,1,1,1,0,0,0},
     ...
    {"del",delCommand,-2,
     "write @keyspace",
     0,NULL,1,-1,1,0,0,0},

    {"unlink",unlinkCommand,-2,
     "write fast @keyspace",
     0,NULL,1,-1,1,0,0,0},
     ...
};

隨機淘汰

volatile-randomallkeys-random 這兩個隨機淘汰機制相對比較簡單,也比較暴力,隨機從庫中挑選數據進行淘汰。

int freeMemoryIfNeeded(void) {
    ...
        /* volatile-random and allkeys-random policy */
        else if (server.maxmemory_policy == MAXMEMORY_ALLKEYS_RANDOM ||
                 server.maxmemory_policy == MAXMEMORY_VOLATILE_RANDOM)
        {
            /* When evicting a random key, we try to evict a key for
             * each DB, so we use the static 'next_db' variable to
             * incrementally visit all DBs. */
            for (i = 0; i < server.dbnum; i++) {
                j = (++next_db) % server.dbnum;
                db = server.db+j;
                dict = (server.maxmemory_policy == MAXMEMORY_ALLKEYS_RANDOM) ?
                        db->dict : db->expires;
                if (dictSize(dict) != 0) {
                    de = dictGetRandomKey(dict);
                    bestkey = dictGetKey(de);
                    bestdbid = j;
                    break;
                }
            }
        }
    ...
}

採樣淘汰

redis 作爲一個數據庫,裏面保存了大量數據,可以根據到期時間(ttl),lrulfu 進行數據淘汰,嚴格來說,需要維護一些數據結構才能準確篩選出目標數據,但是 maxmemory 觸發的概率比較低,小系統有可能永遠不會觸發。爲了一個概率低的場景去維護一些數據結構,這顯然不是一個聰明的做法。所以 redis 通過採樣的方法,近似的數據淘汰策略。


採樣方法:遍歷數據庫,每個數據庫隨機採集maxmemory_samples個樣本,放進一個樣本池中(數組)。樣本池中的樣本 idle 值從低到高排序(數組從左到右存儲),數據淘汰策略將會每次淘汰 idle 最高的那個數據。因爲樣本池大小是有限制的(EVPOOL_SIZE),所以採集的樣本要根據自己的 idle 值大小或池中是否有空位來確定是否能成功插入到樣本池中。如果池中沒有空位或被插入樣本的idle 值都小於池子中的數據,那插入將會失敗。所以池子中一直存儲着idle最大,最大機率被淘汰的那些數據樣本

redis 近似算法採樣流程


對於樣本,顯然是採樣越多,篩選目標數據就越精確。redis 作者根據實踐經驗,maxmemory_samples 默認每次採樣 5 個已經比較高效了,10 個就非常接近 LRU 算法效果。例如下圖近似 lru 算法:

圖 1 是正常的 LRU 算法。

  1. 淺灰色表示已經刪除的鍵。
  2. 深灰色表示沒有被刪除的鍵。
  3. 綠色表示新加入的鍵。

近似採樣算法與原算法比較

  • 樣本數據池
#define EVPOOL_SIZE 16
#define EVPOOL_CACHED_SDS_SIZE 255
struct evictionPoolEntry {
    unsigned long long idle;    /* Object idle time (inverse frequency for LFU) */
    sds key;                    /* Key name. */
    sds cached;                 /* Cached SDS object for key name. */
    int dbid;                   /* Key DB number. */
};

static struct evictionPoolEntry *EvictionPoolLRU;

void evictionPoolAlloc(void) {
    struct evictionPoolEntry *ep;
    int j;

    ep = zmalloc(sizeof(*ep)*EVPOOL_SIZE);
    for (j = 0; j < EVPOOL_SIZE; j++) {
        ep[j].idle = 0;
        ep[j].key = NULL;
        ep[j].cached = sdsnewlen(NULL,EVPOOL_CACHED_SDS_SIZE);
        ep[j].dbid = 0;
    }
    EvictionPoolLRU = ep;
}
  • 採樣淘汰機制實現,掃描數據庫,從樣本池中取出淘汰鍵 bestkey 進行淘汰。
int freeMemoryIfNeeded(void) {
    ...
    while (mem_freed < mem_tofree) {
        ...
        // 採樣,從樣本中選出一個合適的鍵,進行數據淘汰。
        if (server.maxmemory_policy & (MAXMEMORY_FLAG_LRU|MAXMEMORY_FLAG_LFU) ||
            server.maxmemory_policy == MAXMEMORY_VOLATILE_TTL)
        {
            struct evictionPoolEntry *pool = EvictionPoolLRU;

            while(bestkey == NULL) {
                unsigned long total_keys = 0, keys;

                // 將採集的鍵放進 pool 中。
                for (i = 0; i < server.dbnum; i++) {
                    db = server.db+i;
                    // 從過期鍵中掃描,還是全局鍵掃描抽樣。
                    dict = (server.maxmemory_policy & MAXMEMORY_FLAG_ALLKEYS) ?
                            db->dict : db->expires;
                    if ((keys = dictSize(dict)) != 0) {
                        // 採樣到樣本池中
                        evictionPoolPopulate(i, dict, db->dict, pool);
                        total_keys += keys;
                    }
                }
                if (!total_keys) break; /* No keys to evict. */

                // 從數組高到低,查找鍵進行數據淘汰
                for (k = EVPOOL_SIZE-1; k >= 0; k--) {
                    if (pool[k].key == NULL) continue;
                    bestdbid = pool[k].dbid;

                    if (server.maxmemory_policy & MAXMEMORY_FLAG_ALLKEYS) {
                        de = dictFind(server.db[pool[k].dbid].dict,
                            pool[k].key);
                    } else {
                        de = dictFind(server.db[pool[k].dbid].expires,
                            pool[k].key);
                    }

                    /* Remove the entry from the pool. */
                    if (pool[k].key != pool[k].cached)
                        sdsfree(pool[k].key);
                    pool[k].key = NULL;
                    pool[k].idle = 0;

                    /* If the key exists, is our pick. Otherwise it is
                     * a ghost and we need to try the next element. */
                    if (de) {
                        bestkey = dictGetKey(de);
                        break;
                    } else {
                        /* Ghost... Iterate again. */
                    }
                }
            }
        }
        ...
    }
}
  • 採樣到樣本池中
void evictionPoolPopulate(int dbid, dict *sampledict, dict *keydict, struct evictionPoolEntry *pool) {
    int j, k, count;
    dictEntry *samples[server.maxmemory_samples];

    // 隨機採樣多個數據。
    count = dictGetSomeKeys(sampledict,samples,server.maxmemory_samples);
    for (j = 0; j < count; j++) {
        ...
        if (server.maxmemory_policy & MAXMEMORY_FLAG_LRU) {
            // lru 近似算法,淘汰長時間沒有使用的數據。
            idle = estimateObjectIdleTime(o);
        } else if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
            // 淘汰使用頻率比較小的數據。
            idle = 255-LFUDecrAndReturn(o);
        } else if (server.maxmemory_policy == MAXMEMORY_VOLATILE_TTL) {
            // 淘汰最快過期數據。
            idle = ULLONG_MAX - (long)dictGetVal(de);
        } else {
            serverPanic("Unknown eviction policy in evictionPoolPopulate()");
        }

        // 將採集的 key,填充到 pool 數組中去。
        // 在 pool 數組中,尋找合適到位置。pool[k].key == NULL 或者 idle < pool[k].idle
        k = 0;
        while (k < EVPOOL_SIZE &&
               pool[k].key &&
               pool[k].idle < idle) k++;

        if (k == 0 && pool[EVPOOL_SIZE-1].key != NULL) {
            // pool 已滿,當前採樣沒能找到合適位置插入。
            continue;
        } else if (k < EVPOOL_SIZE && pool[k].key == NULL) {
            // 找到合適位置插入,不需要移動數組其它元素。
        } else {
            // 找到數組中間位置,需要移動數據。
            if (pool[EVPOOL_SIZE-1].key == NULL) {
                // 數組還有空間,數據從插入位置向右移動。
                sds cached = pool[EVPOOL_SIZE-1].cached;
                memmove(pool+k+1,pool+k,
                    sizeof(pool[0])*(EVPOOL_SIZE-k-1));
                pool[k].cached = cached;
            } else {
                // 數組右邊已經沒有空間,那麼刪除 idle 最小的元素。
                k--;
                sds cached = pool[0].cached;
                if (pool[0].key != pool[0].cached) sdsfree(pool[0].key);
                memmove(pool,pool+1,sizeof(pool[0])*k);
                pool[k].cached = cached;
            }
        }

        // 內存的分配和銷燬開銷大,pool 緩存空間比較小的 key,方便內存重複使用。
        int klen = sdslen(key);
        if (klen > EVPOOL_CACHED_SDS_SIZE) {
            pool[k].key = sdsdup(key);
        } else {
            memcpy(pool[k].cached,key,klen+1);
            sdssetlen(pool[k].cached,klen);
            pool[k].key = pool[k].cached;
        }
        pool[k].idle = idle;
        pool[k].dbid = dbid;
    }
}

淘汰快到期數據(volatile-ttl)

  • 數據庫 redisDbexpires 字典保存了 key 對應的過期時間。
typedef struct redisDb {
    dict *dict;                 /* The keyspace for this DB */
    dict *expires;              /* Timeout of keys with a timeout set */
    ...
} redisDb;
  • volatile-ttl 淘汰那些設置了過期時間且最快到期的數據。隨機採樣放進樣本池,從樣本池中先淘汰idle值最大數據。
void evictionPoolPopulate(int dbid, dict *sampledict, dict *keydict, struct evictionPoolEntry *pool) {
    ...
    else if (server.maxmemory_policy == MAXMEMORY_VOLATILE_TTL) {
        // (long)dictGetVal(de) 時間越小,越快到期;idle 越大,越容易從樣本池中淘汰。
        idle = ULLONG_MAX - (long)dictGetVal(de);
    }
    ...
}

lru

緩存目的是緩存活躍數據,volatile-ttl 淘汰最快到期的數據,存在缺陷:有可能把活躍的數據先淘汰了,可以採用 allkeys-lruvolatile-lru 策略,根據當前時間與上一次訪問的時間間隔,間隔越小說明越活躍。通過採樣,用近似 lru 算法淘汰那些很久沒有使用的數據。

簡單的 lru 實現可以看看我這個帖子 lru c++ 實現


  • redisObject 成員 lru 保存了一個 24 bit 的系統訪問數據時間戳。保存 lru 時間精度是秒,LRU_CLOCK_MAX 時間範圍大概 194 天。
#define LRU_BITS 24
#define LRU_CLOCK_MAX ((1<<LRU_BITS)-1) /* Max value of obj->lru */
#define LRU_CLOCK_RESOLUTION 1000 /* LRU clock resolution in ms */

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 時間。
/* Low level key lookup API, not actually called directly from commands
 * implementations that should instead rely on lookupKeyRead(),
 * lookupKeyWrite() and lookupKeyReadWithFlags(). */
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. */
        // 當主進程 fork 子進程處理數據時,不要更新。
        // 否則父子進程 `copy-on-write` 模式將被破壞,產生大量新增內存。
        if (!hasActiveChildProcess() && !(flags & LOOKUP_NOTOUCH)){
            if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
                updateLFU(val);
            } else {
                // 更新 lru 時間
                val->lru = LRU_CLOCK();
            }
        }
        return val;
    } else {
        return NULL;
    }
}
  • 近似 lru 淘汰長時間沒使用數據。
void evictionPoolPopulate(int dbid, dict *sampledict, dict *keydict, struct evictionPoolEntry *pool) {
    ...
    if (server.maxmemory_policy & MAXMEMORY_FLAG_LRU) {
        // lru 近似算法,淘汰長時間沒有使用的數據。
        idle = estimateObjectIdleTime(o);
    }
    ...
}
  • 返回當前時間與上一次訪問時間間距。間隔越小,說明越活躍。(時間精度毫秒)

時間間隔

unsigned long long estimateObjectIdleTime(robj *o) {
    unsigned long long lruclock = LRU_CLOCK();
    if (lruclock >= o->lru) {
        return (lruclock - o->lru) * LRU_CLOCK_RESOLUTION;
    } else {
        return (lruclock + (LRU_CLOCK_MAX - o->lru)) *
                    LRU_CLOCK_RESOLUTION;
    }
}

lfu

近似 lru 淘汰策略,似乎要比前面講的策略都要先進,但是它也是有缺陷的。因爲根據當前時間與上一次訪問時間兩個時間點間隔來判斷數據是否活躍。也只能反映兩個時間點的活躍度。對於一段時間內的活躍度是很難反映出來的。


在同一個時間段內,B 的訪問頻率明顯要比 A 高,顯然 B 要比 A 熱度更高。然而 lru 算法會把 B 數據淘汰掉。

~~~~~A~~~~~A~~~~~A~~~~A~~~~~A~~~~A~A~|
~~B~~B~~B~~B~~B~~B~~B~~B~~B~~B~~B~~B~|

所以 redis 作者又引入了一種新的算法,近似 lfu 算法,反映數值訪問頻率,也就是數據訪問熱度。它重複利用了 redisObject 結構 lru 成員。

typedef struct redisObject {
    ...
    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). */
    ...
} robj;
#           16 bits      8 bits
#      +----------------+--------+
#      + Last decr time | LOG_C  |
#      +----------------+--------+

前 16 bits 用來存儲上一個訪問衰減時間(ldt),後 8 bits 用來存儲衰減計數頻率(counter)。那衰減時間和計數到底有什麼用呢?其實是在一個時間段內,訪問頻率越高,計數就越大(計數最大值爲 255)。我們通過計數的大小判斷數據的熱度。


  • 近似 lfu 淘汰使用頻率比較低的數據。
void evictionPoolPopulate(int dbid, dict *sampledict, dict *keydict, struct evictionPoolEntry *pool) {
    ...
    if (server.maxmemory_policy & MAXMEMORY_FLAG_LRU) {
        // 淘汰使用頻率比較小的數據。
        idle = 255-LFUDecrAndReturn(o);
    }
    ...
}
  • 當前時間與上次訪問的時間間隔,時間精度是分鐘。
unsigned long LFUTimeElapsed(unsigned long ldt) {
    unsigned long now = LFUGetTimeInMinutes();
    if (now >= ldt) return now-ldt;
    return 65535-ldt+now;
}

unsigned long LFUGetTimeInMinutes(void) {
    return (server.unixtime/60) & 65535;
}
  • 衰減計數

    LFUTimeElapsed 值越大,counter 就越小。也就是說,兩次訪問的時間間隔越大,計數的遞減就越厲害。這個遞減速度會受到衰減時間因子(lfu_decay_time)影響。可以在配置文件中調節,一般默認爲 1。

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;
}

  • 訪問觸發頻率更新,更新 lfu 數據
robj *lookupKey(redisDb *db, robj *key, int flags) {
    dictEntry *de = dictFind(db->dict,key->ptr);
    if (de) {
        robj *val = dictGetVal(de);
        if (!hasActiveChildProcess() && !(flags & LOOKUP_NOTOUCH)){
            if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
                // 更新頻率
                updateLFU(val);
            } else {
                val->lru = LRU_CLOCK();
            }
        }
        return val;
    } else {
        return NULL;
    }
}

// 更新 lfu 數據
void updateLFU(robj *val) {
    // LFUDecrAndReturn 的時間精度是分鐘,所以只會每分鐘更新一次 counter.
    unsigned long counter = LFUDecrAndReturn(val);
    // 實時更新當前 counter
    counter = LFULogIncr(counter);
    // 保存 lfu 數據。
    val->lru = (LFUGetTimeInMinutes()<<8) | counter;
}
  • 計數器統計訪問頻率

    這其實是一個概率計算,當數據被訪問次數越多,那麼隨機數落在某個數據段的概率就越大。計數增加的可能性就越高。 redis 作者添加了控制因子 lfu_log_factor,當因子越大,那計數增長速度就越緩慢。

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;
}
  • 數據庫新增數據默認計數爲 LFU_INIT_VAL,這樣不至於剛添加進來就被淘汰了。
robj *createEmbeddedStringObject(const char *ptr, size_t len) {
    ...
    if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
        o->lru = (LFUGetTimeInMinutes()<<8) | LFU_INIT_VAL;
    }
    ...
}

下面是 redis 作者壓力測試得出的 factorcounter 測試數據。因子越大,counter 增長越緩慢。

測試數據來自 redis.conf

# +--------+------------+------------+------------+------------+------------+
# | 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        |
# +--------+------------+------------+------------+------------+------------+
#
# NOTE: The above table was obtained by running the following commands:
#
#   redis-benchmark -n 1000000 incr foo
#   redis-cli object freq foo

總結

  • maxmemory 淘汰數據機制,主要淘汰兩種目標數據:整個數據庫數據和設置了過期時間的數據。

  • maxmemory 淘汰策略,有:不使用淘汰策略淘汰數據,隨機淘汰數據,採樣的近似算法 ttllrulfu

  • redis 版本從 2.x 到 6.x,一直不停地改進迭代,redis 作者精益求精的精神值得我們學習。

  • 採樣近似淘汰策略,巧妙避免了維護額外的數據結構,達到差不多的效果,這個思路獨具匠心。

  • 採樣算法,根據樣本的 idle 值進行數據淘汰,所以當我們採用一種採樣算法時,不要密集地設置大量相似的 idle 數據,否則效率也是很低的。

  • maxmemory 設置其實是一個學問,到底應該設置多少,才比較合理。很多人建議是物理內存大小的一半,原因如下:

    1. 數據持久化過程中,redis 會 fork 子進程,在 linux 系統中雖然父子進程有 ‘copy-on-write’ 模式,redis 也儘量避免子進程工作過程中修改數據,子進程部分操作會使用內存,例如寫 rdb 文件。
    2. maxmemory 限制的內存並不包括 aof 緩存和主從同步積壓緩衝區部分內存。
    3. redis 集羣數據同步機制,全量同步數據,某些場景也要也要佔用不少內存。
    4. 我們的機器很多時候不是隻跑 redis 進程的,系統其它進程也要使用內存。
  • maxmemory 雖然有衆多的處理策略,然而超過閾值運行,這是不健康的,生產環境應該實時監控程序運行的健康狀況。

  • redis 經常作爲緩存使用,其實它也有持久化,可以存儲數據。redis 作爲緩存和數據庫一般都是交叉使用,沒有明確的界限,所以不建議設置 allkeys-xxx 全局淘汰數據的策略。

  • 當redis 內存到達 maxmemory,觸發了數據淘汰,但是一頓操作後,內存始終無法成功降到閾值以下,那麼 redis 主進程將會進入睡眠等待。這種問題是隱性的,很難查出來。新手很容易犯錯誤,經常把 redis 當做數據庫使用,併發量高的系統,一段時間就跑滿內存了,沒經驗的運維肯定第一時間想到切換到好點的機器解決問題。

    int freeMemoryIfNeeded(void) {
        ...
    cant_free:
        // 如果已經沒有合適的鍵進行回收了,而且內存還沒降到 maxmemory 以下,
        // 那麼需要看看回收線程中是否還有數據需要進行回收,通過 sleep 主線程等待回收線程處理。
        while(bioPendingJobsOfType(BIO_LAZY_FREE)) {
            if (((mem_reported - zmalloc_used_memory()) + mem_freed) >= mem_tofree)
                break;
            usleep(1000);
        }
        return C_ERR;
    }
    

參考


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