Redis Cluster 通信流程深入剖析
1. Redis Cluster 介紹和搭建
請查看這篇博客:Redis Cluster 介紹與搭建
這篇博客會介紹Redis Cluster
的數據分區理論和一個三主三從集羣的搭建。
Redis Cluster文件詳細註釋
本文會詳細剖析搭建 Redis Cluster 的通信流程
2. Redis Cluster 和 Redis Sentinel
Redis 2.8
之後正式提供了Redis Sentinel(哨兵)
架構,而Redis Cluster(集羣)
是在Redis 3.0
正式加入的功能。
Redis Cluster
和 Redis Sentinel
都可以搭建Redis
多節點服務,而目的都是解決Redis
主從複製的問題,但是他們還是有一些不同。
Redis
主從複製可將主節點數據同步給從節點,從節點此時有兩個作用:
- 一旦主節點宕機,從節點作爲主節點的備份可以隨時頂上來。
- 擴展主節點的讀能力,分擔主節點讀壓力。
但是,會出現以下問題:
- 一旦主節點宕機,從節點晉升成主節點,同時需要修改應用方的主節點地址,還需要命令所有從節點去複製新的主節點,整個過程需要人工干預。
- 主節點的寫能力或存儲能力受到單機的限制。
Redis的解決方案:
Redis Sentinel
旨在解決第一個問題,即使主節點宕機下線,Redis Sentinel
可以自動完成故障檢測和故障轉移,並通知應用方,真正實現高可用性(HA)。Redis Cluster
則是Redis
分佈式的解決方案,解決後兩個問題。當單機內存、併發、流量等瓶頸時,可以採用Cluster
架構達到負載均衡的目的。
關於
Redis Sentinel
的介紹和分析:
3. 搭建 Redis Cluster的通信流程深入剖析
在Redis Cluster 介紹與搭建一文中介紹了搭建集羣的流程,分爲三步:
- 準備節點
- 節點握手
- 分配槽位
我們就根據這個流程分析Redis Cluster
的執行過程。
3.1 準備節點
我們首先要準備6
個節點,並且準備號對應端口號的配置文件,在配置文件中,要打開cluster-enabled yes
選項,表示該節點以集羣模式打開。因爲集羣節點服務器可以看做一個普通的Redis
服務器,因此,集羣節點開啓服務器的流程和普通的相似,只不過打開了一些關於集羣的標識。
當我們執行這條命令時,就會執行主函數
sudo redis-server conf/redis-6379.conf
在main()
函數中,我們需要關注這幾個函數:
loadServerConfig(configfile,options)
載入配置文件。
- 底層最終調用
loadServerConfigFromString()
函數,會解析到cluster-
開頭的集羣的相關配置,並且保存到服務器的狀態中。
- 底層最終調用
initServer()
初始化服務器。
- 會爲服務器設置時間事件的處理函數
serverCron()
,該函數會每間隔100ms
執行一次集羣的週期性函數clusterCron()
。 - 之後會執行
clusterInit()
,來初始化server.cluster
,這是一個clusterState
類型的結構,保存的是集羣的狀態信息。 - 接着在
clusterInit()
函數中,如果是第一次創建集羣節點,會創建一個隨機名字的節點並且會生成一個集羣專有的配置文件。如果是重啓之前的集羣節點,會讀取第一次創建的集羣專有配置文件,創建與之前相同名字的集羣節點。
- 會爲服務器設置時間事件的處理函數
verifyClusterConfigWithData()
該函數在載入AOF文件或RDB文件後被調用,用來檢查載入的數據是否正確和校驗配置是否正確。aeSetBeforeSleepProc()
在進入事件循環之前,爲服務器設置每次事件循環之前都要執行的一個函數beforeSleep()
,該函數一開始就會執行集羣的clusterBeforeSleep()
函數。aeMain()
進入事件循環,一開始就會執行之前設置的beforeSleep()
函數,之後就等待事件發生,處理就緒的事件。
以上就是主函數在開啓集羣節點時會執行到的主要代碼。
在第二步初始化時,會創建一個clusterState
類型的結構來保存當前節點視角下的集羣狀態。我們列出該結構體的代碼:
typedef struct clusterState {
clusterNode *myself; /* This node */
// 當前紀元
uint64_t currentEpoch;
// 集羣的狀態
int state; /* CLUSTER_OK, CLUSTER_FAIL, ... */
// 集羣中至少負責一個槽的主節點個數
int size; /* Num of master nodes with at least one slot */
// 保存集羣節點的字典,鍵是節點名字,值是clusterNode結構的指針
dict *nodes; /* Hash table of name -> clusterNode structures */
// 防止重複添加節點的黑名單
dict *nodes_black_list; /* Nodes we don't re-add for a few seconds. */
// 導入槽數據到目標節點,該數組記錄這些節點
clusterNode *migrating_slots_to[CLUSTER_SLOTS];
// 導出槽數據到目標節點,該數組記錄這些節點
clusterNode *importing_slots_from[CLUSTER_SLOTS];
// 槽和負責槽節點的映射
clusterNode *slots[CLUSTER_SLOTS];
// 槽映射到鍵的有序集合
zskiplist *slots_to_keys;
/* The following fields are used to take the slave state on elections. */
// 之前或下一次選舉的時間
mstime_t failover_auth_time; /* Time of previous or next election. */
// 節點獲得支持的票數
int failover_auth_count; /* Number of votes received so far. */
// 如果爲真,表示本節點已經向其他節點發送了投票請求
int failover_auth_sent; /* True if we already asked for votes. */
// 該從節點在當前請求中的排名
int failover_auth_rank; /* This slave rank for current auth request. */
// 當前選舉的紀元
uint64_t failover_auth_epoch; /* Epoch of the current election. */
// 從節點不能執行故障轉移的原因
int cant_failover_reason;
/* Manual failover state in common. */
// 如果爲0,表示沒有正在進行手動的故障轉移。否則表示手動故障轉移的時間限制
mstime_t mf_end;
/* Manual failover state of master. */
// 執行手動孤戰轉移的從節點
clusterNode *mf_slave; /* Slave performing the manual failover. */
/* Manual failover state of slave. */
// 從節點記錄手動故障轉移時的主節點偏移量
long long mf_master_offset;
// 非零值表示手動故障轉移能開始
int mf_can_start;
/* The followign fields are used by masters to take state on elections. */
// 集羣最近一次投票的紀元
uint64_t lastVoteEpoch; /* Epoch of the last vote granted. */
// 調用clusterBeforeSleep()所做的一些事
int todo_before_sleep; /* Things to do in clusterBeforeSleep(). */
// 發送的字節數
long long stats_bus_messages_sent; /* Num of msg sent via cluster bus. */
// 通過Cluster接收到的消息數量
long long stats_bus_messages_received; /* Num of msg rcvd via cluster bus.*/
} clusterState;
初始化完當前集羣狀態後,會創建集羣節點,執行的代碼是這樣的:
myself = server.cluster->myself = createClusterNode(NULL,CLUSTER_NODE_MYSELF|CLUSTER_NODE_MASTER);
首先myself
是一個全局變量,定義在cluster.h
中,它指向當前集羣節點,server.cluster->myself
是集羣狀態結構中指向當前集羣節點的變量,createClusterNode()
函數用來創建一個集羣節點,並設置了兩個標識,表明身份狀態信息。
該函數會創建一個如下結構來描述集羣節點。
typedef struct clusterNode {
// 節點創建的時間
mstime_t ctime; /* Node object creation time. */
// 名字
char name[CLUSTER_NAMELEN]; /* Node name, hex string, sha1-size */
// 標識
int flags; /* CLUSTER_NODE_... */
uint64_t configEpoch; /* Last configEpoch observed for this node */
// 節點的槽位圖
unsigned char slots[CLUSTER_SLOTS/8]; /* slots handled by this node */
// 當前節點複製槽的數量
int numslots; /* Number of slots handled by this node */
// 從節點的數量
int numslaves; /* Number of slave nodes, if this is a master */
// 從節點指針數組
struct clusterNode **slaves; /* pointers to slave nodes */
// 指向主節點,即使是從節點也可以爲NULL
struct clusterNode *slaveof;
// 最近一次發送PING的時間
mstime_t ping_sent; /* Unix time we sent latest ping */
// 接收到PONG的時間
mstime_t pong_received; /* Unix time we received the pong */
// 被設置爲FAIL的下線時間
mstime_t fail_time; /* Unix time when FAIL flag was set */
// 最近一次爲從節點投票的時間
mstime_t voted_time; /* Last time we voted for a slave of this master */
// 更新複製偏移量的時間
mstime_t repl_offset_time; /* Unix time we received offset for this node */
// 孤立的主節點遷移的時間
mstime_t orphaned_time; /* Starting time of orphaned master condition */
// 該節點已知的複製偏移量
long long repl_offset; /* Last known repl offset for this node. */
// ip地址
char ip[NET_IP_STR_LEN]; /* Latest known IP address of this node */
// 節點端口號
int port; /* Latest known port of this node */
// 與該節點關聯的連接對象
clusterLink *link; /* TCP/IP link with this node */
// 保存下線報告的鏈表
list *fail_reports; /* List of nodes signaling this as failing */
} clusterNode;
初始化該結構時,會創建一個link
爲空的節點,該變量是clusterLink
的指針,用來描述該節點與一個節點建立的連接。該結構定義如下:
typedef struct clusterLink {
// 連接創建的時間
mstime_t ctime; /* Link creation time */
// TCP連接的文件描述符
int fd; /* TCP socket file descriptor */
// 輸出(發送)緩衝區
sds sndbuf; /* Packet send buffer */
// 輸入(接收)緩衝區
sds rcvbuf; /* Packet reception buffer */
// 關聯該連接的節點
struct clusterNode *node; /* Node related to this link if any, or NULL */
} clusterLink;
該結構用於集羣兩個節點之間相互發送消息。如果節點A發送MEET
消息給節點B,那麼節點A會創建一個clusterLink
結構的連接,fd
設置爲連接後的套節字,node
設置爲節點B,最後將該clusterLink
結構保存到節點B的link
中。
3.2 節點握手
當我們創建好了6個節點時,需要通過節點握手來感知到到指定的進程。節點握手是指一批運行在集羣模式的節點通過Gossip
協議彼此通信。節點握手是集羣彼此通信的第一步,可以詳細分爲這幾個過程:
myself
節點發送MEET
消息給目標節點。- 目標節點處理
MEET
消息,並回復一個PONG
消息給myself
節點。 myself
節點處理PONG
消息,回覆一個PING
消息給目標節點。
這裏只列出了握手階段的通信過程,之後無論什麼節點,都會每隔1s
發送一個PING
命令給隨機篩選出的5
個節點,以進行故障檢測。
接下來會分別以myself
節點和目標節點的視角分別剖析這個握手的過程。
3.2.1 myself
節點發送 MEET 消息
由客戶端發起命令:cluster meet <ip> <port>
當節點接收到客戶端的cluster meet
命令後會調用對應的函數來處理命令,該命令的執行函數是clusterCommand()
函數,該函數能夠處理所有的cluster
命令,因此我們列出處理meet
選項的代碼:
// CLUSTER MEET <ip> <port>命令
// 與給定地址的節點建立連接
if (!strcasecmp(c->argv[1]->ptr,"meet") && c->argc == 4) {
long long port;
// 獲取端口
if (getLongLongFromObject(c->argv[3], &port) != C_OK) {
addReplyErrorFormat(c,"Invalid TCP port specified: %s",
(char*)c->argv[3]->ptr);
return;
}
// 如果沒有正在進行握手,那麼根據執行的地址開始進行握手操作
if (clusterStartHandshake(c->argv[2]->ptr,port) == 0 &&
errno == EINVAL)
{
addReplyErrorFormat(c,"Invalid node address specified: %s:%s",
(char*)c->argv[2]->ptr, (char*)c->argv[3]->ptr);
// 連接成功回覆ok
} else {
addReply(c,shared.ok);
}
}
該函數先根據cluster meet <ip> <port>
命令傳入的參數,獲取要與目標節點建立連接的節點地址,然後根據節點地址執行clusterStartHandshake()
函數來開始執行握手操作。該函數代碼如下:
int clusterStartHandshake(char *ip, int port) {
clusterNode *n;
char norm_ip[NET_IP_STR_LEN];
struct sockaddr_storage sa;
// 檢查地址是否非法
if (inet_pton(AF_INET,ip,
&(((struct sockaddr_in *)&sa)->sin_addr)))
{
sa.ss_family = AF_INET;
} else if (inet_pton(AF_INET6,ip,
&(((struct sockaddr_in6 *)&sa)->sin6_addr)))
{
sa.ss_family = AF_INET6;
} else {
errno = EINVAL;
return 0;
}
// 檢查端口號是否合法
if (port <= 0 || port > (65535-CLUSTER_PORT_INCR)) {
errno = EINVAL;
return 0;
}
// 設置 norm_ip 作爲節點地址的標準字符串表示形式
memset(norm_ip,0,NET_IP_STR_LEN);
if (sa.ss_family == AF_INET)
inet_ntop(AF_INET,
(void*)&(((struct sockaddr_in *)&sa)->sin_addr),
norm_ip,NET_IP_STR_LEN);
else
inet_ntop(AF_INET6,
(void*)&(((struct sockaddr_in6 *)&sa)->sin6_addr),
norm_ip,NET_IP_STR_LEN);
// 判斷當前地址是否處於握手狀態,如果是,則設置errno並返回,該函數被用來避免重複和相同地址的節點進行握手
if (clusterHandshakeInProgress(norm_ip,port)) {
errno = EAGAIN;
return 0;
}
// 爲node設置一個隨機的地址,當握手完成時會爲其設置真正的名字
// 創建一個隨機名字的節點
n = createClusterNode(NULL,CLUSTER_NODE_HANDSHAKE|CLUSTER_NODE_MEET);
// 設置地址
memcpy(n->ip,norm_ip,sizeof(n->ip));
n->port = port;
// 添加到集羣中
clusterAddNode(n);
return 1;
}
該函數先判斷傳入的地址是否非法,如果非法會設置errno
,然後會調用clusterHandshakeInProgress()
函數來判斷是否要進行握手的節點也處於握手狀態,以避免重複和相同地址的目標節點進行握手。然後創建一個隨機名字的目標節點,並設置該目標節點的狀態,如下:
n = createClusterNode(NULL,CLUSTER_NODE_HANDSHAKE|CLUSTER_NODE_MEET);
然後調用clusterAddNode()
函數將該目標節點添加到集羣中,也就是server.cluster->nodes
字典,該字典的鍵是節點的名字,值是指向clusterNode()
結構的指針。
此時myself
節點並沒有將meet
消息發送給指定地址的目標節點,而是設置集羣中目標節點的狀態。而發送meet
消息則是在clusterCron()
函數中執行。我們列出週期性函數中發送MEET
消息的代碼:
// 獲取握手狀態超時的時間,最低爲1s
// 如果一個處於握手狀態的節點如果沒有在該超時時限內變成一個普通的節點,那麼該節點從節點字典中被刪除
handshake_timeout = server.cluster_node_timeout;
if (handshake_timeout < 1000) handshake_timeout = 1000;
// 檢查是否當前集羣中有斷開連接的節點和重新建立連接的節點
di = dictGetSafeIterator(server.cluster->nodes);
// 遍歷所有集羣中的節點,如果有未建立連接的節點,那麼發送PING或PONG消息,建立連接
while((de = dictNext(di)) != NULL) {
clusterNode *node = dictGetVal(de);
// 跳過myself節點和處於NOADDR狀態的節點
if (node->flags & (CLUSTER_NODE_MYSELF|CLUSTER_NODE_NOADDR)) continue;
// 如果仍然node節點處於握手狀態,但是從建立連接開始到現在已經超時
if (nodeInHandshake(node) && now - node->ctime > handshake_timeout) {
// 從集羣中刪除該節點,遍歷下一個節點
clusterDelNode(node);
continue;
}
// 如果節點的連接對象爲空
if (node->link == NULL) {
int fd;
mstime_t old_ping_sent;
clusterLink *link;
// myself節點連接這個node節點
fd = anetTcpNonBlockBindConnect(server.neterr, node->ip,
node->port+CLUSTER_PORT_INCR, NET_FIRST_BIND_ADDR);
// 連接出錯,跳過該節點
if (fd == -1) {
// 如果ping_sent爲0,察覺故障無法執行,因此要設置發送PING的時間,當建立連接後會真正的的發送PING命令
if (node->ping_sent == 0) node->ping_sent = mstime();
serverLog(LL_DEBUG, "Unable to connect to "
"Cluster Node [%s]:%d -> %s", node->ip,
node->port+CLUSTER_PORT_INCR,
server.neterr);
continue;
}
// 爲node節點創建一個連接對象
link = createClusterLink(node);
// 設置連接對象的屬性
link->fd = fd;
// 爲node設置連接對象
node->link = link;
// 監聽該連接的可讀事件,設置可讀時間的讀處理函數
aeCreateFileEvent(server.el,link->fd,AE_READABLE,clusterReadHandler,link);
// 備份舊的發送PING的時間
old_ping_sent = node->ping_sent;
// 如果node節點指定了MEET標識,那麼發送MEET命令,否則發送PING命令
clusterSendPing(link, node->flags & CLUSTER_NODE_MEET ?
CLUSTERMSG_TYPE_MEET : CLUSTERMSG_TYPE_PING);
// 如果不是第一次發送PING命令,要將發送PING的時間還原,等待被clusterSendPing()更新
if (old_ping_sent) {
node->ping_sent = old_ping_sent;
}
// 發送MEET消息後,清除MEET標識
// 如果沒有接收到PONG回覆,那麼不會在向該節點發送消息
// 如果接收到了PONG回覆,取消MEET/HANDSHAKE狀態,發送一個正常的PING消息。
node->flags &= ~CLUSTER_NODE_MEET;
serverLog(LL_DEBUG,"Connecting with Node %.40s at %s:%d",
node->name, node->ip, node->port+CLUSTER_PORT_INCR);
}
}
dictReleaseIterator(di);
clusterNode()
函數一開始就會處理集羣中斷開連接的節點和重新建立連接的節點。
以myself
節點的視角,遍歷集羣中所有的節點,跳過操作當前myself
節點和沒有指定地址的節點,然後判斷處於握手狀態的節點是否在建立連接的過程中超時,如果超時則會刪除該節點。如果還沒有創建連接,那麼myself
節點會與當前這個目標節點建立TCP
連接,並獲取套接字fd
,根據這個套接字,就可以創建clusterLink
結構的連接對象,並將這個連接對象保存到當前這個目標節點。
myself
節點創建完連接後,首先會監聽與目標節點建立的fd
的可讀事件,並設置對應的處理程序clusterReadHandler()
,因爲當發送MEET
消息給目標節點後,要接收目標節點回復的PING
。
接下來,myself
節點就調用clusterSendPing()
函數發送MEET
消息給目標節點。MEET
消息是特殊的PING
消息,只用於通知新節點的加入,而PING
消息還需要更改一些時間信息,以便進行故障檢測。
最後無論如何都要取消CLUSTER_NODE_MEET
標識,但是沒有取消CLUSTER_NODE_HANDSHAKE
該標識,表示仍處於握手狀態,但是已經發送了MEET
消息了。
3.2.2 目標節點處理 MEET 消息回覆 PONG 消息
當myself
節點將MEET
消息發送給目標節點之前,就設置了clusterReadHandler()
函數爲處理接收的PONG
消息。當時目標節點如何接收到MEET
消息,並且回覆PONG
消息給myself
節點呢?
在集羣模式下,每個節點初始化時調用的clusterInit
時,會監聽節點的端口等待客戶端的連接,並且會將該監聽的套接字fd
保存到server.cfd
數組中,然後創建文件事件,監聽該套接字fd
的可讀事件,並設置可讀事件處理函數clusterAcceptHandler()
,等待客戶端發送數據。
那麼,在myself
節點在發送MEET
消息首先會連接目標節點所監聽的端口,觸發目標節點執行clusterAcceptHandler()
函數,該函數實際上就是accept()
函數,接收myself
節點的連接,然後監聽該連接上的可讀事件,設置可讀事件的處理函數爲clusterReadHandler()
,等待myself
節點發送數據,當myself
節點發送MEET
消息給目標節點時,觸發目標節點執行clusterReadHandler()
函數來處理消息。
接下來,我們以目標節點的視角,來分析處理MEET
消息的過程。
clusterReadHandler()
函數底層就是一個read()
函數,代碼如下:
void clusterReadHandler(aeEventLoop *el, int fd, void *privdata, int mask) {
char buf[sizeof(clusterMsg)];
ssize_t nread;
clusterMsg *hdr;
clusterLink *link = (clusterLink*) privdata;
unsigned int readlen, rcvbuflen;
UNUSED(el);
UNUSED(mask);
// 循環從fd讀取數據
while(1) { /* Read as long as there is data to read. */
// 獲取連接對象的接收緩衝區的長度,表示一次最多能多大的數據量
rcvbuflen = sdslen(link->rcvbuf);
// 如果接收緩衝區的長度小於八字節,就無法讀入消息的總長
if (rcvbuflen < 8) {
readlen = 8 - rcvbuflen;
// 能夠讀入完整數據信息
} else {
hdr = (clusterMsg*) link->rcvbuf;
// 如果是8個字節
if (rcvbuflen == 8) {
// 如果前四個字節不是"RCmb"簽名,釋放連接
if (memcmp(hdr->sig,"RCmb",4) != 0 ||
ntohl(hdr->totlen) < CLUSTERMSG_MIN_LEN)
{
serverLog(LL_WARNING,
"Bad message length or signature received "
"from Cluster bus.");
handleLinkIOError(link);
return;
}
}
// 記錄已經讀入的內容長度
readlen = ntohl(hdr->totlen) - rcvbuflen;
if (readlen > sizeof(buf)) readlen = sizeof(buf);
}
// 從fd中讀數據
nread = read(fd,buf,readlen);
// 沒有數據可讀
if (nread == -1 && errno == EAGAIN) return; /* No more data ready. */
// 讀錯誤,釋放連接
if (nread <= 0) {
serverLog(LL_DEBUG,"I/O error reading from node link: %s",
(nread == 0) ? "connection closed" : strerror(errno));
handleLinkIOError(link);
return;
} else {
// 將讀到的數據追加到連接對象的接收緩衝區中
link->rcvbuf = sdscatlen(link->rcvbuf,buf,nread);
hdr = (clusterMsg*) link->rcvbuf;
rcvbuflen += nread;
}
// 檢查接收的數據是否完整
if (rcvbuflen >= 8 && rcvbuflen == ntohl(hdr->totlen)) {
// 如果讀到的數據有效,處理讀到接收緩衝區的數據
if (clusterProcessPacket(link)) {
// 處理成功,則設置新的空的接收緩衝區
sdsfree(link->rcvbuf);
link->rcvbuf = sdsempty();
} else {
return; /* Link no longer valid. */
}
}
}
}
之前在介紹clusterLink
對象時,每個連接對象都有一個link->rcvbuf
接收緩衝區和link->sndbuf
發送緩衝區,因此這個函數就是從fd
將數據讀到link
的接收緩衝區,然後進行是否讀完整的判斷,如果完整的讀完數據,就調用clusterProcessPacket()
函數來處理讀到的數據,這裏會處理MEET
消息。該函數是一個通用的處理函數,因此能夠處理各種類型的消息,所列只列出處理MEET
消息的重要部分:
// 從集羣中查找sender節點
sender = clusterLookupNode(hdr->sender);
// 初始處理PING和MEET請求,用PONG作爲回覆
if (type == CLUSTERMSG_TYPE_PING || type == CLUSTERMSG_TYPE_MEET) {
serverLog(LL_DEBUG,"Ping packet received: %p", (void*)link->node);
// 我們使用傳入的MEET消息來設置當前myself節點的地址,因爲只有其他集羣中的節點在握手的時會發送MEET消息,當有節點加入集羣時,或者如果我們改變地址,這些節點將使用我們公開的地址來連接我們,所以在集羣中,通過套接字來獲取地址是一個簡單的方法去發現或更新我們自己的地址,而不是在配置中的硬設置
// 但是,如果我們根本沒有地址,即使使用正常的PING數據包,我們也會更新該地址。 如果是錯誤的,那麼會被MEET修改
// 如果是MEET消息
// 或者是其他消息但是當前集羣節點的IP爲空
if (type == CLUSTERMSG_TYPE_MEET || myself->ip[0] == '\0') {
char ip[NET_IP_STR_LEN];
// 可以根據fd來獲取ip,並設置myself節點的IP
if (anetSockName(link->fd,ip,sizeof(ip),NULL) != -1 &&
strcmp(ip,myself->ip))
{
memcpy(myself->ip,ip,NET_IP_STR_LEN);
serverLog(LL_WARNING,"IP address for this node updated to %s",
myself->ip);
clusterDoBeforeSleep(CLUSTER_TODO_SAVE_CONFIG);
}
}
// 如果當前sender節點是一個新的節點,並且消息是MEET消息類型,那麼將這個節點添加到集羣中
// 當前該節點的flags、slaveof等等都沒有設置,當從其他節點接收到PONG時可以從中獲取到信息
if (!sender && type == CLUSTERMSG_TYPE_MEET) {
clusterNode *node;
// 創建一個處於握手狀態的節點
node = createClusterNode(NULL,CLUSTER_NODE_HANDSHAKE);
// 設置ip和port
nodeIp2String(node->ip,link);
node->port = ntohs(hdr->port);
// 添加到集羣中
clusterAddNode(node);
clusterDoBeforeSleep(CLUSTER_TODO_SAVE_CONFIG);
}
// 如果是從一個未知的節點發送過來MEET包,處理流言信息
if (!sender && type == CLUSTERMSG_TYPE_MEET)
// 處理流言中的 PING or PONG 數據包
clusterProcessGossipSection(hdr,link);
/* Anyway reply with a PONG */
// 回覆一個PONG消息
clusterSendPing(link,CLUSTERMSG_TYPE_PONG);
}
在該函數中,首先先會對消息中的簽名、版本、消息總大小,消息中包含的節點信息數量等等都進行判斷,確保該消息是一個合法的消息,然後就計算消息的總長度,來判斷接收到的消息和讀到的消息是否一致完整。
現在,再次強調一遍,當前是以目標節點的視角處理MEET
消息。
目標節點調用clusterLookupNode()
函數在目標節點視角中的集羣查找MEET
消息的發送節點hdr->sender
,該節點就是myself
節點,由於這是第一次兩個節點之間的握手,那麼myself
節點一定在目標節點視角中的集羣是找不到的,所以sender
變量爲NULL
。
然後就進入if
條件判斷,首先目標節點會根據MEET
消息來獲取自己的地址並更新自己的地址,因爲如果通過從配置文件來設置地址,當節點重新上線,地址就有可能改變,但是配置文件中卻沒有修改,所用通過套接字獲取地址來更新節點地址是一種非常好的辦法。
然後繼續執行第二個if
中的代碼,第一次MEET
消息,而且sender
發送該消息的節點並不存在目標節點視角中的集羣,所以會爲發送消息的myself
節點創建一個處於握手狀態的節點,並且,將該節點加入到目標節點視角中的集羣。這樣一來,目標節點就知道了myself
節點的存在。
最後就是調用clusterSendPing()
函數,指定回覆一個PONG
消息給myself
節點。
3.2.3 myself
節點處理 PONG 消息回覆 PING 消息
myself
在發送消息MEET
消息之前,就已經爲監聽fd
的可讀消息,當目標節點處理完MEET
消息並回復PONG
消息之後,觸發myself
節點的可讀事件,調用clusterReadHandler()
函數來處理目標節點發送來的PONG
消息。
這次是以myself
節點的視角來分析處理PONG
消息。
clusterReadHandler()
函數就是目標節點第一次接收myself
節點發送MEET
消息的函數,底層是read()
函數來將套接字中的數據讀取到link->rcvbuf
接收緩衝區中,代碼在標題3.2.2
。它最後還是調用clusterProcessPacket()
函數來處理PONG
消息。
但是這次處理代碼的部分不同,因爲myself
節點視角中的集羣可以找到目標節點,也就是說,myself
節點已經“認識”了目標節點。
if (type == CLUSTERMSG_TYPE_PING || type == CLUSTERMSG_TYPE_PONG ||
type == CLUSTERMSG_TYPE_MEET)
{
serverLog(LL_DEBUG,"%s packet received: %p",
type == CLUSTERMSG_TYPE_PING ? "ping" : "pong",
(void*)link->node);
// 如果關聯該連接的節點存在
if (link->node) {
// 如果關聯該連接的節點處於握手狀態
if (nodeInHandshake(link->node)) {
// sender節點存在,用該新的連接地址更新sender節點的地址
if (sender) {
serverLog(LL_VERBOSE,
"Handshake: we already know node %.40s, "
"updating the address if needed.", sender->name);
if (nodeUpdateAddressIfNeeded(sender,link,ntohs(hdr->port)))
{
clusterDoBeforeSleep(CLUSTER_TODO_SAVE_CONFIG|
CLUSTER_TODO_UPDATE_STATE);
}
// 釋放關聯該連接的節點
clusterDelNode(link->node);
return 0;
}
// 將關聯該連接的節點的名字用sender的名字替代
clusterRenameNode(link->node, hdr->sender);
serverLog(LL_DEBUG,"Handshake with node %.40s completed.",
link->node->name);
// 取消握手狀態,設置節點的角色
link->node->flags &= ~CLUSTER_NODE_HANDSHAKE;
link->node->flags |= flags&(CLUSTER_NODE_MASTER|CLUSTER_NODE_SLAVE);
clusterDoBeforeSleep(CLUSTER_TODO_SAVE_CONFIG);
// 如果sender的地址和關聯該連接的節點的地址不相同
} else if (memcmp(link->node->name,hdr->sender,
CLUSTER_NAMELEN) != 0)
{
serverLog(LL_DEBUG,"PONG contains mismatching sender ID. About node %.40s added %d ms ago, having flags %d",
link->node->name,
(int)(mstime()-(link->node->ctime)),
link->node->flags);
// 設置NOADDR標識,情況關聯連接節點的地址
link->node->flags |= CLUSTER_NODE_NOADDR;
link->node->ip[0] = '\0';
link->node->port = 0;
// 釋放連接對象
freeClusterLink(link);
clusterDoBeforeSleep(CLUSTER_TODO_SAVE_CONFIG);
return 0;
}
}
// 關聯該連接的節點存在,且消息類型爲PONG
if (link->node && type == CLUSTERMSG_TYPE_PONG) {
// 更新接收到PONG的時間
link->node->pong_received = mstime();
// 清零最近一次發送PING的時間戳
link->node->ping_sent = 0;
// 接收到PONG回覆,可以刪除PFAIL(疑似下線)標識
// FAIL標識能否刪除,需要clearNodeFailureIfNeeded()來決定
// 如果關聯該連接的節點疑似下線
if (nodeTimedOut(link->node)) {
// 取消PFAIL標識
link->node->flags &= ~CLUSTER_NODE_PFAIL;
clusterDoBeforeSleep(CLUSTER_TODO_SAVE_CONFIG|
CLUSTER_TODO_UPDATE_STATE);
// 如果關聯該連接的節點已經被判斷爲下線
} else if (nodeFailed(link->node)) {
// 如果一個節點被標識爲FAIL,需要檢查是否取消該節點的FAIL標識,因爲該節點在一定時間內重新上線了
clearNodeFailureIfNeeded(link->node);
}
}
}
和之前處理MEET
消息一樣,首先先會對消息中的簽名、版本、消息總大小,消息中包含的節點信息數量等等都進行判斷,確保該消息是一個合法的消息,然後就計算消息的總長度,來判斷接收到的消息和讀到的消息是否一致完整。然後處理上述部分的代碼。
由於myself
節點已經“認識”目標節點,因此myself
節點在發送MEET
消息時已經爲集羣(myself
節點視角)中的目標節點設置了連接對象,因此會執行判斷連接對象是否存在的代碼if (nodeInHandshake(link->node))
,並且在myself
節點發送完MEET
消息後,只取消了目標節點的CLUSTER_NODE_MEET
標識,保留了CLUSTER_NODE_HANDSHAKE
標識,因此會執行if (sender)
判斷。
目標節點發送過來的PONG
消息,在消息包的頭部會包含sender
發送節點的信息,但是名字對不上號,這是因爲myself
節點創建目標節點加入集羣的時候,隨機給他起的名字,因爲myself
節點當時也不知道目標節點的名字,所以在集羣中找不到sender
的名字,因此這個判斷會失敗,調用clusterRenameNode()
函數把它的名字改過來,這樣myself
節點就真正的認識了目標節點,重新認識。之後會將目標節點的CLUSTER_NODE_HANDSHAKE
狀態取消,並且設置它的角色狀態。
然後就是執行if (link->node && type == CLUSTERMSG_TYPE_PONG)
判斷,更新接收PONG
的時間戳,清零發送PING
的時間戳,根據接收PONG
的時間等信息判斷目標節點是否下線,如果下線要進行故障轉移等操作。
之後myself
節點並不會立即向目標節點發送PING
消息,而是要等待下一次時間事件的發生,在clusterCron()
函數中,每次執行都需要對集羣中所有節點進行故障檢測和主從切換等等操作,因此在遍歷節點時,會處理以下一種情況:
while((de = dictNext(di)) != NULL) {
if (node->flags &
(CLUSTER_NODE_MYSELF|CLUSTER_NODE_NOADDR|CLUSTER_NODE_HANDSHAKE))
continue;
if (node->link && node->ping_sent == 0 &&
(now - node->pong_received) > server.cluster_node_timeout/2)
{
// 給node節點發送一個PING消息
clusterSendPing(node->link, CLUSTERMSG_TYPE_PING);
continue;
}
}
首先跳過操作myself
節點和處於握手狀態的節點,在myself
節點重新認識目標節點後,就將目標節點的握手狀態取消了,因此會對目標節點做下面的判斷操作。
當myself
節點接收到PONG
就會將目標節點node->ping_sent
設置爲0
,表示目標節點還沒有發送過PING
消息,因此會發送PING
消息給目標節點。
當發送了這個PING
消息之後,節點之間的握手操作就完成了。之後每隔1s
都會發送PING
包,來進行故障檢測等工作。
3.2.4 Gossip協議
搭建Redis Cluster
時,首先通過CLUSTER MEET
命令將所有的節點加入到一個集羣中,但是並沒有在所有節點兩兩之間都執行CLUSTER MEET
命令,那麼因爲節點之間使用Gossip
協議進行工作。
Gossip
翻譯過來就是流言,類似與病毒傳播一樣,只要一個人感染,如果時間足夠,那麼和被感染的人在一起的所有人都會被感染,因此隨着時間推移,集羣內的所有節點都會互相知道對方的存在。
關於Gossip介紹可以參考:Gossip 算法
在Redis
中,節點信息是如何傳播的呢?答案是通過發送PING
或PONG
消息時,會包含節點信息,然後進行傳播的。
我們先介紹一下Redis Cluster
中,消息是如何抽象的。一個消息對象可以是PING
、PONG
、MEET
,也可以是UPDATE
、PUBLISH
、FAIL
等等消息。他們都是clusterMsg
類型的結構,該類型主要由消息包頭部和消息數據組成。
- 消息包頭部包含簽名、消息總大小、版本和發送消息節點的信息。
- 消息數據則是一個聯合體
union clusterMsgData
,聯合體中又有不同的結構體來構建不同的消息。
PING
、PONG
、MEET
屬於一類,是clusterMsgDataGossip
類型的數組,可以存放多個節點的信息,該結構如下:
typedef struct {
// 節點名字
char nodename[CLUSTER_NAMELEN];
// 最近一次發送PING的時間戳
uint32_t ping_sent;
// 最近一次接收PONG的時間戳
uint32_t pong_received;
// 節點的IP地址
char ip[NET_IP_STR_LEN]; /* IP address last time it was seen */
// 節點的端口號
uint16_t port; /* port last time it was seen */
// 節點的標識
uint16_t flags; /* node->flags copy */
// 未使用
uint16_t notused1; /* Some room for future improvements. */
uint32_t notused2;
} clusterMsgDataGossip;
在clusterSendPing()
函數中,首先就是會將隨機選擇的節點的信息加入到消息中。代碼如下:
void clusterSendPing(clusterLink *link, int type) {
unsigned char *buf;
clusterMsg *hdr;
int gossipcount = 0; /* Number of gossip sections added so far. */
int wanted; /* Number of gossip sections we want to append if possible. */
int totlen; /* Total packet length. */
// freshnodes 的值是除了當前myself節點和發送消息的兩個節點之外,集羣中的所有節點
// freshnodes 表示的意思是gossip協議中可以包含的有關節點信息的最大個數
int freshnodes = dictSize(server.cluster->nodes)-2;
// wanted 的值是集羣節點的十分之一向下取整,並且最小等於3
// wanted 表示的意思是gossip中要包含的其他節點信息個數
wanted = floor(dictSize(server.cluster->nodes)/10);
if (wanted < 3) wanted = 3;
// 因此 wanted 最多等於 freshnodes。
if (wanted > freshnodes) wanted = freshnodes;
// 計算分配消息的最大空間
totlen = sizeof(clusterMsg)-sizeof(union clusterMsgData);
totlen += (sizeof(clusterMsgDataGossip)*wanted);
// 消息的總長最少爲一個消息結構的大小
if (totlen < (int)sizeof(clusterMsg)) totlen = sizeof(clusterMsg);
// 分配空間
buf = zcalloc(totlen);
hdr = (clusterMsg*) buf;
// 設置發送PING命令的時間
if (link->node && type == CLUSTERMSG_TYPE_PING)
link->node->ping_sent = mstime();
// 構建消息的頭部
clusterBuildMessageHdr(hdr,type);
int maxiterations = wanted*3;
// 構建消息內容
while(freshnodes > 0 && gossipcount < wanted && maxiterations--) {
// 隨機選擇一個集羣節點
dictEntry *de = dictGetRandomKey(server.cluster->nodes);
clusterNode *this = dictGetVal(de);
clusterMsgDataGossip *gossip;
int j;
// 1. 跳過當前節點,不選myself節點
if (this == myself) continue;
// 2. 偏愛選擇處於下線狀態或疑似下線狀態的節點
if (maxiterations > wanted*2 &&
!(this->flags & (CLUSTER_NODE_PFAIL|CLUSTER_NODE_FAIL)))
continue;
// 以下節點不能作爲被選中的節點:
/*
1. 處於握手狀態的節點
2. 帶有NOADDR標識的節點
3. 因爲不處理任何槽而斷開連接的節點
*/
if (this->flags & (CLUSTER_NODE_HANDSHAKE|CLUSTER_NODE_NOADDR) ||
(this->link == NULL && this->numslots == 0))
{
freshnodes--; /* Tecnically not correct, but saves CPU. */
continue;
}
// 如果已經在gossip的消息中添加過了當前節點,則退出循環
for (j = 0; j < gossipcount; j++) {
if (memcmp(hdr->data.ping.gossip[j].nodename,this->name,
CLUSTER_NAMELEN) == 0) break;
}
// j 一定 == gossipcount
if (j != gossipcount) continue;
/* Add it */
// 這個節點滿足條件,則將其添加到gossip消息中
freshnodes--;
// 指向添加該節點的那個空間
gossip = &(hdr->data.ping.gossip[gossipcount]);
// 添加名字
memcpy(gossip->nodename,this->name,CLUSTER_NAMELEN);
// 記錄發送PING的時間
gossip->ping_sent = htonl(this->ping_sent);
// 接收到PING回覆的時間
gossip->pong_received = htonl(this->pong_received);
// 設置該節點的IP和port
memcpy(gossip->ip,this->ip,sizeof(this->ip));
gossip->port = htons(this->port);
// 記錄標識
gossip->flags = htons(this->flags);
gossip->notused1 = 0;
gossip->notused2 = 0;
// 已經添加到gossip消息的節點數加1
gossipcount++;
}
// 計算消息的總長度
totlen = sizeof(clusterMsg)-sizeof(union clusterMsgData);
totlen += (sizeof(clusterMsgDataGossip)*gossipcount);
// 記錄消息節點的數量到包頭
hdr->count = htons(gossipcount);
// 記錄消息節點的總長到包頭
hdr->totlen = htonl(totlen);
// 發送消息
clusterSendMessage(link,buf,totlen);
zfree(buf);
}
重點關注這幾個變量:
freshnodes
int freshnodes = dictSize(server.cluster->nodes)-2;
freshnodes
的值是除了當前myself節點和發送消息的兩個節點之外,集羣中的所有節點。freshnodes
表示的意思是gossip協議中可以包含的有關節點信息的最大個數
wanted
wanted = floor(dictSize(server.cluster->nodes)/10);
wanted
的值是集羣節點的十分之一向下取整,並且最小等於3。wanted
表示的意思是gossip
中要包含的其他節點信息個數。
Gossip
協議包含的節點信息個數是wanted
個,wanted
的值是集羣節點的十分之一向下取整,並且最小等於3。爲什麼選擇十分之一,這是因爲Redis Cluster
中計算故障轉移超時時間是server.cluster_node_timeout*2
,因此如果有節點下線,就能夠收到大部分集羣節點發送來的下線報告。
十分之一的由來:如果有N
個主節點,那麼wanted
就是N/10
,我們認爲,在一個node_timeout
的時間內,我們會接收到任意一個節點的4個消息包,因爲,發送一個消息包,最慢被接收也不過node_timeout/2
的時間,如果超過這個時間,那麼接收回復的消息包就會超時,所以一個node_timeout
時間內,當前節點會發送兩個PING
包,同理,接收當前節點的PING
包,也會發送兩個PING
包給當前節點,並且會回覆兩個PONG
包,這樣一來,在一個node_timeout
時間內,當前節點就會接收到4個包。
但是Redis Cluster
中計算故障轉移超時時間是server.cluster_node_timeout*2
,是兩倍的node_timeout
時間,那麼當前節點會接收到8個消息包。
因爲N
個主節點,那麼wanted
就是N/10
,所以收到集羣下線報告的概率就是8*N/10
,也就是80%
,這樣就收到了大部分集羣節點發送來的下線報告。
然後計算消息的總的大小,也就是totlen
變量,消息包頭部加上wanted
個節點信息。
爲消息分配空間,並調用clusterBuildMessageHdr()
函數來構建消息包頭部,將發送節點的信息填充進去。
接着使用while
循環,選擇wanted
個集羣節點,選擇節點有一下幾個特點:
- 當然不會選擇
myself
節點,因爲,在包頭中已經包含了myself
節點也就是發送節點的信息。 - 偏愛選擇處於下線狀態或疑似下線狀態的節點,這樣有利於進行故障檢測。
- 不選,處於握手狀態或沒有地址狀態的節點,還有就是因爲不負責任何槽而斷開連接的節點。
如果滿足了上述條件,就會將節點的信息加入到gossip
中,如果節點不夠最少的3個,那麼重複選擇時會提前跳出循環。
最後,更新一下消息的總長度,然後調用clusterSendMessage()
函數發送消息。
通過Gossip
協議,每次能夠將一些節點信息發送給目標節點,而每個節點都這麼幹,只要時間足夠,理論上集羣中所有的節點都會互相認識。
3.3 分配槽位
Redis Cluster
採用槽分區,所有的鍵根據哈希函數映射到0 ~ 16383
,計算公式:slot = CRC16(key)&16383
。每一個節點負責維護一部分槽位以及槽位所映射的鍵值數據。
當將所有節點組成集羣后,還不能工作,因爲集羣的節點還沒有分配槽位(slot)。
分配槽位的命令cluster addslots
,假如我們爲6379
端口的myself
節點指定{0..5461}
的槽位,命令如下:
redis-cli -h 127.0.0.1 -p 6379 cluster addslots {0..5461}
3.3.1 槽位分配信息管理
就如上面爲6379
端口的myself
節點指定{0..5461}
的槽位,在clusterNode
中,定義了該節點負責的槽位:
typedef struct clusterNode {
// 節點的槽位圖
unsigned char slots[CLUSTER_SLOTS/8]; /* slots handled by this node */
// 當前節點複製槽的數量
int numslots; /* Number of slots handled by this node */
} clusterNode;
因此,6379
端口的myself
節點所負責的槽,如圖所示:如果節點負責該槽,那麼設置爲1,否則設置爲0
每個節點會維護自己所負責的槽位的信息。那麼在管理集羣狀態clusterState
的結構中,也有對應的管理槽位的信息:
typedef struct clusterState {
// 導出槽數據到目標節點,該數組記錄這些節點
clusterNode *migrating_slots_to[CLUSTER_SLOTS];
// 導入槽數據到目標節點,該數組記錄這些節點
clusterNode *importing_slots_from[CLUSTER_SLOTS];
// 槽和負責槽節點的映射
clusterNode *slots[CLUSTER_SLOTS];
// 槽映射到鍵的跳躍表
zskiplist *slots_to_keys;
} clusterState;
migrating_slots_to
是一個數組,用於重新分片時保存:從當前節點導出的槽位的到負責該槽位的節點的映射關係。importing_slots_from
是一個數組,用於重新分片時保存:往當前節點導入的槽位的到負責該槽位的節點的映射關係。slots
是一個數組,保存集羣中所有主節點和其負責的槽位的映射關係。slots_to_keys
是一個跳躍表,用於CLUSTER GETKEYSINSLOT
命令可以返回多個屬於槽位的鍵,通過遍歷跳躍表實現。
3.3.2 分配槽位剖析
由客戶端發起命cluster addslots <slot> [slot ...]
當節點接收到客戶端的cluster addslots
命令後會調用對應的函數來處理命令,該命令的執行函數是clusterCommand()
函數,該函數能夠處理所有的cluster
命令,因此我們列出處理addslots
選項的代碼:
if ((!strcasecmp(c->argv[1]->ptr,"addslots") ||
!strcasecmp(c->argv[1]->ptr,"delslots")) && c->argc >= 3)
{
int j, slot;
unsigned char *slots = zmalloc(CLUSTER_SLOTS);
// 刪除操作
int del = !strcasecmp(c->argv[1]->ptr,"delslots");
memset(slots,0,CLUSTER_SLOTS);
// 遍歷所有指定的槽
for (j = 2; j < c->argc; j++) {
// 獲取槽位的位置
if ((slot = getSlotOrReply(c,c->argv[j])) == -1) {
zfree(slots);
return;
}
// 如果是刪除操作,但是槽沒有指定負責的節點,回覆錯誤信息
if (del && server.cluster->slots[slot] == NULL) {
addReplyErrorFormat(c,"Slot %d is already unassigned", slot);
zfree(slots);
return;
// 如果是添加操作,但是槽已經指定負責的節點,回覆錯誤信息
} else if (!del && server.cluster->slots[slot]) {
addReplyErrorFormat(c,"Slot %d is already busy", slot);
zfree(slots);
return;
}
// 如果某個槽已經指定過多次了(在參數中指定了多次),那麼回覆錯誤信息
if (slots[slot]++ == 1) {
addReplyErrorFormat(c,"Slot %d specified multiple times",
(int)slot);
zfree(slots);
return;
}
}
// 上個循環保證了指定的槽的可以處理
for (j = 0; j < CLUSTER_SLOTS; j++) {
// 如果當前槽未指定
if (slots[j]) {
int retval;
// 如果這個槽被設置爲導入狀態,那麼取消該狀態
if (server.cluster->importing_slots_from[j])
server.cluster->importing_slots_from[j] = NULL;
// 執行刪除或添加操作
retval = del ? clusterDelSlot(j) :
clusterAddSlot(myself,j);
serverAssertWithInfo(c,NULL,retval == C_OK);
}
}
zfree(slots);
// 更新集羣狀態和保存配置
clusterDoBeforeSleep(CLUSTER_TODO_UPDATE_STATE|CLUSTER_TODO_SAVE_CONFIG);
addReply(c,shared.ok);
}
首先判斷當前操作是刪除還是添加。
其次判斷指定要加入的槽位值是否合法,符合以下條件:
- 如果是刪除操作,但是槽位沒有指定負責的節點,回覆錯誤信息。
- 如果是添加操作,但是槽位已經指定負責的節點,回覆錯誤信息。
- 如果某個槽位值已經指定過多次了(在參數中指定了多次),那麼回覆錯誤信息。
最後遍歷所有參數中指定的槽位值,調用clusterAddSlot()
將槽位指派給myself
節點。這個函數比較簡單,代碼如下:
int clusterAddSlot(clusterNode *n, int slot) {
// 如果已經指定有節點,則返回C_ERR
if (server.cluster->slots[slot]) return C_ERR;
// 設置該槽被指定
clusterNodeSetSlotBit(n,slot);
// 設置負責該槽的節點n
server.cluster->slots[slot] = n;
return C_OK;
}
clusterNodeSetSlotBit()
會將myself
節點槽位圖中對應參數指定的槽值的那些位,設置爲1,表示這些槽位由myself
節點負責。源碼如下:
int clusterNodeSetSlotBit(clusterNode *n, int slot) {
// 查看slot槽位是否被設置
int old = bitmapTestBit(n->slots,slot);
// 將slot槽位設置爲1
bitmapSetBit(n->slots,slot);
// 如果之前沒有被設置
if (!old) {
// 那麼要更新n節點負責槽的個數
n->numslots++;
// 如果主節點是第一次指定槽,即使它沒有從節點,也要設置MIGRATE_TO標識
// 當且僅當,至少有一個其他的主節點有從節點時,主節點就是有效的遷移目標
if (n->numslots == 1 && clusterMastersHaveSlaves())
// 設置節點遷移的標識,表示該節點可以遷移
n->flags |= CLUSTER_NODE_MIGRATE_TO;
}
return old;
}
3.3.3 廣播節點的槽位信息
每個節點除了保存自己負責槽位的信息還要維護自己節點視角中,集羣中關於槽位分配的全部信息server.cluster->slots
,因此,需要獲取每個主節點負責槽位的信息,這是通過發送消息實現的。
在調用clusterBuildMessageHdr()
函數構建消息包的頭部時,會將發送節點的槽位信息添加進入。
在調用clusterProcessPacket()
函數處理消息包時,會根據消息包的信息,如果出現槽位分配信息不匹配的情況,會更新當前節點視角的槽位分配的信息。該函數的處理這種情況的代碼如下:
sender = clusterLookupNode(hdr->sender);
clusterNode *sender_master = NULL; /* Sender or its master if slave. */
int dirty_slots = 0; /* Sender claimed slots don't match my view? */
if (sender) {
// 如果sender是從節點,那麼獲取其主節點信息
// 如果sender是主節點,那麼獲取sender的信息
sender_master = nodeIsMaster(sender) ? sender : sender->slaveof;
if (sender_master) {
// sender發送的槽信息和主節點的槽信息是否匹配
dirty_slots = memcmp(sender_master->slots,
hdr->myslots,sizeof(hdr->myslots)) != 0;
}
}
// 1. 如果sender是主節點,但是槽信息出現不匹配現象
if (sender && nodeIsMaster(sender) && dirty_slots)
// 檢查當前節點對sender的槽信息,並且進行更新
clusterUpdateSlotsConfigWith(sender,senderConfigEpoch,hdr->myslots);
sender
變量是根據消息包中提供的發送節點在myself
節點視角的集羣中查找的節點。因此發送節點負責了一些槽位之後,將這些槽位信息通過發送包發送給myself
節點,在myself
節點視角的集羣中查找的sender
節點則是沒有設置關於發送節點的槽位信息。所以dirty_slots
被賦值爲1,表示出現了槽位信息不匹配的情況。最終會調用clusterUpdateSlotsConfigWith()
函數更新myself
節點視角中,集羣關於發送節點的槽位信息。該函數代碼如下:
void clusterUpdateSlotsConfigWith(clusterNode *sender, uint64_t senderConfigEpoch, unsigned char *slots) {
int j;
clusterNode *curmaster, *newmaster = NULL;
uint16_t dirty_slots[CLUSTER_SLOTS];
int dirty_slots_count = 0;
// 如果當前節點是主節點,那麼獲取當前節點
// 如果當前節點是從節點,那麼獲取當前從節點所從屬的主節點
curmaster = nodeIsMaster(myself) ? myself : myself->slaveof;
// 如果發送消息的節點就是本節點,則直接返回
if (sender == myself) {
serverLog(LL_WARNING,"Discarding UPDATE message about myself.");
return;
}
// 遍歷所有槽
for (j = 0; j < CLUSTER_SLOTS; j++) {
// 如果當前槽已經被分配
if (bitmapTestBit(slots,j)) {
// 如果當前槽是sender負責的,那麼跳過當前槽
if (server.cluster->slots[j] == sender) continue;
// 如果當前槽處於導入狀態,它應該只能通過redis-trib 被手動修改,所以跳過該槽
if (server.cluster->importing_slots_from[j]) continue;
// 將槽重新綁定到新的節點,如果滿足以下條件
/*
1. 該槽沒有被指定或者新的節點聲稱它有一個更大的配置紀元
2. 當前沒有導入該槽
*/
if (server.cluster->slots[j] == NULL ||
server.cluster->slots[j]->configEpoch < senderConfigEpoch)
{
// 如果當前槽被當前節點所負責,而且槽中有數據,表示該槽發生衝突
if (server.cluster->slots[j] == myself &&
countKeysInSlot(j) &&
sender != myself)
{
// 將發生衝突的槽記錄到髒槽中
dirty_slots[dirty_slots_count] = j;
// 髒槽數加1
dirty_slots_count++;
}
// 如果當前槽屬於當前節點的主節點,表示發生了故障轉移
if (server.cluster->slots[j] == curmaster)
newmaster = sender;
// 刪除當前被指定的槽
clusterDelSlot(j);
// 將槽分配給sender
clusterAddSlot(sender,j);
clusterDoBeforeSleep(CLUSTER_TODO_SAVE_CONFIG|
CLUSTER_TODO_UPDATE_STATE|
CLUSTER_TODO_FSYNC_CONFIG);
}
}
}
// 如果至少一個槽被重新分配,從一個節點到另一個更大配置紀元的節點,那麼可能發生了:
/*
1. 當前節點是一個不在處理任何槽的主節點,這是應該將當前節點設置爲新主節點的從節點
2. 當前節點是一個從節點,並且當前節點的主節點不在處理任何槽,這是應該將當前節點設置爲新主節點的從節點
*/
if (newmaster && curmaster->numslots == 0) {
serverLog(LL_WARNING,
"Configuration change detected. Reconfiguring myself "
"as a replica of %.40s", sender->name);
// 將sender設置爲當前節點myself的主節點
clusterSetMaster(sender);
clusterDoBeforeSleep(CLUSTER_TODO_SAVE_CONFIG|
CLUSTER_TODO_UPDATE_STATE|
CLUSTER_TODO_FSYNC_CONFIG);
} else if (dirty_slots_count) {
// 如果執行到這裏,我們接收到一個刪除當前我們負責槽的所有者的更新消息,但是我們仍然負責該槽,所以主節點不能被降級爲從節點
// 爲了保持鍵和槽的關係,需要從我們丟失的槽中將鍵刪除
for (j = 0; j < dirty_slots_count; j++)
// 遍歷所有的髒槽,刪除槽中的鍵-
delKeysInSlot(dirty_slots[j]);
}
}
該函數會遍歷所有槽,然後處理已經被分配的槽(通過消息得知)
- 跳過已經被
myself
節點視角下集羣中的sender
節點所負責的槽位,沒必要更新。 - 跳過處於
myself
節點視角中的集羣中導入狀態的槽位,因爲它應該被專門的工具redis-trib
修改。
更新槽位信息的兩種情況:
- 如果
myself
節點視角下集羣關於該槽沒有指定負責的節點,會直接調用函數指派槽位。 - 如果發送節點的配置紀元更大,表示發送節點版本更新。這種情況需要進行兩個
if
判斷,判斷是否發生了槽位指派節點衝突和是否檢測到了故障。
- 當前槽是
myself
節點負責,並且槽中還有鍵,但是消息中確實發送節點負責,這樣就發生了槽位指派節點衝突的情況,會將發生衝突的節點保存到dirty_slots
數組中。 - 這種情況的處理辦法是:遍歷所有發生衝突的槽位,遍歷
dirty_slots
數組,將發生衝突的槽位和myself
節點解除關係,也就是從myself
節點負責的槽位中取消負責發生衝突的槽位。因爲消息中的信息的最準確的,要以消息中的信息爲準。 - 當
myself
節點是從節點,並且當前槽是myself
從節點的主節點負責,但是消息中顯示該槽屬於sender
節點,這樣檢測到了故障。 - 這種情況的處理辦法是:將
sender
節點作爲myself
從節點的新的主節點newmaster = sender
。調用clusterSetMaster()
函數將sender
節點設置爲myself
從節點的新主節點。
- 當前槽是
兩種情況,最後都需要調用clusterAddSlot()
函數,將當前槽位指派給myself
節點視角下的集羣中的sender
節點。這樣myself
節點就知道了發送節點的槽分配信息。
如果時間足夠,每個主節點都會將自己負責的槽位信息告知給每一個集羣中的其他節點,於是,集羣中的每一個節點都會知道16384
個槽分別指派給了集羣中的哪個節點。