從實現角度看redis lazy free的使用和注意事項

本文主要從實現角度分析了redis lazy free特性的使用方法和注意事項redis

有幫助的話就點個贊,關注專欄數據庫,不跑路吧~~
不按期更新數據庫的小知識和實用經驗,讓你不用再須要擔憂跑路數據庫


衆所周知,redis對外提供的服務是由單線程支撐,經過事件(event)驅動各類內部邏輯,好比網絡IO、命令處理、過時key處理、超時等邏輯。在執行耗時命令(如範圍掃描類的keys, 超大hash下的hgetall等)、瞬時大量key過時/驅逐等狀況下,會形成redis的QPS降低,阻塞其餘請求。近期就遇到過大容量而且大量key的場景,因爲各類緣由引起的redis內存耗盡,致使有6位數的key幾乎同時被驅逐,短時間內redis hang住的狀況segmentfault

耗時命令是客戶端行爲,服務端不可控,優化餘地有限,做者antirez在4.0這個大版本中增長了針對大量key過時/驅逐的lazy free功能,服務端的事情仍是可控的,甚至提供了異步刪除的命令unlink(來龍去脈和做者的思路變遷,見做者博客:Lazy Redis is better Redis - <antirez>網絡

lazy free的功能在使用中有幾個注意事項(如下爲我的觀點,有誤的地方請評論區交流):架構

  1. lazy free不是在遇到快OOM的時候直接執行命令,放後臺釋放內存,而是也須要block一段時間去得到足夠的內存來執行命令
  2. lazy free不適合kv的平均大小過小或太大的場景,大小均衡的場景下性價比比較高(固然,能夠根據業務場景調整源碼裏的宏,從新編譯一個版本)
  3. redis短時間內實際上是能夠略微超出一點內存上限的,由於前一條命令沒檢測到內存超標(其實快超了)的狀況下,是能夠寫入一個很大的kv的,當後續命令進來以後會發現內存不夠了,交給後續命令執行釋放內存操做
  4. 若是業務能預估到可能會有集中的大量key過時,那麼最好ttl上加個隨機數,勻開來,避免集中expire形成的blocking,這點無論開不開lazy free都同樣

具體分析請見下文dom

參數

redis 4.0新加了4個參數,用來控制這種lazy free的行爲異步

  • lazyfree-lazy-eviction:是否異步驅逐key,當內存達到上限,分配失敗後
  • lazyfree-lazy-expire:是否異步進行key過時事件的處理
  • lazyfree-lazy-server-del:del命令是否異步執行刪除操做,相似unlink
  • replica-lazy-flush:replica client作全同步的時候,是否異步flush本地db

以上參數默認都是no,按需開啓,下面以lazyfree-lazy-eviction爲例,看看redis怎麼處理lazy free邏輯,其餘參數的邏輯相似函數

源碼分析

命令處理邏輯

int processCommand(client *c)是redis處理命令的主方法,在真正執行命令前,會有各類檢查,包括對OOM狀況下的處理源碼分析

int processCommand(client *c) {
    // ...

    if (server.maxmemory && !server.lua_timedout) {
        // 設置了maxmemory時,若是有必要,嘗試釋放內存(evict)
        int out_of_memory = freeMemoryIfNeededAndSafe() == C_ERR;

        // ...

        // 若是釋放內存失敗,而且當前將要執行的命令不容許OOM(通常是寫入類命令)
        if (out_of_memory &&
            (c->cmd->flags & CMD_DENYOOM ||
             (c->flags & CLIENT_MULTI && c->cmd->proc != execCommand))) {
            flagTransaction(c);
            // 向客戶端返回OOM
            addReply(c, shared.oomerr);
            return C_OK;
        }
    }

    // ...

    /* Exec the command */
    if (c->flags & CLIENT_MULTI &&
        c->cmd->proc != execCommand && c->cmd->proc != discardCommand &&
        c->cmd->proc != multiCommand && c->cmd->proc != watchCommand)
    {
        queueMultiCommand(c);
        addReply(c,shared.queued);
    } else {
        call(c,CMD_CALL_FULL);
        c->woff = server.master_repl_offset;
        if (listLength(server.ready_keys))
            handleClientsBlockedOnKeys();
    }
    return C_OK;

內存釋放(淘汰)邏輯

內存的釋放主要在freeMemoryIfNeededAndSafe()內進行,若是釋放不成功,會返回C_ERRfreeMemoryIfNeededAndSafe()包裝了底下的實現函數freeMemoryIfNeeded()學習

int freeMemoryIfNeeded(void) {
    // slave無論OOM的狀況
    if (server.masterhost && server.repl_slave_ignore_maxmemory) return C_OK;

    // ...

    // 獲取內存用量狀態,若是夠用,直接返回ok
    // 若是不夠用,這個方法會返回總共用了多少內存mem_reported,至少須要釋放多少內存mem_tofree
    // 這個方法頗有意思,暗示了其實redis是能夠用超內存的。即,在當前這個方法調用後,判斷內存足夠,可是寫入了一個很大的kv,等下一個倒黴蛋來請求的時候發現,內存不夠了,這時候纔會在下一次請求時觸發清理邏輯
    if (getMaxmemoryState(&mem_reported,NULL,&mem_tofree,NULL) == C_OK)
        return C_OK;

    // 用來記錄本次調用釋放了多少內存的變量
    mem_freed = 0;

    // 不須要evict的策略下,直接跳到釋放失敗的邏輯
    if (server.maxmemory_policy == MAXMEMORY_NO_EVICTION)
        goto cant_free; /* We need to free memory, but policy forbids. */

    // 循環,嘗試釋放足夠大的內存
    // 同步釋放的狀況下,若是要刪除的對象不少,或者是很大的hash/set/zset等,須要反覆循環屢次
    // 因此通常在監控裏看到有大量key evict的時候,會跟着看到QPS降低,RTT上升
    while (mem_freed < mem_tofree) {
        // 根據配置的maxmemory-policy,拿到一個能夠釋放掉的bestkey
        // 中間邏輯比較多,能夠再開一篇,先略過了
        if (server.maxmemory_policy & (MAXMEMORY_FLAG_LRU|MAXMEMORY_FLAG_LFU) ||
            server.maxmemory_policy == MAXMEMORY_VOLATILE_TTL) {        // 帶LRU/LFU/TTL的策略
            // ...
        }
        else if (server.maxmemory_policy == MAXMEMORY_ALLKEYS_RANDOM ||
                 server.maxmemory_policy == MAXMEMORY_VOLATILE_RANDOM) {    // 帶random的策略
           // ...
        }

        // 最終選中了一個bestkey
        if (bestkey) {
            if (server.lazyfree_lazy_eviction)
                // 若是配置了lazy free,嘗試異步刪除(不必定異步,相見下文)
                dbAsyncDelete(db,keyobj);
            else
                dbSyncDelete(db,keyobj);

            // ...

            // 若是是異步刪除,須要在循環過程當中按期評估後臺清理線程是否釋放了足夠的內存,默認每16次循環檢查一次
            // 能夠想到的是,若是kv都很小,那麼前面的操做並非異步,lazy free不生效。若是kv都很大,那麼幾乎全部kv都走異步清理,主線程接近空轉,若是清理線程不夠,那麼仍是會話相對長的時間的。因此應該是大小混合的場景比較合適lazy free,須要實驗數據驗證
            if (server.lazyfree_lazy_eviction && !(keys_freed % 16)) {
                if (getMaxmemoryState(NULL,NULL,NULL,NULL) == C_OK) {
                    // 若是釋放了足夠內存,那麼能夠直接跳出循環了
                    mem_freed = mem_tofree;
                }
            }
        }
    }

cant_free:
    // 沒法釋放內存時,作個好人,本次請求卡就卡吧,檢查一下後臺清理線程是否還有任務正在清理,等他清理出足夠內存以後再退出
    while(bioPendingJobsOfType(BIO_LAZY_FREE)) {
        if (((mem_reported - zmalloc_used_memory()) + mem_freed) >= mem_tofree)
            // 這裏有點疑問,若是已經能等到足夠的內存被釋放,爲何不直接返回C_OK???
            break;
        usleep(1000);
    }
    return C_ERR;
}

異步刪除邏輯

// 用來評估是否須要異步刪除的閾值
#define LAZYFREE_THRESHOLD 64
int dbAsyncDelete(redisDb *db, robj *key) {
    // 先從expire字典中刪了這個entry(釋放expire字典的entry內存,由於後面用不到),不會釋放key/value自己內存
    if (dictSize(db->expires) > 0) dictDelete(db->expires,key->ptr);

    // 從db的key space中摘掉這個entry,可是不釋放entry/key/value的內存
    dictEntry *de = dictUnlink(db->dict,key->ptr);
    if (de) {
        robj *val = dictGetVal(de);

        // 評估要刪除的代價
        // 默認1
        // list對象,取其長度
        // 以hash格式存儲的set/hash對象,取其元素個數
        // 跳錶存儲的zset,取跳錶長度
        size_t free_effort = lazyfreeGetFreeEffort(val);

        // 若是代價大於閾值,扔給後臺線程刪除
        if (free_effort > LAZYFREE_THRESHOLD && val->refcount == 1) {
            atomicIncr(lazyfree_objects,1);
            bioCreateBackgroundJob(BIO_LAZY_FREE,val,NULL,NULL);
            dictSetVal(db->dict,de,NULL);
        }

        // 釋放entry內存
    }
}

總結

感受redis能夠考慮一個功能,給一個參數配置內存高水位,超太高水位以後就能夠觸發evict操做。可是有個問題,可能清理速度趕不上寫入速度,怎麼合理平衡這二者須要仔細想一下。

另外感嘆一下antirez代碼層面上的架構能力,幾年前看過redis 2.8的代碼,從2.8的分支直接切到5.0以後,原來閱讀的位置並無偏離主線太遠。歷經幾個大版本的迭代,加了N多功能以後,代碼主體邏輯依舊沒有大改,真的是作到了對修改關閉,對擴展開放。向大佬學習

有幫助的話就點個贊,關注專欄數據庫,不跑路吧~~
不按期更新數據庫的小知識和實用經驗,讓你不用再須要擔憂跑路

相關文章
相關標籤/搜索