redis設計與實現讀書筆記-多機數據庫的實現

前言

經過前兩篇讀書筆記的整理對redis設計與實現這本書梳理了下,當然我的梳理稍顯粗糙,因爲很多內容在書上介紹的比較清楚,而這本書就在我手頭上,我在筆記中就不再贅述,有資源的最好讀原書,看一本好書的時候最直觀的感受就是這本書看的很順暢,津津有味,對很多之前的疑惑有解謎的作用,而不是逼着自己今天看幾頁,明天看幾頁,而這本書就是讓我感覺比較舒服的一本,接下來這篇重點介紹redis的主從複製,哨兵模式和集羣,這裏也是很多面試愛問的點.

複製

這裏的複製,指的就是我們都懂的主從複製.書中講述了redis2.8版本之前的複製原理和2.8之後的複製原理,接下來描述中舊版指的就是2.8版本之前的,新版指的就是2.8版本之後的.

舊版複製功能的實現

Redis的複製功能分爲同步(sync)和命令傳播(command propagate)兩個操作:

  • 同步操作將從服務器的數據庫狀態更新至主服務器當前所處的數據庫狀態
  • 命令傳播操作用於當主服務器數據庫狀態被修改,導致主從不一致時,使主從數據庫重新回到一致狀態
同步

當客戶端向從服務器發送SLAVEOF命令要求從服務器複製主服務器的時候,從服務器首先執行的就是同步操作.

從服務器會向主服務器發送SYNC命令來完成同步,大致過程如下:

  • 從服務器向主服務器發送SYNC命令
  • 主服務器收到命令後執行BGSAVE命令,在後臺生成RDB文件,並使用一個緩衝區來記錄從現在開始執行的所有寫命令
  • 當主服務器執行完BGSAVE命令之後,主服務器會將生成的RDB文件發送給從服務器,從服務接受並載入文件,使自己數據庫狀態更新至主服務器執行BGSAVE命令時的數據庫狀態
  • 主服務器將記錄在緩衝區裏面的所有寫命令發送給從服務器,從服務器收到後執行,使自己數據庫狀態更新至主服務器當前的數據庫狀態
命令傳播

當同步之後,主從服務器處於一致狀態,但是當主服務器執行新的寫命令之後,兩者又不一致了,這時候主服務器會將剛纔執行的寫命令發送給從服務器讓其執行,以使兩者重新一致,這個過程就是命令傳播

舊版複製功能缺陷

舊版複製分爲以下兩種情況:

初次複製: 從服務器之前沒有複製過現在要複製的這臺主服務器

斷線後重複製: 處於命令傳播階段的主從服務器因爲網絡原因中斷複製,之後從服務器通過自動重連重新接上了主服務器,並繼續複製主服務器

缺陷之處: 對於初次複製,舊版複製有很好的支持,問題就在於斷線後複製,在斷線後複製的時候,理想的狀態是將斷線前從服務器目前複製到的位置之後所有的內容進行復制,但是舊版的斷線複製,卻是重新執行了所有的複製操作,依然是從服務器向主服務器發送SYNC指令,之後主服務器在後臺生成對應的RDB文件…,將之前的老路重新走了一遍,這其實非常消耗性能

新版複製功能的實現

爲了解決舊版斷線重連後複製的低效問題,新版採用了PSYNC命令代替SYNC命令來執行復制時的同步操作.

PSYNC完整重同步部分重同步兩種模式:

  • 完整從同步與初次複製的步驟類似
  • 部分重同步就是主服務器只將主從服務器斷開這段時間執行的指令發給從服務器執行

可以看到部分重同步的開銷比之前舊版的小了很多,實現部分重同步的三個部分如下:

  • 主服務器的複製偏移量(replication offset)和從服務器的複製偏移量
  • 主服務器的複製積壓緩衝區(replication backlog)
  • 服務器的運行ID(run ID)
複製偏移量

主服務器和從服務器在執行復制的過程中會分別維護一個複製偏移量:

主服務器每次向從服務器傳播N個字節時,會在自己的複製偏移量上加N;而從服務器每次收到從主服務器傳播來的N個字節的數據時,也會在自己的複製偏移量上加N

通過對主從複製偏移量的對比,可以判斷主從服務器是否處於一致狀態:如果複製偏移量相同,說明處於一致狀態,否則不一致.

假設斷線重連後,從服務器向主服務器發送PSYNC命令,同時彙報自己的複製偏移量,那麼主服務器如何判斷是該對從服務器進行全部重同步還是部分重同步,如果是部分重同步,又如何判斷要傳遞的數據是哪些呢,這些都和複製積壓緩衝區有關

複製積壓緩衝區

複製積壓緩衝區是由主服務器維護的一個固定長度(fixed-size)先進先出(FIFO)隊列,默認大小爲1MB

當主服務器向從服務器進行命令傳播時,它會同時將命令放入複製積壓緩衝區,如下圖所示

在這裏插入圖片描述

複製積壓緩衝區會爲隊列中的每個字節記錄相應的複製偏移量,如下表:

偏移量 10087 10088 10089 10090 10091 10092 10093 10095 10096 10096
字節值 ‘*’ 3 ‘\r’ ‘\n’ ‘$’ 3 ‘S’ ‘E’ ‘T’

