Redis 專題:深入 Redis Cluster 集羣容錯機制

哨兵模式的自動故障轉移能力爲其提供高可用保障,同樣的,爲了提供集羣的可用性,Redis Cluster 提供了自動故障檢測及故障轉移能力。兩者在設計思想上有很大的相似之處,本節將圍繞這個話題進行剖析。

心跳機制

Redis Cluster 作爲無中心的分佈式系統,集羣容錯機制依靠各個節點共同協作,在節點檢測到某個節點故障時,通過傳播節點故障並達成共識,然後觸發一系列的從節點選舉及故障轉移工作。這一工作完成的基礎是節點之間通過心跳機制對集羣狀態的維護。

下圖是從節點 A 視角來看集羣的狀態示意圖(僅繪製與集羣容錯有關的內容),myself 指向 A 節點本身,它是節點 A 對自身狀態的描述;nodes[B] 指向節點 B,它是從 A 節點來看 B 節點的狀態;還有集羣當前紀元、哈希槽與節點映射關係等。在集羣中,每兩個節點之間通過PINGPONG兩種類型的消息保持心跳,由前文可知這兩種消息採用完全相同的結構(消息頭和消息體都相同),僅消息頭中的type字段不同,我們稱這個消息對爲心跳消息。

心跳消息在集羣節點兩兩之間以 “我知道的給你,你知道的給我” 這樣 “瘟疫傳播” 的方式傳播、交換信息,可以保證在短時間內節點狀態達成一致。我們從心跳觸發的時機、消息體的構成、應用幾個方面深入理解心跳機制。

觸發時機

在集羣模式下,心跳動作是由週期性函數clusterCron()觸發的,該函數每個 100 毫秒執行一次。爲了控制集羣內消息的規模,同時兼顧與節點之間心跳的時效性,Redis Cluster 採取了不同的處理策略。

正常情況下,clusterCron()每隔一秒(該函數每執行 10 次)向一個節點發送PING消息。這個節點的選擇是隨機的,隨機方式爲:

補充說明 Redis Cluster 心跳消息發送與接收的檢查依據,這對後續故障檢測也是非常重要的:

  • 當源節點向目標節點發送PING命令後,將設置目標節點的ping_sent爲當前時間。

  • 當源節點接收到目標節點的PONG回覆後,將設置目標節點的ping_sent爲 0,同時更新pong_received爲當前時間。

也就是說, ping_sent 爲 0,說明已收到 PONG 回覆並等待下次發送; ping_sent 不爲 0,說明正在等待 PONG 回覆。

我們在集羣配置文件中設置了超時參數cluster-node-timeout,對應變量NODE_TIMEOUT,節點將以此參數作爲目標節點心跳超時的依據。爲了確保 PING-PONG 消息不超時並保留重試餘地,Redis Cluster 將以NODE_TIMEOUT/2爲界限進行心跳補償。

clusterCron()每次執行時(100 毫秒)會依次檢查每個節點:

總體來講,集羣內每秒的心跳消息收發數量是穩定的,即使集羣有很多節點也不會導致瞬時網絡 I/O 過大,給集羣帶來負擔。集羣中每兩個節點之間都在保持心跳,按照 N 個節點的有向完全圖,整個集羣會有N*(N-1)個鏈接,每個鏈接都需要保持心跳,心跳消息成對出現。

假如集羣有 100 個節點,NODE_TIMEOUT爲 60 秒,那就意味着每個節點在 30 秒內要發送 99 條 PING 消息,平均每秒發送 3.3 條。100 個節點每秒發送總計 330 條消息,這個數量級的網絡流量壓力還是可以接受的。

不過,我們需要注意節點數確定的情況下,需要合理設置NODE_TIMEOUT參數。如果過小,會導致心跳消息對網絡帶來較大壓力;如果太大,可能會影響及時發現節點故障。

消息構成

PING/PONG消息採用一致的數據結構。其中,消息頭的內容來自集羣狀態的myself,這點很容易理解;而消息體需要追加若干節點的狀態,但是集羣中有很多節點,到底應該添加哪幾個節點呢?

按照 Redis Cluster 的設計,每個消息體將會包含正常節點和PFAIL狀態節點,具體獲取方式如下(該部分源碼位於cluster.c函數clusterSendPing中):

