Redis将所有的数据都分到了16384个slots里面同时每个节点负责一部分slots。slot和节点的对应关系是多对一的关系,即每个slot只能被至多一个节点负责存储,每个节点可以负责存储多个slots。此时如果集群中的某个Master节点因为故障下线,就会导致该Master节点负责的slots不能被继续提供服务,那么整个集群就下线(CLUSTER_FAIL)了,不然客户端请求下线Master节点上的slots内的数据时总会报错。所谓的高可用指的是,即使其中一个Master节点下线,整个集群依然能够正常向外提供服务。这是如何做到的呢?简单的来说就是让下线Master节点的Slave节点来成为新的Master节点,接管旧Master负责的所有slots向外提供服务。比如下面的集群拓扑结构,每个Master节点带一个Slave节点。如果M2永久下线之后,那么S2就会替代M2继续向外服务。那么如果替代的S2再次下线后会怎么样呢?显然由于S2不再有Slave节点了,所以S2下线之后整个集群就下线了。为了解决这个问题,Redis还提出一个叫 Replica Migration的解决方案:当集群中的某个Master节点没有Slave节点时(称之为 Orphaned Master),其他有富余Slave节点的主节点会向该节点迁移一个Slave节点以防该节点下线之后没有子节点来替换从而导致整个集群下线。
所以我们的目标很简单:
1 当集群中的一个Master节点故障之后,我们让该Master节点的子节点代替该Master节点继续向外提供即可。这个步骤我们叫slave promotion。
2 当集群中的一个Master节点成为Orphaned Master节点时,我们从其他有富余子节点的地方迁移过来一个子节点给这个孤立节点。
我们先讨论第一个问题。要解决第一个问题我们就得先解决如下的几个问题:
1 故障发现: 如何判定某个Master节点故障了?是管理员来决定?还是某个节点来决定?还是说集群中的节点都来参与投票决定? Redis采用了多数投票的方案。
2 子节点选取:Master下线后其子节点要继位,当Master有多个Slave节点的时候该选择哪个Slave节点继位?如何去选?
3 配置更新:当slave promotion完成之后,那么之前的Master节点以及该节点的其他子节点该如何处置?Redis采用了让这些节点成为新的master节点的子节点的方案。
接下里的文章中,我们就依次介绍Redis对上述问题的解决方案。同时我们需要事先指出的是,Redis集群中只有Master节点具备参与节点状态判定和Slave节点选举的资格,同时Redis集群中的cluster_size指的也是Master节点的个数。
故障发现
PFAIL
和哨兵部分的故障发现一样,Redis集群的故障发现也经历两个阶段:PFail和Fail。PFAIL就是主观下线,比如节点1判定节点3下线,那么他会标记节点3的状态为PFAIL。但是如果绝大部分节点都判定节点3为PFAIL,那么我们就可以断定节点3故障下线,其状态判定为FAIL状态。就像你发现你自己打不开百度网页,于是你认为百度搜索崩了,你标记它为PFAIL。但这不代表百度真的崩了,也许只是因为你没联网。但是如果全国大部分人都打不开百度网页了,那么基本就可以说百度搜索是真的崩了,这时可以判定他的状态是FAIL的状态。
我们以如下的过程示例介绍故障发现的过程。
while((de = dictNext(di)) != NULL) {
clusterNode *node = dictGetVal(de);
now = mstime(); /* Use an updated time at every iteration. */
mstime_t delay;
// 当我们已经与对方节点建立了连接,同时我们向对方节点发送了PING命令,如果对方超时未回复
// 有可能时当前节点与对方节点的连接出了问题,所以就重新建立连接
if (node->link && /* is connected */
now - node->link->ctime >
server.cluster_node_timeout && /* was not already reconnected */
node->ping_sent && /* we already sent a ping */
node->pong_received < node->ping_sent && /* still waiting pong */
/* and we are waiting for the pong more than timeout/2 */
now - node->ping_sent > server.cluster_node_timeout/2)
{
/* 释放掉node->link,此时node->link=NULL, 这个结论在下面的链接重新建立会有用到. */
freeClusterLink(node->link);
}
}
由于此时节点3处于故障状态,那么节点1就会重新建立连接失败。此时节点1会记录连接建立失败的时刻,在实现中,这个时间也是记录到ping_sent变量中。
if (node->link == NULL) {
clusterLink *link = createClusterLink(node);
link->conn = server.tls_cluster ? connCreateTLS() : connCreateSocket();
connSetPrivateData(link->conn, link);
// 尝试再次建立连接
if (connConnect(link->conn, node->ip, node->cport, NET_FIRST_BIND_ADDR,
clusterLinkConnectHandler) == -1) {
// 当建立连接失败时,记录连接建立失败的时刻
if (node->ping_sent == 0) node->ping_sent = mstime();
serverLog(LL_DEBUG, "Unable to connect to "
"Cluster Node [%s]:%d -> %s", node->ip,
node->cport, server.neterr);
freeClusterLink(link);
continue;
}
node->link = link;
}
当节点1发现与节点3的断连时间超过了node_timeout之后,就会标记节点3为PFAIL,即Possible failure,可以中文意为主观下线。节点1标记节点3为PFAIL只是说明节点1认为节点3故障了,但并不代表节点3真正的故障了,因为或许是因为节点1和节点3之间的网络出了问题。
while((de = dictNext(di)) != NULL) {
clusterNode *node = dictGetVal(de);
now = mstime(); /* Use an updated time at every iteration. */
mstime_t delay;
// 计算节点3断连时间
delay = now - node->ping_sent;
// 如果节点3断连时间超过cluster_node_timeout, 则标记节点3为PFAIL
if (delay > server.cluster_node_timeout) {
node->flags |= CLUSTER_NODE_PFAIL;
update_state = 1;
}
}
}
FAIL
当节点1标记节点3为PFAIL后,节点1会通过Gossip消息把这个信息发送给其他节点,接收到信息的节点会进行节点3客观下线状态判定。之前我们简单介绍过Redis的Gossip协议使用,节点1每次随机向其他几个节点发送自己视角下的部分节点状态信息。当节点2接收到来自节点1关于节点3的状态判定信息之后,节点2首先会把节点1加入到节点3的下线报告列表(Fail Report)中。每个节点都会维护一个下线报告列表,主要维护一个节点被哪些节点报告处于下线状态。比如节点4在节点1之前就向节点2报告了节点3的PFAIL信息,当节点2把节点1加入到节点3的下线报告后下线报告如下图所示。
节点2把节点1加入到下线报告后,会进行节点2客观下线状态(FAIL)的判定。
void clusterProcessGossipSection(clusterMsg *hdr, clusterLink *link) {
// 获取该条消息包含的节点数信息
uint16_t count = ntohs(hdr->count);
// clusterMsgDataGossip数组的地址
clusterMsgDataGossip *g = (clusterMsgDataGossip*) hdr->data.ping.gossip;
// 发送消息的节点
clusterNode *sender = link->node ? link->node : clusterLookupNode(hdr->sender);
// 遍历所有节点的信息
while(count--) {
// 获取节点的标识信息
uint16_t flags = ntohs(g->flags);
clusterNode *node;
// 根据指定name从集群中查找并返回节点
node = clusterLookupNode(g->nodename);
// 如果node存在
if (node) {
// 如果发送者是主节点,且不是node本身
if (sender && nodeIsMaster(sender) && node != myself) {
// 如果标识中指定了关于下线的状态
if (flags & (CLUSTER_NODE_FAIL|CLUSTER_NODE_PFAIL)) {
// 将sender的添加到node的故障报告中
if (clusterNodeAddFailureReport(node,sender)) {
serverLog(LL_VERBOSE,
"Node %.40s reported node %.40s as not reachable.",
sender->name, node->name);
}
// 判断node节点是否处于真正的下线FAIL状态
markNodeAsFailingIfNeeded(node);
// 如果标识表示节点处于正常状态
}
}
}
}
所谓客观下线状态的判定规则是: 当集群中有超过1/2数目的节点都认为节点3处于PFAIL,那么就判定节点3为FAIL。同时需要指出的是,节点1把Gossip消息发送给其他节点后,只有同样认为节点3处于PFAIL状态的节点才会去做客观下线状态判定。由于节点2也判定节点3处于PFAIL,所以节点2进入客观下线的判定。当节点2发现有一半以上(包括自己)的主节点都报告节点3处在PFAIL状态时,节点2标记节点3为FAIL状态,并立刻向集群所有节点广播这个信息。
void markNodeAsFailingIfNeeded(clusterNode *node) {
int failures;
// 需要大多数的票数,超过一半的节点数量
int needed_quorum = (server.cluster->size / 2) + 1;
// 不处于pfail(需要确认是否故障)状态,则直接返回
if (!nodeTimedOut(node)) return; /* We can reach it. */
// 处于fail(已确认为故障)状态,则直接返回
if (nodeFailed(node)) return; /* Already FAILing. */
// 返回认为node节点下线(标记为 PFAIL or FAIL 状态)的其他节点数量
failures = clusterNodeFailureReportsCount(node);
// 如果当前节点是主节点,也投一票
if (nodeIsMaster(myself)) failures++;
// 如果报告node故障的节点数量不够总数的一半,无法判定node是否下线,直接返回
if (failures < needed_quorum) return; /* No weak agreement from masters. */
serverLog(LL_NOTICE, "Marking node %.40s as failing (quorum reached).", node->name);
// 取消PFAIL,设置为FAIL
node->flags &= ~CLUSTER_NODE_PFAIL;
node->flags |= CLUSTER_NODE_FAIL;
// 并设置下线时间
node->fail_time = mstime();
// 广播下线节点的名字给所有的节点,强制所有的其他可达的节点为该节点设置FAIL标识
if (nodeIsMaster(myself)) clusterSendFail(node->name);
clusterDoBeforeSleep(CLUSTER_TODO_UPDATE_STATE|CLUSTER_TODO_SAVE_CONFIG);
}
广播信息
节点2判定节点3为FAIL状态后,向全集群的节点广播Node3的故障消息CLUSTERMSG_TYPE_FAIL。当集群中的节点收到此消息时,都会标记节点3的状态为FAIL状态,包括节点3的两个子节点S1,S2也会标记节点3为FAIL状态。
至此,故障发现判定到此就介绍完了。整体过程为,首先每个节点都会自主判断其他节点状态,当一个节点A与自己断连时间过长,则判定该节点为PFAIL状态,并将A节点的判定状态Gossip给集群的其他节点。其他节点接收到消息后,会累计节点A的下线报告数,如果节点A的下线报告数目超过了cluster_size/2,即说明一半以上的节点都认为A下线,那么就判定A真正的下线,标记为FAIL。判定结束后,向集群广播节点A下线消息,其他节点都会更新自己维护的节点A的状态信息,标记A为FAIL。
当节点3故障后,我们要采用的故障恢复的方案就是让节点3的子节点代替节点3继续向外提供服务。那么节点3有两个子节点,到底该选择哪个子节点来替代呢?这就是我们接下来要介绍的故障迁移。
故障迁移
我们接着上面节点2向集群广播消息往下讲。当节点3的的两个子节点接收到其主节点的FAIL状态消息时,两个节点就会开始发起故障迁移,竞选成为新的Master节点。两个节点参与竞选之前,首先要检查自身是否有资格参与竞选。
资格检查
Slave节点会不停的与Master节点通信来复制Master节点的数据,如果一个Slave节点长时间不与Master节点通信,那么很可能意味着该Slave节点上的数据已经落后Master节点过多(因为Master节点再不停的更新数据但是Slave节点并没有随之更新)。Redis认为,当一个Slave节点过长时间不与Master节点通信,那么该节点就不具备参与竞选的资格。具体的判定如下:
/* 计算与Master节点上次通信过去的时间*/
if (server.repl_state == REPL_STATE_CONNECTED) {
data_age = (mstime_t)(server.unixtime - server.master->lastinteraction)
* 1000;
} else {
data_age = (mstime_t)(server.unixtime - server.repl_down_since) * 1000;
}
if (data_age >server.repl_ping_slave_period * 1000+
(server.cluster_node_timeout * server.cluster_slave_validity_factor)){
该节点不具备参与竞选资格
}
休眠时间计算
当S1和S3都发现自己具备竞选资格时,就开始参与竞选。Redis子节点竞选成为新的Master节点采用了Raft协议。Raft协议选举过程中,所有参与选举的节点首先随机休眠一段时间,每个节点一旦唤醒就立刻向所有的投票节点发起拉票请求。对于投票节点来说,每一轮选举中只能投出一票,投票的规则就是先到先得。所以一般情况下,都是休眠时间最短的节点容易获得大部分投票。所以S1、S3也需要进行随机休眠,其每个节点的随即休眠时长计算公式如下。整个休眠时间由两部分组成:
- 一部分为固定的500ms时间,这500ms主要是为了等待集群状态同步。上面我们讲到节点2会向集群所有节点广播消息,那么这500ms就是等待确保集群的所有节点都收到了消息并更新了状态。
- 另一部分主要是一个随机的时间加上由该Slave节点的排名决定的附加时间。我们在之前主从复制的文章中讲过,每个slave都会记录自己从主节点同步数据的复制偏移量。复制偏移量越大,说明该节点与主节点数据保持的越一致。那么显然我们选举的时候肯定是想选状态更新最近的子节点,所以我们按照更新状态的排序来确定休眠时间的附加部分。状态更新最近的节点SLAVE_RANK排名为1,那么其休眠的时间相应的也最短,也就意味着该节点最有可能获得大部分选票。
DELAY = 500 milliseconds + random delay between 0 and 500 milliseconds
+ SLAVE_RANK * 1000 milliseconds.
发起拉票&选举投票
我们假设S1先唤醒,S1唤醒后向所有节点发起拉票请求,即向其他节点发送CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST类型的消息。但是我们需要说明的是,虽然所有的节点(主节点+子节点)都会收到拉票请求,但是只有主节点才具备投票资格。当其他主节点接收到拉票请求时,如果这一轮投票过程中该主节点没有投出自己的票,那么就会把自己的票投给S1,即向S1回复FAILOVER_AUTH_ACK消息。当S1接收到来自其他节点的ACK消息时会统计自己获得的票数,当S1发现自己收到集群中一半以上的主节点的投票时就会开始执行failover,即替换自己的主节点过程。
// 发现自己获得了超过半数的集群节点的投票
if (server.cluster->failover_auth_count >= needed_quorum) {
serverLog(LL_WARNING,"Failover election won: I'm the new master.");
if (myself->configEpoch < server.cluster->failover_auth_epoch) {
myself->configEpoch = server.cluster->failover_auth_epoch;
serverLog(LL_WARNING,
"configEpoch set to %llu after successful failover",
(unsigned long long) myself->configEpoch);
}
// 执行自动或手动故障转移,从节点获取其主节点的哈希槽,并传播新配置
clusterFailoverReplaceYourMaster();
} else {
clusterLogCantFailover(CLUSTER_CANT_FAILOVER_WAITING_VOTES);
}
替换节点
S1替换节点3的过程比较清晰易懂。即首先标记自己为主节点,然后将原来由节点3负责的slots标记为由自己负责,最后向整个集群广播现在自己是Master同时负责旧Master所有slots的信息。其他节点接收到该信息后会更新自己维护的S1的状态并标记S1为主节点,将节点3负责的slots的负责节点设置为S1节点。
void clusterFailoverReplaceYourMaster(void) {
int j;
clusterNode *oldmaster = myself->slaveof;
if (nodeIsMaster(myself) || oldmaster == NULL) return;
/* 1) Turn this node into a master. */
clusterSetNodeAsMaster(myself);
replicationUnsetMaster();
/* 2) Claim all the slots assigned to our master. */
for (j = 0; j < CLUSTER_SLOTS; j++) {
if (clusterNodeGetSlotBit(oldmaster,j)) {
clusterDelSlot(j);
clusterAddSlot(myself,j);
}
}
/* 3) Update state and save config. */
clusterUpdateState();
clusterSaveConfigOrDie(1);
/* 4) Pong all the other nodes so that they can update the state
* accordingly and detect that we switched to master role. */
clusterBroadcastPong(CLUSTER_BROADCAST_ALL);
/* 5) If there was a manual failover in progress, clear the state. */
resetManualFailover();
}
集群配置更新
那么最后我们需要解决的是,当S1成为了新的Master之后,S2和节点3该如何处理?显然并不是篡位之后就杀掉hh。实际上我们是让S2和节点3成为新的主节点S1的Slave节点,去备份S1节点的数据。那这个过程是如何进行的呢?这是在各个节点信息更新的时候自动实现的。当节点3故障恢复重新上线后,发现原先本该由自己负责的slot被S1负责了,那么他就知道自己被替代了,会自动成为S1节点的子节点,当S2节点发现原先应该由其Master节点3负责的slot被S1负责了,那么他就知道自己的Master被替代了,就会成为S1的Slave节点。
至此我们完成了整个故障迁移的内容介绍。总共分为如下几步:
- 子节点竞选资格检查
- 子节点休眠时间计算
- 子节点发起拉票,其他主节点投票
- 获得多数选票的子节点替换其主节点并向集群所有节点广播该信息
- 其他节点接收信息后更新配置
- 原先的主节点以及该主节点的其他节点自动成为新的主节点的Slave节点
。