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

一、背景

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

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

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

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

1.1 問題描述

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

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

1.2 現網環境說明

  • 目前現網環境部署的Redis版本多數是3.x或者4.x版本;

  • 業務訪問Redis集群的客戶端品類繁多,較多的使用Jedis。本次問題排查的業務使用客戶端Hiredis-vip進行訪問;

  • Redis集群的節點數比較大,規模是100+;

  • 集群之前存在擴容操作。

1.3 觀察現象

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

  • 頻寬是否打滿;

  • CPU是否佔用過高;

  • OPS是否很高;

通過簡單的監控排查,頻寬負載不高。但是發現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的使用會明顯上漲。

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

  • 業務側執行MGET,因為一些原因執行了Cluster指令;

  • Cluster指令因為一些原因導致CPU佔用較高影響其他操作;

  • 懷疑Cluster指令是性能瓶頸。

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

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

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

為什麼節點規模大的集群遷移slot操作容易「中招」?

二、問題排查

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,因此:

  • off_t byte = pos/8:拿到在bitmap上對應的哪一個位元組(Byte)上存放這個pos位置的資訊。因為一個Byte有8個bit。使用pos/8可以指導需要找的Byte在哪一個。此處把bitmap當成數組處理,這裡對應的便是對應下標的Byte。

  • int bit = pos&7:拿到是在這個位元組上對應哪一個bit表示這個pos位置的資訊。&7其實就是%8。可以想像對pos每8個一組進行分組,最後一組(不滿足8)的個數對應的便是在bitmap對應的Byte上對應的bit數組下標位置。

  • (bitmap[byte] & (1<<bit)):判斷對應的那個bit在bitmap[byte]上是否存在。

以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大概邏輯表示如下:

大概步驟如下:

  • 對每一個節點進行遍歷;

  • 對於每一個節點,遍歷所有的slots,使用ClusterNodeGetSlotBit判斷遍歷中的slot是否分配於當前節點;

從獲取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集群拓撲結構,有下面的特性:

  • 因為節點通過ip:port作為key,哈希方式一樣,如果集群拓撲類似,多個客戶端很容易同時到同一個節點進行訪問;

  • 如果某個節點訪問失敗,會通過迭代器找下一個節點,由於上述的原因,多個客戶端很容易同時到下一個節點進行訪問。

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:

  • 對Redis節點進行CLUSTER SLOTS指令執行,更新拓撲;

  • 使用新的拓撲對key重新訪問。

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的時延上升,在外部的感知是執行指令的時延升高。

三、優化

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 優化方案

簡單的優化思路如下:

  • 對slot進行遍歷,找出slot中節點是連續的塊;

  • 當前遍歷的slot的節點如果和之前遍歷的節點一致,說明目前訪問的slot和前面的是在同一個節點下,也就是是在某個節點下的「連續」的slot區域內;

  • 當前遍歷的slot的節點如果和之前遍歷的節點不一致,說明目前訪問的slot和前面的不同,前面的「連續」slot區域可以進行輸出;而當前slot作為下一個新的「連續」slot區域的開始。

因此只要對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

硬體配置:

  • CPU:AMD Ryzen 7 4800H

  • DRAM:DDR4 3200MHz 8G*2

Redis集群資訊:

1)持久化配置

  • 關閉aof

  • 關閉bgsave

2)集群節點資訊:

  • 節點個數:100

  • 所有節點都是主節點

壓測場景:

  • 使用benchmark工具對集群單個節點持續發送CLUSTER SLOTS指令;

  • 對其中一個版本壓測完後,回收集群,重新部署後再進行下一輪壓測。

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命令導致某些節點的訪問延遲明顯;

  • 使用perf top指令對Redis實例進行排查,發現clusterReplyMultiBulkSlots命令佔用CPU資源異常;

  • 對clusterReplyMultiBulkSlots進行分析,該函數存在明顯的性能問題;

  • 對clusterReplyMultiBulkSlots進行優化,性能提升明顯。

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

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

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

1)如果Redis集群較大,CLUSTER SLOTS響應比較慢;

2)如果某個節點沒有響應或者返回報錯,Hiredis-vip客戶端會對下一個節點繼續進行請求;

3)Hiredis-vip客戶端中對Redis集群節點迭代遍歷的方法相同(因為集群的資訊在各個客戶端基本一致),此時當客戶端規模較大的時候,某個Redis節點可能存在阻塞,就會導致hiredis-vip客戶端遍歷下一個Redis節點;

4)大量Hiredis-vip客戶端挨個地對一些Redis節點進行訪問,如果Redis節點無法負擔這樣的請求,這樣會導致Redis節點在大量Hiredis-vip客戶端的「遍歷」下挨個請求:

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

5)最終的表現是大部分Redis節點的CPU負載暴漲,很多Hiredis-vip客戶端則繼續無法更新slot拓撲。

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

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

六、參考資料

1、Hiredis-vip: //github.com

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

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

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

作者:vivo互聯網資料庫團隊—Yuan Jianwei