當主從斷線重連之後,從服務器向主服務器發送PSUNC命令同時彙報自己的複製偏移量offset之後,主服務器會拿着這個複製偏移量去複製積壓緩衝區中查看,如果從該offset開始往後的數據仍然存在,就執行部分重同步,如果已經不存在了,就執行完整重同步操作.

複製積壓加緩衝區的大小可以在配置文件中配置:repl-backlog-size

服務器運行ID

除了複製偏移量和複製積壓緩衝區之外,實現部分重同步還需要用到服務器運行ID

  • 每個redis服務器,不論主從都有自己的運行ID
  • 運行ID在服務器啓動時自動生成,由40個隨機的16進制字符組成,如:53b9b28df…

當從服務器對主服務器進行初次複製的時候,主服務器會將自己的運行ID傳送給從服務器,從服務器會將這個運行ID保存起來,當斷線重連後,從服務器會向主服務器發送這個運行ID,如果與當前主服務器的運行ID相同,則可以由主服務器根據情況判斷是否可以執行部分重同步,如果這個ID和當前主服務器的ID不同,那麼直接執行完整重同步.

PSYNC命令的實現

PSYNC命令的調用方法有兩種情況:

  • 如果從服務器之前沒有複製過任何主服務器,或者之前執行過SLAVEOF NO ONE命令,那麼從服務器在開始一次新的複製時將向主服務器發送PSYNC ? -1命令,主動請求主服務器進行完整重同步
  • 如果從服務器已經複製過某個主服務器,那麼重連後從服務器將向主服務器發送PSYNC runid offset命令,runid是上次複製的主服務器運行id,offset是從服務器的複製偏移量,之後主服務器會根據收到的信息判定是進行部分重同步還是完整重同步

關於複製過程的整體實現過程,可以看原書15.6節-複製的實現

Sentinel哨兵模式

哨兵模式是redis爲保證高可用所提供的解決方案,由一個或多個哨兵組成的哨兵系統,監控系統中任意多個主服務器以及這些主服務器下的所有從服務器,當發生故障的時候,比如主服務器掛了,哨兵可以通過選舉機制產生新的主服務器並進行故障轉移,從而保證可用性

啓動並初始化sentinel

啓動一個sentinel可以使用命令:

redis-sentinel /path/to/your/sentinel.conf或者命令redis-server /path/to/your/sentinel.conf --sentinel

當一個sentinel啓動時,需要執行以下步驟:

  1. 初始化服務器
  2. 將普通redis服務器使用的代碼替換成sentinel專用代碼
  3. 初始化sentinel狀態
  4. 根據指定的配置文件,初始化sentinel監視的主服務器列表
  5. 創建連向主服務器的網絡連接
初始化服務器

sentinel本質上只是一個運行在特殊模式下的redis服務器,因爲sentinel並不使用數據庫,所以初始化sentinel的時候就不會載入RDB文件或者AOF文件

使用sentinel專用代碼

該步驟中將一部分普通redis使用的代碼替換成sentinel專用代碼,特別指出PING SENTINEL INFO SUBSCRIBE UNSUBSCRIBE PSUBSCRIBE 和 PUNSUBSCRIBE這七個命令是客戶端可以對sentinel執行的全部命令

初始化sentinel狀態

在應用了sentinel專用代碼之後,服務器會初始化一個sentinel.c/sentinelState結構(sentinel狀態),這個結構保存了服務器所有與sentinel功能有關的狀態(服務器的一般狀態仍然由redis.h/redisServer結構保存):

struct snetinelState{
    //當前紀元,用於實現故障轉移(選舉機制會用到)
    uint64_t current_epoch;
    //保存了所有被這個sentinel監視的主服務器
    //字典的鍵是主服務器的名字,值是一個指向sentinelRedisInstance結構的指針
    dict *masters;
    //是否進入了TILT模式;
    int tilt;
    //目前正在執行的腳本的數量
    int running_scripts;
    //進入TITL模式的時間
    mstime_t titl_start_time;
    //最後一次執行時間處理器的時間
    mstime_t previous_time;
    //一個FIFO隊列,包含了所有需要執行的用戶腳本
    list *scripts_queue;   
}sentinel
初始化sentinel狀態的masters屬性

每個sentinelRedisInstance(實例結構)結構代表一個被sentinel監視的redis服務器實例,這個實例可以是主服務器,從服務器或者另外一個sentinel.實例結構包含的屬性較多,下面代碼展示了作爲主服務器使用時用到的一部分屬性:

typedef struct sentinelRedisInstance{
    //標識值,記錄了實例的類型以及該實例的當前狀態
    int flags;
    //實例的名字,主服務的名字由用戶在配置文件中配置,從服務器以及sentinel的名字由sentinel自動設置
    //格式爲ip:port,比如"127.0.0.1:26379"
    char *name;
    //實例的運行id
    char *runid;
    //配置紀元,用於實現故障轉移
    uint64_t config_epoch;
    //實例的地址
    sentinelAddr *addr;
    //SENTINEL down-after-milliseconds選項設定的值
    //實例無響應多少毫秒之後纔會被判斷爲主觀下線(subjectively down)
    mstime_t down_after_period;
    //SENTINEL monitor <master-name>  <IP> <port> <quorum>選項中的quorum參數
    //判斷這個實例爲客觀下線(objectively down)所需的支持投票數量
    int quorum;
    //SENTINEL parallel-syncs <master-name> <number>選項的值
    //在執行故障轉移時,可以同時對新的主服務器執行同步的從服務器數量
    int parallel_syncs;
    //SENTINEL failover-timeout<master-name> <ms> 選項的值
    //刷新故障遷移狀態的最大時限
    mstime_t failover_timeout;
    //...
}sentinelRedisInstance

