redis 數據刪除策略和逐出算法

數據存儲和有效期

redis 工做流程中,過時的數據並不須要立刻就要執行刪除操做。由於這些刪不刪除只是一種狀態表示,能夠異步的去處理,在不忙的時候去把這些不緊急的刪除操做作了,從而保證 redis 的高效redis

數據的存儲

在redis中數據的存儲不只僅須要保存數據自己還要保存數據的生命週期,也就是過時時間。在redis 中 數據的存儲結構以下圖:算法

獲取有效期

Redis是一種內存級數據庫,全部數據均存放在內存中,內存中的數據能夠經過TTL指令獲取其狀態數據庫

刪除策略

在內存佔用與CPU佔用之間尋找一種平衡,顧此失彼都會形成總體redis性能的降低,甚至引起服務器宕機或內存泄漏。緩存

定時刪除

建立一個定時器,當key設置過時時間,且過時時間到達時,由定時器任務當即執行對鍵的刪除操做服務器

優勢

節約內存,到時就刪除,快速釋放掉沒必要要的內存佔用網絡

缺點

CPU壓力很大,不管CPU此時負載多高,均佔用CPU,會影響redis服務器響應時間和指令吞吐量數據結構

總結

用處理器性能換取存儲空間併發

惰性刪除

數據到達過時時間,不作處理。等下次訪問該數據,若是未過時,返回數據。發現已通過期,刪除,返回不存在。這樣每次讀寫數據都須要檢測數據是否已經到達過時時間。也就是惰性刪除老是在數據的讀寫時發生的。dom

expireIfNeeded函數

對全部的讀寫命令進行檢查,檢查操做的對象是否過時。過時就刪除返回過時,不過時就什麼也不作~。異步

執行數據寫入過程當中,首先經過expireIfNeeded函數對寫入的key進行過時判斷。

/*
 * 爲執行寫入操做而取出鍵 key 在數據庫 db 中的值。
 *
 * 和 lookupKeyRead 不一樣,這個函數不會更新服務器的命中/不命中信息。
 *
 * 找到時返回值對象,沒找到返回 NULL 。
 */
robj *lookupKeyWrite(redisDb *db, robj *key) {

    // 刪除過時鍵
    expireIfNeeded(db,key);

    // 查找並返回 key 的值對象
    return lookupKey(db,key);
}

執行數據讀取過程當中,首先經過expireIfNeeded函數對寫入的key進行過時判斷。

/*
 * 爲執行讀取操做而取出鍵 key 在數據庫 db 中的值。
 *
 * 並根據是否成功找到值,更新服務器的命中/不命中信息。
 *
 * 找到時返回值對象,沒找到返回 NULL 。
 */
robj *lookupKeyRead(redisDb *db, robj *key) {
    robj *val;

    // 檢查 key 釋放已通過期
    expireIfNeeded(db,key);

    // 從數據庫中取出鍵的值
    val = lookupKey(db,key);

    // 更新命中/不命中信息
    if (val == NULL)
        server.stat_keyspace_misses++;
    else
        server.stat_keyspace_hits++;

    // 返回值
    return val;
}

執行過時動做expireIfNeeded其實內部作了三件事情,分別是:

  • 查看key判斷是否過時
  • 向slave節點傳播執行過時key的動做併發送事件通知
  • 刪除過時key
/*
 * 檢查 key 是否已通過期,若是是的話,將它從數據庫中刪除。
 *
 * 返回 0 表示鍵沒有過時時間,或者鍵未過時。
 *
 * 返回 1 表示鍵已經由於過時而被刪除了。
 */
