Redis叢集竟然越擴越慢?這種匪夷所思的現象竟然真的出現了!下面就來一探究竟。
1. 遇到了什麼問題
618期間,爲了應對壓測流量,我們擴容Redis叢集到40分片,但業務方反饋999線相比擴容前有明顯劣化(擴容前60ms,擴容後100ms+):
擴容後的99線反而不如擴容前的
比如5.31擴容了cl8個分片,擴容前999線在4ms左右:
擴容後就到了8ms,然而無論是整體cl叢集的QPS等各項指標都沒有明顯變化:
問題是擴容前後整體Redis指標沒有太大變化,QPS也沒有明顯波動,但999線就是比擴容前高,這是為什麼呢?
2. 原理
Cluster Nodes慢查詢?
初步排查發現有大量的Cluster Nodes慢查詢(是Redis自己記錄的執行時間超過10ms的命令)
而且還存在低峰期時CPU使用率偏高的問題
選取cl叢集的一臺8C16G的Master節點在上午5點45左右的CPU使用率和QPS,可以看到QPS不到1k,但是Redis程序使用單核CPU可以達到50%左右。
這些空閒時間的CPU在做什麼?使用perf取樣redis程序CPU使用率最高的函式:
perf record -F 99 $(pgrep redis)
特別明顯的是處於第一位的函式超過50%,透過檢視原始碼,發現這個函式就是cluster nodes命令會呼叫的函式!
所以我們懷疑此次擴容出現效能變差的原因很可能來自cluster nodes,這又和cluster nodes出現大量慢查詢關聯起來。
難道是這些慢查詢引發的嗎?首先我們要搞清楚cluster nodes是什麼,為什麼需要執行它,真的是這些慢查詢影響到叢集效能了嗎?
Cluster Nodes是什麼以及它的作用
我們知道Redis叢集的資料是分片的,所有key都分佈到16384個slot裡,每個Master都負責一部分slot,如果想訪問一個key,但是訪問的Redis節點不包含這次命令執行的slot,會返回MOVED ip port, 告訴客戶端正確的Redis節點的地址,然後客戶端再發送命令到正確的Redis節點。
可以看到這樣做存在兩次命令互動,爲了提升效能,一般的客戶端都會快取一個檢視儲存slot到Redis例項的對映關係。
Spring Redis中不同的底層客戶端實現:Jedis和Lettuce 有不一樣的策略,Jedis本身沒有拓撲快取,Spring在Jedis之外實現了拓撲快取,而由於Lettuce具備拓撲快取功能,就直接使用其本身的實現。
這個檢視可以用cluster nodes 或者 cluster slots獲取(在Redis7中新增了一個cluster shards命令,cluster slots被考慮廢棄),lettuce使用cluster nodes獲取,其輸出大致如下:
ca2dabcdeeeb2d7dececeaf18133c11bc2ada69e 10.3.14.155:6379@16379 slave 875a64d4afd9901ea7bbd8512c4d413a0cc6e313 0 1718349194000 22930 connected 2b81a3db103fb523ffd08968300003527c1359d8 10.3.10.66:6379@16379 master - 0 1718349193000 18909 connected 1765-1819 2244-2348 2731-2970 3097-3105 a3a1d93d882bd7a7f4d3a094632bd90db3c9bf49 10.3.28.148:6379@16379 slave 552a9698a2a1878de0f3372a776998c8fdb310aa 0 1718349192000 18908 connected d7603324abb7792c454079a0349ae4715d388d76 10.20.22.224:6379@16379 slave 5edc58c17900ea236a9ba8a665b2c9d5b021ba01 0 1718349193000 21766 connected 69e0048f9504f98800a2f3b3db21af5b0a99a193 10.20.22.40:6379@16379 slave 53cd8c03e28ff74e17f35997c7c4391a6f411d2e 0 1718349194000 21714 connected 773486cd616c232f4b87ac69b9151453ca4bcabc 10.20.21.51:6379@16379 master - 0 1718349193819 21823 connected 12782-12857 14101-14344 14687-14703 14794-14860 15302-15306
從中我們可以獲取到redis節點和slots之間的對應關係。
但是又引申出來一個問題,當叢集擴縮容 或 Failover造成叢集拓撲的變更,這時就需要透過cluster nodes命令獲取最新的叢集拓撲,下面就來介紹lettuce具體是如何重新整理拓撲的。
when-檢視何時重新整理?
Lettuce提供了兩種重新整理模式:
1、被動重新整理
2、定期重新整理
被動重新整理是指當發生了以下幾種情況會呼叫cluster nodes獲取最新拓撲:
拓撲重新整理機制 | 觸發時機 | 約束條件 |
---|---|---|
onAskRedirection | 執行 Redis 命令時候,Key 所在 SLOT 正在遷移資料 | 預設 30 秒內已經重新整理過,則跳過重新整理 |
onReconnectAttempt | ConnectionWatchdog 監聽到 channelInactive,立即觸發延遲重連,預設最多重連 5 次延遲提交機制 | |
onUncoveredSlot | 客戶端建立 Redis 連線時,找不到 SLOT 或者 Host/Port 對應的 partition 資訊 |
除此之外Lettuce還提供定時重新整理機制,Renault SDK預設60s重新整理一次。同樣定時重新整理也適用於上述30s內重新整理一次就不會再重新整理的限制,防止過多重新整理。
為什麼除了被動重新整理之外還需要定時重新整理?
考慮Master宕機/程序hang住的場景,按理說應該會觸發ConnectionWatchdog的自動重連,然而並不會!
經過實際測試,此時不會觸發重連(原因是:ConnectionWatchdog此時並不會觸發channelInactive事件,channelInactive只有自己主動關閉或者對端關閉纔會觸發,宕機或者對端不響應不包含在內),而宕機並沒有Slot的移動,並且在此時客戶端根據存量的拓撲快取是能夠給所有Slot都找到對應的Redis節點,而且Redis節點都是已知的。
因此以上被動重新整理的條件都不滿足,所以需要定時重新整理
How-檢視如何重新整理?
前面講了檢視重新整理的時機,現在分析下Lettuce具體重新整理檢視的方式。
Lettuce透過向一批Redis節點發送Cluster Nodes命令獲取Slot和Redis節點之間的對應關係,之後根據策略決定使用哪一個Redis節點返回的拓撲。
這裏涉及到兩個問題:
1、Lettuce如何選擇要傳送的Redis節點
2、Lettuce如何選擇哪一個Redis例項返回的拓撲
Lettuce如何選擇要傳送的Redis節點
預設Lettuce開啟了dynamicRefreshSources開關,也就是說會選擇叢集內的所有節點。
另一方面可以透過關閉這個開關,Lettuce只會從seed節點獲取拓撲。
Lettuce如何選擇哪一個Redis例項返回的拓撲
Lettuce提供了兩種策略,分別在第一次獲取拓撲和後續獲取時使用:
1、選擇包含最多健康節點的拓撲
2、選擇包含當前已知節點最多的拓撲
小結
理解了拓撲快取是什麼,如何獲取,以及拓撲快取重新整理的兩種方式,這下應該可以推斷出來為什麼有那麼多cluster nodes慢查了,因為這些慢查即使沒有給Redis做運維依然存在,所以可以確定的是由於定時重新整理才造成了慢查,那麼為什麼會造成慢查?這些慢查對Redis效能的影響到底有多大?接下來繼續分析。
Cluster Nodes內部實現
在Redis原始碼中是這樣實現的: 處理cluster nodes命令過程中會遍歷每個Node——呼叫clusterGenNodeDescription函式,生成每個Cluster節點的資訊,包括其負責的Slot:
sds clusterGenNodesDescription(int filter) { sds ci = sdsempty(), ni; dictIterator *di; dictEntry *de; di = dictGetSafeIterator(server.cluster->nodes); while((de = dictNext(di)) != NULL) { // ... ni = clusterGenNodeDescription(node); } // ... }
clusterGenNodeDescription函式的邏輯是遍歷所有Slot,確定屬於當前Node的Slot範圍。
sds clusterGenNodeDescription(clusterNode *node) { int j, start; sds ci; //...省略 /* Slots served by this instance */ start = -1; for (j = 0; j < CLUSTER_SLOTS; j++) { int bit; if ((bit = clusterNodeGetSlotBit(node,j)) != 0) { if (start == -1) start = j; } if (start != -1 && (!bit || j == CLUSTER_SLOTS-1)) { if (bit && j == CLUSTER_SLOTS-1) j++; if (start == j-1) { ci = sdscatprintf(ci," %d",start); } else { ci = sdscatprintf(ci," %d-%d",start,j-1); } start = -1; } } // ...省略 return ci; }
為什麼會變慢呢?
透過分析可以得知上面clusterGenNodesDescription函式處理過程的時間複雜度是O(M*N), 其中M是叢集節點數量,N是Slot數量為16384。
在壓測期間,使用perf分析CPU,50%水位:
100%水位
可以看到在CPU滿載的情況下,cluster nodes命令足足佔用了至少18%的CPU!而且根據上述時間複雜度分析隨著叢集規模的增長這個值也會線性增長!
然後我們發現了另外一個問題,Redis叢集的客戶端數量非常多:
每個Master足有4K+的client!。
可以看到隨著叢集節點數量變多,Cluster nodes的處理時間會隨之線性增長,而且如果客戶端數量較多(比如我們的叢集客戶端有4K+,每秒約有66個cluster nodes處理)進一步造成cluster nodes命令佔用CPU。
總結
透過原始碼分析我們知道了Redis5中cluster nodes的實現,以及它的時間複雜度會隨著叢集規模增長而增長。在高峰期佔用了接近20%的CPU,可以基本確定cluster nodes慢查詢就是Redis效能劣化的原因,下面我們來看如何進行最佳化解決,需要解決的問題是:
cluster nodes本身的效能問題
客戶端規模大造成cluster nodes呼叫過於頻繁的問題
3. 最佳化方案
下面透過幾個方面考慮最佳化:
cluster nodes本身效能的最佳化 => 解決cluster nodes本身的效能問題
定時重新整理的最佳化 => 客戶端規模大造成cluster nodes呼叫過於頻繁的問題
Redis6的最佳化
原理
可以看到在Redis5中每個Node都遍歷了每個Slot,造成大量的重複計算,是否可以將這部分重複計算提取出來呢?答案是可以的,Redis6就是這麼最佳化的:
sds clusterGenNodesDescription(int filter, int use_pport) { sds ci = sdsempty(), ni; dictIterator *di; dictEntry *de; /* 提前生成Node擁有的Slot. */ clusterGenNodesSlotsInfo(filter); di = dictGetSafeIterator(server.cluster->nodes); while((de = dictNext(di)) != NULL) { clusterNode *node = dictGetVal(de); if (node->flags & filter) continue; ni = clusterGenNodeDescription(node, use_pport); // ... } }
Redis6在遍歷每個Node之前,先使用clusterGenNodesSlotsInfo生成每個Node擁有的Slot,準確來說是Slot區間列表:
void clusterGenNodesSlotsInfo(int filter) { clusterNode *n = NULL; int start = -1; for (int i = 0; i <= CLUSTER_SLOTS; i++) { /* Find start node and slot id. */ if (n == NULL) { if (i == CLUSTER_SLOTS) break; n = server.cluster->slots[i]; start = i; continue; } /* Generate slots info when occur different node with start * or end of slot. */ if (i == CLUSTER_SLOTS || n != server.cluster->slots[i]) { if (!(n->flags & filter)) { if (!n->slot_info_pairs) { n->slot_info_pairs = zmalloc(2 * n->numslots * sizeof(uint16_t)); } serverAssert((n->slot_info_pairs_count + 1) < (2 * n->numslots)); n->slot_info_pairs[n->slot_info_pairs_count++] = start; n->slot_info_pairs[n->slot_info_pairs_count++] = i-1; } if (i == CLUSTER_SLOTS) break; n = server.cluster->slots[i]; start = i; } } }
生成所有節點的Slot拓撲,並將字串表示形式儲存在該節點上的slots_info結構體中。提高了clusterGenNodesDescription()函式的效率,因為避免了為每個節點單獨生成槽資訊時遍歷Slot的迴圈:
sds clusterGenNodeDescription(clusterNode *node, int use_pport) { // ... if (node->slot_info_pairs) { ci = representSlotInfo(ci, node->slot_info_pairs, node->slot_info_pairs_count); } // ... } sds representSlotInfo(sds ci, uint16_t *slot_info_pairs, int slot_info_pairs_count) { for (int i = 0; i< slot_info_pairs_count; i+=2) { unsigned long start = slot_info_pairs[i]; unsigned long end = slot_info_pairs[i+1]; if (start == end) { ci = sdscatfmt(ci, " %i", start); } else { ci = sdscatfmt(ci, " %i-%i", start, end); } } return ci; }
效能比較
cluster nodes命令效能比較
分別在cl叢集增加一個Redis5和Redis6的空Master,然後分別執行:
./redis-benchmark -c 1 -n 10000 cluster nodes
即用一個客戶端,執行10000次cluster nodes。
redis5的結果是:
Summary: throughput summary: 458.06 requests per second latency summary (msec): avg min p50 p95 p99 max 2.160 1.808 1.887 3.575 5.375 12.455
redis6的結果是:
Summary: throughput summary: 8583.69 requests per second latency summary (msec): avg min p50 p95 p99 max 0.111 0.096 0.111 0.135 0.191 1.015
Redis6和5相比吞吐量接近20倍。
耗時方面Redis6的99線只有0.2ms,平均0.1ms。
而Redis5的99線有5.3ms,平均2.1ms。
小結
Redis6針對Redis5做的最佳化是透過減少重複計算達到的,經過驗證耗時減少了95%,效果非常明顯。
Redis主動通知客戶端拓撲變化
除了最佳化Redis服務端以外,另一個最佳化方向是最佳化客戶端重新整理拓撲快取的頻率,首先我們來分析如果不使用定時重新整理會存在什麼問題,能不能使用定時重新整理之外的方法解決,即使這個方法需要修改Redis原始碼。
只使用被動重新整理的問題主要有兩個:
在拓撲發生改變之前就去獲取拓撲,獲取的是一個過期的檢視
機器宕機,Lettuce的連線保活機制(ConnectionWatchDog)不能識別機器宕機、程序Hang住的情況
下面依次分析這這些場景
場景一 普通擴容
節點一 遷移 5個slot 到節點二
1.節點一遷移一個slot到節點二
2.客戶端感知到MOVED響應,重新整理拓撲,之後30s不重新整理拓撲
3.節點一繼續遷移到節點二
4.30s內客戶端即使發現MOVED響應,也不能做任何事,只能重定向。
原因:客戶端獲取的拓撲是過時的,客戶端即使接收到MOVED也不會更新拓撲檢視,只會在30s後重新整理全量的拓撲。
場景二 程序重啟
節點一為master,節點二為節點一的replica,客戶端設定為優先讀從節點
1.節點一程序shutdown
2.客戶端感知到inactive,5次重連之後(31ms)開始刷拓撲,得到的拓撲和之前一樣,30s內不再刷拓撲
3.節點二成為master,然後節點一重啟完成開始同步RDB資料
4.客戶端嘗試重連,繼續獲取獲取拓撲,發現節點一是slave,傳送讀命令到節點一,由於此時節點一還在載入RDB因此報:'-LOADING Redis is loading the dataset in memory'錯誤
原因:客戶端無法知道failover是否完成,導致傳送命令到還在載入RDB的從節點上。
場景三 Failover
節點一為master,節點二為節點一的replica
1.節點一主機故障,程序Hang住
2.客戶端開始報錯
3.節點二failover成為master
4.客戶端依然在報錯(由於沒有ASK、MOVED、連線Inactive等可觸發被動重新整理的事件,獲得不了新拓撲)
原因:客戶端不知道failover已經完成,也不知道對端已經無法響應,還在傳送命令到失敗的節點上。
問題總結
客戶端不知道Redis的叢集拓撲變化:Slot遷移、failover等何時完成,導致其在下一次重新整理拓撲之前快取的拓撲檢視其實是過期的。除此之外,由於Redis使用Gossip協議傳播叢集內部節點狀態資訊,Lettuce獲取的檢視也可能不是最新的。
另一方面,隨著節點數增加,cluster nodes命令輸出大小也會增加,在大多數情況下,我們只需要發生變動的那一部分資訊。
總而言之,定期重新整理只是一個不完美的解決方案,根本原因是我們不知道服務端的拓撲變更在什麼時候完成,總是會過早/過慢的獲取到拓撲。
訂閱拓撲變更訊息
像Sentinel一樣,Sentinel透過pub/sub自動發現新的Sentinel例項以及master/slave的狀態變更資訊,我們可以仿照這一機制:
Redis內部建立一個內部channel用於釋出拓撲變更訊息“__cluster__:moved”。
客戶端啟動時訂閱上述“__cluster__:moved”的channel。
當Slot新增到一個Redis Server時,構建一個訊息:"slot_id ip:port"通知客戶端Slot新增到自己了。
客戶端只需要更新本地的拓撲就可以,不需要再用cluster nodes重新整理所有的了。
(對於failover的場景,可以用另外一個訂閱名字: "__cluster__:failover"在Slave成功promotion的時候傳送“slot_range ip:port”通知客戶端。)
Redis6實現了一個新的協議RESP3,相比於RESP2,其支援了PUSH訊息:Server可以在任意時機向Client主動推送資料,這裏也可以考慮使用RESP3的Push訊息。
具體做法:
下面是valkey(一個Redis的開源替代品,基於Redis 7.2.4版本)社羣裡的一個相關的PR,我們可以參考一下它的實現方式:
新增Slot時的處理
初始化叢集時,clusterState裡新增:
moved_slot_since_sleep:記錄移動到這個Redis例項的slotId(since_sleep含義: Redis透過sleep等待可讀寫的socket fd,這裏是在每一次等待可讀寫之前處理移動的slot)
moved_slot_channel:一個內部的channel,名字是__cluster__:moved,用於傳送slot移動訊息給訂閱的客戶端。
定義常量CLUSTER_MOVED_SLOT_NONE
和CLUSTER_MOVED_SLOT_MULTIPLE
:
CLUSTER_MOVED_SLOT_NONE
:沒有Slot移動
CLUSTER_MOVED_SLOT_MULTIPLE
:有多個Slot移動
新增Slot時在clusterAddSlot
函式裡記錄:
如果上次沒有新增的Slot,那麼將slotId記錄到
moved_slot_since_sleep
如果
moved_slot_since_sleep
已經記錄了一個slotId那麼說明有多個slot移動了,就記錄爲CLUSTER_MOVED_SLOT_MULTIPLE
。
等待處理命令前通知客戶端
實踐效果
修改了原始碼,啟動客戶端訂閱__cluster__:moved 這個channel:
然後在我們的Redis管理平臺上進行一個slot遷移,遷移16190到10.100.140.229這個節點:
客戶端成功接收到訂閱的訊息:
小結
這個最佳化解決了Redis無法主動通知拓撲變更完成的時間點,而且改動較少比較簡單,但這個提交目前只能做到單個Slot移動時通知,如果sleep前有多Slot移動和failover的場景還不支援通知客戶端,但是思路可以借鑑。
拓撲重新整理只查詢從節點
在2. 原理中瞭解到拓撲重新整理在Lettuce的實現中預設會查詢所有節點,那麼我們可以將這個策略修改爲只查詢一部分節點,比如只查詢從節點,這樣會減少cluster nodes對主節點的效能影響,理論上也是可以達到目的的,現在我們來看如何拓展Lettuce的拓撲查詢:
在io.lettuce.core.cluster.RedisClusterClient的getTopologyRefreshSource方法裡有預設的獲取拓撲重新整理源的實現,如果useDynamicRefreshSources配置為true(預設是true)的話會透過RedisClusterClient的partitions欄位獲取,partitions欄位包含了叢集內所有Redis節點:
protected Iterable<RedisURI> getTopologyRefreshSource() { boolean initialSeedNodes = !useDynamicRefreshSources(); Iterable<RedisURI> seed; if (initialSeedNodes || partitions == null || partitions.isEmpty()) { seed = this.initialUris; } else { List<RedisURI> uris = new ArrayList<>(); for (RedisClusterNode partition : TopologyComparators.sortByUri(partitions)) { uris.add(partition.getUri()); } seed = uris; } return seed; }
由於這個方法是protected修飾的,因此我們可以繼承RedisClusterClient這個類並且override這個方法,返回從節點的URI就可以了:
protected Iterable<RedisURI> getTopologyRefreshSource() { if (Objects.equals(getTopologyRefreshSourceFromApollo(), TopologyRefreshSource.ALL.name())) { return super.getTopologyRefreshSource(); } try { Partitions partitions = partitions(); if (partitions == null) { return super.getTopologyRefreshSource(); } List<RedisURI> uris = new ArrayList<>(); for (RedisClusterNode partition : TopologyComparators.sortByUri(partitions)) { if (partition.is(RedisClusterNode.NodeFlag.REPLICA) || partition.is(RedisClusterNode.NodeFlag.SLAVE)) { uris.add(partition.getUri()); } } if (uris.isEmpty()) { return super.getTopologyRefreshSource(); } return uris; } catch (Exception e) { PolarisTracer.logError(e); return super.getTopologyRefreshSource(); } }
如上所示,透過判斷partition是否有Slave標識獲取所有從節點。當然也要注意兜底邏輯,如果沒有從節點的話還是走預設邏輯。
4. 總結
這篇文章中我們透過分析線上出現的慢查詢,找到了造成慢查詢根因:大規模叢集下頻繁的cluster nodes查詢,並且分析cluster nodes查詢和拓撲重新整理的原理,最後提出了三種解決方案:
升級到Redis
Redis二開實現主動通知客戶端
最佳化拓撲重新整理邏輯只查詢從節點
最後我們選擇了1和3同時進行,由於2需要Redis二開,因此作為長期方案。