sentinelRedisInstance.addr屬性是一個指向sentinel.c/sentinelAddr結構的指針,這個結構保存着實例的IP地址和端口號:

typedef struct sentinelAddr{
    char *ip;
    int port;
}sentinelAddr

對sentinel狀態的初始化將引發對master字典的初始化,masters字典的初始化時根據被載入的sentinel配置文件來進行的.

創建連向主服務器的網絡連接

初始化sentinel的最後一步是創建連向被監視主服務器的網絡連接,sentinel將成爲主服務器的客戶端,它可以向主服務器發送命令,並從命令回覆中獲取相關信息.

對於每個被sentinel監視的主服務器來說,sentinel會創建兩個連向主服務器的異步網絡連接:

  • 一個是命令連接,這個連接專門用於向主服務器發送命令,並接受命令回覆
  • 另一個是訂閱連接,這個連接專門用於訂閱主服務器的_sentinel_:hello頻道

獲取主服務器信息

sentinel會默認以每十秒一次的頻率,通過命令連接向被監視的主服務器發送INFO命令,並通過分析命令回覆來獲取主服務器當前的信息,包括:

  • 主服務器本身的信息:runid,role(服務器角色)
  • 主服務器下所有從服務器信息,sentinel無須用戶提供從服務器的地址,可以自動根據主服務器的回覆獲取

獲取從服務器信息

同樣的,sentinel會默認以每十秒一次的頻率,通過命令連接向從服務器發送INFO命令,並通過分析命令回覆來獲取從服務器當前的信息,包括:

  • 從服務器的運行ID run_id
  • 從服務器的角色role
  • 主服務器的ip地址master_host,以及主服務區的端口號master_port
  • 主從服務器的連接狀態master_link_status
  • 從服務器的優先級slave_priority
  • 從服務器的複製偏移量slave_repl_offeset(這個在主服務器掛了,重新選主的時候有用)

向主服務器和從服務器發送信息

默認情況下,sentinel會以每兩秒一次的頻率,通過命令連接向所有被監視的主服務器和從服務器發送以下格式的命令:

PUBLISH _sentinel_:hello “<s_ip>,<s_port>,<s_runid>,<s_epoch>,<m_name>,<m_ip>,<m_port>,<m_epoch>”

該命令向服務器的_sentinel_:hello頻道發送了一條信息,參數中以s_開頭的是 sentinel本身的信息,m_開頭的記錄的是主服務器的信息,如果此時監控的是主服務器,就是主服務器自己的信息,如果監控的是從服務器,也是從服務器對應主服務器的信息

接收來自主服務器和從服務器的頻道信息

當sentinel與一個主服務器或者從服務器建立起連接之後,sentinel就會通過訂閱連接,向服務器發送以下命令:

SUBSCRIBE _sentinel_:hello

sentinel對 _sentinel_:hello頻道的訂閱會一直持續到sentinel與服務器斷開爲止,也就是說對於每個與sentinel連接的服務器,sentinel既通過命令連接向服務器的_sentinel_:hello 頻道發送信息,又通過訂閱連接從服務器的該頻道接收信息.

用戶在使用sentinel的時候不需要提供各個sentinel的地址信息,監視同一個主服務器的多個sentinel可以自動發現對方.

sentinel在連接主服務器或者從服務器的時候會同時創建命令連接和訂閱連接,但是在連接其他sentinel的時候只會創建命令連接而不創建訂閱連接.

主觀下線

所謂主觀下線(Subjectively Down, 簡稱 SDOWN)指的是單個Sentinel實例對服務器做出的下線判斷,即單個sentinel認爲某個服務下線(有可能是接收不到訂閱,之間的網絡不通等等原因)。

主觀下線就是說如果服務器在down-after-milliseconds給定的毫秒數之內, 沒有返回 Sentinel 發送的 PING 命令的回覆, 或者返回一個錯誤, 那麼 Sentinel 將這個服務器標記爲主觀下線(SDOWN )。

sentinel會以每秒一次的頻率向所有與其建立了命令連接的實例(master,從服務,其他sentinel)發ping命令,通過判斷ping回覆是有效回覆,還是無效回覆來判斷實例時候在線(對該sentinel來說是“主觀在線”)。
sentinel配置文件中的down-after-milliseconds設置了判斷主觀下線的時間長度,如果實例在down-after-milliseconds毫秒內,返回的都是無效回覆,那麼sentinel回認爲該實例已**(主觀)下線**,修改其flags狀態爲SRI_S_DOWN。如果多個sentinel監視一個服務,有可能存在多個sentinel的down-after-milliseconds配置不同,這個在實際生產中要注意。

客觀下線

客觀下線(Objectively Down, 簡稱 ODOWN)指的是多個 Sentinel 實例在對同一個服務器做出 SDOWN 判斷, 並且通過 SENTINEL is-master-down-by-addr 命令互相交流之後, 得出的服務器下線判斷,然後開啓failover。