消息應用

節點接收到 PING 或 PONG 消息後,將按照消息頭及消息體中的內容對本地維護的節點狀態進行更新。順着源碼說明的話,其中涉及的字段和邏輯還是比較複雜的,我將從應用場景角度來說明消息的處理過程(結合源碼函數clusterProcessPacket)。

集羣紀元和配置紀元

心跳消息頭包含了源節點的配置紀元(configEpoch)及在他看來的集羣當前紀元(currentEpoch),目標節點接收後將檢查自身維護的源節點的配置紀元和集羣當前紀元。具體方式爲:

哈希槽變更檢測

消息頭包含了源節點當前負責的哈希槽列表,目標節點會檢查本地緩存的哈希槽與節點的映射關係,看是否存在與映射關係不一致的哈希槽。當發現不一致的映射關係時,將按照以下情況進行處理:

新節點發現

在節點握手過程中,我們知道,新節點加入集羣僅需與集羣中任意一個節點通過握手加入集羣,但是其他節點並不知道有新節點加入,新節點也不知道其他節點的存在;對於兩者而言,都是新節點的發現過程。

在心跳過程中,源節點會把對方未知的新節點信息加入消息體,通知目標節點。目標節點將執行以下流程:

節點故障發現

節點故障發現是心跳的核心功能,該部分在下一節單獨介紹。

故障發現與轉移

PFAIL 與 FAIL 概念

Redis Cluster 使用兩個概念PFAILFAIL來描述節點的運行狀態,這與哨兵模式中的SDOWNODOWN類似。

當一個節點在超過 NODE_TIMEOUT 時間後仍無法訪問某個節點,那麼它會用 PFAIL 來標識這個不可達的節點。無論節點類型是什麼,主節點和從節點都能標識其他的節點爲 PFAIL

Redis 集羣節點的不可達性是指:源節點向目標節點發送 PING 命令後,超過 NODE_TIMEOUT 時間仍未得到它的PONG回覆,那麼就認爲目標節點具有不可達性。

這是由心跳引出的一個概念。爲了讓PFAIL儘可能,NODE_TIMEOUT 必須比兩節點間的網絡往返時間大;爲了確保可靠性,當在經過一半 NODE_TIMEOUT 時間還沒收到目標節點對於 PONG 命令的回覆時,源節點就會馬上嘗試重連接該目標節點。

所以,PFAIL是從源節點對目標節點心跳檢測的結果,具有一定的主觀性。

PFAIL狀態具有一定的主觀性,此時不代表目標節點真正的宕機,只有達到FAIL狀態,才意味着節點真正宕機。

不過我們已經知道,在心跳過程中,每個節點都會把檢測到 PFAIL 的節點告知其他節點。所以,如果某個節點宕機是客觀存在的,那其他節點也必然會檢測到 PFAIL 狀態。

在一定的時間窗口內,當集羣中一定數量的節點都認爲目標節點爲PFAIL狀態時,節點就會將該節點的狀態提升爲FAIL(宕機)狀態。

節點故障檢測

本節將詳細說明節點故障檢測的實現原理,還是以下圖爲例(A、B、C 節點爲主節點,以 B 節點宕機爲例),重點關注集羣狀態節點列表(nodes)的ping_sentpong_receivedfail_reports幾個字段。節點如何達到 PFAIL 狀態?

每個節點維護的集羣狀態中包含節點列表,節點信息如上圖節點 B 所示,其中字段ping_sent代表了 B 節點對 A 節點的心跳狀態:如果值爲 0,說明 A 節點與 B 節點心跳正常;如果值不是 0,說明 A 節點已經向 B 節點發送了PING,正在等待 B 節點回復PONG

集羣節點每隔 100 毫秒執行一次clusterCron()函數,其中會檢查與每個節點的心跳及數據交互狀態,若 A 節點在NODE_TIMEOUT時間內未收到 B 節點的任何數據,則視爲 B 節點發生故障,A 節點設置節點狀態爲PFAIL。具體代碼如下所示:

void clusterCron(void) {
    /* 省略…… */
    
    // ping消息已經發送的時間
    mstime_t ping_delay = now - node->ping_sent;
    // 已經多久沒有收到節點的數據了
    mstime_t data_delay = now - node->data_received;
    
    // 兩者取較早的
    mstime_t node_delay = (ping_delay < data_delay) ? ping_delay : data_delay;

    // 判斷超時
    if (node_delay > server.cluster_node_timeout) {
        /* 節點超時,如果當前節點不是PFAIL或FAIL狀態,則設置爲PFAIL狀態 */
        if (!(node->flags & (CLUSTER_NODE_PFAIL|CLUSTER_NODE_FAIL))) {
            serverLog(LL_DEBUG,"*** NODE %.40s possibly failing", node->name);
            node->flags |= CLUSTER_NODE_PFAIL;
            update_state = 1;
        }
    }
    /* 省略…… */
}

PFAIL 狀態傳播

由 “心跳機制——消息構成” 可知,PFAIL狀態的節點將會隨着心跳傳播至集羣內所有可達節點,不再贅述。

PFAIL 狀態切換至 FAIL 狀態

PFAILFAIL的狀態切換需要集羣內過半數主節點的認可,集羣節點通過心跳消息收集節點的PFAIL的標誌。如果 B 節點發生故障,A、C 節點都將檢測到 B 節點故障並標記 B 節點爲PFAIL;那麼 A、C 節點之間的心跳消息都會包含 B 節點已經PFAIL的狀態。以 A 節點來看,Redis Cluster 是如何處理。

由於其他節點的狀態在心跳消息的消息體內,消息接收方通過clusterProcessGossipSection函數進行處理,C 節點是主節點,並且聲明 B 節點爲PFAIL狀態。從源碼可知,將執行以下流程:

fail_reportsclusterNodeFailReport列表,保存了所有認爲 B 節點故障的節點列表。結構如下所示,其中time字段代表其被加入的時間,即聲明該節點故障的最新時間,當再次報告該節點狀態時,僅刷新time字段。

typedef struct clusterNodeFailReport {
    /* 報告節點故障的節點 */
    struct clusterNode *node;  /* Node reporting the failure condition. */
    /* 故障報告的時間 */
    mstime_t time;             /* Time of the last report from this node. */
} clusterNodeFailReport;

詳細的代碼過程如下函數所示:

void markNodeAsFailingIfNeeded(clusterNode *node) {
    int failures;
    // 計算判定節點宕機的法定數量
    int needed_quorum = (server.cluster->size / 2) + 1;
    // 判斷當前節點是否認爲該節點已經超時
    if (!nodeTimedOut(node)) return; /* We can reach it. */
    if (nodeFailed(node)) return; /* Already FAILing. */

    failures = clusterNodeFailureReportsCount(node);
    /* 當前節點也認可該節點宕機 */
    if (nodeIsMaster(myself)) failures++;
    if (failures < needed_quorum) return; /* No weak agreement from masters. */

    serverLog(LL_NOTICE, "Marking node %.40s as failing (quorum reached).", node->name);

    /* 設置節點爲FAIL狀態 */
    node->flags &= ~CLUSTER_NODE_PFAIL;
    node->flags |= CLUSTER_NODE_FAIL;
    node->fail_time = mstime();

    /* 向所有可達節點廣播節點的FAIL狀態,所有節點接收後將被強制接收認可 */
    clusterSendFail(node->name);
    clusterDoBeforeSleep(CLUSTER_TODO_UPDATE_STATE|CLUSTER_TODO_SAVE_CONFIG);
}

需要注意的是:fail_reports中的記錄是有有效期的,默認是 2 倍的NODE_TIMEOUT,超過該時間限制記錄會被移除。也就是說,必須在一定的時間窗口內收集足夠的記錄才能完成PFAILFAIL的狀態轉移。如果某個主節點對該節點的心跳恢復正常,會立刻從fail_reports移除。

主節點故障後,關於它的FAIL消息被傳播至集羣內的所有可達節點,這些節點標記其爲FAIL狀態。爲了保證集羣的可用性,該主節點的從節點們將啓動故障轉移動作,選擇最優的從節點提升爲主節點,Redis Cluster 的故障轉移包含兩個關鍵過程:從節點選舉和從節點提升。

