Redis 大集羣擴容性能優化實踐

作者:vivo 互聯網數據庫團隊—Yuan Jianwei

一、背景

在現網環境,一些使用 Redis 集羣的業務隨着業務量的上漲,往往需要進行節點擴容操作。

之前有了解到運維同學對一些節點數比較大的 Redis 集羣進行擴容操作後,業務側反映集羣性能下降,具體表現在訪問時延增長明顯。

某些業務對 Redis 集羣訪問時延比較敏感,例如現網環境對模型實時讀取,或者一些業務依賴讀取 Redis 集羣的同步流程,會影響業務的實時流程時延。業務側可能無法接受。

爲了找到這個問題的根因,我們對某一次的 Redis 集羣遷移操作後的集羣性能下降問題進行排查。

1.1 問題描述

這一次具體的 Redis 集羣問題的場景是:某一個 Redis 集羣進行過擴容操作。業務側使用 Hiredis-vip 進行 Redis 集羣訪問,進行 MGET 操作。

業務側感知到訪問 Redis 集羣的時延變高。

1.2 現網環境說明

1.3 觀察現象

因爲時延變高,我們從幾個方面進行排查:

通過簡單的監控排查,帶寬負載不高。但是發現 CPU 表現異常:

圖片

1.3.1 對比 ops 和 CPU 負載

觀察業務反饋使用的 MGET 和 CPU 負載,我們找到了對應的監控曲線。

從時間上分析,MGET 和 CPU 負載高並沒有直接關聯。業務側反饋的是 MGET 的時延普遍增高。此處看到 MGET 的 OPS 和 CPU 負載是錯峯的。

圖片

此處可以暫時確定業務請求和 CPU 負載暫時沒有直接關係,但是從曲線上可以看出:在同一個時間軸上,業務請求和 cpu 負載存在錯峯的情況,兩者間應該有間接關係。

1.3.2 對比 Cluster 指令 OPS 和 CPU 負載

由於之前有運維側同事有反饋集羣進行過擴容操作,必然存在 slot 的遷移。

考慮到業務的客戶端一般都會使用緩存存放 Redis 集羣的 slot 拓撲信息,因此懷疑 Cluster 指令會和 CPU 負載存在一定聯繫。

我們找到了當中確實有一些聯繫:

圖片

此處可以明顯看到:某個實例在執行 Cluster 指令的時候,CPU 的使用會明顯上漲。

根據上述現象,大致可以進行一個簡單的聚焦:

同時,引申幾個需要關注的問題:

二、問題排查

2.1 Redis 熱點排查

我們對一臺現場出現了 CPU 負載高的 Redis 實例使用 perf top 進行簡單的分析:

圖片

從上圖可以看出來,函數(ClusterReplyMultiBulkSlots)佔用的 CPU 資源高達 51.84%,存在異常

2.1.1 ClusterReplyMultiBulkSlots 實現原理

我們對 clusterReplyMultiBulkSlots 函數進行分析:

void clusterReplyMultiBulkSlots(client *c) {
    /* Format: 1) 1) start slot
     *            2) end slot
     *            3) 1) master IP
     *               2) master port
     *               3) node ID
     *            4) 1) replica IP
     *               2) replica port
     *               3) node ID
     *           ... continued until done
     */
    int num_masters = 0;
    void *slot_replylen = addDeferredMultiBulkLength(c);
    dictEntry *de;
    dictIterator *di = dictGetSafeIterator(server.cluster->nodes);
    while((de = dictNext(di)) != NULL) {
        /*注意:此處是對當前Redis節點記錄的集羣所有主節點都進行了遍歷*/
        clusterNode *node = dictGetVal(de);
        int j = 0, start = -1;
        /* Skip slaves (that are iterated when producing the output of their
         * master) and  masters not serving any slot. */
        /*跳過備節點。備節點的信息會從主節點側獲取。*/
        if (!nodeIsMaster(node) || node->numslots == 0) continue;
        for (j = 0; j < CLUSTER_SLOTS; j++) {
            /*注意:此處是對當前節點中記錄的所有slot進行了遍歷*/
            int bit, i;
            /*確認當前節點是不是佔有循環終端的slot*/
            if ((bit = clusterNodeGetSlotBit(node,j)) != 0) {
                if (start == -1) start = j;
            }
            /*簡單分析,此處的邏輯大概就是找出連續的區間,是的話放到返回中;不是的話繼續往下遞歸slot。
              如果是開始的話,開始一個連續區間,直到和當前的不連續。*/
            if (start != -1 && (!bit || j == CLUSTER_SLOTS-1)) {
                int nested_elements = 3; /* slots (2) + master addr (1). */
                void *nested_replylen = addDeferredMultiBulkLength(c);
                if (bit && j == CLUSTER_SLOTS-1) j++;
                /* If slot exists in output map, add to it's list.
                 * else, create a new output map for this slot */
                if (start == j-1) {
                    addReplyLongLong(c, start); /* only one slot; low==high */
                    addReplyLongLong(c, start);
                } else {
                    addReplyLongLong(c, start); /* low */
                    addReplyLongLong(c, j-1);   /* high */
                }
                start = -1;
                /* First node reply position is always the master */
                addReplyMultiBulkLen(c, 3);
                addReplyBulkCString(c, node->ip);
                addReplyLongLong(c, node->port);
                addReplyBulkCBuffer(c, node->name, CLUSTER_NAMELEN);
                /* Remaining nodes in reply are replicas for slot range */
                for (i = 0; i < node->numslaves; i++) {
                    /*注意:此處遍歷了節點下面的備節點信息,用於返回*/
                    /* This loop is copy/pasted from clusterGenNodeDescription()
                     * with modifications for per-slot node aggregation */
                    if (nodeFailed(node->slaves[i])) continue;
                    addReplyMultiBulkLen(c, 3);
                    addReplyBulkCString(c, node->slaves[i]->ip);
                    addReplyLongLong(c, node->slaves[i]->port);
                    addReplyBulkCBuffer(c, node->slaves[i]->name, CLUSTER_NAMELEN);
                    nested_elements++;
                }
                setDeferredMultiBulkLength(c, nested_replylen, nested_elements);
                num_masters++;
            }
        }
    }
    dictReleaseIterator(di);
    setDeferredMultiBulkLength(c, slot_replylen, num_masters);
}
/* Return the slot bit from the cluster node structure. */
/*該函數用於判斷指定的slot是否屬於當前clusterNodes節點*/
int clusterNodeGetSlotBit(clusterNode *n, int slot) {
    return bitmapTestBit(n->slots,slot);
}
/* Test bit 'pos' in a generic bitmap. Return 1 if the bit is set,
 * otherwise 0. */
/*此處流程用於判斷指定的的位置在bitmap上是否爲1*/
int bitmapTestBit(unsigned char *bitmap, int pos) {
    off_t byte = pos/8;
    int bit = pos&7;
    return (bitmap[byte] & (1<<bit)) != 0;
}
typedef struct clusterNode {
    ...
    /*使用一個長度爲CLUSTER_SLOTS/8的char數組對當前分配的slot進行記錄*/
    unsigned char slots[CLUSTER_SLOTS/8]; /* slots handled by this node */
    ...
} clusterNode;

每一個節點(ClusterNode)使用位圖(char slots[CLUSTER_SLOTS/8])存放 slot 的分配信息。

簡要說一下 BitmapTestBit 的邏輯:clusterNode->slots 是一個長度爲 CLUSTER_SLOTS/8 的數組。CLUSTER_SLOTS 是固定值 16384。數組上的每一個位分別代表一個 slot。此處的 bitmap 數組下標則是 0 到 2047,slot 的範圍是 0 到 16383。

因爲要判斷 pos 這個位置的 bit 上是否是 1,因此:

以 slot 爲 10001 進行舉例:

圖片

因此 10001 這個 slot 對應的是下標 1250 的 Byte, 要校驗的是下標 1 的 bit。