客觀下線就是說只有在足夠數量的 Sentinel 都將一個服務器標記爲主觀下線之後, 服務器纔會被標記爲客觀下線(ODOWN)。

只有當master被認定爲客觀下線時,纔會發生故障遷移。

當sentinel監視的某個服務主觀下線後,sentinel會詢問其它監視該服務的sentinel,看它們是否也認爲該服務主觀下線,接收到足夠數量(這個值可以配置)的sentinel判斷爲主觀下線,既認爲該服務客觀下線,並對其做故障轉移操作。

sentinel通過發送 SENTINEL is-master-down-by-addr ip port current_epoch runid,(ip:主觀下線的服務ip,port:主觀下線的服務端口,current_epoch:sentinel的紀元,runid:*表示檢測服務下線狀態,如果是sentinel 運行id,表示用來選舉領頭sentinel)來詢問其它sentinel是否同意服務下線。

一個sentinel接收另一個sentinel發來的is-master-down-by-addr後,提取參數,根據ip和端口,檢測該服務是否在該sentinel主觀下線,並且回覆is-master-down-by-addr,回覆包含三個參數:down_state(1表示已下線,0表示未下線),leader_runid(領頭sentinal id),leader_epoch(領頭sentinel紀元)。

sentinel接收到回覆後,根據配置設置的下線最小數量,達到這個值,既認爲該服務客觀下線。

客觀下線條件只適用於主服務器: 對於任何其他類型的 Redis 實例, Sentinel 在將它們判斷爲下線前不需要進行協商, 所以從服務器或者其他 Sentinel 永遠不會達到客觀下線條件。只要一個 Sentinel 發現某個主服務器進入了客觀下線狀態, 這個 Sentinel 就可能會被其他 Sentinel 推選出, 並對失效的主服務器執行自動故障遷移操作。

在redis-sentinel的conf文件裏有這麼兩個配置:
1)sentinel monitor <masterName> <ip> <port> <quorum>

四個參數含義:
masterName這個是對某個master+slave組合的一個區分標識(一套sentinel是可以監聽多套master+slave這樣的組合的)。
ip 和 port 就是master節點的 ip 和 端口號。
quorum這個參數是進行客觀下線的一個依據,意思是至少有 quorum 個sentinel主觀的認爲這個master有故障,纔會對這個master進行下線以及故障轉移。因爲有的時候,某個sentinel節點可能因爲自身網絡原因,導致無法連接master,而此時master並沒有出現故障,所以這就需要多個sentinel都一致認爲該master有問題,纔可以進行下一步操作,這就保證了公平性和高可用。

2)sentinel down-after-milliseconds ** < masterName > <timeout>
這個配置其實就是進行
主觀下線的一個依據**,masterName這個參數不用說了,timeout是一個毫秒值,表示:如果這臺sentinel超過timeout這個時間都無法連通master包括slave(slave不需要客觀下線,因爲不需要故障轉移)的話,就會主觀認爲該master已經下線(實際下線需要客觀下線的判斷通過纔會下線)

那麼,多個sentinel之間是如何達到共識的呢?
某個sentinel先將master節點進行主觀下線,然後會將這個判定通過sentinel is-master-down-by-addr這個命令問對應的節點是否也同樣認爲該addr的master節點要做客觀下線。最後當達成這一共識的sentinel個數達到前面說的quorum設置的這個值時,就會對該master節點下線進行故障轉移。quorum的值一般設置爲sentinel個數的二分之一加1,例如3個sentinel就設置2。

選舉領頭sentinel

一個redis服務被判斷爲客觀下線時,多個監視該服務的sentinel協商,選舉一個領頭sentinel,對該redis服務進行故障轉移操作。選舉領頭sentinel遵循以下規則:

1)所有的sentinel都有公平被選舉成領頭的資格。
2)所有的sentinel都有且只有一次將某個sentinel選舉成領頭的機會(在一輪選舉中),一旦選舉某個sentinel爲領頭,不能更改。
3)sentinel設置領頭sentinel是先到先得,一旦當前sentinel設置了領頭sentinel,以後要求設置其他sentinel爲領頭請求都會被拒絕。
4)每個發現服務客觀下線的sentinel,都會要求其他sentinel將自己設置成領頭。
5)當一個sentinel(源sentinel)向另一個sentinel(目標sentinel)發送is-master-down-by-addr ip port current_epoch runid命令的時候,runid參數不是*,而是sentinel運行id,就表示源sentinel要求目標sentinel選舉其爲領頭。
6)源sentinel會檢查目標sentinel對其要求設置成領頭的回覆,如果回覆的leader_runid和leader_epoch爲源sentinel,表示目標sentinel同意將源sentinel設置成領頭。
7)如果某個sentinel被半數以上的sentinel設置成領頭,那麼該sentinel既爲領頭。
8)如果在限定時間內,沒有選舉出領頭sentinel,暫定一段時間,再選舉。

故障轉移

所謂故障轉移就是當master宕機,選一個合適的slave來晉升爲master的操作,redis-sentinel會自動完成這個,不需要我們手動來實現。