從節點選舉

若主節點故障,該主節點的所有從節點都會啓動一個選舉流程,在其他主節點的投票表決下,只有投票勝出的從節點纔有機會提升爲主節點。從節點選舉的準備與執行過程是在 clusterCron 中進行的。

發起選舉的條件與時機

從節點發起選舉流程必須滿足以下條件(選舉流程發起前的檢查工作位於函數clusterHandleSlaveFailover(void)):

/* 數據有效時間 */
mstime_t data_age;

/* 取從節點與主節點斷開的時間間隔 */
if (server.repl_state == REPL_STATE_CONNECTED) {
    data_age = (mstime_t)(server.unixtime - server.master->lastinteraction) * 1000;
} else {
    data_age = (mstime_t)(server.unixtime - server.repl_down_since) * 1000;
}

/*  */
if (data_age > server.cluster_node_timeout)
    data_age -= server.cluster_node_timeout;

data_age > 
    (((mstime_t)server.repl_ping_slave_period * 1000) 
     + (server.cluster_node_timeout * server.cluster_slave_validity_factor)

如果FAIL狀態的主節點擁有多個從節點,Redis Cluster 總是希望數據最完整的從節點被提升爲新的主節點。然而,假如所有從節點同時啓動選舉流程,所有從節點公平競爭,無法保證數據最完整的節點優先被提升。爲了提高該節點的優先級,Redis Cluster 在啓動選舉流程時引入了延遲啓動機制。結合源碼,每個從節點會計算一個延遲值並據此計算該節點選舉流程啓動的時間,計算公式如下:

/* 選舉流程啓動延遲值 */
DELAY = 500 + random(0,500) + SLAVE_RANK * 1000

/* 計算得出從節點啓動選舉流程的時間 */
server.cluster->failover_auth_time = DELAY + mstime()

解釋一下這個公式:

所以,數據完整程度最高的節點將最先啓動選舉流程,如果後續一切順利,它將被提升爲新的主節點。

設置failover_auth_time後,當clusterCron()再次運行時,如果系統時間達到這個預設值(並且failover_auth_sent=0)就會進入選舉流程。

從節點選舉流程

從節點啓動選舉流程,先把自身維護的集羣當前紀元(currentEpoch)加 1,並設置failover_auth_sent=1以表示已經啓動選舉流程;然後通過FAILOVER_AUTH_REQUEST類型的消息向集羣內的所有主節點發起選舉請求,並在 2 倍NODE_TIMEOUT(至少 2 秒)時間內等待主節點的投票回覆。

集羣內的其他主節點是從節點選舉的決策者,投票前需要做出嚴格的檢查。爲了避免多個從節點在選舉中同時勝出,並且保證選舉過程合法性,主節點接收到FAILOVER_AUTH_REQUEST命令消息後,將會做以下條件校驗:

主節點投票完成將記錄信息,並安全持久化保存到配置文件:

  • 保存上次投票的集羣當前紀元:lastVoteEpoch

  • 保存投票時間,存儲在集羣節點列表的voted_time中。

爲了避免把上一輪投票計入本輪投票,從節點會檢查FAILOVER_AUTH_ACK消息所聲明的currentEpoch,若該值小於從節點的集羣當前紀元,該選票會被丟棄。確認投票有效,從節點將通過cluster->failover_auth_count進行計數。

在得到大多數主節點的投票認可後,從節點將從選舉中勝出。如果在 2 倍NODE_TIMEOUT(至少 2 秒)時間內未得到大多數節點的投票認可,選舉流程將會終止,並且在 4 倍NODE_TIMEOUT(至少 4 秒)時間後啓動新的選舉流程。

從節點提升

從節點獲得有效選票後,將把投票計數器failover_auth_count加 1,並通過從節點選舉與提升處理函數clusterHandleSlaveFailover進行週期性檢查,如果從節點得到大多數(法定數量)主節點的認可,將觸發從節點提升流程。

這裏選舉通過法定數量與觸發FAIL狀態的法定數量一致,即(server.cluster->size / 2) + 1,半數以上的主節點。

從節點啓動提升流程,將會對自身的狀態信息進行一系列的修改,最終把自己提升爲主節點,具體內容如下:

通用處理邏輯

新晉主節點被提升後,向集羣內所有可達節點發送了 PONG 消息。其他節點收到該PONG消息,除了進行通用的處理邏輯(如提升配置紀元等)外,會檢測到該節點的角色變化(從節點提升爲主節點),從而進行本地集羣狀態cluterState更新。具體的更新內容爲:

兄弟從節點切換主從複製

以上過程是 “其他節點”、“兄弟從節點” 通用的處理過程,“舊主節點”暫時失聯,無法被通知到。基於此PONG消息,“其他節點”已經認可新晉主節點的角色變更信息。但是 “兄弟節點” 仍然是把舊的主節點作爲自己的主節點,按照故障遷移的思想,它應該以新晉主節點作爲自己的主從複製對象,怎麼實現呢?

在哈希槽衝突處理過程中,“兄弟從節點” 會發現,衝突的哈希槽是原來它的主節點負責的,“兄弟從節點” 檢測到這一變化,就會把新晉主節點作爲自己的主節點,並以它爲新的主節點進行主從複製。

舊主節點重新加入

舊主節點恢復後,將以宕機前的配置信息(集羣當前紀元、配置紀元、哈希槽等等)與其他節點保持心跳。

當集羣內任一節點收到它的PING消息後,會發現它的配置信息已經過時(節點配置紀元),並且哈希槽的分配情況存在衝突,此時節點將通過UPDATE消息通知它更新配置。

UPDATE 消息包含了衝突哈希槽的負責權節點信息,舊主節點接收後會發現自身的節點配置紀元已經過時,從而把 UPDATE 消息的節點作爲自己的主節點,並切換自己的身份爲從節點,然後更新本地的哈希槽映射關係。

在後續的心跳中,其他節點將把舊主節點作爲新晉主節點的從節點進行更新。

至此,故障轉移完成。

容錯有關的其他話題

從節點遷移

爲了提供集羣系統的可用性,Redis Cluster 實現了從節點遷移機制:集羣建立時,每個主節點都有若干從節點,如果在運行過程中因爲幾次獨立的節點故障事件,導致某個主節點沒有正常狀態的從節點(被孤立),那麼該主節點一旦宕機,集羣將無法工作。Redis Cluster 會及時發現被孤立的主節點和從節點數量最大的主節點,然後挑選合適的從節點遷移至被孤立的主節點,使得其能夠再抵禦一次宕機事件,從而提高整個系統的可用性。

以下圖爲例進行說明:初始狀態時,集羣有 7 個節點,其中 A、B、C 爲主節點,A 有兩個從節點 A1、A2,B、C 各有一個從節點,分別時 B1、C1。通過以下過程,闡述節點故障時,從節點遷移的作用:

集羣腦裂

作爲分佈式系統,必須解決網絡分區帶來的各種複雜問題。在 Redis Cluster 中,由於網絡分區問題,導致集羣節點分佈在兩個分區,使得集羣發生 “腦裂”。此時從節點的選舉與提升在兩個網絡分區是如何工作的呢?如上圖所示,節點 A 及其從節點 A1,由於網絡分區與其他節點失聯。我們來看下兩個分區內的節點是如何工作的?

該分區內的節點將檢測到節點 A 的PFAIL狀態,然後經過傳播確認節點 A 達到FAIL狀態;A2 節點將觸發選舉流程並勝出,提升爲新的主節點,繼續工作。經過故障轉移,含有大部分節點的網絡分區可以繼續工作。

位於少數節點分區的節點 A、A1,會檢測到其他節點 B、C 的PFAIL狀態,但是由於無法得到大多數主節點的確認,B、C 無法達到FAIL狀態,進而導致不能發生後續的故障轉移工作。

Redis Cluster 總結

本文從三個主要部分介紹了 Redis Cluster 的工作原理:集羣結構、數據分片、容錯機制,差不多覆蓋了 Redis Cluster 的所有內容,希望能夠給大家學習 Redis Cluster 帶來幫助。

在研究官方文檔、系統源碼的過程中,確實遇到了好多不解的內容,通過反覆梳理代碼流程,逐個揭開各個謎底,最終建立起了整個知識體系。

參考資料

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