對應在 ClusterNode->slots 上的對應位置:

圖片

圖示綠色的方塊表示 bitmap[1250],也就是對應存放 slot 10001 的 Byte;紅框標識(bit[1])對應的就是 1<<bit 的位置。bitmap[byte] & (1<<bit),也就是確認紅框對應的位置是否是 1。是的話表示 bitmap 上 10001 已經打標。

總結 ClusterNodeGetSlotBit 的概要邏輯是:判斷當前的這個 slot 是否分配在當前 node 上。因此 ClusterReplyMultiBulkSlots 大概邏輯表示如下:

圖片

大概步驟如下:

從獲取 CLUSTER SLOTS 指令的結果來看,可以看到,複雜度是 <集羣主節點個數> *<slot 總個數 >。其中 slot 的總個數是 16384,固定值。

2.1.2 Redis 熱點排查總結

就目前來看,CLUSTER SLOTS 指令時延隨着 Redis 集羣的主節點個數,線性增長。而這次我們排查的集羣主節點數比較大,可以解釋這次排查的現網現象中 CLUSTER SLOTS 指令時延爲何較大。

2.2 客戶端排查

瞭解到運維同學們存在擴容操作,擴容完成後必然涉及到一些 key 在訪問的時候存在 MOVED 的錯誤。

當前使用的 Hiredis-vip 客戶端代碼進行簡單的瀏覽,簡要分析以下當前業務使用的 Hiredis-vip 客戶端在遇到 MOVED 的時候會怎樣處理。由於其他的大部分業務常用的 Jedis 客戶端,此處也對 Jedis 客戶端對應流程進行簡單分析。

2.2.1 Hiredis-vip 對 MOVED 處理實現原理

Hiredis-vip 針對 MOVED 的操作:

查看 Cluster_update_route 的調用過程:

此處的 cluster_update_route_by_addr 進行了 CLUSTER SLOT 操作。可以看到,當獲取到 MOVED 報錯的時候,Hiredis-vip 會重新更新 Redis 集羣拓撲結構,有下面的特性:

2.2.2 Jedis 對 MOVED 處理實現原理

對 Jedis 客戶端代碼進行簡單瀏覽,發現如果存在 MOVED 錯誤,會調用 renewSlotCache。

繼續看 renewSlotCache 的調用,此處可以確認: Jedis 在集羣模式下在遇到 MOVED 的報錯時候,會發送 Redis 命令 CLUSTER SLOTS, 重新拉取 Redis 集羣的 slot 拓撲結構。

2.2.3 客戶端實現原理小結

由於 Jedis 是 Java 的 Redis 客戶端,Hiredis-vip 是 c++ 的 Redis 客戶端,可以簡單認爲這種異常處理機制是共性操作。

對客戶端集羣模式下對 MOVED 的流程梳理大概如下:

圖片

總的來說:

1)使用客戶端緩存的 slot 拓撲進行對 key 的訪問;

2)Redis 節點返回正常:

3)Redis 節點返回 MOVED:

2.2.3 客戶端排查小結

Redis 集羣正在擴容,也就是必然存在一些 Redis 客戶端在訪問 Redis 集羣遇到 MOVED,執行 Redis 指令 CLUSTER SLOTS 進行拓撲結構更新。

如果遷移的 key 命中率高,CLUSTER SLOTS 指令會更加頻繁的執行。這樣導致的結果是遷移過程中 Redis 集羣會持續被客戶端執行 CLUSTER SLOTS 指令。

2.3 排查小結

此處,結合 Redis 側的 CLUSTER SLOTS 機制以及客戶端對 MOVED 的處理邏輯,可以解答之前的幾個個問題:

爲什麼會有較多的 Cluster 指令被執行?

因爲發生過遷移操作,業務訪問一些遷移過的 key 會拿到 MOVED 返回,客戶端會對該返回重新拉取 slot 拓撲信息,執行 CLUSTER SLOTS。

爲什麼 Cluster 指令執行的時候 CPU 資源比較高?