一次故障轉移操作大致分爲以下流程:
發現主服務器已經進入客觀下線狀態。
對我們的當前集羣進行自增, 並嘗試在這個集羣中當選。
如果當選失敗, 那麼在設定的故障遷移超時時間的兩倍之後, 重新嘗試當選。 如果當選成功, 那麼執行以下步驟:

選出一個從服務器,並將它升級爲主服務器
向被選中的從服務器發送 SLAVEOF NO ONE 命令,讓它轉變爲主服務器。
通過發佈與訂閱功能, 將更新後的配置傳播給所有其他 Sentinel , 其他 Sentinel 對它們自己的配置進行更新。
向已下線主服務器的從服務器發送 SLAVEOF 命令, 讓它們去複製新的主服務器。
當所有從服務器都已經開始複製新的主服務器時, 領頭 Sentinel 終止這次故障遷移操作。
每當一個 Redis 實例被重新配置(reconfigured) —— 無論是被設置成主服務器、從服務器、又或者被設置成其他主服務器的從服務器 —— Sentinel 都會向被重新配置的實例發送一個 CONFIG REWRITE 命令, 從而確保這些配置會持久化在硬盤裏。

Sentinel 使用以下規則來選擇新的主服務器:

  • 在失效主服務器屬下的從服務器當中, 那些被標記爲主觀下線、已斷線、或者最後一次回覆 PING 命令的時間大於五秒鐘的從服務器都會被淘汰。
  • 在失效主服務器屬下的從服務器當中, 那些與失效主服務器連接斷開的時長超過 down-after 選項指定的時長十倍的從服務器都會被淘汰。
  • 在經歷了以上兩輪淘汰之後剩下來的從服務器中, 我們選出複製偏移量(replication offset)最大的那個從服務器作爲新的主服務器; 如果複製偏移量不可用, 或者從服務器的複製偏移量相同, 那麼帶有最小運行 ID 的那個從服務器成爲新的主服務器。

Sentinel 自動故障遷移的一致性特質

Sentinel 自動故障遷移使用 Raft 算法來選舉領頭(leader) Sentinel , 從而確保在一個給定的紀元(epoch)裏, 只有一個領頭產生。

這表示在同一個紀元中, 不會有兩個 Sentinel 同時被選中爲領頭, 並且各個 Sentinel 在同一個紀元中只會對一個領頭進行投票。

更高的配置紀元總是優於較低的紀元, 因此每個 Sentinel 都會主動使用更新的紀元來代替自己的配置。

簡單來說, 可以將 Sentinel 配置看作是一個帶有版本號的狀態。 一個狀態會以最後寫入者勝出(last-write-wins)的方式(也即是,最新的配置總是勝出)傳播至所有其他 Sentinel 。

舉個例子, 當出現網絡分割(network partitions)時, 一個 Sentinel 可能會包含了較舊的配置, 而當這個 Sentinel 接到其他 Sentinel 發來的版本更新的配置時, Sentinel 就會對自己的配置進行更新。

如果要在網絡分割出現的情況下仍然保持一致性, 那麼應該使用 min-slaves-to-write 選項, 讓主服務器在連接的從實例少於給定數量時停止執行寫操作, 與此同時, 應該在每個運行 Redis 主服務器或從服務器的機器上運行 Redis Sentinel 進程。

Sentinel 狀態的持久化

Sentinel 的狀態會被持久化在 Sentinel 配置文件裏面。每當 Sentinel 接收到一個新的配置, 或者當領頭 Sentinel 爲主服務器創建一個新的配置時, 這個配置會與配置紀元一起被保存到磁盤裏面。這意味着停止和重啓 Sentinel 進程都是安全的。

Sentinel 在非故障遷移的情況下對實例進行重新配置
即使沒有自動故障遷移操作在進行, Sentinel 總會嘗試將當前的配置設置到被監視的實例上面。 特別是:

根據當前的配置, 如果一個從服務器被宣告爲主服務器, 那麼它會代替原有的主服務器, 成爲新的主服務器, 並且成爲原有主服務器的所有從服務器的複製對象。
那些連接了錯誤主服務器的從服務器會被重新配置, 使得這些從服務器會去複製正確的主服務器。

不過, 在以上這些條件滿足之後, Sentinel 在對實例進行重新配置之前仍然會等待一段足夠長的時間, 確保可以接收到其他 Sentinel 發來的配置更新, 從而避免自身因爲保存了過期的配置而對實例進行了不必要的重新配置。

總結來說,故障轉移分爲三個步驟:

1)從下線的主服務的所有從服務裏面挑選一個從服務,將其轉成主服務
sentinel狀態數據結構中保存了主服務的所有從服務信息,領頭sentinel按照如下的規則從從服務列表中挑選出新的主服務;
刪除列表中處於下線狀態的從服務;
刪除最近5秒沒有回覆過領頭sentinel info信息的從服務;
刪除與已下線的主服務斷開連接時間超過 down-after-milliseconds*10毫秒的從服務,這樣就能保留從的數據比較新(沒有過早的與主斷開連接);
領頭sentinel從剩下的從列表中選擇優先級高的,如果優先級一樣,選擇偏移量最大的(偏移量大說明覆制的數據比較新),如果偏移量一樣,選擇運行id最小的從服務。

