Redis 專題:詳解 Redis Cluster 數據分片原理

通過上一節的內容,我們已經知道了 Redis Cluster 結構、設計理念以及從無到有創建一個集羣,總體上來講對於 Redis Cluster 有了一個初步的認識。本節將重點解析 Redis Cluster 數據分片的更多細節,幫助大家更好的理解與使用。

數據分片機制

數據分片

不同於單機版 Redis 及 Sentinel 模式中一個節點負責所有 key 的管理工作,Redis Cluster 採用了類似於一致性哈希算法的哈希槽(hash slot)機制、由多個主節點共同分擔所有 key 的管理工作。

Redis Cluster 使用 CRC16 算法把 key 空間分佈在 16384 個哈希槽內,哈希槽是按照序號從 0~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 將會如下規則處理:

滿足以上兩個條件,Redis Cluster 將把 “{” 與“}”之間的內容作爲真正的 key 進行哈希槽計算,否則還是使用原來的輸入執行計算。需要注意:“{”和 “}” 的匹配遵循最左匹配原則。舉例看下:

重新分片

當集羣中節點壓力過大時,我們會考慮通過擴容,讓新增節點分擔其他節點的哈希槽;當集羣中節點壓力不平衡時,我們會考慮把部分哈希槽從壓力較大的節點轉移至壓力較小的節點。

Redis Cluster 支持在不停機的情況下添加或移除節點,以及節點間哈希槽的遷出和導入,這種動態擴容或配置的方式對於我們的生產實踐好處多多。比如:電商場景中,日常流量比較穩定,只要按需分配資源確保安全水位即可;當遇到大促時,流量較大,我們可以新增資源,以不停機、不影響業務的方式實現服務能力的水平擴展。以上兩種情況我們稱之爲重新分片(Resharding)或者在線重配置(Live Reconfiguration),我們來分析下 Redis 是如何實現的。

通過前面瞭解集羣狀態的數據結構,我們知道哈希槽的分配其實是一個數組,數組索引序號對應哈希槽,數組值爲負責哈希槽的節點。理論上,哈希槽的重新分配實質上是根據數組索引修改對應的節點對象,然後通過狀態傳播在集羣所有節點達到最終一致。如下圖中,把負責哈希槽 1001 的節點從 7000 修改爲 7001。實際中,爲了實現上面的過程,還需要考慮更多方面。

我們知道,哈希槽是由 key 經過 CRC16 計算而來的,哈希槽只是爲了把 key 存儲到真正節點時一個虛擬的存在,一切的操作還得迴歸到 key 上。當把哈希槽負責的節點從舊節點改爲新節點時,需要考慮舊節點存量 key 的遷移問題,也就是要把舊節點哈希槽中的 key 全部轉移至新的節點。

但是,無論哈希槽對應多少個 key,key 中存儲了多少數據,把 key 從一個節點遷移至另外一個節點總是消耗時間的,同時需要保證原子性;而且,重新分片過程中,客戶端的請求並沒有停止,Redis 還需要正確響應客戶端請求,使之不受影響。

接下來,我們利用示例集羣做一次重新分片的實踐,並且結合源碼深入剖析一下 Redis 的實現過程。以下示例是把 7002 節點的兩個哈希槽遷移至 7000 節點,過程簡述如下:

執行過程截圖如下:

以上過程對應的源碼爲文件redis-cli.cclusterManagerCommandReshard函數,代碼比較多,我們關注的是哈希槽是如何在節點間遷移的,所以我們僅貼出哈希槽遷移部分代碼進行分析:

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函數逐個哈希槽進行遷移,它是遷移哈希槽的核心方法,主要包含幾個步驟。大家可以結合示意圖和文字說明了解一下(每幅圖上面爲源節點,下面爲目標節點):上圖是把哈希槽 1000,從 7000 節點遷移至 7001 節點的集羣狀態變化過程,步驟說明:

好了,重新分片的過程就介紹完了。

重定向

數據分片使得所有的 key 分散存儲在不同的節點,而且隨着重新分片或者故障轉移,哈希槽與節點之間的映射關係會發生改變,那麼當客戶端發起對一個可以的操作時,集羣節點與客戶端是如何處理的呢?我們解析來了解一下兩種重定向機制。

MOVED 重定向

由於數據分片機制,Redis 集羣中每個節點僅負責一部分哈希槽,也就是一部分 key 的存儲及管理工作。客戶端可以隨意向集羣中任何一個節點發起命令請求,此時節點會計算當前請求 key 對應的哈希槽,並通過哈希槽與節點的映射關係查詢負責該哈希槽的節點,根據查詢結果 Redis 會有如下操作:

舉個例子來看,首先通過常規方式使用 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 官方提出了兩種可選的緩存辦法:

在集羣穩定運行期間,當然大部分時間也是穩定運行的,以上方式都能夠大大提高命令執行的效率。但是,由於集羣運行期間可能發生重新分片,客戶端維護的信息就會變得不準確,所以當客戶端哈希槽對應的節點發生改變時,客戶端應該及時修正。

自 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 重定向是不適用的。爲此,Redis Cluster 引入了 ASK 重定向,我們來看下 ASK 重定向的工作原理。

客戶端根據本地緩存的哈希槽與節點的映射關係,向 7000 節點發起 keys 請求,根據 keys 的遷移進度,7000 節點的執行流程如下:

(error) -ASK <slot> <ip>:<port>
# 對應示例結果爲:
(error) -ASK 1000 127.0.0.1:7001

這樣,如果客戶端請求的 keys 處於遷移過程,節點將以 ASK 重定向錯誤的方式返回客戶端,客戶端再向新的節點發起請求。當然,會有一定的概率由於 keys 未遷移完成而導致請求失敗,此時節點將回復 “TRYAGAIN”,客戶端可以稍後重試。

一旦哈希槽遷移完成,客戶端將收到節點回復的 MOVED 重定向錯誤,意味着哈希槽的管理權已經轉移至新的節點,此時客戶端可修改本地的哈希槽與節點映射關係,採用 “MOVED 重定向” 邏輯向新節點發起請求。

MOVED 重定向與 ASK 重定向

通過前面部分的介紹,相信大家已經對兩者的區別有了一定的瞭解,簡單總結一下。

集羣擴容或縮容期間可以正常提供服務嗎?

這個是面試中經常遇到的問題,如果你理解問題的本質,這個問題就不難回答了。我們來分析一下:

對主節點的擴容或者縮容本質上是一個重新分片的過程,重新分片涉及哈希槽遷移,也就是哈希槽內 key 的遷移。Redis Cluster 提供了 ASK 重定向來告知客戶端目前集羣發生的狀況,以便客戶端進行調整:ASKING 重定向或者重試。

所以,整體上來講,擴容或者縮容期間,集羣是可以正常提供服務的。

總結

數據分片是 Redis Cluster 動態收縮,具備可擴展性的根基,雖然本文內容寫的比較囉嗦,但是原理還是比較簡單的。大家重點理解擴容的過程與本質,就可以以不變應萬變。

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