分析 Redis 源碼,發現 CLUSTER SLOT 指令的時間複雜度和主節點個數成正比。業務當前的 Redis 集羣主節點個數比較多,自然耗時高,佔用 CPU 資源高。

爲什麼節點規模大的集羣遷移 slot 操作容易 “中招”?

遷移操作必然帶來一些客戶端訪問 key 的時候返回 MOVED;

客戶端對於 MOVED 的返回會執行 CLUSTER SLOTS 指令;

CLUSTER SLOTS 指令隨着集羣主節點個數的增加,時延會上升;

業務的訪問在 slot 的遷移期間會因爲 CLUSTER SLOTS 的時延上升,在外部的感知是執行指令的時延升高。

此段與本文無關(webflux 是兼容 Spring MVC 基於 @Controller,@RequestMapping 等註解的編程開發方式的,可以做到平滑切換)

三、優化

3.1 現狀分析

根據目前的情況來看,客戶端遇到 MOVED 進行 CLUSTER SLOTS 執行是正常的流程,因爲需要更新集羣的 slot 拓撲結構提高後續的集羣訪問效率。

此處流程除了 Jedis,Hiredis-vip,其他的客戶端應該也會進行類似的 slot 信息緩存優化。此處流程優化空間不大,是 Redis 的集羣訪問機制決定。

因此對 Redis 的集羣信息記錄進行分析。

3.1.1 Redis 集羣元數據分析

集羣中每一個 Redis 節點都會有一些集羣的元數據記錄,記錄於 server.cluster,內容如下:

typedef struct clusterState {
    ...
    dict *nodes;          /* Hash table of name -> clusterNode structures */
    /*nodes記錄的是所有的節點,使用dict記錄*/
    ...
    clusterNode *slots[CLUSTER_SLOTS];/*slots記錄的是slot數組,內容是node的指針*/
    ...
} clusterState;

如 2.1 所述,原有邏輯通過遍歷每個節點的 slot 信息獲得拓撲結構。

3.1.2 Redis 集羣元數據分析

觀察 CLUSTER SLOTS 的返回結果:

/* Format: 1) 1) start slot
 *            2) end slot
 *            3) 1) master IP
 *               2) master port
 *               3) node ID
 *            4) 1) replica IP
 *               2) replica port
 *               3) node ID
 *           ... continued until done
 */

結合 server.cluster 中存放的集羣信息,筆者認爲此處可以使用 server.cluster->slots 進行遍歷。因爲 server.cluster->slots 已經在每一次集羣的拓撲變化得到了更新,保存的是節點指針。

3.2 優化方案

簡單的優化思路如下:

因此只要對 server.cluster->slots 進行遍歷,可以滿足需求。簡單表示大概如下:

圖片

這樣的時間複雜度降低到 <slot 總個數>

3.3 實現

優化邏輯如下:

void clusterReplyMultiBulkSlots(client * c) {
    /* Format: 1) 1) start slot
     *            2) end slot
     *            3) 1) master IP
     *               2) master port
     *               3) node ID
     *            4) 1) replica IP
     *               2) replica port
     *               3) node ID
     *           ... continued until done
     */
    clusterNode *n = NULL;
    int num_masters = 0, start = -1;
    void *slot_replylen = addReplyDeferredLen(c);
    for (int i = 0; i <= CLUSTER_SLOTS; i++) {
        /*對所有slot進行遍歷*/
        /* Find start node and slot id. */
        if (n == NULL) {
            if (i == CLUSTER_SLOTS) break;
            n = server.cluster->slots[i];
            start = i;
            continue;
        }
        /* Add cluster slots info when occur different node with start
         * or end of slot. */
        if (i == CLUSTER_SLOTS || n != server.cluster->slots[i]) {
            /*遍歷主節點下面的備節點,添加返回客戶端的信息*/
            addNodeReplyForClusterSlot(c, n, start, i-1);
            num_masters++;
            if (i == CLUSTER_SLOTS) break;
            n = server.cluster->slots[i];
            start = i;
        }
    }
    setDeferredArrayLen(c, slot_replylen, num_masters);
}