2)已下線主服務的所有從服務改爲複製新的主服務
挑選出新的主服務之後,領頭sentinel 向原主服務的從服務發送 slaveof 新主服務 的命令,複製新master。

3)將已下線的主服務設置成新的主服務的從服務,當其回覆正常時,複製新的主服務,變成新的主服務的從服務
同理,當已下線的服務重新上線時,sentinel會向其發送slaveof命令,讓其成爲新主的從。

溫馨提示:還可以向任意sentinel發生sentinel failover 進行手動故障轉移,這樣就不需要經過上述主客觀和選舉的過程。

集羣模式

Redis Cluster實現在多個節點之間進行數據共享,即使部分節點失效或者無法進行通訊時,Cluster仍然可以繼續處理請求。若每個主節點都有一個從節點支持,在主節點下線或者無法與集羣的大多數節點進行通訊的情況下, 從節點提升爲主節點,並提供服務,保證Cluster正常運行,Redis Cluster的節點分片是通過哈希槽(hash slot)實現的,每個鍵都屬於這 16384(0~16383) 個哈希槽的其中一個,每個節點負責處理一部分哈希槽。

數據sharding

Redis 集羣使用數據分片(sharding)而非一致性哈希(consistency hashing)來實現: 一個 Redis 集羣包含 16384 個哈希槽(hash slot), 數據庫中的每個鍵都屬於這 16384 個哈希槽的其中一個, 集羣使用公式 CRC16(key) % 16384 來計算鍵 key 屬於哪個槽, 其中 CRC16(key) 語句用於計算鍵 key 的 CRC16 校驗和 。集羣中的每個節點負責處理一部分哈希槽。 舉個例子, 一個集羣可以有三個節點, 其中:

  • 節點 A 負責處理 0 號至 5500 號哈希槽。
  • 節點 B 負責處理 5501 號至 11000 號哈希槽。
  • 節點 C 負責處理 11001 號至 16384 號哈希槽。

這種將哈希槽分佈到不同節點的做法使得用戶可以很容易地向集羣中添加或者刪除節點。 比如說:

  • 如果用戶將新節點 D 添加到集羣中, 那麼集羣只需要將節點 A 、B 、 C 中的某些槽移動到節點 D 就可以了。
  • 如果用戶要從集羣中移除節點 A , 那麼集羣只需要將節點 A 中的所有哈希槽移動到節點 B 和節點 C , 然後再移除空白(不包含任何哈希槽)的節點 A 就可以了。

因爲將一個哈希槽從一個節點移動到另一個節點不會造成節點阻塞, 所以無論是添加新節點還是移除已存在節點, 又或者改變某個節點包含的哈希槽數量, 都不會造成集羣下線

集羣內部數據結構

Redis Cluster功能涉及三個核心的數據結構clusterState、clusterNode、clusterLink都在cluster.h中定義。這三個數據結構中最重要的屬性就是:clusterState.slots、clusterState.slots_to_keys和clusterNode.slots了,它們保存了三種映射關係:

  • clusterState:集羣狀態
  • nodes:所有結點
  • migrating_slots_to:遷出中的槽
  • importing_slots_from:導入中的槽
  • slots_to_keys:槽中包含的所有Key,用於遷移Slot時獲得其包含的Key
  • slots:Slot所屬的結點,用於處理請求時判斷Key所在Slot是否自己負責
  • clusterNode:結點信息
  • slots:結點負責的所有Slot,用於發送Gossip消息通知其他結點自己負責的Slot。通過位圖方式保存節省空間,16384/8恰好是2048字節,所以槽總數16384不能隨意定!
  • clusterLink:與其他結點通信的連接

集羣狀態,每個節點都保存着一個這樣的狀態,記錄了它們眼中的集羣的樣子。另外,雖然這個結構主要用於記錄集羣的屬性,但是爲了節約資源,有些與節點有關的屬性,比如 slots_to_keys 、 failover_auth_count 也被放到了這個結構裏面。

typedef struct clusterState {
    ...
    //指向當前節點的指針
    clusterNode *myself;  /* This node */
 
    //集羣當前的狀態:是在線還是下線
    int state;            /* REDIS_CLUSTER_OK, REDIS_CLUSTER_FAIL, ... */
    //集羣當前的配置紀元,用於實現故障轉移
    uint64_t currentEpoch;
    //集羣中至少處理着一個槽的節點的數量
    int size;
 
    //集羣節點名單(包括 myself 節點)
    //字典的鍵爲節點的名字,字典的值爲 clusterNode 結構
    dict *nodes;          /* Hash table of name -> clusterNode structures */
 
    //記錄要從當前節點遷移到目標節點的槽,以及遷移的目標節點
    //migrating_slots_to[i] = NULL 表示槽 i 未被遷移
    //migrating_slots_to[i] = clusterNode_A 表示槽 i 要從本節點遷移至節點 A
    clusterNode *migrating_slots_to[REDIS_CLUSTER_SLOTS];
 
    //記錄要從源節點遷移到本節點的槽,以及進行遷移的源節點
    //importing_slots_from[i] = NULL 表示槽 i 未進行導入
    //importing_slots_from[i] = clusterNode_A 表示正從節點 A 中導入槽 i
    clusterNode *importing_slots_from[REDIS_CLUSTER_SLOTS];
 
    //負責處理各個槽的節點
    //例如 slots[i] = clusterNode_A 表示槽 i 由節點 A 處理
    clusterNode *slots[REDIS_CLUSTER_SLOTS];
 
    //跳躍表,表中以槽作爲分值,鍵作爲成員,對槽進行有序排序
    //當需要對某些槽進行區間(range)操作時,這個跳躍表可以提供方便
    //具體操作定義在 db.c 裏面
    zskiplist *slots_to_keys;
    ...
} clusterState;
 
