Redis(開發與運維):51---集羣之(故障轉移:故障發現、故障恢復、故障轉移時間、故障轉移演示案例)

  • Redis集羣自身實現了高可用。高可用首先需要解決集羣部分失敗的場景:當集羣內少量節點出現故障時通過自動故障轉移保證集羣可以正常對外提供服務。本文介紹故障轉移的細節,分析故障發現和替換故障節點的過程

一、故障發現

  • 當集羣內某個節點出現問題時,需要通過一種健壯的方式保證識別出節點是否發生了故障。Redis集羣內節點通過ping/pong消息實現節點通信,消 息不但可以傳播節點槽信息,還可以傳播其他狀態如:主從狀態、節點故障等
  • 因此故障發現也是通過消息傳播機制實現的,主要環節包括:
    • 主觀下線 (pfail):指某個節點認爲另一個節點不可用,即下線狀態,這個狀態並不是最終的故障判定,只能代表一個節點的意見,可能存在誤判情況
    • 客觀下線(fail):指標記一個節點真正的下線,集羣內多個節點都認爲該節點不可用,從而達成共識的結果。如果是持有槽的主節點故障,需要爲該節點進行故障轉移

①主觀下線

  • 集羣中每個節點都會定期向其他節點發送ping消息,接收節點回復pong消息作爲響應。如果在cluster-node-timeout時間內通信一直失敗,則發送節點會認爲接收節點存在故障,把接收節點標記爲主觀下線(pfail)狀態
  • 流程如下圖所示:
    • 1)節點a發送ping消息給節點b,如果通信正常將接收到pong消息,節點a更新最近一次與節點b的通信時間
    • 2)如果節點a與節點b通信出現問題則斷開連接,下次會進行重連。如果一直通信失敗,則節點a記錄的與節點b最後通信時間將無法更新
    • 3)節點a內的定時任務檢測到與節點b最後通信時間超高cluster-nodetimeout時,更新本地對節點b的狀態爲主觀下線(pfail)

  • 主觀下線簡單來講就是,當cluster-note-timeout時間內某節點無法與另一個節點順利完成ping消息通信時,則將該節點標記爲主觀下線狀態。每個節點內的cluster State結構都需要保存其他節點信息,用於從自身視角判斷其他節點的狀態。結構關鍵屬性如下:
typedef struct clusterState {
    clusterNode *myself; /* 自身節點 /
    dict *nodes;/* 當前集羣內所有節點的字典集合,key爲節點ID,value爲對應節點ClusterNode結構 */
    ...
} clusterState;字典nodes屬性中的clusterNode結構保存了節點的狀態,關鍵屬性如下:

typedef struct clusterNode {
    int flags; /* 當前節點狀態,如:主從角色,是否下線等 */
    mstime_t ping_sent; /* 最後一次與該節點發送ping消息的時間 */
    mstime_t pong_received; /* 最後一次接收到該節點pong消息的時間 */
    ...
} clusterNode;

//其中最重要的屬性是flags,用於標示該節點對應狀態,取值範圍如下:
CLUSTER_NODE_MASTER 1 /* 當前爲主節點 */
CLUSTER_NODE_SLAVE 2 /* 當前爲從節點 */
CLUSTER_NODE_PFAIL 4 /* 主觀下線狀態 */
CLUSTER_NODE_FAIL 8 /* 客觀下線狀態 */
CLUSTER_NODE_MYSELF 16 /* 表示自身節點 */
CLUSTER_NODE_HANDSHAKE 32 /* 握手狀態,未與其他節點進行消息通信 */
CLUSTER_NODE_NOADDR 64 /* 無地址節點,用於第一次meet通信未完成或者通信失敗 */
CLUSTER_NODE_MEET 128 /* 需要接受meet消息的節點狀態 */
CLUSTER_NODE_MIGRATE_TO 256 /* 該節點被選中爲新的主節點狀態 */
  • 使用以上結構,主觀下線判斷僞代碼如下:
// 定時任務,默認每秒執行10次
def clusterCron():
    // ... 忽略其他代碼
    for(node in server.cluster.nodes):
        // 忽略自身節點比較
        if(node.flags == CLUSTER_NODE_MYSELF):
            continue;
        // 系統當前時間
        long now = mstime();
        // 自身節點最後一次與該節點PING通信的時間差
        long delay = now - node.ping_sent;
        // 如果通信時間差超過cluster_node_timeout,將該節點標記爲PFAIL(主觀下線)
        if (delay > server.cluster_node_timeout) :
            node.flags = CLUSTER_NODE_PFAIL;
  • Redis集羣對於節點最終是否故障判斷非常嚴謹,只有一個節點認爲主觀下線並不能準確判斷是否故障。例如下圖所示的場景,節點6379與6385通信中斷,導致6379判斷6385爲主觀下線狀態,但是 6380與6385節點之間通信正常,這種情況不能判定節點6385發生故障

  • 因此對於一個健壯的故障發現機制,需要集羣內大多數節點都判斷6385故障時, 才能認爲6385確實發生故障,然後爲6385節點進行故障轉移。而這種多個節點協作完成故障發現的過程叫做客觀下線

②客觀下線

  • 當某個節點判斷另一個節點主觀下線後,相應的節點狀態會跟隨消息在集羣內傳播。ping/pong消息的消息體會攜帶集羣1/10的其他節點狀態數據, 當接受節點發現消息體中含有主觀下線的節點狀態時,會在本地找到故障節點的ClusterNode結構,保存到下線報告鏈表中。結構如下:
struct clusterNode { /* 認爲是主觀下線的clusterNode結構 */
    list *fail_reports; /* 記錄了所有其他節點對該節點的下線報告 */
    ...
};
  • 通過Gossip消息傳播,集羣內節點不斷收集到故障節點的下線報告。當半數以上持有槽的主節點都標記某個節點是主觀下線時。觸發客觀下線流程。這裏有兩個疑問:
    • 1)爲什麼必須是負責槽的主節點參與故障發現決策?因爲集羣模式下只有處理槽的主節點才負責讀寫請求和集羣槽等關鍵信息維護,而從節點只 進行主節點數據和狀態信息的複製
    • 2)爲什麼半數以上處理槽的主節點?必須半數以上是爲了應對網絡分 區等原因造成的集羣分割情況,被分割的小集羣因爲無法完成從主觀下線到 客觀下線這一關鍵過程,從而防止小集羣完成故障轉移之後繼續對外提供服務
  • 假設節點a標記節點b爲主觀下線,一段時間後節點a通過消息把節點b的狀態發送到其他節點,當節點c接受到消息並解析出消息體含有節點b的pfail狀態時,會觸發客觀下線流程,如下圖所示:
    • 1)當消息體內含有其他節點的pfail狀態會判斷髮送節點的狀態,如果發送節點是主節點則對報告的pfail狀態處理,從節點則忽略
    • 2)找到pfail對應的節點結構,更新clusterNode內部下線報告鏈表
    • 3)根據更新後的下線報告鏈表告嘗試進行客觀下線

  • 下面針對維護下線報告和嘗試客觀下線邏輯進行詳細說明

維護下線報告鏈表

  • 每個節點ClusterNode結構中都會存在一個下線鏈表結構,保存了其他主節點針對當前節點的下線報告,結構如下:
typedef struct clusterNodeFailReport {
    struct clusterNode *node; /* 報告該節點爲主觀下線的節點 */
    mstime_t time; /* 最近收到下線報告的時間 */
} clusterNodeFailReport;
  • 下線報告中保存了報告故障的節點結構和最近收到下線報告的時間,當接收到fail狀態時,會維護對應節點的下線上報鏈表,僞代碼如下:
def clusterNodeAddFailureReport(clusterNode failNode, clusterNode senderNode) :
    // 獲取故障節點的下線報告鏈表
    list report_list = failNode.fail_reports;
    // 查找發送節點的下線報告是否存在
    for(clusterNodeFailReport report : report_list):
        // 存在發送節點的下線報告上報
        if(senderNode == report.node):
            // 更新下線報告時間
            report.time = now();
            return 0;
        // 如果下線報告不存在,插入新的下線報告
        report_list.add(new clusterNodeFailReport(senderNode,now()));
    return 1;
  • 每個下線報告都存在有效期:每次在嘗試觸發客觀下線時,都會檢測下線報告是否過期,對於過期的下線報告將被刪除。如果在cluster-node-time*2的時間內該下線報告沒有得到更新則過期並刪除,僞代碼如下: 
def clusterNodeCleanupFailureReports(clusterNode node) :
    list report_list = node.fail_reports;
    long maxtime = server.cluster_node_timeout * 2;
    long now = now();
    for(clusterNodeFailReport report : report_list):
        // 如果最後上報過期時間大於cluster_node_timeout * 2則刪除
        if(now - report.time > maxtime):
            report_list.del(report);
  • 下線報告的有效期限是server.cluster_node_timeout*2,主要是針對故障誤報的情況。例如節點A在上一小時報告節點B主觀下線,但是之後又恢復正常。現在又有其他節點上報節點B主觀下線,根據實際情況之前的屬於誤報不能被使用
  • 運維提示:如果在cluster-node-time*2時間內無法收集到一半以上槽節點的下線報告,那麼之前的下線報告將會過期,也就是說主觀下線上報的速度追趕不上下線報告過期的速度,那麼故障節點將永遠無法被標記爲客觀下線從而導致故障轉移失敗。因此不建議將cluster-node-time設置得過小

嘗試客觀下線

  • 集羣中的節點每次接收到其他節點的pfail狀態,都會嘗試觸發客觀下線,流程如下圖所示:
    • 1)首先統計有效的下線報告數量,如果小於集羣內持有槽的主節點總數的一半則退出
    • 2)當下線報告大於槽主節點數量一半時,標記對應故障節點爲客觀下線狀態
    • 3)向集羣廣播一條fail消息,通知所有的節點將故障節點標記爲客觀下線,fail消息的消息體只包含故障節點的ID

  • 使用僞代碼分析客觀下線的流程,如下所示:
def markNodeAsFailingIfNeeded(clusterNode failNode) {
    // 獲取集羣持有槽的節點數量
    int slotNodeSize = getSlotNodeSize();
    // 主觀下線節點數必須超過槽節點數量的一半
    int needed_quorum = (slotNodeSize / 2) + 1;
    // 統計failNode節點有效的下線報告數量(不包括當前節點)
    int failures = clusterNodeFailureReportsCount(failNode);
    // 如果當前節點是主節點,將當前節點計累加到failures
    if (nodeIsMaster(myself)):
        failures++;
    // 下線報告數量不足槽節點的一半退出
    if (failures < needed_quorum):
        return;
    // 將改節點標記爲客觀下線狀態(fail)
    failNode.flags = REDIS_NODE_FAIL;
    // 更新客觀下線的時間
    failNode.fail_time = mstime();
    // 如果當前節點爲主節點,向集羣廣播對應節點的fail消息
    if (nodeIsMaster(myself))
        clusterSendFail(failNode);
  • 廣播fail消息是客觀下線的最後一步,它承擔着非常重要的職責:
    • 通知集羣內所有的節點標記故障節點爲客觀下線狀態並立刻生效
    • 通知故障節點的從節點觸發故障轉移流程
  • 需要理解的是,儘管存在廣播fail消息機制,但是集羣所有節點知道故障節點進入客觀下線狀態是不確定的。比如當出現網絡分區時有可能集羣被分割爲一大一小兩個獨立集羣中。大的集羣持有半數槽節點可以完成客觀下線並廣播fail消息,但是小集羣無法接收到fail消息,如下圖所示:
  • 但是當網絡恢復後,只要故障節點變爲客觀下線,最終總會通過Gossip消息傳播至集羣的所有節點

  • 運維提示:網絡分區會導致分割後的小集羣無法收到大集羣的fail消息,因此如果故障節點所有的從節點都在小集羣內將導致無法完成後續故障轉移,因此部署主從結構時需要根據自身機房/機架拓撲結構,降低主從被分區的可能性

二、故障恢復

  • 故障節點變爲客觀下線後,如果下線節點是持有槽的主節點則需要在它的從節點中選出一個替換它,從而保證集羣的高可用。下線主節點的所有從節點承擔故障恢復的義務,當從節點通過內部定時任務發現自身複製的主節點進入客觀下線時,將會觸發故障恢復流程,如下圖所示:

①資格檢查

  • 每個從節點都要檢查最後與主節點斷線時間,判斷是否有資格替換故障的主節點
  • 如果從節點與主節點斷線時間超過cluster-node-time*cluster-slavevalidity-factor,則當前從節點不具備故障轉移資格。參數cluster-slavevalidity-factor用於從節點的有效因子,默認爲10

②準備選舉時間

  • 當從節點符合故障轉移資格後,更新觸發故障選舉的時間,只有到達該時間後才能執行後續流程。故障選舉時間相關字段如下:
struct clusterState {
    ...
    mstime_t failover_auth_time; /* 記錄之前或者下次將要執行故障選舉時間 */
    int failover_auth_rank;      /* 記錄當前從節點排名 */
}
  • 這裏之所以採用延遲觸發機制,主要是通過對多個從節點使用不同的延遲選舉時間來支持優先級問題。複製偏移量越大說明從節點延遲越低,那麼它應該具有更高的優先級來替換故障主節點。優先級計算僞代碼如下:
def clusterGetSlaveRank():
    int rank = 0;
    // 獲取從節點的主節點
    ClusteRNode master = myself.slaveof;
    // 獲取當前從節點複製偏移量
    long myoffset = replicationGetSlaveOffset();
    // 跟其他從節點複製偏移量對比
    for (int j = 0; j < master.slaves.length; j++):
        // rank表示當前從節點在所有從節點的複製偏移量排名,爲0表示偏移量最大.
        if (master.slaves[j] != myself && master.slaves[j].repl_offset > myoffset):
            rank++;
    return rank;
  • 使用之上的優先級排名,更新選舉觸發時間,僞代碼如下:
def updateFailoverTime():
    // 默認觸發選舉時間:發現客觀下線後一秒內執行。
    server.cluster.failover_auth_time = now() + 500 + random() % 500;
    // 獲取當前從節點排名
    int rank = clusterGetSlaveRank();
    long added_delay = rank * 1000;
    // 使用added_delay時間累加到failover_auth_time中
    server.cluster.failover_auth_time += added_delay;
    // 更新當前從節點排名
    server.cluster.failover_auth_rank = rank;
  • 所有的從節點中複製偏移量最大的將提前觸發故障選舉流程,如下圖所示

  • 主節點b進入客觀下線後,它的三個從節點根據自身複製偏移量設置延遲選舉時間,如複製偏移量最大的節點slave b-1延遲1秒執行,保證複製延遲低的從節點優先發起選舉

③發起選舉

  • 當從節點定時任務檢測到達故障選舉時間(failover_auth_time)到達後,發起選舉流程如下:
    • (1)更新配置紀元
    • (2)廣播選舉消息

(1)更新配置紀元

  • 配置紀元是一個只增不減的整數:
    • 每個主節點自身維護一個配置紀元 (clusterNode.configEpoch)標示當前主節點的版本,所有主節點的配置紀元都不相等,從節點會複製主節點的配置紀元
    • 整個集羣又維護一個全局的配置紀元(clusterState.current Epoch),用於記錄集羣內所有主節點配置紀元的最大版本
  • 執行cluster info命令可以查看配置紀元信息:
127.0.0.1:6379> cluster info
...
cluster_current_epoch:15 // 整個集羣最大配置紀元
cluster_my_epoch:13      // 當前主節點配置紀元
  • 配置紀元會跟隨ping/pong消息在集羣內傳播,當發送方與接收方都是主節點且配置紀元相等時代表出現了衝突,nodeId更大的一方會遞增全局配置紀元並賦值給當前節點來區分衝突,僞代碼如下: 
def clusterHandleConfigEpochCollision(clusterNode sender) :
    if (sender.configEpoch != myself.configEpoch || !nodeIsMaster(sender) || !nodeIsMaster(myself)) :
        return;
    // 發送節點的nodeId小於自身節點nodeId時忽略
    if (sender.nodeId <= myself.nodeId):
        return
    // 更新全局和自身配置紀元
    server.cluster.currentEpoch++;
    myself.configEpoch = server.cluster.currentEpoch;
  • 配置紀元的主要作用:
    • 標示集羣內每個主節點的不同版本和當前集羣最大的版本
    • 每次集羣發生重要事件時,這裏的重要事件指出現新的主節點(新加 入的或者由從節點轉換而來),從節點競爭選舉。都會遞增集羣全局的配置紀元並賦值給相關主節點,用於記錄這一關鍵事件
    • 主節點具有更大的配置紀元代表了更新的集羣狀態,因此當節點間進行ping/pong消息交換時,如出現slots等關鍵信息不一致時,以配置紀元更大的一方爲準,防止過時的消息狀態污染集羣
  • 配置紀元的應用場景有:
    • 新節點加入
    • 槽節點映射衝突檢測
    • 從節點投票選舉衝突檢測
  • 開發提示:
    • 之前在通過cluster setslot命令修改槽節點映射時,需要確保執行請求的主節點本地配置紀元(configEpoch)是最大值,否則修改後的槽信息在消息 傳播中不會被擁有更高的配置紀元的節點採納。由於Gossip通信機制無法準 確知道當前最大的配置紀元在哪個節點,因此在槽遷移任務最後的cluster setslot {slot} node {nodeId}命令需要在全部主節點中執行一遍
  • 從節點每次發起投票時都會自增集羣的全局配置紀元,並單獨保存在clusterState.failover_auth_epoch變量中用於標識本次從節點發起選舉的版本

(2)廣播選舉消息

  • 在集羣內廣播選舉消息(FAILOVER_AUTH_REQUEST),並記錄已發送過消息的狀態,保證該從節點在一個配置紀元內只能發起一次選舉。消息內容如同ping消息只是將type類型變爲FAILOVER_AUTH_REQUEST

④選舉投票

  • 只有持有槽的主節點纔會處理故障選舉消息 (FAILOVER_AUTH_REQUEST),因爲每個持有槽的節點在一個配置紀元內都有唯一的一張選票,當接到第一個請求投票的從節點消息時回覆FAILOVER_AUTH_ACK消息作爲投票,之後相同配置紀元內其他從節點的 選舉消息將忽略
  • 投票過程其實是一個領導者選舉的過程,如集羣內有N個持有槽的主節點代表有N張選票。由於在每個配置紀元內持有槽的主節點只能投票給一個 從節點,因此只能有一個從節點獲得N/2+1的選票,保證能夠找出唯一的從節點
  • Redis集羣沒有直接使用從節點進行領導者選舉,主要因爲從節點數必須大於等於3個才能保證湊夠N/2+1個節點,將導致從節點資源浪費。使用 集羣內所有持有槽的主節點進行領導者選舉,即使只有一個從節點也可以完 成選舉過程
  • 當從節點收集到N/2+1個持有槽的主節點投票時,從節點可以執行替換主節點操作,例如集羣內有5個持有槽的主節點,主節點b故障後還有4個, 當其中一個從節點收集到3張投票時代表獲得了足夠的選票可以進行替換主 節點操作,如圖10-41所示

  • 運維提示:故障主節點也算在投票數內,假設集羣內節點規模是3主3從,其中有2 個主節點部署在一臺機器上,當這臺機器宕機時,由於從節點無法收集到3/2+1個主節點選票將導致故障轉移失敗。這個問題也適用於故障發現環 節。因此部署集羣時所有主節點最少需要部署在3臺物理機上才能避免單點問題
  • 投票作廢:每個配置紀元代表了一次選舉週期,如果在開始投票之後的cluster-node-timeout*2時間內從節點沒有獲取足夠數量的投票,則本次選舉作廢。從節點對配置紀元自增併發起下一輪投票,直到選舉成功爲止

⑤替換主節點

  • 當從節點收集到足夠的選票之後,觸發替換主節點操作:
    • 1)當前從節點取消複製變爲主節點
    • 2)執行clusterDelSlot操作撤銷故障主節點負責的槽,並執行clusterAddSlot把這些槽委派給自己
    • 3)向集羣廣播自己的pong消息,通知集羣內所有的節點當前從節點變爲主節點並接管了故障主節點的槽信息

