Redis 故障转移流程和原理
1. 故障转移介绍
Redis
集群自身实现了高可用。高可用首先要解决集群部分失败的场景:当集群内少量节点出现故障时通过自动故障转移保证集群可以正常对外提供服务。接下来就介绍故障转移的细节,分析故障检测和故障转移。
- 故障检测
- 故障转移
2. 故障检测
2.1 主观故障的检测
当一个节点出现问题,需要使用一种健壮的方法保证识别出节点是否发生了故障。在之前的 Redis Cluster 通信流程深入剖析 一文中,介绍了Redis
的gossip
协议,集群节点通过PING/PONG
消息实现节点通信,消息不但可以传播节点槽信息,还可以传播主从状态、节点故障信息等。因此故障检测也是就是通过消息传播机制实现的。
首先Redis
集群节点每隔1s
会随机向一个最有可能发生故障的节点发送PING
消息。执行该操作的函数是集群的定时函数clusterCron()
。Redis Cluster文件详细注释
if (!(iteration % 10)) {
int j;
// 随机抽查5个节点,向pong_received值最小的发送PING消息
for (j = 0; j < 5; j++) {
// 随机抽查一个节点
de = dictGetRandomKey(server.cluster->nodes);
clusterNode *this = dictGetVal(de);
// 跳过无连接或已经发送过PING的节点
if (this->link == NULL || this->ping_sent != 0) continue;
// 跳过myself节点和处于握手状态的节点
if (this->flags & (CLUSTER_NODE_MYSELF|CLUSTER_NODE_HANDSHAKE))
continue;
// 查找出这个5个随机抽查的节点,接收到PONG回复过去最久的节点
if (min_pong_node == NULL || min_pong > this->pong_received) {
min_pong_node = this;
min_pong = this->pong_received;
}
}
// 向接收到PONG回复过去最久的节点发送PING消息,判断是否可达
if (min_pong_node) {
serverLog(LL_DEBUG,"Pinging node %.40s", min_pong_node->name);
clusterSendPing(min_pong_node->link, CLUSTERMSG_TYPE_PING);
}
}
最有可以能发生故障的节点的判断方法是:随机抽取5
个节点,根据pong_received
值的大小来判断,这个变量代表最后一次接收到PONG
消息回复的时间,所以会向随机选取的5
个节点中,最久没有接收到PONG
消息回复的节点发送PING
消息,来回复该节点的PONG
消息。发送PING
消息会更新最近一次发送PING
消息的时间信息ping_sent
。
这两个时间信息对于判断节点故障扮演非常重要的作用。
如果这个节点真的发生了故障,当发送了它PING
消息后,就不会接收到PONG
消息作为回复,因此会触发超时判断。
当前以myself
节点为主视角,如果向一个节点发送了PING
消息,但是在一定时间内没有收到PONG
回复,那么会检测到该节点可能疑似下线。处理该情况的代码在clusterCron()
函数中。
while((de = dictNext(di)) != NULL) {
clusterNode *node = dictGetVal(de);
now = mstime(); /* Use an updated time at every iteration. */
mstime_t delay;
// 跳过myself节点,无地址NOADDR节点,和处于握手状态的节点
if (node->flags &
(CLUSTER_NODE_MYSELF|CLUSTER_NODE_NOADDR|CLUSTER_NODE_HANDSHAKE))
continue;
......
// 如果当前还没有发送PING消息,则跳过,只要发送了PING消息之后,才会执行以下操作
if (node->ping_sent == 0) continue;
// 计算已经等待接收PONG回复的时长
delay = now - node->ping_sent;
// 如果等待的时间超过了限制
if (delay > server.cluster_node_timeout) {
/* Timeout reached. Set the node as possibly failing if it is
* not already in this state. */
// 设置该节点为疑似下线的标识
if (!(node->flags & (CLUSTER_NODE_PFAIL|CLUSTER_NODE_FAIL))) {
serverLog(LL_DEBUG,"*** NODE %.40s possibly failing",
node->name);
node->flags |= CLUSTER_NODE_PFAIL;
// 设置更新状态的标识
update_state = 1;
}
}
}
这个循环会迭代所有的节点,来检测是否需要将某个节点标记为下线的状态。还会做一些其他的操作,例如:
- 判断孤立的主节点的个数,如果存在孤立的主节点并且某些条件满足,之后会为其迁移一个其他主节点的从节点。
- 释放回复
PONG
消息过慢(超过超时时间的一半)的节点连接,等待下个周期重新建立连接。这样做是为了连接更加健壮。 - 触发第一次
PING
消息发送。当节点第一次加入集群时,发送完MEET
消息,也接受PONG
回复后,会触发该条件,来执行第一次PING
消息通信。 - 如果一个从节点请求了手动故障转移,发送给请求节点一个
PING
消息。 - 最后,则是对节点的故障检测。
如果发送PING
消息的时间已经超过了cluster_node_timeout
限制,默认是15S
,那么会将迭代的该节点的flags
打开CLUSTER_NODE_PFAIL
标识,表示myself
节点主观判断该节点下线。但是这不代表最终的故障判定。
2.2 客观故障的检测
当myself
节点检测到一个节点疑似下线后,就会打开该节点的CLUSTER_NODE_PFAIL
标识,表示判断该节点主观下线,但是可能存在误判的情况,因此为了真正的标记该节点的下线状态,会进行客观故障的检测。
客观故障的检测仍然依赖PING/PONG
消息的传播,每次发送PING/PONG
消息,总会携带集群节点个数的十分之一个节点信息,发送PING/PONG
消息的函数clusterSendPing()
具体代码如下:Redis Cluster文件详细注释
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(