//節點狀態
struct clusterNode {
    ...
    //創建節點的時間
    mstime_t ctime;
    //從節點的名字,由40個十六進制字符組成
    char name[REDIS_CLUSTER_NAMELEN];
    //節點標識
    //使用各種不同的標識值記錄節點的角色(比如主節點或者從節點),
    //以及節點目前所處的狀態(比如在線或者下線)。
    int flags;      /* REDIS_NODE_... */
    //節點當前的配置紀元,用於實現故障轉移
    uint64_t configEpoch;
    //節點的ip地址
    char ip[REDIS_IP_STR_LEN]
    //節點的端口號
    int port;
    //由這個節點負責處理的槽
    //一共有 REDIS_CLUSTER_SLOTS / 8 個字節長
    //每個字節的每個位記錄了一個槽的保存狀態
    //位的值爲 1 表示槽正由本節點處理,值爲 0 則表示槽並非本節點處理
    //比如 slots[0] 的第一個位保存了槽 0 的保存情況
    //slots[0] 的第二個位保存了槽 1 的保存情況,以此類推
    unsigned char slots[REDIS_CLUSTER_SLOTS/8]; /* slots handled by this node */
 
    //指針數組,指向各個從節點
    struct clusterNode **slaves; /* pointers to slave nodes */
 
    //如果這是一個從節點,那麼指向主節點
    struct clusterNode *slaveof; /* pointer to the master node */
    ...
    //保存連接點所需的有關信息
    clusterLink *link;
};
 
/* clusterLink encapsulates everything needed to talk with a remote node. */
//clusterLink 包含了與其他節點進行通訊所需的全部信息
typedef struct clusterLink {
    ...
    //TCP 套接字描述符
    int fd;                     /* TCP socket file descriptor */
    //輸出緩衝區,保存着等待發送給其他節點的消息(message)
    sds sndbuf;
    //輸入緩衝區,保存着從其他節點收到的消息
    sds rcvbuf;
 
    //與這個連接相關聯的節點,如果沒有的話就爲 NULL
    struct clusterNode *node;   /* Node related to this link if any, or NULL */
    ...
} clusterLink;

Redis Cluster集羣的處理流程

在單機模式下,Redis對請求的處理很簡單。Key存在的話,就執行請求中的操作;Key不存在的話,就告訴客戶端Key不存在。然而在集羣模式下,因爲涉及到請求重定向和Slot遷移,所以對請求的處理變得很複雜,流程如下:

  • 檢查Key所在Slot是否屬於當前Node?
  • 計算crc16(key) % 16384得到Slot
  • 查詢clusterState.slots負責Slot的結點指針
  • 與myself指針比較
  • 若不屬於,則響應MOVED錯誤重定向客戶端
  • 若屬於且Key存在,則直接操作,返回結果給客戶端
  • 若Key不存在,檢查該Slot是否遷出中?(clusterState.migrating_slots_to)
  • 若Slot遷出中,返回ASK錯誤重定向客戶端到遷移的目的服務器上
  • 若Slot未遷出,檢查Slot是否導入中?(clusterState.importing_slots_from)
  • 若Slot導入中且有ASKING標記,則直接操作
  • 否則響應MOVED錯誤重定向客戶端

Redis Cluster容錯機制

failover是redis cluster的容錯機制,是redis cluster最核心功能之一;它允許在某些節點失效情況下,集羣還能正常提供服務。

redis cluster採用主從架構,任何時候只有主節點提供服務,從節點進行熱備份,故其容錯機制是主從切換機制,即主節點失效後,選取一個從節點作爲新的主節點。在實現上也複用了舊版本的主從同步機制。

從縱向看,redis cluster是一層架構,節點分爲主節點和從節點。從節點掛掉或失效,不需要進行failover,redis cluster能正常提供服務;主節點掛掉或失效需要進行failover。另外,redis cluster還支持manual failover,即人工進行failover,將從節點變爲主節點,即使主節點還活着。下面將介紹這兩種類型的failover。