int expireIfNeeded(redisDb *db, robj *key) {

    // 取出鍵的過時時間
    mstime_t when = getExpire(db,key);
    mstime_t now;

    // 沒有過時時間
    if (when < 0) return 0; /* No expire for this key */

    /* Don't expire anything while loading. It will be done later. */
    // 若是服務器正在進行載入,那麼不進行任何過時檢查
    if (server.loading) return 0;

    // 當服務器運行在 replication 模式時
    // 附屬節點並不主動刪除 key
    // 它只返回一個邏輯上正確的返回值
    // 真正的刪除操做要等待主節點發來刪除命令時才執行
    // 從而保證數據的同步
    if (server.masterhost != NULL) return now > when;

    // 運行到這裏,表示鍵帶有過時時間,而且服務器爲主節點

    /* Return when this key has not expired */
    // 若是未過時,返回 0
    if (now <= when) return 0;

    /* Delete the key */
    server.stat_expiredkeys++;

    // 向 AOF 文件和附屬節點傳播過時信息
    propagateExpire(db,key);

    // 發送事件通知
    notifyKeyspaceEvent(REDIS_NOTIFY_EXPIRED,
        "expired",key,db->id);

    // 將過時鍵從數據庫中刪除
    return dbDelete(db,key);
}

判斷key是否過時的數據結構是db->expires,也就是經過expires的數據結構判斷數據是否過時。
內部獲取過時時間並返回。

/*
 * 返回字典中包含鍵 key 的節點
 *
 * 找到返回節點,找不到返回 NULL
 *
 * T = O(1)
 */
dictEntry *dictFind(dict *d, const void *key)
{
    dictEntry *he;
    unsigned int h, idx, table;

    // 字典(的哈希表)爲空
    if (d->ht[0].size == 0) return NULL; /* We don't have a table at all */

    // 若是條件容許的話,進行單步 rehash
    if (dictIsRehashing(d)) _dictRehashStep(d);

    // 計算鍵的哈希值
    h = dictHashKey(d, key);
    // 在字典的哈希表中查找這個鍵
    // T = O(1)
    for (table = 0; table <= 1; table++) {

        // 計算索引值
        idx = h & d->ht[table].sizemask;

        // 遍歷給定索引上的鏈表的全部節點,查找 key
        he = d->ht[table].table[idx];
        // T = O(1)
        while(he) {

            if (dictCompareKeys(d, key, he->key))
                return he;

            he = he->next;
        }

        // 若是程序遍歷完 0 號哈希表,仍然沒找到指定的鍵的節點
        // 那麼程序會檢查字典是否在進行 rehash ,
        // 而後才決定是直接返回 NULL ,仍是繼續查找 1 號哈希表
        if (!dictIsRehashing(d)) return NULL;
    }

    // 進行到這裏時,說明兩個哈希表都沒找到
    return NULL;
}

優勢

節約CPU性能,發現必須刪除的時候才刪除。

缺點

內存壓力很大,出現長期佔用內存的數據。

總結

用存儲空間換取處理器性能

按期刪除

週期性輪詢redis庫中時效性數據,採用隨機抽取的策略,利用過時數據佔比的方式刪除頻度。

優勢

CPU性能佔用設置有峯值,檢測頻度可自定義設置

內存壓力不是很大,長期佔用內存的冷數據會被持續清理

缺點

須要週期性抽查存儲空間

按期刪除詳解

redis的按期刪除是經過定時任務實現的,也就是定時任務會循環調用serverCron方法。而後定時檢查過時數據的方法是databasesCron。按期刪除的一大特色就是考慮了定時刪除過時數據會佔用cpu時間,因此每次執行databasesCron的時候會限制cpu的佔用不超過25%。真正執行刪除的是 activeExpireCycle方法。

時間事件

