Redis 哨兵高可用(Sentinel)

buttercup發表於2021-02-12

哨兵機制是 Redis 高可用中重要的一環,其核心是 通過高可用哨兵叢集,監控主從複製的健康狀態,並實現自動災備

Redis 哨兵高可用(Sentinel)

哨兵叢集以叢集的方式進行部署,這種分散式特性具有以下優點:

  • 避免系統中存在單點,防止災備機制失效
  • 切換 master 必須經過多個 sentinel 節點協商同意,避免出現誤判

為了保證 Redis 服務的高可用,哨兵機制提供了以下功能:

  • 監控Monitoring:實時監控主從節點的健康狀況
  • 通知Notification:通過事件 API 將服務例項異常情況即時告知監聽者
  • 自動災備Automatic failover:當 master 節點失效時從 slave 中選舉出新的 master
  • 服務發現Configuration provider:客戶端通過哨兵叢集獲取 master 例項資訊,在發生自動災備時能及時將 master 變化告知客戶端

相關配置

建立哨兵叢集的配置相對簡單,只需配置以下選項即可:

sentinel monitor <master-name> <ip> <port> <quorum>

 需要監控的 master 節點的資訊(由於 sentinel 會自動發現 slave 節點資訊,因此無需配置)

  • master-name 用於區分不同的 master 節點,會被用於 master 發現
  • quorum 是啟動故障轉移failover時,所需的最小 sentinel 數量
 故障轉移前需要選舉出一個 leader 節點執行 master 切換,為了達成共識,該過程必須有半數以上的節點majority參與

 假設當前哨兵叢集總共有 m 個節點,當 quorum 設定為 n 時(nm

  • 若同時有 n 個節點判斷當前 master 已經下線,則其中的某個 sentinel 會嘗試發起故障轉移
  • 實際執行故障轉移需要進行 leader 選舉,因此僅當叢集中至少有 m/2 以上的 sentinel 節點可用時,故障轉移才可能啟動

 簡而言之,quorum 僅影響故障檢測流程,用於控制發起故障轉移的時機,但是無法決定 failover 是否會被執行
 因此,哨兵例項應不少於 3 個,否則一旦某個哨兵節點失效後,即便 quorum 設定為 1,依然無法啟動 faiover

sentinel down-after-milliseconds <master-name> <milliseconds>

 當某個 redis 節點超過該時間無法正常響應(對 PING 請求沒有響應或返回錯誤碼),sentinel 會認為其已經下線

sentinel parallel-syncs <master-name> <numslaves>

 將 slave 中的某個節點中提升為 master 後,剩餘節點立即重連新 master 的數量

 重新同步會導致 slave 節點從 master 批量同步資料,間接會造成 slave 的短暫停頓

 假如當前總共有 m 個 slave 節點,當 parallel-syncs 設定為 n 時,則 failover 會將 slave 分為 m/n 批進行調整

 該值越小,則 failover 所需的時間越長,但對訪問 slave 客戶端的影響越小

sentinel failover-timeout <master-name> <milliseconds>

 故障轉移對重試間隔,預設值為 3min,該選項會影響:

  • sentinel 對同個 master 發起 failover 後,再次重試的時間間隔 (2 * failover-timeout)
  • sentinel 發現 salve 配置過期後,將其指向新的 master 所需的時間
  • 取消一個正在進行的 failover 流程所需的時間
  • failover 等待 slave 重連新 master 完成的時間

配置發現

仔細觀察哨兵配置時,會發現缺失了 其他哨兵節點資訊從節點資訊
這是因為哨兵機制本身支援配置發現,每個哨兵節點能夠自行從監控的 master 節點處獲取到上面兩項資訊:

Redis 哨兵高可用(Sentinel)

哨兵發現

哨兵利用了 Redis 的 釋出/訂閱 實現來實現互相通訊,哨兵和主庫建立連線後:

  • 向主庫上名為 __sentinel__:hello 的頻道釋出訊息,將自己的 IP 和埠告知其他節點
  • 訂閱 __sentinel__:hello 訊息,獲得其他哨兵釋出的連線資訊

當多個哨兵例項都在主庫上做了釋出和訂閱操作後,它們互相感知到彼此的 IP 地址和埠。
因此擴容哨兵叢集十分簡單,只需要啟動一個新的哨兵節點即可,剩餘的操作交由自動發現機制完成即可。

從庫發現

哨兵向主庫傳送 INFO 命令,主庫接受到這個命令後,就會把從庫列表返回給哨兵。
然後哨兵根據從庫列表中的連線資訊,和每個從庫建立連線,並在這個連線上持續地對從庫進行監控。
同時,哨兵也會向從庫傳送 INFO 命令以獲取以下資訊:

  • run_id : 從庫的執行ID
  • slave_priority : 從庫的優先順序
  • slave_repl_offset : 從庫的複製偏移量

節點下線

哨兵永遠不會忘記已經見過的節點,無論這個節點是哨兵還是從庫。如果需要下線叢集中的節點時,需要用到SENTINEL RESET命令:

  • 當需要下線哨兵節點時,首先停止該節點程式,然後在剩餘哨兵節點上執行SENTINEL RESET *更新叢集資訊
  • 當需要下線某個 master 的從庫時,首先停止該節點程式,然後在所有哨兵節點上執行SENTINEL RESET <master-name>更新監控列表

狀態監控

為了保證主庫的可用性,哨兵叢集會以一定的間隔向主從庫傳送PING命令,並根據命令的返回結果判斷主庫是否健康:

  • 返回值為+PONG-LOADING-MASTERDOWN,則認為這個節點健康
  • 返回其他值或沒有響應,則認為這個節點不健康

不健康的狀態持續超過 down-after-milliseconds,則認為這個節點已經下線。

還有一種特殊情況:某個本應是 master 的節點,在INFO命令返回值中將自己標榜為 slave,那麼哨兵也會認為該節點已經下線。

為了降低誤判率,哨兵叢集將節點下線分為兩個階段:

  • 主觀下線SDOWN:某個 sentinel 例項認為該節點已經下線
  • 客觀下線ODOWN:某個 sentinel 通過 SENTINEL is-master-down-by-addr 命令向其他節點詢問,發現同時有 quorum 個 sentinel 例項認為該節點已經下線

只有 master 節點會被標記為ODOWN,並且僅當 master 節點被標記為ODOWN時才肯會觸發 failover 流程。而 slave 與 sentinel 節點僅會被標記為SDOWN

故障轉移

故障轉移過程被設計為一個非同步的狀態機,其主要步驟如下:

void sentinelFailoverStateMachine(sentinelRedisInstance *ri) {
    serverAssert(ri->flags & SRI_MASTER);

    if (!(ri->flags & SRI_FAILOVER_IN_PROGRESS)) return;

    switch(ri->failover_state) {
        // 選舉 leader
        case SENTINEL_FAILOVER_STATE_WAIT_START:
            sentinelFailoverWaitStart(ri);
            break;
        // 從已下線 master 的 slave 中挑選出一個候選節點
        case SENTINEL_FAILOVER_STATE_SELECT_SLAVE:
            sentinelFailoverSelectSlave(ri);
            break;
        // 向候選節點傳送 SLAVEOF NO ONE 命令將其轉化為 master 節點
        case SENTINEL_FAILOVER_STATE_SEND_SLAVEOF_NOONE:
            sentinelFailoverSendSlaveOfNoOne(ri);
            break;
        // 通過 INFO 命令檢查新的 master 節點是否已經就緒
        case SENTINEL_FAILOVER_STATE_WAIT_PROMOTION:
            sentinelFailoverWaitPromotion(ri);
            break;
        // 向剩餘的 slave 節點傳送 SLAVEOF 命令指向新的 master
        case SENTINEL_FAILOVER_STATE_RECONF_SLAVES:
            sentinelFailoverReconfNextSlave(ri);
            break;
    }
}

選舉 leader

當 master 被判斷為客觀下線時,會觸發一次故障轉移。為了保證系統最終能夠收斂於一致的狀態,每次對主從配置進行修改前,都會將變更關聯到一個全域性唯一的單調遞增版本號 —— 配置紀元epochepoch 較小的變更會被更大的變更覆蓋,從而保證來併發修改的分散式一致性

此外,哨兵叢集每個會為每個epoch選舉出一個 leader 來實施配置變更,避免發生不必要的故障轉移:

Redis 哨兵高可用(Sentinel)

選舉通過命令SENTINEL IS-MASTER-DOWN-BY-ADDR <ip> <port> <current-epoch> <runid>完成:

char *sentinelVoteLeader(sentinelRedisInstance *master, uint64_t req_epoch, char *req_runid, uint64_t *leader_epoch) {

    // 如果投票請求的 epoch 比已知更大,則更新本地的 epoch
    if (req_epoch > sentinel.current_epoch) {
        sentinel.current_epoch = req_epoch;
        sentinelFlushConfig();
        sentinelEvent(LL_WARNING,"+new-epoch",master,"%llu",
            (unsigned long long) sentinel.current_epoch);
    }

    // 如果投票請求的的 epoch 比當前 leader 更大
    if (master->leader_epoch < req_epoch && sentinel.current_epoch <= req_epoch)
    {
        // 根據 FCFS 原則,增將 epoch 的票投給該 sentinel
        sdsfree(master->leader);
        master->leader = sdsnew(req_runid);
        master->leader_epoch = sentinel.current_epoch;
        sentinelFlushConfig();
        sentinelEvent(LL_WARNING,"+vote-for-leader",master,"%s %llu",
            master->leader, (unsigned long long) master->leader_epoch);

        // 如果是接收到來自其他 sentinel 的投票請求,則更新 failover 開始時間
        // 避免本例項在 failover timeout 時間內觸發不必要的投票
        if (strcasecmp(master->leader,sentinel.myid))
            master->failover_start_time = mstime()+rand()%SENTINEL_MAX_DESYNC;
    } 

    // 小於 sentinel.current_epoch 的請求會被忽略

    // 更新 leader 資訊
    *leader_epoch = master->leader_epoch;
    return master->leader ? sdsnew(master->leader) : NULL;
}

該選舉流程是 Raft 協議的簡化版,有興趣的朋友可以深入瞭解。

篩選 slave

為了保證新的 master 擁有最新的狀態,leader 會排除以下 slave 節點:

  • 排除所有處於主觀下線狀態的節點(節點健康)
  • 排除最近 5 秒內沒有響應 leader 發出 INFO 命令的節點(通訊正常)
  • 排除與原 master 斷線時間超過 down-after-milliseconds * 10 的節點(副本較新)

最後,按照 slave_priorityslave_repl_offsetrun_id 對進行排序,選擇其中優先順序最高、偏移量最大、執行ID最小的節點作為新的 master。

提升 master

首先呼叫sentinelFailoverSendSlaveOfNoOne提升候選節點為 master:

void sentinelFailoverSendSlaveOfNoOne(sentinelRedisInstance *ri) {
    int retval;

    // 如果候選節點不可用,則一直嘗試直到 failover 超時
    if (ri->promoted_slave->link->disconnected) {
        if (mstime() - ri->failover_state_change_time > ri->failover_timeout) {
            sentinelEvent(LL_WARNING,"-failover-abort-slave-timeout",ri,"%@");
            sentinelAbortFailover(ri);
        }
        return;
    }

    // 傳送 SLAVEOF ON ONE 命令並等待其轉化為 master
    retval = sentinelSendSlaveOf(ri->promoted_slave,NULL,0);
    if (retval != C_OK) return;
    sentinelEvent(LL_NOTICE, "+failover-state-wait-promotion",
        ri->promoted_slave,"%@");
    ri->failover_state = SENTINEL_FAILOVER_STATE_WAIT_PROMOTION;
    ri->failover_state_change_time = mstime();
}

之後呼叫sentinelFailoverReconfNextSlave令剩餘 slave 複製新的 master 節點:

void sentinelFailoverReconfNextSlave(sentinelRedisInstance *master) {
    // ...

    // 批量調整 slave 節點,並保證每批數量不超過 parallel syncs 配置
    di = dictGetIterator(master->slaves);
    while(in_progress < master->parallel_syncs &&
          (de = dictNext(di)) != NULL)
    {
        sentinelRedisInstance *slave = dictGetVal(de);
        int retval;

        // 跳過調整完成的節點
        if (slave->flags & (SRI_PROMOTED|SRI_RECONF_DONE)) continue;

        // 如果 slave 長時間沒有完成配置修改,則依然認為已經完成
        // 哨兵節點會在後續流程中檢測出配置異常並進行修復
        if ((slave->flags & SRI_RECONF_SENT) &&
            (mstime() - slave->slave_reconf_sent_time) >
            SENTINEL_SLAVE_RECONF_TIMEOUT)
        {
            sentinelEvent(LL_NOTICE,"-slave-reconf-sent-timeout",slave,"%@");
            slave->flags &= ~SRI_RECONF_SENT;
            slave->flags |= SRI_RECONF_DONE;
        }

        // 跳過已發出過命令或已經下線的 slave 節點
        if (slave->flags & (SRI_RECONF_SENT|SRI_RECONF_INPROG)) continue;
        if (slave->link->disconnected) continue;

        // 傳送 SLAVEOF 令其複製新的 master
        retval = sentinelSendSlaveOf(slave,
                master->promoted_slave->addr->ip,
                master->promoted_slave->addr->port);
        if (retval == C_OK) {
            slave->flags |= SRI_RECONF_SENT;
            slave->slave_reconf_sent_time = mstime();
            sentinelEvent(LL_NOTICE,"+slave-reconf-sent",slave,"%@");
            in_progress++;
        }
    }
    
    // 檢查是否已經完成所有 slave 的配置修改
    sentinelFailoverDetectEnd(master);
}

當已下線的 master 再次上線時,哨兵節點會檢測出其配置已經失效,並會將其作為 slave 對待,令其複製新的 master 資料。這也意味著該節點上未被同步到新 master 的那部分資料會永遠丟失。

為了減少資料丟失,可以配合引數min-replicas-to-writemin-replicas-max-lag阻止客戶端向失去 slave 的 master 節點寫入資料。

事件API

為了方便客戶端感知叢集狀態變化,哨兵叢集定義了一系列的事件event,客戶端可以通過訂閱 sentinel 節點上與這些事件同名的 channel 來監聽狀態變化。

大部分事件的內容格式如下(@ 之後的部分是可選的):

<instance-type> <name> <ip> <port> @ <master-name> <master-ip> <master-port>

這裡列出部分可供監聽事件:

  • switch-master : 最新的 master 節點資訊,其內容為 <master-name> <oldip> <oldport> <newip> <newport>
  • +sdown : 某節點進入主觀下線狀態
  • -sdown : 某節點退出主觀下線狀態
  • +odown : 某節點進入客觀下線狀態
  • -odown : 某節點退出客觀下線狀態
  • +tilt : 哨兵叢集進入 TILT 模式
  • -tilt : 哨兵叢集退出 TILT 模式
  • +reset-master : 重置了某個 master-name 下的監控資訊
  • +failover-detected : 感知到故障轉移(可能是由 sentinel 發起的,也可能是人工將某個 slave 節點提升為 master)
  • failover-end : 故障轉移結束,並且所有 slave 已經指向新 master
  • failover-end-for-timeout : 故障轉移結束超時,部分 slave 未指向新 master,叢集狀態尚需時間完成收斂

如果需要訂閱所有事件,只需要執行命令PSUBSCRIBE *即可。

JedisSentinelPool

為了加深印象,下面通過分析 jedis-3.3.0JedisSentinelPool 的原始碼來觀察如何使用事件 API。

JedisSentinelPool啟動時呼叫初始化函式initSentinels獲取 master 資訊:

private HostAndPort initSentinels(Set<String> sentinels, final String masterName) {
  HostAndPort master = null;
  
  // 遍歷 sentinel 資訊並建立連線
  for (String sentinel : sentinels) {
    final HostAndPort hap = HostAndPort.parseString(sentinel);

    Jedis jedis = null;
    try {
      jedis = new Jedis(hap.getHost(), hap.getPort(), sentinelConnectionTimeout, sentinelSoTimeout);
      // ...

      // 傳送 get-master-addr-by-name 命令獲取 master 節點
      List<String> masterAddr = jedis.sentinelGetMasterAddrByName(masterName);

      if (masterAddr == null || masterAddr.size() != 2) {
        log.warn("Can not get master addr, master name: {}. Sentinel: {}", masterName, hap);
        continue;
      }

      // 獲取到 master 節點資訊後退出
      master = toHostAndPort(masterAddr);
      break;
    } catch (JedisException e) {
      log.warn(
        "Cannot get master address from sentinel running @ {}. Reason: {}. Trying next one.", hap, e);
    } finally {
      if (jedis != null) {
        jedis.close();
      }
    }
  }

  if (master == null) {
    // 無法獲取到 master 資訊,此處會丟擲異常
    // ...
  }

  // 啟動監聽執行緒,監聽所有 sentinel,保證及時感知到叢集變化
  for (String sentinel : sentinels) {
    final HostAndPort hap = HostAndPort.parseString(sentinel);
    MasterListener masterListener = new MasterListener(masterName, hap.getHost(), hap.getPort());
    masterListener.setDaemon(true);
    masterListeners.add(masterListener);
    masterListener.start();
  }

  return master;
}

MasterListener類通過事件 API 監聽 master 節點變化並在重新初始化連線池:

class MasterListener extends Thread {

  protected String masterName;
  protected String host;
  protected int port;
  protected long subscribeRetryWaitTimeMillis = 5000;
  protected volatile Jedis j;
  protected AtomicBoolean running = new AtomicBoolean(false);

  public MasterListener(String masterName, String host, int port) {
    super(String.format("MasterListener-%s-[%s:%d]", masterName, host, port));
    this.masterName = masterName;
    this.host = host;
    this.port = port;
  }

  @Override
  public void run() {

    running.set(true);

    while (running.get()) {

      try {
        // 與 sentinel 建立連線
        j = new Jedis(host, port, sentinelConnectionTimeout, sentinelSoTimeout);
        
        // ...

        // 再次獲取 master 資訊
        List<String> masterAddr = j.sentinelGetMasterAddrByName(masterName);
        if (masterAddr == null || masterAddr.size() != 2) {
          log.warn("Can not get master addr, master name: {}. Sentinel: {}:{}.", masterName, host, port);
        } else {
          // 如果 master 發生變化則重新重新初始化連線池
          initPool(toHostAndPort(masterAddr));
        }

        // 監聽 +switch-master 事件感知 master 節點變化
        j.subscribe(new JedisPubSub() {
          @Override
          public void onMessage(String channel, String message) {
            // master 發生了變化
            String[] switchMasterMsg = message.split(" ");
            if (switchMasterMsg.length > 3) {
              // 只處理與當前 master-name 相關的資訊
              if (masterName.equals(switchMasterMsg[0])) {
                // 如果 master 發生變化則重新重新初始化連線池
                initPool(toHostAndPort(Arrays.asList(switchMasterMsg[3], switchMasterMsg[4])));
              }
            } else {
              log.error(
                "Invalid message received on Sentinel {}:{} on channel +switch-master: {}", host, port, message);
            }
          }
        }, "+switch-master");

      } catch (JedisException e) {
        if (running.get()) {
          // 連線斷開後,等待 5s 重連
          log.error("Lost connection to Sentinel at {}:{}. Sleeping 5000ms and retrying.", host, port, e);
          try {
            Thread.sleep(subscribeRetryWaitTimeMillis);
          } catch (InterruptedException e1) {
            log.error("Sleep interrupted: ", e1);
          }
        } else {
          log.debug("Unsubscribing from Sentinel at {}:{}", host, port);
        }
      } finally {
        if (j != null) {
          j.close();
        }
      }
    }
  }

  public void shutdown() {
    try {
      log.debug("Shutting down listener on {}:{}", host, port);
      running.set(false);
      // This isn't good, the Jedis object is not thread safe
      if (j != null) {
        j.disconnect();
      }
    } catch (Exception e) {
      log.error("Caught exception while shutting down: ", e);
    }
  }
}

至此,對 redis 的哨兵機制分析完畢,後續將對 redis 的一些其他細節進行分享,感謝觀看。

相關文章