一、背景
在現網環境,一些使用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: https://github.com
2、Jedis: https://github.com/redis/jedis
3、Redis: https://github.com/redis/redis
4、Perf:https://perf.wiki.kernel.org
作者:vivo網際網路資料庫團隊—Yuan Jianwei