通過對 server.cluster->slots 進行遍歷,找到某個節點下的 “連續” 的 slot 區域,一旦後續不連續,把之前的“連續”slot 區域的節點信息以及其備節點信息進行輸出,然後繼續下一個“連續”slot 區域的查找於輸出。

四、優化結果對比

對兩個版本的 Redis 的 CLUSTER SLOTS 指令進行橫向對比。

4.1 測試環境 & 壓測場景

操作系統:manjaro 20.2

硬件配置

Redis 集羣信息

1)持久化配置

2)集羣節點信息:

壓測場景

4.2 CPU 資源佔用對比

perf 導出火焰圖。原有版本:

圖片

優化後:

圖片

可以明顯看到,優化後的佔比大幅度下降。基本符合預期。

4.3 耗時對比

在上進行測試,嵌入耗時測試代碼:

else if (!strcasecmp(c->argv[1]->ptr,"slots") && c->argc == 2) {
        /* CLUSTER SLOTS */
        long long now = ustime();
        clusterReplyMultiBulkSlots(c);
        serverLog(LL_NOTICE,
            "cluster slots cost time:%lld us", ustime() - now);
    }

輸入日誌進行對比;

原版的日誌輸出

37351:M 06 Mar 2021 16:11:39.313 * cluster slots cost time:2061 us。

優化後版本日誌輸出

35562:M 06 Mar 2021 16:11:27.862 * cluster slots cost time:168 us。

從耗時上看下降明顯:從 2000+us 下降到 200-us;在 100 個主節點的集羣中的耗時縮減到原來的 8.2%;優化結果基本符合預期。

五、總結

這裏可以簡單描述下文章上述的動作從而得出的這樣的一個結論:性能缺陷

簡單總結下上述的排查以及優化過程:

從上述的排查以及優化過程可以得出一個結論:目前的 Redis 在 CLUSTER SLOT 指令存在性能缺陷。

因爲 Redis 的數據分片機制,決定了 Redis 集羣模式下的 key 訪問方法是緩存 slot 的拓撲信息。優化點也只能在 CLUSTER SLOTS 入手。而 Redis 的集羣節點個數一般沒有這麼大,問題暴露的不明顯。

其實 Hiredis-vip 的邏輯也存在一定問題。如 2.2.1 所說,Hiredis-vip 的 slot 拓撲更新方法是遍歷所有的節點挨個進行 CLUSTER SLOTS。如果 Redis 集羣規模較大而且業務側的客戶端規模較多,會出現連鎖反應:

結合上述第 3 點,可以想象一下:有 1w 個客戶端對該 Redis 集羣進行訪問。因爲某個命中率較高的 key 存在遷移操作,所有的客戶端都需要更新 slot 拓撲。由於所有客戶端緩存的集羣節點信息相同,因此遍歷各個節點的順序是一致的。這 1w 個客戶端都使用同樣的順序對集羣各個節點進行遍歷地操作 CLUSTER SLOTS。由於 CLUSTER SLOTS 在大集羣中性能較差,Redis 節點很容易會被大量客戶端請求導致不可訪問。Redis 節點會根據遍歷順序依次被大部分的客戶端(例如 9k + 個客戶端)訪問,執行 CLUSTER SLOTS 指令,導致 Redis 節點挨個被阻塞。

最終結果是大規模的 Redis 集羣在進行 slot 遷移操作後,在大規模的 Hiredis-vip 客戶端訪問下業務側感知是普通指令時延變高,而 Redis 實例 CPU 資源佔用高漲。這個邏輯可以進行一定優化。

目前上述分節 3 的優化已經提交併合併到 Redis 6.2.2 版本中。

六、參考資料

1、Hiredis-vip: https://github.com

2、Jedis: https://github.com/redis/jedis

3、Redis: https://github.com/redis/redis

4、Perf:https://perf.wiki.kernel.org

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/yyEsHLGP-HKPjrGweNF50Q