1)主節點失效產生的failover

  • (主)節點失效檢測
    一般地,集羣中的節點會向其他節點發送PING數據包,同時也總是應答(accept)來自集羣連接端口的連接請求,並對接收到的PING數據包進行回覆。當一個節點向另一個節點發PING命令,但是目標節點未能在給定的時限(node timeout)內回覆時,那麼發送命令的節點會將目標節點標記爲PFAIL(possible failure)。

    由於節點間的交互總是伴隨着信息傳播的功能,此時每次當節點對其他節點發送 PING 命令的時候,就會告知目標節點此時集羣中已經被標記爲PFAIL或者FAIL標記的節點。相應的,當節點接收到其他節點發來的信息時, 它會記下那些被其他節點標記爲失效的節點。 這稱爲失效報告(failure report)。

    如果節點已經將某個節點標記爲PFAIL,並且根據節點所收到的失效報告顯式,集羣中的大部分其他主節點(n/2+1)也認爲那個節點進入了失效狀態,那麼節點會將那個PFAIL節點的狀態標記爲FAIL。

    一旦某個節點被標記爲FAIL,關於這個節點已失效的信息就會被廣播到整個集羣,所有接收到這條信息的節點都會將失效節點標記爲FAIL。

  • 選舉主節點
    一旦某個主節點進入 FAIL 狀態, 集羣變爲FAIL狀態,同時會觸發failover。failover的目的是從從節點中選舉出新的主節點,使得集羣恢復正常繼續提供服務。
    整個主節點選舉的過程可分爲申請、授權、升級、同步四個階段:

    • 申請
      新的主節點由原已失效的主節點屬下的所有從節點中自行選舉產生,從節點的選舉遵循以下條件:
      a、這個節點是已下線主節點的從節點;
      b、已下線主節點負責處理的哈希槽數量非空;
      c、主從節點之間的複製連接的斷線時長有限,不超過 ( (node-timeout * slave-validity-factor) + repl-ping-slave-period )。

      如果一個從節點滿足了以上的所有條件,那麼這個從節點將向集羣中的其他主節點發送授權請求,詢問它們是否允許自己升級爲新的主節點。
      從節點發送授權請求的時機會根據各從節點與主節點的數據偏差來進行排序,讓偏差小的從節點優先發起授權請求。

    • 授權
      其他主節點會遵信以下三點標準來進行判斷:
      a、 發送授權請求的是從節點,而且它所屬的主節點處於FAIL狀態 ;
      b、 從節點的currentEpoch〉自身的currentEpoch,從節點的configEpoch>=自身保存的該從節點的configEpoch;
      c、 這個從節點處於正常的運行狀態,沒有被標記爲FAIL或PFAIL狀態;

    ​ 如果發送授權請求的從節點滿足以上標準,那麼主節點將同意從節點的升級要求,向從節點返回 CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK授權。

    • 升級
      一旦某個從節點在給定的時限內得到大部分主節點(n/2+1)的授權,它就會接管所有由已下線主節點負責處理的哈希槽,並主動向其他節點發送一個PONG數據包,包含以下內容:
      a、 告知其他節點自己現在是主節點了
      b、 告知其他節點自己是一個ROMOTED SLAVE,即已升級的從節點;
      c、告知其他節點都根據自己新的節點屬性信息對配置進行相應的更新
    • 同步
      其他節點在接收到ROMOTED SLAVE的告知後,會根據新的主節點對配置進行相應的更新。特別地,其他從節點會將新的主節點設爲自己的主節點,從而與新的主節點進行數據同步。
      至此,failover結束,集羣恢復正常狀態。

此時,如果原主節點恢復正常,但由於其的configEpoch小於其他節點保存的configEpoch(failover了產生較大的configEpoch),故其配置會被更新爲最新配置,並將自己設新主節點的從節點。

另外,在failover過程中,如果原主節點恢復正常,failover中止,不會產生新的主節點。

2)Manual Failover
Manual Failover是一種運維功能,允許手動設置從節點爲新的主節點,即使主節點還活着。
Manual Failover與上面介紹的Failover流程大都相同,除了下面兩點不同:
a)觸發機制不同,Manual Failover是通過客戶端發送cluster failover觸發,而且發送對象只能是從節點;
b)申請條件不同,Manual Failover不需要主節點失效,failover有效時長固定爲5秒,而且只有收到命令的從節點纔會發起申請。

另外,Manual Failover分force和非force,區別在於:非force需要等從節點完全同步完主節點的數據後才進行failover,保證不丟失數據,在這過程中,原主節點停止寫操作;而force不進行進行數據完整同步,直接進行failover。

3)集羣狀態檢測
集羣有OK和FAIL兩種狀態,可以通過CLUSTER INFO命令查看。當集羣發生配置變化時, 集羣中的每個節點都會對它所知道的節點進行掃描,只要集羣中至少有一個哈希槽不可用(即負責該哈希槽的主節點失效),集羣就會進入FAIL狀態,停止處理任何命令。
另外,當大部分主節點都進入PFAIL狀態時,集羣也會進入FAIL狀態。這是因爲要將一個節點從PFAIL狀態改變爲FAIL狀態,必須要有大部分主節點(n/2+1)認可,當集羣中的大部分主節點都進入PFAIL時,單憑少數節點是沒有辦法將一個節點標記爲FAIL狀態的。 然而集羣中的大部分主節點(n/2+1)進入了下線狀態,讓集羣變爲FAIL,是爲了防止少數存着主節點繼續處理用戶請求,這解決了出現網絡分區時,一個可能被兩個主節點負責的哈希槽,同時被用戶進行讀寫操作(通過禁掉其中少數派讀寫操作,證保只有一個讀寫操作),造成數據丟失數據問題。
說明:上面n/2+1的n是指集羣裏有負責哈希槽的主節點個數。

參考文獻

Redis Cluster集羣知識學習總結

redis系列:集羣

高可用Redis:Redis Cluster

redis cluster介紹
Redis哨兵模式(sentinel)學習總結及部署記錄(主從複製、讀寫分離、主從切換)

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