對於持續運行的服務器來講, 服務器須要按期對自身的資源和狀態進行必要的檢查和整理, 從而讓服務器維持在一個健康穩定的狀態, 這類操做被統稱爲常規操做(cron job

在 Redis 中, 常規操做由 redis.c/serverCron() 實現, 它主要執行如下操做

1 更新服務器的各種統計信息,好比時間、內存佔用、數據庫佔用狀況等。

2 清理數據庫中的過時鍵值對。

3 對不合理的數據庫進行大小調整。

4 關閉和清理鏈接失效的客戶端。

5 嘗試進行 AOF 或 RDB 持久化操做。

6 若是服務器是主節點的話,對附屬節點進行按期同步。

7 若是處於集羣模式的話,對集羣進行按期同步和鏈接測試。

由於 serverCron() 須要在 Redis 服務器運行期間一直按期運行, 因此它是一個循環時間事件: serverCron() 會一直按期執行,直到服務器關閉爲止。

在 Redis 2.6 版本中, 程序規定 serverCron() 每秒運行 10 次, 平均每 100 毫秒運行一次。 從 Redis 2.8 開始, 用戶能夠經過修改 hz選項來調整 serverCron() 的每秒執行次數, 具體信息請參考 redis.conf 文件中關於 hz 選項的說明

查看hz

way1 : config get hz  # "hz" "10"
way2 : info server  # server.hz 10

serverCron()

serverCron()會按期的執行,在serverCron()執行中會調用databasesCron() 方法(serverCron()還作了其餘不少事情,可是如今不討論,只談刪除策略)

int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
    // 略去多無關代碼

    /* We need to do a few operations on clients asynchronously. */
    // 檢查客戶端,關閉超時客戶端,並釋放客戶端多餘的緩衝區
    clientsCron();

    /* Handle background operations on Redis databases. */
    // 對數據庫執行各類操做
    databasesCron();   /* !咱們關注的方法! */

databasesCron()

databasesCron() 中 調用了 activeExpireCycle()方法,來對過時的數據進行處理。(在這裏還會作一些其餘操做~ 調整數據庫大小,主動和漸進式rehash)

// 對數據庫執行刪除過時鍵,調整大小,以及主動和漸進式 rehash
void databasesCron(void) {

    // 判斷是不是主服務器 若是是 執行主動過時鍵清除
    if (server.active_expire_enabled && server.masterhost == NULL)
        // 清除模式爲 CYCLE_SLOW ,這個模式會盡可能多清除過時鍵
        activeExpireCycle(ACTIVE_EXPIRE_CYCLE_SLOW);

    // 在沒有 BGSAVE 或者 BGREWRITEAOF 執行時,對哈希表進行 rehash
    if (server.rdb_child_pid == -1 && server.aof_child_pid == -1) {
        static unsigned int resize_db = 0;
        static unsigned int rehash_db = 0;
        unsigned int dbs_per_call = REDIS_DBCRON_DBS_PER_CALL;
        unsigned int j;

        /* Don't test more DBs than we have. */
        // 設定要測試的數據庫數量
        if (dbs_per_call > server.dbnum) dbs_per_call = server.dbnum;

        /* Resize */
        // 調整字典的大小
        for (j = 0; j < dbs_per_call; j++) {
            tryResizeHashTables(resize_db % server.dbnum);
            resize_db++;
        }

        /* Rehash */
        // 對字典進行漸進式 rehash
        if (server.activerehashing) {
            for (j = 0; j < dbs_per_call; j++) {
                int work_done = incrementallyRehash(rehash_db % server.dbnum);
                rehash_db++;
                if (work_done) {
                    /* If the function did some work, stop here, we'll do
                     * more at the next cron loop. */
                    break;
                }
            }
        }
    }
}

activeExpireCycle()

大體流程以下

1 遍歷指定個數的db(默認的 16 )進行刪除操做

2 針對每一個db隨機獲取過時數據每次遍歷不超過指定數量(如20),發現過時數據並進行刪除。

3 若是有多於25%的keys過時,重複步驟 2

除了主動淘汰的頻率外,Redis對每次淘汰任務執行的最大時長也有一個限定,這樣保證了每次主動淘汰不會過多阻塞應用請求,如下是這個限定計算公式:

#define ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC 25 /* CPU max % for keys collection */ ``... ``timelimit = 1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/server.hz/100;

也就是每次執行時間的25%用於過時數據刪除。

void activeExpireCycle(int type) {
    // 靜態變量,用來累積函數連續執行時的數據
    static unsigned int current_db = 0; /* Last DB tested. */
    static int timelimit_exit = 0;      /* Time limit hit in previous call? */
    static long long last_fast_cycle = 0; /* When last fast cycle ran. */

    unsigned int j, iteration = 0;
    // 默認每次處理的數據庫數量
    unsigned int dbs_per_call = REDIS_DBCRON_DBS_PER_CALL;
    // 函數開始的時間
    long long start = ustime(), timelimit;

    // 快速模式
    if (type == ACTIVE_EXPIRE_CYCLE_FAST) {
        // 若是上次函數沒有觸發 timelimit_exit ,那麼不執行處理
        if (!timelimit_exit) return;
        // 若是距離上次執行未夠必定時間,那麼不執行處理
        if (start < last_fast_cycle + ACTIVE_EXPIRE_CYCLE_FAST_DURATION*2) return;
        // 運行到這裏,說明執行快速處理,記錄當前時間
        last_fast_cycle = start;
    }

    /* 
     * 通常狀況下,函數只處理 REDIS_DBCRON_DBS_PER_CALL 個數據庫,
     * 除非:
     *
     * 1) 當前數據庫的數量小於 REDIS_DBCRON_DBS_PER_CALL
     * 2) 若是上次處理遇到了時間上限,那麼此次須要對全部數據庫進行掃描,
     *     這能夠避免過多的過時鍵佔用空間
     */
    if (dbs_per_call > server.dbnum || timelimit_exit)
        dbs_per_call = server.dbnum;

    // 函數處理的微秒時間上限
    // ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC 默認爲 25 ,也便是 25 % 的 CPU 時間
    timelimit = 1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/server.hz/100;
    timelimit_exit = 0;
    if (timelimit <= 0) timelimit = 1;

    // 若是是運行在快速模式之下
    // 那麼最多隻能運行 FAST_DURATION 微秒 
    // 默認值爲 1000 (微秒)
    if (type == ACTIVE_EXPIRE_CYCLE_FAST)
        timelimit = ACTIVE_EXPIRE_CYCLE_FAST_DURATION; /* in microseconds. */

    // 遍歷數據庫
    for (j = 0; j < dbs_per_call; j++) {
        int expired;
        // 指向要處理的數據庫
        redisDb *db = server.db+(current_db % server.dbnum);

        // 爲 DB 計數器加一,若是進入 do 循環以後由於超時而跳出
        // 那麼下次會直接從下個 DB 開始處理
        current_db++;

        do {
            unsigned long num, slots;
            long long now, ttl_sum;
            int ttl_samples;

            /* If there is nothing to expire try next DB ASAP. */
            // 獲取數據庫中帶過時時間的鍵的數量
            // 若是該數量爲 0 ,直接跳過這個數據庫
            if ((num = dictSize(db->expires)) == 0) {
                db->avg_ttl = 0;
                break;
            }
            // 獲取數據庫中鍵值對的數量
            slots = dictSlots(db->expires);
            // 當前時間
            now = mstime();

            // 這個數據庫的使用率低於 1% ,掃描起來太費力了(大部分都會 MISS)
            // 跳過,等待字典收縮程序運行
            if (num && slots > DICT_HT_INITIAL_SIZE &&
                (num*100/slots < 1)) break;

            /* 
             * 樣本計數器
             */
            // 已處理過時鍵計數器
            expired = 0;
            // 鍵的總 TTL 計數器
            ttl_sum = 0;
            // 總共處理的鍵計數器
            ttl_samples = 0;

            // 每次最多隻能檢查 LOOKUPS_PER_LOOP 個鍵
            if (num > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP)
                num = ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP;

            // 開始遍歷數據庫
            while (num--) {
                dictEntry *de;
                long long ttl;

                // 從 expires 中隨機取出一個帶過時時間的鍵
                if ((de = dictGetRandomKey(db->expires)) == NULL) break;
                // 計算 TTL
                ttl = dictGetSignedIntegerVal(de)-now;
                // 若是鍵已通過期,那麼刪除它,並將 expired 計數器增一
                if (activeExpireCycleTryExpire(db,de,now)) expired++;
                if (ttl < 0) ttl = 0;
                // 累積鍵的 TTL
                ttl_sum += ttl;
                // 累積處理鍵的個數
                ttl_samples++;
            }

            /* Update the average TTL stats for this database. */
            // 爲這個數據庫更新平均 TTL 統計數據
            if (ttl_samples) {
                // 計算當前平均值
                long long avg_ttl = ttl_sum/ttl_samples;
                
                // 若是這是第一次設置數據庫平均 TTL ,那麼進行初始化
                if (db->avg_ttl == 0) db->avg_ttl = avg_ttl;
                /* Smooth the value averaging with the previous one. */
                // 取數據庫的上次平均 TTL 和今次平均 TTL 的平均值
                db->avg_ttl = (db->avg_ttl+avg_ttl)/2;
            }

            // 咱們不能用太長時間處理過時鍵,
            // 因此這個函數執行必定時間以後就要返回

            // 更新遍歷次數
            iteration++;

            // 每遍歷 16 次執行一次
            if ((iteration & 0xf) == 0 && /* check once every 16 iterations. */
                (ustime()-start) > timelimit)
            {
                // 若是遍歷次數正好是 16 的倍數
                // 而且遍歷的時間超過了 timelimit
                // 那麼斷開 timelimit_exit
                timelimit_exit = 1;
            }

            // 已經超時了,返回
            if (timelimit_exit) return;

            // 若是已刪除的過時鍵佔當前總數據庫帶過時時間的鍵數量的 25 %
            // 那麼再也不遍歷
        } while (expired > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP/4);
    }
}

hz調大將會提升Redis主動淘汰的頻率,若是你的Redis存儲中包含不少冷數據佔用內存過大的話,能夠考慮將這個值調大,但Redis做者建議這個值不要超過100。咱們實際線上將這個值調大到100,觀察到CPU會增長2%左右,但對冷數據的內存釋放速度確實有明顯的提升(經過觀察keyspace個數和used_memory大小)。

能夠看出timelimit和server.hz是一個倒數的關係,也就是說hz配置越大,timelimit就越小。換句話說是每秒鐘指望的主動淘汰頻率越高,則每次淘汰最長佔用時間就越短。這裏每秒鐘的最長淘汰佔用時間是固定的250ms(1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/100),而淘汰頻率和每次淘汰的最長時間是經過hz參數控制的。

所以當redis中的過時key比率沒有超過25%以前,提升hz能夠明顯提升掃描key的最小個數。假設hz爲10,則一秒內最少掃描200個key(一秒調用10次*每次最少隨機取出20個key),若是hz改成100,則一秒內最少掃描2000個key;另外一方面,若是過時key比率超過25%,則掃描key的個數無上限,可是cpu時間每秒鐘最多佔用250ms。

當REDIS運行在主從模式時,只有主結點纔會執行上述這兩種過時刪除策略,而後把刪除操做」del key」同步到從結點。

if (server.active_expire_enabled && server.masterhost == NULL)  // 判斷是不是主節點 從節點不須要執行activeExpireCycle()函數。
        // 清除模式爲 CYCLE_SLOW ,這個模式會盡可能多清除過時鍵
        activeExpireCycle(ACTIVE_EXPIRE_CYCLE_SLOW);

隨機個數

redis.config.ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 決定每次循環從數據庫 expire中隨機挑選值的個數

逐出算法

若是不限制 reids 對內存使用的限制,它將會使用所有的內存。能夠經過 config.memory 來指定redis 對內存的使用量 。

下面是redis 配置文件中的說明

543 # Set a memory usage limit to the specified amount of bytes.
 544 # When the memory limit is reached Redis will try to remove keys
 545 # according to the eviction policy selected (see maxmemory-policy).
 546 #
 547 # If Redis can't remove keys according to the policy, or if the policy is
 548 # set to 'noeviction', Redis will start to reply with errors to commands
 549 # that would use more memory, like SET, LPUSH, and so on, and will continue
 550 # to reply to read-only commands like GET.
 551 #
 552 # This option is usually useful when using Redis as an LRU or LFU cache, or to
 553 # set a hard memory limit for an instance (using the 'noeviction' policy).
 554 #
 555 # WARNING: If you have replicas attached to an instance with maxmemory on,
 556 # the size of the output buffers needed to feed the replicas are subtracted
 557 # from the used memory count, so that network problems / resyncs will
 558 # not trigger a loop where keys are evicted, and in turn the output
 559 # buffer of replicas is full with DELs of keys evicted triggering the deletion
 560 # of more keys, and so forth until the database is completely emptied.
 561 #
 562 # In short... if you have replicas attached it is suggested that you set a lower
 563 # limit for maxmemory so that there is some free RAM on the system for replica
 564 # output buffers (but this is not needed if the policy is 'noeviction').
 
將內存使用限制設置爲指定的字節。當已達到內存限制Redis將根據所選的逐出策略(請參閱maxmemory策略)嘗試刪除數據。

若是Redis沒法根據逐出策略移除密鑰,或者策略設置爲「noeviction」,Redis將開始對使用更多內存的命令(如set、LPUSH等)進行錯誤回覆,並將繼續回覆只讀命令,如GET。

當將Redis用做LRU或LFU緩存或設置實例的硬內存限制(使用「noeviction」策略)時,此選項一般頗有用。

警告:若是將副本附加到啓用maxmemory的實例,則將從已用內存計數中減去饋送副本所需的輸出緩衝區的大小,這樣,網絡問題/從新同步將不會觸發收回密鑰的循環,而副本的輸出緩衝區將充滿收回的密鑰增量,從而觸發刪除更多鍵,依此類推,直到數據庫徹底清空。

簡而言之。。。若是附加了副本,建議您設置maxmemory的下限,以便系統上有一些空閒RAM用於副本輸出緩衝區(但若是策略爲「noeviction」,則不須要此限制)。

驅逐策略的配置

Maxmemery-policy volatile-lru

當前已用內存超過 maxmemory 限定時,觸發主動清理策略

易失數據清理

volatile-lru:只對設置了過時時間的key進行LRU(默認值)

volatile-random:隨機刪除即將過時key

volatile-ttl : 刪除即將過時的

volatile-lfu:挑選最近使用次數最少的數據淘汰

所有數據清理

allkeys-lru : 刪除lru算法的key

allkeys-lfu:挑選最近使用次數最少的數據淘汰

allkeys-random:隨機刪除

禁止驅逐

(Redis 4.0 默認策略)

noeviction : 永不過時,返回錯誤當mem_used內存已經超過maxmemory的設定,對於全部的讀寫請求都會觸發redis.c/freeMemoryIfNeeded(void)函數以清理超出的內存。注意這個清理過程是阻塞的,直到清理出足夠的內存空間。因此若是在達到maxmemory而且調用方還在不斷寫入的狀況下,可能會反覆觸發主動清理策略,致使請求會有必定的延遲。

清理時會根據用戶配置的maxmemory-policy來作適當的清理(通常是LRU或TTL),這裏的LRU或TTL策略並非針對redis的全部key,而是以配置文件中的maxmemory-samples個key做爲樣本池進行抽樣清理。

maxmemory-samples在redis-3.0.0中的默認配置爲5,若是增長,會提升LRU或TTL的精準度,redis做者測試的結果是當這個配置爲10時已經很是接近全量LRU的精準度了,而且增長maxmemory-samples會致使在主動清理時消耗更多的CPU時間,建議:

1 儘可能不要觸發maxmemory,最好在mem_used內存佔用達到maxmemory的必定比例後,須要考慮調大hz以加快淘汰,或者進行集羣擴容。

2 若是可以控制住內存,則能夠不用修改maxmemory-samples配置;若是Redis自己就做爲LRU cache服務(這種服務通常長時間處於maxmemory狀態,由Redis自動作LRU淘汰),能夠適當調大maxmemory-samples。

這裏提一句,實際上redis根本就不會準確的將整個數據庫中最久未被使用的鍵刪除,而是每次從數據庫中隨機取5個鍵並刪除這5個鍵裏最久未被使用的鍵。上面提到的全部的隨機的操做實際上都是這樣的,這個5能夠用過redis的配置文件中的maxmemeory-samples參數配置。

數據逐出策略配置依據

使用INFO命令輸出監控信息,查詢緩存int和miss的次數,根據業務需求調優Redis配置。

相關文章
相關標籤/搜索