文章目錄
- 一、Redis集群概念
- 二、集群節點
- 1. 節點如何啟動
- 2. 節點的集群數據結構
- 2.1 `clusterNode`結構
- 2.2 `clusterLink`結構
- 2.3 `clusterState`結構
- 3. 節點如何加入集群
- 三、數據分片機制
- 1. 記錄節點的槽指派信息
- 2. 傳播節點的槽指派信息
- 3. 記錄集群所有槽的指派信息
- 4. 節點的槽指派命令
- 四、客戶端命令解析
- 1. 槽位分配算法
- 2. 判斷槽是否由當前節點負責處理
- 3. MOVED錯誤
- 4. 節點數據庫的實現
- 五、重新分片
- 1. 手動重新分片(`redis-trib.rb` / `redis-cli`)
- 2. 自動重新分片(`redis-cli --cluster reshard`)
- 3. ASK錯誤
- 六、復制與故障轉移
- 1. 節點復制
- 2. 故障檢測
- 3. 故障轉移
- 4. 選舉新的主節點
- 七、消息
- 總結
Redis集群是Redis提供的分布式數據庫方案,采用去中心化的P2P架構,通過哈希槽分片、Gossip協議通信和哨兵機制,實現高可用、橫向擴展和自動故障轉移。以下是Redis集群底層實現原理的詳細介紹:
一、Redis集群概念
Redis基于主從復制和哨兵模式的實現基礎,提供了更優的Redis分布式部署方案:Cluster模式,實現了分布式存儲方案,支持水平橫向擴展,解決在線擴容問題。
Redis集群是一個由多個主從節點群組成的分布式服務集群,需要將每個節點設置成集群模式,這種集群模式沒有中心節點,可水平擴展,可以線性擴展到上萬個節點,但官方推薦不超過1000個節點。以下是Redis Cluster部署的架構圖,支持多個客戶端Client通過JedisCluster API同時訪問Redis集群,集群下可水平擴展master+slave主從結構的數量,每一個master下的slave節點數量也可以視情況部署若干個。
二、集群節點
一個Redis集群通常由多個節點(node)組成,在剛開始的時候,每個節點都是相互獨立的,它們都處于一個只包含自己的集群當中,要組建一個真正可工作的集群,我們必須將各個獨立的節點連接起來,構成一個包含多個節點的集群。
1. 節點如何啟動
一個節點就是一個運行在集群模式下的Redis服務器,Redis服務器在啟動時會根據cluster-enabled
配置選項是否為yes來決定是否開啟服務器的集群模式。
每一個節點服務器啟動后都具有正常的Redis單機服務功能,當他們加入到集群模式之后,節點服務器會將集群相關的信息保存到了cluster.h/clusterNode
結構、cluster.h/clusterLink
結構,以及cluster.h/clusterState
結構里面,依此來提供集群相關的功能。
2. 節點的集群數據結構
2.1 clusterNode
結構
每個節點都會使用一個clusterNode
結構來記錄自己的狀態,并為集群中的所有其他節點(包括主節點和從節點)都創建一個相應的clusterNode
結構,以此來記錄其他節點的狀態。具體結構如下:
typedef struct clusterNode {mstime_t ctime; // 節點創建時間char name[REDIS_CLUSTER_NAMELEN]; // 節點ID(40位十六進制字符串)int flags; // 節點標志(主/從、在線/下線等)uint64_t configEpoch; // 配置紀元(故障轉移時使用)char ip[REDIS_IP_STR_LEN]; // 節點IP地址int port; // 節點端口clusterLink *link; // 與該節點的TCP連接信息unsigned char slots[REDIS_CLUSTER_SLOTS/8]; // 槽位分配位圖int numslots; // 節點負責的槽位總數int numslaves; // 從節點數量struct clusterNode **slaves; // 從節點列表struct clusterNode *slaveof; // 指向主節點的指針// ...其他字段(心跳時間、故障報告等)
} clusterNode;
-
ctime
:記錄節點創建時間,用于追蹤節點的啟動時間。 -
name
:節點唯一ID,由40位十六進制字符串組成(如68eef66df23420a5862208ef5b1a7005b806f2ff
),通過CLUSTER NODES
命令可查看。 -
flags
:節點標志位,通過位掩碼表示節點角色和狀態:REDIS_NODE_MASTER
:主節點REDIS_NODE_SLAVE
:從節點REDIS_NODE_PFAIL
:疑似下線REDIS_NODE_FAIL
:確認下線REDIS_NODE_HANDSHAKE
:握手階段
-
configEpoch
:配置紀元,用于故障轉移時的版本控制。每次主節點切換或配置變更時遞增,確保節點狀態一致性。 -
ip
&port
:節點的IP地址和端口,用于節點間通信。 -
link
:指向clusterLink
結構的指針,記錄與該節點的TCP連接信息。 -
slots
:二進制位數組,長度為16384/8 = 2048
字節,每位代表一個槽位(0~16383)。若某位為1,表示該節點負責對應槽位。比如:slots[0] = 0b11111111
表示槽位0~7由該節點負責。 -
numslots
:節點負責的槽位總數,用于快速統計。 -
slaves
&numslaves
:從節點列表及數量,僅在主節點中有效。 -
slaveof
:指向主節點的指針,若為從節點,則指向其主節點。 -
ping_sent
&pong_received
:記錄最后一次發送PING和接收PONG的時間戳,用于心跳檢測。若超時未響應,則標記為疑似下線(PFAIL
)。 -
fail_time
:節點被標記為確認下線(FAIL
)的時間。
2.2 clusterLink
結構
clusterNode
結構的link屬性是一個clusterLink
結構,該結構保存了連接節點所需的有關信息,比如套接字描述符,輸入緩沖區和輸出緩沖區,具體結構如下:
typedef struct clusterLink {
//連接的創建時間
mstime_t ctime;
// TCP套接字描述符
int fd;
//輸出緩沖區,保存著等待發送給其他節點的消息(message)。
sds sndbuf;
//輸入緩沖區,保存著從其他節點接收到的消息。
sds rcvbuf;
//與這個連接相關聯的節點,如果沒有的話就為NULL
struct clusterNode *node;
} clusterLink;
redisClient結構和clusterLink結構的相同和不同之處:redisClient結構和clusterLink結構都有自己的套接字描述符和輸入、輸出緩沖區,這兩個結構的區別在于,redisClient結構中的套接字和緩沖區是用于連接客戶端的,而clusterLink結構中的套接字和緩沖區則是用于連接節點的。
2.3 clusterState
結構
每個節點都保存著一個clusterState
結構,這個結構記錄了在當前節點的視角下,集群目前所處的狀態,例如集群是在線還是下線,集群包含多少個節點,集群當前的配置紀元。具體結構如下:
typedef struct clusterState {clusterNode *myself; // 指向自身節點的clusterNode結構dict *nodes; // 節點ID到clusterNode的映射字典clusterNode *slots[REDIS_CLUSTER_SLOTS]; // 槽位到節點的直接映射uint64_t currentEpoch; // 當前配置紀元(用于故障轉移)int state; // 集群整體狀態(OK/FAIL)int size; // 集群節點總數(主+從)int dir; // 集群配置目錄(用于持久化)// ...其他字段(如遷移狀態、故障報告等)
} clusterState;
myself
:自身節點指針,指向當前節點對應的clusterNode
結構,通過該指針可快速訪問自身節點的詳細信息(如IP、端口、槽位分配等)。nodes
:節點字典,一個字典(dict
類型),鍵為節點ID(40位十六進制字符串),值為對應的clusterNode
結構指針。存儲集群中所有節點的信息,包括主節點和從節點。slots
數組:槽位映射,長度為16384
的數組,索引對應槽位編號(0~16383),值指向負責該槽位的clusterNode
結構。通過槽位編號直接獲取負責節點,時間復雜度為O(1),是Redis集群高效路由的關鍵。currentEpoch
:配置紀元,無符號64位整數,用于標記集群配置的版本號。在故障轉移、槽位遷移等操作時遞增,確保所有節點對最新配置達成一致。state
:集群狀態,整數,表示集群整體狀態。客戶端可通過CLUSTER INFO
命令查詢該字段,判斷集群是否可用。REDIS_CLUSTER_OK
:集群正常(所有主節點可用)。REDIS_CLUSTER_FAIL
:集群不可用(部分主節點下線且無足夠從節點晉升)。
size
:集群節點數量,整數。若值為0,表示集群目前沒有任何節點在處理槽。
3. 節點如何加入集群
-
Redis通過向節點A發送
CLUSTER MEET
命令,客戶端可以讓接收命令的節點A將另一個節點B添加到節點A當前所在的集群里面。CLUSTER MEET <ip> <port>
-
收到
CLUSTER MEET
命令的節點A將與節點B進行握手(handshake),以此來確認彼此的存在,并為將來的進一步通信打好基礎。以下是節點A和節點B之間的握手過程的詳細步驟:- 節點A會為節點B創建一個clusterNode結構,并將該結構添加到自己的clusterState.nodes字典里面。
- 之后,節點A將根據CLUSTER MEET命令給定的IP地址和端口號,向節點B發送一條MEET消息(message. 。
- 如果一切順利,節點B將接收到節點A發送的MEET消息,節點B會為節點A創建一個clusterNode結構,并將該結構添加到自己的clusterState.nodes字典里面。
- 之后,節點B將向節點A返回一條PONG消息。
- 如果一切順利,節點A將接收到節點B返回的PONG消息,通過這條PONG消息節點A可以知道節點B已經成功地接收到了自己發送的MEET消息。
- 之后,節點A將向節點B返回一條PING消息。
- 如果一切順利,節點B將接收到節點A返回的PING消息,通過這條PING消息節點B可以知道節點A已經成功地接收到了自己返回的PONG消息,握手完成。
- 節點A和節點B握手完成完成后,節點A會將節點B的信息通過Gossip協議傳播給集群中的其他節點,讓其他節點也與節點B進行握手,最終,經過一段時間之后,節點B會被集群中的所有節點認識。
三、數據分片機制
Redis集群通過分片的方式來保存數據庫中的鍵值對:集群的整個數據庫被分為16384個固定哈希槽位(Slot,編號0~16383),數據庫中的每個鍵都屬于這16384個哈希槽的其中一個,集群中的每個節點可以處理0個或最多16384個哈希槽。
當Redis中的16384個哈希槽都有節點在處理時,集群處于上線狀態(ok);相反地,如果數據庫中有任何一個哈希槽沒有得到處理,那么集群處于下線狀態(fail)。
1. 記錄節點的槽指派信息
集群的每個節點負責處理哪些槽會記錄在clusterNode
結構的slots
屬性和numslot
屬性:
slots
屬性是一個二進制位數組(bit array),這個數組的長度為16384/8=2048個字節,共包含16384個二進制位。numslots
屬性則記錄節點負責處理的槽的數量,也即是slots數組中值為1的二進制位的數量。
Redis以0為起始索引,16383為終止索引,對slots數組中的16384個二進制位進行編號,并根據索引i
上的二進制位的值來判斷節點是否負責處理槽i
:
- 如果slots數組在索引
i
上的二進制位的值為1
,那么表示節點負責處理槽i
。 - 如果slots數組在索引
i
上的二進制位的值為0
,那么表示節點不負責處理槽i
。
如下圖當前節點的哈希槽slots數組記錄值,這個數組索引1、3、5、8、9、10上的二進制位的值都為1,而其余所有二進制位的值都為0,這表示節點負責處理槽1、3、5、8、9、10。
2. 傳播節點的槽指派信息
一個集群節點除了會將自己負責處理的槽記錄在clusterNode
結構的slots
屬性和numslots
屬性之外,它還會將自己的slots數組通過消息發送給集群中的其他節點,以此來告知集群其他節點自己目前負責處理哪些槽。
當節點A通過消息從節點B那里接收到節點B的slots數組時,節點A會在自己的clusterState.nodes
字典中查找節點B對應的clusterNode
結構,并對結構中的slots數組進行保存或者更新。通過傳播記錄節點的槽信息,集群中的每個節點都會知道數據庫中的16384個槽分別被指派給了集群中的哪些節點。
3. 記錄集群所有槽的指派信息
Reids集群中所有16384個槽的指派信息會記錄在clusterState
結構中的slots
數組。slots
數組包含16384個項,每個數組項都是一個指向clusterNode
結構的指針:
- 如果
slots[i]
指針指向NULL
,那么表示槽i
尚未指派給任何節點。 - 如果
slots[i]
指針指向一個clusterNode
結構,那么表示槽i
已經指派給了clusterNode
結構所代表的節點。
Redis集群使用clusterState.slots
數組記錄了集群中所有槽的指派信息,但使用clusterNode.slots
數組來記錄單個節點的槽指派信息的原因:
- 當程序需要將某個節點的槽指派信息通過消息發送給其他節點時,程序只需要將相應節點的
clusterNode.slots
數組整個發送出去就可以了。 - 如果Redis不使用
clusterNode.slots
數組,而單獨使用clusterState.slots
數組的話,那么每次要將節點A的槽指派信息傳播給其他節點時,程序必須先遍歷整個clusterState.slots
數組,記錄節點A負責處理哪些槽,然后才能發送節點A的槽指派信息,這比直接發送clusterNode.slots
數組要麻煩和低效得多。
4. 節點的槽指派命令
集群的每一個節點都可以使用CLUSTER ADDSLOTS <slot> [slot...]
命令接受一個或多個槽作為參數,并將所有輸入的槽指派給接收該命令的節點負責。比如在集群某個節點上執行CLUSTER ADDSLOTS 1 2
命令,將為該節點增加槽1和槽2,更新節點的clusterState、clusterNode
結構槽記錄信息,并通知其他節點自己負責的槽信息。
以下是執行CLUSTER ADDSLOTS命令的偽代碼:
def CLUSTER_ADDSLOTS(*all_input_slots):#遍歷所有輸入槽,檢查它們是否都是未指派槽
for i in all_input_slots:#如果有哪怕一個槽已經被指派給了某個節點,那么向客戶端返回錯誤,并終止命令執行
if clusterState.slots[i] != NULL:
reply_error()
return#如果所有輸入槽都是未指派槽,那么再次遍歷所有輸入槽,將這些槽指派給當前節點
for i in all_input_slots:
#設置clusterState結構的slots數組,將slots[i]的指針指向代表當前節點的clusterNode結構clusterState.slots[i] = clusterState.myself
#訪問代表當前節點的clusterNode結構的slots數組,將數組在索引i上的二進制位設置為1
setSlotBit(clusterState.myself.slots, i)
四、客戶端命令解析
當Reids集群將所有的16384個槽都指派給了對于的集群節點后,集群才可以正常對外提供服務。當客戶端向節點發送與數據庫鍵有關的命令時,接收命令的節點會計算出命令要處理的數據庫鍵屬于哪個槽,并檢查這個槽是否指派給了自己:
- 如果鍵所在的槽正好就指派給了當前節點,那么節點直接執行這個命令。
- 如果鍵所在的槽并沒有指派給當前節點,那么節點會向客戶端返回一個MOVED錯誤,指引客戶端轉向(redirect)至正確的節點,并再次發送之前想要執行的命令。
1. 槽位分配算法
節點使用以下算法來計算給定鍵key屬于哪個槽:
def slot_number(key):
return CRC16(key) & 16384
首先是CRC16(key)
語句對鍵(Key)計算CRC16哈希值,在通過&16384
語句對16384取模,計算出一個介于0至16383之間的整數作為鍵key的槽號。比如鍵"date"
的CRC16值為45678
,則槽位為45678 % 16384 = 12910
。
可以通過命令CLUSTER KEYSLOT<key>
查看一個給定鍵屬于哪個槽:
127.0.0.1:7000> CLUSTER KEYSLOT "date"
(integer) 12910
127.0.0.1:7000> CLUSTER KEYSLOT "msg"
(integer) 6257
127.0.0.1:7000> CLUSTER KEYSLOT "name"
(integer) 5798
2. 判斷槽是否由當前節點負責處理
當節點計算出鍵所屬的槽i之后,節點就會檢查自己在clusterState.slots
數組中的項i
,判斷鍵所在的槽是否由自己負責:
- 如果
clusterState.slots[i]
等于clusterState.myself
,那么說明槽i
由當前節點負責,節點可以執行客戶端發送的命令。 - 如果
clusterState.slots[i]
不等于clusterState.myself
,那么說明槽i
并非由當前節點負責,節點會根據clusterState.slots[i]
指向的clusterNode
結構所記錄的節點IP和端口號,向客戶端返回MOVED
錯誤,指引客戶端轉向至正在處理槽i的節點。
3. MOVED錯誤
當節點發現鍵所在的槽并非由自己負責處理的時候,節點就會向客戶端返回一個MOVED錯誤,指引客戶端轉向至正在負責槽的節點。MOVED錯誤的格式如下,其中slot為鍵所在的槽,而ip和port則是負責處理槽slot的節點的IP地址和端口號。
// MOVED 10086 127.0.0.1:7002
MOVED <slot> <ip>:<port>
當客戶端接收到節點返回的MOVED錯誤時,客戶端會根據MOVED錯誤中提供的IP地址和端口號,轉向至負責處理槽slot的節點,并向該節點重新發送之前想要執行的命令。如果兩個節點之間未連接過,則會根據ip和端口號先建立節點連接。
$ redis-cli -c -p 7001 ...
# 集群模式
127.0.0.1:7001>SET msg "happy new year!"
->Redirected to slot [6257] located at 127.0.0.1:7002
OK
127.0.0.1:7002>
值得注意的是,單機模式的redis-cli客戶端,再次向節點7001發送相同的命令,那么MOVED錯誤就會被客戶端打印出來:
$ redis-cli -p 7001 ...
# 單機模式
127.0.0.1:7001>SET msg "happy new year!"
(error)MOVED 6257 127.0.0.1:7002
127.0.0.1:7001>
這是因為單機模式的redis-cli客戶端不清楚MOVED錯誤的作用,所以它只會直接將MOVED錯誤直接打印出來,而不會進行自動轉向。
4. 節點數據庫的實現
-
Redis集群節點服務器和單機服務器在數據庫方面的區別是:集群節點只能使用0號數據庫,而單機Redis服務器則沒有這一限制。
-
集群節點服務器保存鍵值對以及鍵值對過期時間的方式與單機服務器完全相同。但是集群節點服務器除了將鍵值對保存在數據庫里面之外,集群節點服務器還會用
clusterState
結構中的slots_to_keys
跳躍表來保存槽和鍵之間的關系。比如一個簡單的slots_to_keys
跳躍表結構圖如下:
-
slots_to_keys
跳躍表每個節點的分值(score)都是一個槽號,而每個節點的成員(member)都是一個數據庫鍵:- 每當節點往數據庫中添加一個新的鍵值對時,節點就會將這個鍵以及鍵的槽號關聯到
slots_to_keys
跳躍表。 - 當節點刪除數據庫中的某個鍵值對時,節點就會在
slots_to_keys
跳躍表解除被刪除鍵與槽號的關聯。
- 每當節點往數據庫中添加一個新的鍵值對時,節點就會將這個鍵以及鍵的槽號關聯到
-
通過在
slots_to_keys
跳躍表中記錄各個數據庫鍵所屬的槽,節點可以很方便地對屬于某個或某些槽的所有數據庫鍵進行批量操作,例如命令CLUSTER GETKEYSINSLOT <slot> <count>
命令可以返回最多count個屬于槽slot的數據庫鍵,而這個命令就是通過遍歷slots_to_keys
跳躍表來實現的。
五、重新分片
Redis集群的重新分片操作可以將任意數量已經指派給某個節點(源節點)的槽改為指派給另一個節點(目標節點),并且相關槽所屬的鍵值對也會從源節點被移動到目標節點。
重新新分片操作可以在線(online)進行,在重新分片的過程中,集群不需要下線,并且源節點和目標節點都可以繼續處理命令請求。
1. 手動重新分片(redis-trib.rb
/ redis-cli
)
Redis集群的重新分片操作在Redis 3.x~5.x 使用redis-trib.rb
工具,在Redis 6.x 及以上推薦使用 redis-cli
。以下是redis-trib.rb/redis-cli
對集群的單個槽slot進行重新分片的詳細步驟如下:
-
目標節點準備導入數據:
redis-trib.rb/redis-cli
通知目標節點(Target Node)準備接收屬于該槽的所有鍵值對。目標節點會記錄該槽的導入狀態,并在后續遷移過程中處理來自源節點的數據。使用如下命令:CLUSTER SETSLOT <slot> IMPORTING <source_node_id>
-
源節點準備遷移數據:
redis-trib.rb/redis-cli
通知源節點(Source Node)準備將該槽的鍵值對遷移至目標節點。源節點會記錄該槽的遷移狀態,并在后續處理客戶端請求時返回ASK
重定向。使用如下命令:CLUSTER SETSLOT <slot> MIGRATING <target_node_id>
-
獲取槽中的鍵名列表:
redis-trib.rb/redis-cli
向源節點發送請求,獲取最多count
個屬于該槽的鍵名(Key Name)。此命令用于分批遷移鍵值對,避免一次性傳輸大量數據導致性能問題。使用如下命令:CLUSTER GETKEYSINSLOT <slot> <count>
-
逐個遷移鍵值對:對于步驟3獲得的每個鍵名,
redis-trib.rb/redis-cli
都向源節點發送MIGRATE
命令,將鍵值對原子性地從源節點遷移至目標節點。遷移過程中:- 源節點會鎖定鍵并刪除本地數據。
- 目標節點接收鍵并寫入本地存儲。
- 遷移是原子的,確保鍵在任意時刻只存在于源節點或目標節點之一。
MIGRATE <target_ip> <target_port> <key_name> 0 <timeout>
-
重復遷移直到完成:重復執行步驟3和步驟4,直到源節點保存的所有屬于槽slot的鍵值對都被遷移至目標節點為止。
-
更新槽指派給目標節點:
redis-trib.rb/redis-cli
向集群中的任意節點發送命令,將該槽的歸屬權更新為目標節點。此信息會通過 Gossip 協議傳播到整個集群,確保所有節點同步最新槽分配。使用如下命令:CLUSTER SETSLOT <slot> NODE <target_node_id>
-
如果重新分片涉及多個槽,那么
redis-trib.rb/redis-cli
將對每個給定的槽分別執行1~6給出的步驟。
單個槽手動重新分片完整的偽代碼:
#假設需將槽 `0` 從節點 `A`(IP: 192.168.1.101:6379)遷移到節點 `B`(IP:192.168.1.102:6379)# 1.標記目標節點為導入狀態
redis-cli -h 192.168.1.102 -p 6379 CLUSTER SETSLOT 0 IMPORTING a1b2c3d4...# 2.標記源節點為遷移狀態
redis-cli -h 192.168.1.101 -p 6379 CLUSTER SETSLOT 0 MIGRATING b3a4c5d6...# 3.獲取槽中的鍵名
redis-cli -h 192.168.1.101 -p 6379 CLUSTER GETKEYSINSLOT 0 100# 4~5.逐個遷移鍵
for key in $(redis-cli -h 192.168.1.101 -p 6379 CLUSTER GETKEYSINSLOT 0 100); doredis-cli -h 192.168.1.101 -p 6379 MIGRATE 192.168.1.102 6379 $key 0 5000
done# 6.更新槽歸屬
redis-cli -h 192.168.1.101 -p 6379 CLUSTER SETSLOT 0 NODE b3a4c5d6...
2. 自動重新分片(redis-cli --cluster reshard
)
Redis 6.x 推薦使用 redis-cli --cluster reshard
自動完成槽遷移,支持批量遷移和交互式操作。以下是參考步驟:
-
查看槽分配
redis-cli -p 7001 CLUSTER SLOTS | grep 1000
-
執行重新分片(假設槽1000在節點A,需遷移到節點B)
redis-cli --cluster reshard 127.0.0.1:7001 --cluster-from "a1b2c3d4" --cluster-to "e5f6g7h8" --cluster-slots 1
-
輸入交互式參數
#輸入你要遷移的槽數量(1~16384)。 How many slots do you want to move (from 1 to 16384)? 1 #輸入目標節點的 Node ID。 #可以通過 redis-cli --cluster nodes <host>:<port> 查看集群節點的ID。 What is the destination node ID? e5f6g7h8 #輸入源節點的 Node ID。可以是一個或多個,輸入`all`時表示從所有節點遷移。 Please enter all the source node IDs. a1b2c3d4 #輸入 `yes` 確認。 Do you want to proceed with the proposed reshard plan (yes/no)? yes
-
驗證結果
redis-cli -p 7001 CLUSTER SLOTS | grep 1000
3. ASK錯誤
在進行重新分片期間,源節點向目標節點遷移一個槽的過程中,可能會出現這樣一種情況:屬于被遷移槽的一部分鍵值對保存在源節點里面,而另一部分鍵值對則保存在目標節點里面。當客戶端向源節點發送一個與數據庫鍵有關的命令,并且命令要處理的數據庫鍵恰好就屬于正在被遷移的槽時:
- 源節點會先在自己的數據庫里面查找指定的鍵,如果找到的話,就直接執行客戶端發送的命令。
- 相反地,如果源節點沒能在自己的數據庫里面找到指定的鍵,那么這個鍵有可能已經被遷移到了目標節點,源節點將向客戶端返回一個ASK錯誤,指引客戶端轉向正在導入槽的目標節點,并再次發送之前想要執行的命令。
ASK錯誤和MOVED錯誤都會導致客戶端轉向,它們的區別在于:
- MOVED錯誤:MOVED錯誤代表槽的負責權已經從一個節點轉移到了另一個節點。在客戶端收到關于槽i的MOVED錯誤之后,客戶端每次遇到關于槽i的命令請求時,都可以直接將命令請求發送至MOVED錯誤所指向的節點,因為該節點就是目前負責槽i的節點。
- ASK錯誤:ASK錯誤只是兩個節點在遷移槽的過程中使用的一種臨時措施。在客戶端收到關于槽i的ASK錯誤之后,客戶端只會在接下來的一次命令請求中將關于槽i的命令請求發送至ASK錯誤所指示的節點,但這種轉向不會對客戶端今后發送關于槽i的命令請求產生任何影響,客戶端仍然會將關于槽i的命令請求發送至目前負責處理槽i的節點,除非ASK錯誤再次出現。
六、復制與故障轉移
Redis集群中的節點分為主節點(master)和從節點(slave),其中主節點用于處理槽,而從節點則用于復制某個主節點,并在被復制的主節點下線時,代替下線主節點繼續處理命令請求。
1. 節點復制
向一個節點發送命令,可以讓接收命令的節點成為node_id所指定節點的從節點,并開始對主節點進行復制:
CLUSTER REPLICATE <node_id>
與主從復制原理一樣,集群節點會在clusterState.nodes
內保存主從節點標識、主從節點ip和端口等信息,通過這些信息建立主從關系復制。當一個從節點開始復制某個主節點時,這一信息會通過消息發送給集群中的其他節點,最終集群中的所有節點都會在記錄該主節點的clusterNode
結構記錄并保存從節點正在復制該主節點相關的信息。
2. 故障檢測
集群中的每個節點都會定期地向集群中的其他節點發送PING消息,以此來檢測對方是否在線,如果接收PING消息的節點沒有在規定的時間內,向發送PING消息的節點返回PONG消息,那么發送PING消息的節點就會將接收PING消息的節點標記為疑似下線(probable fail,PFAIL)。
當一個主節點A通過消息得知主節點B認為主節點C進入了疑似下線狀態時,主節點A會在自己的clusterState.nodes
字典中找到主節點C所對應的clusterNode
結構,并將主節點B的下線報告(每個下線報告由一個clusterNodeFailReport
結構表示)添加到clusterNode
結構的fail_reports
鏈表里面。
如果在一個集群里面,半數以上負責處理槽的主節點都將某個主節點x報告為疑似下線,那么這個主節點x將被標記為已下線(FAIL),將主節點x標記為已下線的節點會向集群廣播一條關于主節點x的FAIL消息,所有收到這條FAIL消息的節點都會立即將主節點x標記為已下線。
3. 故障轉移
當一個從節點發現自己正在復制的主節點進入了已下線狀態時,從節點將開始對下線主節點進行故障轉移,以下是故障轉移的執行步驟:
- 復制下線主節點的所有從節點里面,會有一個從節點被選中。
- 被選中的從節點會執行
SLAVEOF no one
命令,成為新的主節點。 - 新的主節點會撤銷所有對已下線主節點的槽指派,并將這些槽全部指派給自己。
- 新的主節點向集群廣播一條PONG消息,這條PONG消息可以讓集群中的其他節點立即知道這個節點已經由從節點變成了主節點,并且這個主節點已經接管了原本由已下線節點負責處理的槽。
- 新的主節點開始接收和自己負責處理的槽有關的命令請求,故障轉移完成。
4. 選舉新的主節點
與哨兵領頭選舉機制原理類似,新的主節點也是基于Raft
算法的領頭選舉產生的。以下是集群選舉新的主節點的方法:
- 集群的配置紀元是一個自增計數器,它的初始值為0。
- 當集群里的某個節點開始一次故障轉移操作時,集群配置紀元的值會被增一。
- 對于每個配置紀元,集群里每個負責處理槽的主節點都有一次投票的機會,而第一個向主節點要求投票的從節點將獲得主節點的投票。
- 當從節點發現自己正在復制的主節點進入已下線狀態時,從節點會向集群廣播一條
CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST
消息,要求所有向這個從節點投票。 - 如果一個主節點具有投票權(它正在負責處理槽),并且這個主節點尚未投票給其他從節點,那么主節點將向要求投票的從節點返回一條
CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK
消息,表示這個主節點支持從節點成為新的主節點。 - 每個參與選舉的從節點都會接收
CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK
消息,并根據自己收到了多少條這種消息來統計自己獲得了多少主節點的支持。 - 如果集群里有N個具有投票權的主節點,那么當一個從節點收集到大于等于N/2+1張支持票時,這個從節點就會當選為新的主節點。
- 因為在每一個配置紀元里面,每個具有投票權的主節點只能投一次票,所以如果有N個主節點進行投票,那么具有大于等于N/2+1張支持票的從節點只會有一個,這確保了新的主節點只會有一個。
- 如果在一個配置紀元里面沒有從節點能收集到足夠多的支持票,那么集群進入一個新的配置紀元,并再次進行選舉,直到選出新的主節點為止。
七、消息
集群中的各個節點通過發送和接收消息(message)來進行通信,我們稱發送消息的節點為發送者(sender),接收消息的節點為接收者(receiver)。節點發送的消息主要有以下五種:
- MEET消息:當發送者接到客戶端發送的CLUSTER MEET命令時,發送者會向接收者發送MEET消息,請求接收者加入到發送者當前所處的集群里面。
- PING消息:集群里的每個節點默認每隔一秒鐘就會從已知節點列表中隨機選出五個節點,然后對這五個節點中最長時間沒有發送過PING消息的節點發送PING消息,以此來檢測被選中的節點是否在線。除此之外,如果節點A最后一次收到節點B發送的PONG消息的時間,距離當前時間已經超過了節點A的
cluster-node-timeout
選項設置時長的一半,那么節點A也會向節點B發送PING消息,這可以防止節點A因為長時間沒有隨機選中節點B作為PING消息的發送對象而導致對節點B的信息更新滯后。 - PONG消息:當接收者收到發送者發來的MEET消息或者PING消息時,為了向發送者確認這條MEET消息或者PING消息已到達,接收者會向發送者返回一條PONG消息。另外,一個節點也可以通過向集群廣播自己的PONG消息來讓集群中的其他節點立即刷新關于這個節點的認識,例如當一次故障轉移操作成功執行之后,新的主節點會向集群廣播一條PONG消息,以此來讓集群中的其他節點立即知道這個節點已經變成了主節點,并且接管了已下線節點負責的槽。
- FAIL消息:當一個主節點A判斷另一個主節點B已經進入FAIL狀態時,節點A會向集群廣播一條關于節點B的FAIL消息,所有收到這條消息的節點都會立即將節點B標記為已下線。
- PUBLISH消息:當節點接收到一個PUBLISH命令時,節點會執行這個命令,并向集群廣播一條PUBLISH消息,所有接收到這條PUBLISH消息的節點都會執行相同的PUBLISH命令。
每條消息由由消息頭(header)和消息正文(data)組成,每個消息頭都由一個cluster.h/clusterMsg
結構表示,每個消息頭內的clusterMsg.data
屬性指向聯合cluster.h/clusterMsgData
,這個聯合就是消息的正文。以下是消息頭和消息正文的結構體:
typedef struct {uint32_t totlen; // 消息總長度(含頭和正文)uint16_t type; // 消息類型(如MEET、PING、PONG等)uint16_t count; // 正文包含的節點信息數量(僅Gossip協議使用)uint64_t currentEpoch; // 發送者的配置紀元(用于故障轉移)uint64_t configEpoch; // 主節點配置紀元或從節點復制的主節點紀元char sender[REDIS_CLUSTER_NAMELEN]; // 發送者IDunsigned char myslots[REDIS_CLUSTER_SLOTS/8]; // 槽位指派位圖char slaveof[REDIS_CLUSTER_NAMELEN]; // 從節點復制的主節點IDuint16_t port; // 發送者端口uint16_t flags; // 節點標識(如主/從、在線狀態)unsigned char state; // 集群狀態union clusterMsgData data; // 消息正文(聯合體)
} clusterMsg;union clusterMsgData {// Gossip協議消息(MEET、PING、PONG)struct {clusterMsgDataGossip gossip[1]; // 攜帶兩個節點信息} ping;// 故障消息(FAIL)struct {clusterMsgDataFail about; // 故障節點信息} fail;// 發布訂閱消息(PUBLISH)struct {clusterMsgDataPublish msg; // 發布內容} publish;// 主節點選舉請求(FAILOVER_AUTH_REQUEST)struct {clusterMsgDataAuthRequest req; // 選舉請求} authRequest;// 主節點選舉確認(FAILOVER_AUTH_ACK)struct {clusterMsgDataAuthAck ack; // 選舉確認} authAck;
};typedef struct {char nodename[REDIS_CLUSTER_NAMELEN]; // 節點IDuint32_t ping_sent; // 上次發送PING的時間戳uint32_t pong_received; // 上次接收PONG的時間戳char ip[NET_IP_STR_LEN]; // 節點IPuint16_t port; // 節點端口uint16_t cport; // 集群總線端口uint16_t flags; // 節點標識uint32_t notused1; // 保留字段
} clusterMsgDataGossip;typedef struct {char nodename[REDIS_CLUSTER_NAMELEN]; // 故障節點IDuint64_t configEpoch; // 故障節點的配置紀元
} clusterMsgDataFail;typedef struct {char channel[REDIS_CLUSTER_NAMELEN]; // 訂閱的頻道名稱char message[REDIS_CLUSTER_MSGSIZE]; // 發布的消息內容uint64_t sender_epoch; // 發送者的配置紀元(用于版本控制)char sender_id[REDIS_CLUSTER_NAMELEN]; // 發送者節點ID
} clusterMsgDataPublish;typedef struct {uint64_t configEpoch; // 從節點當前配置紀元(或其主節點的配置紀元)char slave_id[REDIS_CLUSTER_NAMELEN]; // 從節點IDuint64_t offset; // 從節點復制主節點的偏移量(數據同步進度)uint16_t priority; // 從節點優先級(配置項`slave-priority`)
} clusterMsgDataAuthRequest;typedef struct {uint64_t configEpoch; // 選舉的配置紀元char slave_id[REDIS_CLUSTER_NAMELEN]; // 確認選舉的從節點ID
} clusterMsgDataAuthAck;
總結
Redis集群通過哈希槽(16384個)實現數據分片將數據分布到多個節點,利用Gossip協議實現節點間通信與狀態同步,結合主從復制與自動故障轉移保障高可用,并通過遷移哈希槽實現動態擴容與負載均衡。