Redis 專題:詳解 Redis Cluster 數據分片原理
通過上一節的內容,我們已經知道了 Redis Cluster 結構、設計理念以及從無到有創建一個集羣,總體上來講對於 Redis Cluster 有了一個初步的認識。本節將重點解析 Redis Cluster 數據分片的更多細節,幫助大家更好的理解與使用。
數據分片機制
數據分片
不同於單機版 Redis 及 Sentinel 模式中一個節點負責所有 key 的管理工作,Redis Cluster 採用了類似於一致性哈希算法的哈希槽(hash slot)機制、由多個主節點共同分擔所有 key 的管理工作。
Redis Cluster 使用 CRC16 算法把 key 空間分佈在 16384 個哈希槽內,哈希槽是按照序號從 0~16383 標號的,每組主從節點只負責一部分哈希槽管理操作;而且通過集羣狀態維護哈希槽與節點之間的映射關係,隨着集羣運行隨時更新。如上面我們示例中,哈希槽與節點關係如下:
-
Master[0] 負責 Slots:0 - 5460
-
Master[1] 負責 Slots:5461 - 10922
-
Master[2] 負責 Slots:10923 - 16383
每當我們通過 Redis Cluster 對某個 key 執行操作時,接收請求的節點會首先對 key 執行計算,得到該 key 對應的哈希槽,然後再從哈希槽與節點的映射關係中找到負責該哈希槽的節點。如果是節點自身,則直接進行處理;如果是其他節點,則通過重定向告知客戶端連接至正確的節點進行處理。
HASH_SLOT = CRC16(key) mod 16384
由於數據分片機制的存在,不同的 key 可能存儲在不同的節點上,這就導致普通 Redis 中的一些多 key 之間的計算命令無法支持。因爲 key 不同,其對應的哈希槽可能不同,導致這些數據存儲在不同的節點上,如果一個命令涉及到多個節點的 key,性能較低。所以,Redis Cluster 實現了所有在普通 Redis 版本中的單一 key 的命令,那些使用多個 key 的複雜操作,比如 set 的 union、intersection 操作只有當這些 key 在同一個哈希槽時纔可用。
但是,實際應用中,我們確實存單個命令涉及多個 key 的情況,基於此問題 Redis Cluster 提供了哈希標籤在一定程度上滿足使用需求。
哈希標籤
Redis Cluster 提供了哈希標籤(Hash Tags)來強制多個 key 存儲到同一個哈希槽內,哈希標籤通過匹配 key 中 “{”、“}” 之間的字符串提取真正用於計算哈希槽的 key。比如:客戶端輸入{abcd}test
,那麼將只把abcd
用於哈希槽的計算;這樣{abcd}test
、{abcd}prod
就會被存儲到同一個哈希槽內。但是,客戶端輸入的 key 可能存在多個 “{” 或“}”,此時 Redis Cluster 將會如下規則處理:
-
key 中存在 “{” 字符,並且 “{” 的右側存在“}”;
-
“{”與 “}” 之間存在一個或多個字符;
滿足以上兩個條件,Redis Cluster 將把 “{” 與“}”之間的內容作爲真正的 key 進行哈希槽計算,否則還是使用原來的輸入執行計算。需要注意:“{”和 “}” 的匹配遵循最左匹配原則。舉例看下:
-
{user1000}.following
和{user1000}.followers
:最終採用user1000
; -
foo{}{bar}
:最終採用foo{}{bar}
; -
foo{{bar}}zap
:最終採用{bar
; -
foo{bar}{zap}
:最終採用bar
;
重新分片
當集羣中節點壓力過大時,我們會考慮通過擴容,讓新增節點分擔其他節點的哈希槽;當集羣中節點壓力不平衡時,我們會考慮把部分哈希槽從壓力較大的節點轉移至壓力較小的節點。
Redis Cluster 支持在不停機的情況下添加或移除節點,以及節點間哈希槽的遷出和導入,這種動態擴容或配置的方式對於我們的生產實踐好處多多。比如:電商場景中,日常流量比較穩定,只要按需分配資源確保安全水位即可;當遇到大促時,流量較大,我們可以新增資源,以不停機、不影響業務的方式實現服務能力的水平擴展。以上兩種情況我們稱之爲重新分片(Resharding)或者在線重配置(Live Reconfiguration),我們來分析下 Redis 是如何實現的。
通過前面瞭解集羣狀態的數據結構,我們知道哈希槽的分配其實是一個數組,數組索引序號對應哈希槽,數組值爲負責哈希槽的節點。理論上,哈希槽的重新分配實質上是根據數組索引修改對應的節點對象,然後通過狀態傳播在集羣所有節點達到最終一致。如下圖中,把負責哈希槽 1001 的節點從 7000 修改爲 7001。
我們知道,哈希槽是由 key 經過 CRC16 計算而來的,哈希槽只是爲了把 key 存儲到真正節點時一個虛擬的存在,一切的操作還得迴歸到 key 上。當把哈希槽負責的節點從舊節點改爲新節點時,需要考慮舊節點存量 key 的遷移問題,也就是要把舊節點哈希槽中的 key 全部轉移至新的節點。
但是,無論哈希槽對應多少個 key,key 中存儲了多少數據,把 key 從一個節點遷移至另外一個節點總是消耗時間的,同時需要保證原子性;而且,重新分片過程中,客戶端的請求並沒有停止,Redis 還需要正確響應客戶端請求,使之不受影響。
接下來,我們利用示例集羣做一次重新分片的實踐,並且結合源碼深入剖析一下 Redis 的實現過程。以下示例是把 7002 節點的兩個哈希槽遷移至 7000 節點,過程簡述如下:
-
使用命令
redis-cli --cluster reshard 127.0.0.1:7000
對集羣發起重新分片的請求; -
redis-cli 輸出集羣當前哈希槽分配情況後,詢問遷移哈希槽的數量
How many slots do you want to move (from 1 to 16384)?
,輸入數字 2,回車確認; -
redis-cli 詢問由哪個節點接收遷移的哈希槽:
What is the receiving node ID?
,輸入節點 7000 的 ID,回車確認; -
redis-cli 詢問遷移哈希槽的來源:輸入
all
代表從其他所有節點中均分,逐行輸入節點 ID 以done
結束代表從輸入節點遷移哈希槽,這裏我輸入了 7002 的節點 ID; -
redis-cli 輸出本次重新分片的計劃,源節點、目標節點以及遷移哈希槽的編號等內容;輸出
yes
確認執行,輸入no
停止; -
輸入
yes
後,redis-cli 執行哈希槽遷移;
執行過程截圖如下:
以上過程對應的源碼爲文件redis-cli.c
中clusterManagerCommandReshard
函數,代碼比較多,我們關注的是哈希槽是如何在節點間遷移的,所以我們僅貼出哈希槽遷移部分代碼進行分析:
static int clusterManagerCommandReshard(int argc, char **argv) {
/* 省略代碼 */
int opts = CLUSTER_MANAGER_OPT_VERBOSE;
listRewind(table, &li);
// 逐個哈希槽遷移
while ((ln = listNext(&li)) != NULL) {
clusterManagerReshardTableItem *item = ln->value;
char *err = NULL;
// 把哈希槽從source節點遷移至target節點
result = clusterManagerMoveSlot(item->source, target, item->slot,
opts, &err);
/* 省略代碼 */
}
}
/* Move slots between source and target nodes using MIGRATE.*/
static int clusterManagerMoveSlot(clusterManagerNode *source, clusterManagerNode *target, int slot, int opts, char**err)
{
if (!(opts & CLUSTER_MANAGER_OPT_QUIET)) {
printf("Moving slot %d from %s:%d to %s:%d: ", slot, source->ip,
source->port, target->ip, target->port);
fflush(stdout);
}
if (err != NULL) *err = NULL;
int pipeline = config.cluster_manager_command.pipeline,
timeout = config.cluster_manager_command.timeout,
print_dots = (opts & CLUSTER_MANAGER_OPT_VERBOSE),
option_cold = (opts & CLUSTER_MANAGER_OPT_COLD),
success = 1;
if (!option_cold) {
// 設置target節點哈希槽爲importing狀態
success = clusterManagerSetSlot(target, source, slot, "importing", err);
if (!success) return 0;
// 設置source節點哈希槽爲migrating狀態
success = clusterManagerSetSlot(source, target, slot, "migrating", err);
if (!success) return 0;
}
// 遷移哈希槽中的key
success = clusterManagerMigrateKeysInSlot(source, target, slot, timeout, pipeline, print_dots, err);
if (!(opts & CLUSTER_MANAGER_OPT_QUIET)) printf("\n");
if (!success) return 0;
/* Set the new node as the owner of the slot in all the known nodes. */
/* 依次通知所有節點:負責這個哈希槽的節點變更了 */
if (!option_cold) {
listIter li;
listNode *ln;
listRewind(cluster_manager.nodes, &li);
while ((ln = listNext(&li)) != NULL) {
clusterManagerNode *n = ln->value;
if (n->flags & CLUSTER_MANAGER_FLAG_SLAVE) continue;
// 向節點發送命令:CLUSTER SETSLOT
redisReply *r = CLUSTER_MANAGER_COMMAND(n, "CLUSTER SETSLOT %d %s %s", slot, "node", target->name);
/* 省略代碼 */
}
}
/* Update the node logical config */
if (opts & CLUSTER_MANAGER_OPT_UPDATE) {
source->slots[slot] = 0;
target->slots[slot] = 1;
}
return 1;
}
clusterManagerCommandReshard
函數首先根據集羣中哈希槽分配情況及遷移計劃,找到需要遷移的哈希槽列表,然後使用clusterManagerMoveSlot
函數逐個哈希槽進行遷移,它是遷移哈希槽的核心方法,主要包含幾個步驟。大家可以結合示意圖和文字說明了解一下(每幅圖上面爲源節點,下面爲目標節點):
-
修改源節點和目標節點的遷移狀態,對應第一幅圖,其中:
-
通知目標節點,把指定 slot 設置爲
importing
狀態; -
通知源節點,把指定哈希槽設置爲
migrating
狀態; -
遷移源節點 slot 中的 key 到目標節點,對應第二、三幅圖(這一步可能會耗時,key 遷移過程中節點的命令處理線程是被佔用的)
-
使用命令
CLUSTER GETKEYSINSLOT <slog> <pipeline>
從源節點查詢 slot 中所有的 keys; -
使用
MIGRATE
程序把 keys 從源節點遷移至目標節點,逐個遷移 key,每個 key 的遷移是原子操作,期間會鎖定雙方節點。 -
通知所有節點,把負責 slot 的節點設置爲最新節點,同時移除源節點、目標節點中的 importing、migrating 狀態,對應第四幅圖。
好了,重新分片的過程就介紹完了。
重定向
數據分片使得所有的 key 分散存儲在不同的節點,而且隨着重新分片或者故障轉移,哈希槽與節點之間的映射關係會發生改變,那麼當客戶端發起對一個可以的操作時,集羣節點與客戶端是如何處理的呢?我們解析來了解一下兩種重定向機制。
MOVED 重定向
由於數據分片機制,Redis 集羣中每個節點僅負責一部分哈希槽,也就是一部分 key 的存儲及管理工作。客戶端可以隨意向集羣中任何一個節點發起命令請求,此時節點會計算當前請求 key 對應的哈希槽,並通過哈希槽與節點的映射關係查詢負責該哈希槽的節點,根據查詢結果 Redis 會有如下操作:
-
如果是當前節點負責該 key,那麼節點就會立即執行命令;
-
如果是其他節點負責該 key,那麼節點就會向客戶端返回一個
MOVED
錯誤。
舉個例子來看,首先通過常規方式使用 redis-cli 連接至 7000 端口節點,然後執行get TestKey
命令,如下所示:
redis-cli -p 7000
127.0.0.1:7000> GET TestKey
(error) MOVED 15013 127.0.0.1:7002
返回結果告訴我們,TestKey
對應的哈希槽爲 15013,應該由 7002 節點負責。客戶端可以根據返回結果中的MOVED
錯誤信息,解析出負責該 key 的節點 ip 和端口,並與之建立連接,然後重新執行即可。做下測試,效果如下:
redis-cli -p 7002
127.0.0.1:7002> GET TestKey
(nil)
爲什麼會這樣呢?
因爲 Redis Cluster 每個節點都保存了哈希槽與節點的映射關係,當客戶端請求的 key 不在當前節點的負責範圍之內時,節點不會充當目標節點的代理,而是以錯誤的方式告知客戶端在它看來應該由那個節點負責該 key。當然,如果正好趕上哈希槽遷移,節點返回的信息不一定準確,客戶端可能還會收到 MOVED 或 ASK 錯誤。
所以,這就要求客戶端具備這種重定向的能力,及時連接之正確的節點重新發起命令請求。如果客戶端與節點之間總是通過重定向的方式處理命令,性能必然不如普通 Redis 模式高。
怎麼辦呢?Redis 官方提出了兩種可選的緩存辦法:
-
執行請求前,客戶端首先根據輸入的 key 計算哈希槽。若當前連接對應的節點可以處理該請求,則把哈希槽與節點(ip 和端口)映射關係保存起來;若發生重定向,則連接至新的節點,重新請求,直到可以執行成功,最後把哈希槽與節點的關係保存起來。這樣,當客戶端就可以在先查詢緩存,再執行請求,提高效率。
-
通過命令
CLUSTER NODES
查詢集羣節點狀態,從中獲取哈希槽與節點的映射關係,在客戶端本地緩存起來。每次請求時,先計算 key 的哈希槽,再查詢節點,最後執行請求,更加高效。
在集羣穩定運行期間,當然大部分時間也是穩定運行的,以上方式都能夠大大提高命令執行的效率。但是,由於集羣運行期間可能發生重新分片,客戶端維護的信息就會變得不準確,所以當客戶端哈希槽對應的節點發生改變時,客戶端應該及時修正。
自 5.0 版本起,redis-cli 已經具備了 MOVED 重定向能力。再以集羣客戶端的方式連接至 7000 節點,執行上述命令,效果圖如下:
redis-cli -c -p 7000
127.0.0.1:7000> GET TestKey
-> Redirected to slot [15013] located at 127.0.0.1:7002
(nil)
127.0.0.1:7002>
雖然向 7000 節點發起請求,但是客戶端在接收到 7000 的返回結果後,自動連接至 7002 並重新執行了請求。
結合以上示例,在重新分片的過程中,客戶端向節點請求 key(CRC16=1000)命令,會不會有影響呢?帶着這個問題,我們一起來看下 ASK 重定向。
ASK 重定向
在重新分片時,源節點向目標節點遷移哈希槽的過程中,該哈希槽所存儲的 key 有的在源節點,有的已經遷移至目標節點。此時客戶端向源節點發起命令請求(尤其是多 key 的情況),MOVED 重定向就無法正常的工作了。下圖爲此時集羣的狀態示意圖,我們來分析下:
爲了全面完整的說明 ASK 重定向過程,本部分所闡述的對節點發起的命令中將包含多個具有相同哈希槽的 key,比如 {test}1、{test}2,用複數 keys 表示,並假設 test 對應的哈希槽爲 1000。
如前文所述,按照 “MOVED 重定向” 原理,當客戶端向節點發起 keys 的請求時,會首先計算 CRC16 得到 keys 對應的哈希槽,然後通過哈希槽與節點的映射關係找到負責該哈希槽的節點,最後決定時立即執行還是返回 MOVED 錯誤。
但是,如果集羣正處於重新分片過程中,客戶端請求的 keys 可能還未遷移,也可能已經遷移,我們看下會發生什麼?
-
未遷移:客戶端直接或者通過 MOVED 重定向請求至 7000 節點,7000 節點檢查後需要自己處理請求,並且 keys 存儲在自己節點內,可以正常處理請求;
-
已遷移:客戶端直接或者通過 MOVED 重定向請求至 7000 節點,7000 節點檢查後需要自己處理請求,但是此時 keys 已經被完全或部分遷移至 7001 節點,所以執行時無法找到 keys,無法正常處理請求;
因此,在這種情況下 MOVED 重定向是不適用的。爲此,Redis Cluster 引入了 ASK 重定向,我們來看下 ASK 重定向的工作原理。
客戶端根據本地緩存的哈希槽與節點的映射關係,向 7000 節點發起 keys 請求,根據 keys 的遷移進度,7000 節點的執行流程如下:
-
keys 對應的哈希槽 slot 是由 7000 節點負責,如果:
-
哈希槽 1000 不在遷移過程中(migrating),則當前請求由 7000 節點執行並返回執行結果;
-
哈希槽 1000 在遷移過程中(migrating),但是 keys 對應的 key 都未遷移走,說明此時 7000 節點可以執行當前請求,則當前請求由 7000 節點處理並返回執行結果;
-
哈希槽 1000 在遷移過程中(migrating),但是 keys 對應的 key 已經完全或部分遷移至 7001,則以 ASK 重定向錯誤告知客戶端需要請求的節點,格式如下:
(error) -ASK <slot> <ip>:<port>
# 對應示例結果爲:
(error) -ASK 1000 127.0.0.1:7001
-
客戶端接收到 ASK 重定向錯誤信息後,將爲該哈希槽(1000)設置強制指向新的節點(7001)的一次性標識,然後執行以下操作:
-
向 7001 節點發送 ASKING 命令,並移除一次性標識;
-
緊接着向 7001 節點發送真正需要請求的命令;
-
7001 節點接收客戶端 ASKING 請求後,如果:
-
哈希槽 1000 正在導入中(importing),當前請求的 keys 對應的 key 已經全部導入完成,則 7001 節點執行該請求並返回執行結果;
-
哈希槽 1000 正在導入中(importing),當前請求的 key 對應的 key 未完全導入完成,則返回重試錯誤(TRYAGAIN);
這樣,如果客戶端請求的 keys 處於遷移過程,節點將以 ASK 重定向錯誤的方式返回客戶端,客戶端再向新的節點發起請求。當然,會有一定的概率由於 keys 未遷移完成而導致請求失敗,此時節點將回復 “TRYAGAIN”,客戶端可以稍後重試。
一旦哈希槽遷移完成,客戶端將收到節點回復的 MOVED 重定向錯誤,意味着哈希槽的管理權已經轉移至新的節點,此時客戶端可修改本地的哈希槽與節點映射關係,採用 “MOVED 重定向” 邏輯向新節點發起請求。
MOVED 重定向與 ASK 重定向
通過前面部分的介紹,相信大家已經對兩者的區別有了一定的瞭解,簡單總結一下。
-
兩者都是以錯誤的方式告知客戶端應該向其他節點發起目標請求;
-
MOVED 重定向:告知客戶端當前哈希槽是由哪個節點負責,它是以哈希槽與節點的映射關係爲基礎的。如果客戶端接收到此錯誤,可以直接更新本地的哈希槽與節點的映射關係緩存。這是一種相對穩定的狀態。
-
ASK 重定向:告知客戶端,它所請求的 keys 對應的哈希槽當前正在遷移至新的節點,當前節點已經無法完成請求,應該向新節點發起請求。客戶端接收到此錯誤,將會臨時(一次性)重定向,以詢問(ASKING)的方式向新節點發起請求嘗試。該錯誤不會影響接下來客戶端對相同哈希槽的請求,除非它再次收到 ASK 重定向錯誤。
集羣擴容或縮容期間可以正常提供服務嗎?
這個是面試中經常遇到的問題,如果你理解問題的本質,這個問題就不難回答了。我們來分析一下:
-
集羣擴容:如果增加主節點:增加主節點後,剛開始它是不負責任何哈希槽的。爲了能夠分攤系統壓力,我們要進行重新分片,把一部分哈希槽轉移到新加入的節點,所以這實質上是一個重新分片的過程。如果增加從節點,只需要與指定的主節點進行主從複製過程。
-
集羣縮容:縮容意味着從集羣中摘除節點。如果摘除主節點,正常情況下,主節點負責一部分哈希槽的讀寫,若要安全摘除,需要先把該哈希槽負責的節點轉移至其他節點,這也是一個重新分片過程。如果摘除從節點,直接摘除即可。
對主節點的擴容或者縮容本質上是一個重新分片的過程,重新分片涉及哈希槽遷移,也就是哈希槽內 key 的遷移。Redis Cluster 提供了 ASK 重定向來告知客戶端目前集羣發生的狀況,以便客戶端進行調整:ASKING 重定向或者重試。
所以,整體上來講,擴容或者縮容期間,集羣是可以正常提供服務的。
總結
數據分片是 Redis Cluster 動態收縮,具備可擴展性的根基,雖然本文內容寫的比較囉嗦,但是原理還是比較簡單的。大家重點理解擴容的過程與本質,就可以以不變應萬變。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/cADNoIZwij4zp6Qdm0VFuQ