三、故障轉移時間

  • 在介紹完故障發現和恢復的流程後,這時我們可以估算出故障轉移時間:
    • 1)主觀下線(pfail)識別時間=cluster-node-timeout
    • 2)主觀下線狀態消息傳播時間<=cluster-node-timeout/2。消息通信機制 對超過cluster-node-timeout/2未通信節點會發起ping消息,消息體在選擇包含 哪些節點時會優先選取下線狀態節點,所以通常這段時間內能夠收集到半數 以上主節點的pfail報告從而完成故障發現
    • 3)從節點轉移時間<=1000毫秒。由於存在延遲發起選舉機制,偏移量最大的從節點會最多延遲1秒發起選舉。通常第一次選舉就會成功,所以從 節點執行轉移時間在1秒以內
  • 根據以上分析可以預估出故障轉移時間,如下:

  • 因此,故障轉移時間跟cluster-node-timeout參數息息相關,默認15秒。 配置時可以根據業務容忍度做出適當調整,但不是越小越好,下一篇文章的帶寬消耗部分會進一步說明

四、故障轉移演示案例

①啓動節點並開啓集羣

  • 開啓6個節點
# 備註:節點的配置文件需要設置logfile參數,否則不會產生日誌文件

sudo redis-server /opt/redis/conf/redis-6379.conf
sudo redis-server /opt/redis/conf/redis-6380.conf
sudo redis-server /opt/redis/conf/redis-6385.conf
sudo redis-server /opt/redis/conf/redis-6382.conf
sudo redis-server /opt/redis/conf/redis-6383.conf
sudo redis-server /opt/redis/conf/redis-6386.conf

  • 使用redis-cli --cluster命令開啓集羣:
redis-cli --cluster create --cluster-replicas 1 127.0.0.1:6379 127.0.0.1:6380 127.0.0.1:6385 127.0.0.1:6382 127.0.0.1:6383 127.0.0.1:6386
  • 查看集羣信息,可以看到集羣結構如下:6382複製6379、6383複製6380、6386複製6385
redis-cli -p 6379 cluster nodes

②關閉6385進程

  • 我們假設關閉6385這個主節點,然後查看故障轉移的日誌
ps -ef | grep redis

  • 可以看到6385節點的PID爲9113,我們使用kill命令強制關閉這個節點
sudo kill -9 9113

③日誌分析

  • 6386:與主節點6385複製中斷

  • 6379、6380:這兩個主節點都標記6385爲主觀下線,超過半數因此標記爲客觀下線狀態,打印日誌如下:

  • 6386:識別正在複製的主節點進入客觀下線後準備選舉時間,日誌打印了選舉延遲562毫秒之後執行,並打印當前從節點複製偏移量

  • 6386:延遲選舉時間到達後,從節點更新配置紀元併發起故障選舉

  • 6379、6380:爲從節點6386投票,日誌如下:

  • 6386:從節點獲取2個主節點投票之後,超過半數執行替換主節點操作,從而完成故障轉移:

④重新啓動6385,並觀察日誌

  • 重新啓動故障節點6385
sudo redis-server /opt/redis/conf/redis-6385.conf

 

  • 6385節點啓動後發現自己負責的槽指派給另一個節點,則以現有集羣配置爲準,變爲新主節點6386的從節點,關鍵日誌如下:

  • 集羣內其他節點接收到6385發來的ping消息,清空客觀下線狀態:

  • 6385節點變爲從節點,對主節點6386發起複製流程:

  • 最終集羣狀態如下圖所示

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章