redis源码阅读笔记-集群

1 集群准备阶段

redis集群搭建与配置可参考: Redis Cluster集群搭建

1.1 读取集群配置

关于集群配置如下:

port 6379                               //端口
cluster-enabled yes                     //开启集群模式
cluster-config-file nodes-6379.conf     //集群内部的配置文件
cluster-node-timeout 15000              //节点超时时间,单位毫秒
// 其他配置和单机模式相同

main中通过loadServerConfig(configfile,options)加载配置

...
	else if (!strcasecmp(argv[0],"cluster-enabled") && argc == 2) {
       if ((server.cluster_enabled = yesnotoi(argv[1])) == -1) {
            err = "argument must be 'yes' or 'no'"; goto loaderr;
        }
    } else if (!strcasecmp(argv[0],"cluster-config-file") && argc == 2) {
        zfree(server.cluster_configfile);
        server.cluster_configfile = zstrdup(argv[1]);
    } else if (!strcasecmp(argv[0],"cluster-node-timeout") && argc == 2) {
        server.cluster_node_timeout = strtoll(argv[1],NULL,10);
        if (server.cluster_node_timeout <= 0) {
            err = "cluster node timeout must be 1 or greater"; goto loaderr;
        }
   ...
1.2 集群初始化

在main中,redis服务器初始化initServer中,开启了集群能力,则调用集群初始化

if (server.cluster_enabled) 
	clusterInit();

1、设置server->cluster状态
2、集群配置文件不存在,创建一个随机名称,flags为CLUSTER_NODE_MYSELF|CLUSTER_NODE_MASTER的集群节点,即全局myself和server.cluster->myself的引用,并将该节点加入server.cluster->nodes字典中,键:节点名称,值:myself节点。
将该信息记录配置和文件,集群中cluster-config-file配置文件格式:
在这里插入图片描述

若集群配置文件存在,则是一个逆向将配置信息读入server.cluster->nodes字典、myself节点的过程

节点flags表示对应解释说明:

static struct redisNodeFlags redisNodeFlagsTable[] = {
    {CLUSTER_NODE_MYSELF,       "myself,"},
    {CLUSTER_NODE_MASTER,       "master,"},
    {CLUSTER_NODE_SLAVE,        "slave,"},
    {CLUSTER_NODE_PFAIL,        "fail?,"},
    {CLUSTER_NODE_FAIL,         "fail,"},
    {CLUSTER_NODE_HANDSHAKE,    "handshake,"},
    {CLUSTER_NODE_NOADDR,       "noaddr,"}
};

3、监听集群端口,设置可读处理事件clusterAcceptHandler
redis端口server.port,如6379,用于客户端的连接。集群端口为server.port+10000,用于集群间连接通信。

1.3 验证更新集群配置

在main函数中,verifyClusterConfigWithData负责校验该节点的配置是否正确,包含的数据是否正确。
1、当前全局myself引用为主节点才进行校验
2、集群模式只使用0号数据库,如果1~15数据库有数据,则报错
3、跳过不包含任何键的slot,跳过myself负责的slot,跳过正在处于导入状态的slot。如果slot未指定节点,将该slot指定未当前节点,否则,将该槽加入到集群的importing_slots_from表中。该步骤完成后,可以确定有数据的slot要么被myself节点负责,要么为其他节点负责(记录在importing_slots_from中)
4、更新配置文件

1.4 下一次事件调用前执行函数

在main中,beforeSleep->clusterBeforeSleep中,根据todo_before_sleep对应的标识,执行对应的函数。

2 节点握手

在集群准备阶段,对集群服务器进行了初始化,监听端口,以及集群状态校验。在当前的集群服务器中,只有myself当前节点,需要通过节点握手,将其他节点加入到集群中。

2.1 myself节点发送meet消息(A向B发起握手)

两个独立运行的集群节点,完成初始化后,server.cluster->nodes中只有myself节点,需要通过一方客户端主动发起cluster meet ip port命令,在建立连接的过程中信息的交互,认识其他节点,将其加入server.cluster->nodes字典中。

节点握手是双向的,在初始A、B两个集群节点初始化完毕后,需要A节点客户端向B节点主动发送cluster meet命令开始握手,B节点回复pong消息,A节点确定B节点的存在。B节点在定时处理函数中,会通过clusterSendPing函数的向A节点发送meet类型消息,请求与A节点握手。

myself 节点客户端发起 cluster meet <ip> <port>

当服务器接收到来在客户端的命令后,调用clusterCommand函数处理命令:

...
// 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);
        }
    } else if (!strcasecmp(c->argv[1]->ptr,"nodes") && c->argc == 2) {
    ...

clusterStartHandshake建立握手初始阶段,获取到B节点ip、port,检测port是否合法,检测该ip、port对应的节点是否已建立握手,已建立,则退出建立握手;否则为B创建一个随机名字的节点,加入到集群节点中。flags标识为CLUSTER_NODE_HANDSHAKE|CLUSTER_NODE_MEET,处于待发送meet消息、待完成建立握手状态。

...
 // 判断当前地址是否处于握手状态,如果是,则设置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;
  ...

在周期执行函数clusterCron中,每100ms处理一次,对处于待发送meet消息的节点,建立clusterLink连接,发送meet消息

  • 遍历server.cluster->nodes,若发现处于CLUSTER_NODE_HANDSHAKE的节点,超时未能完成握手,则在集群节点中删除该节点
  • 处于CLUSTER_NODE_MEET状态的节点,node->link连接通道为空,则向节点B请求建立socket连接,设置该连接可读事件处理函数clusterReadHandler
  • clusterSendPing发送PING消息,取消节点CLUSTER_NODE_MEET标识
...
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);
   }
}
...
2.2 B节点处理meet消息,回复PONG消息

B节点接收到A节点的connect连接时,在clusterAcceptHandler中,为该socket连接创建link连接对象,监听设置该连接的可读处理函数clusterReadHandler
当接收到A节点的消息时,触发可读处理函数clusterReadHandler,该函数主要的作用就是将信息读取保存到接收缓冲区link->rcvbuf中,并对信息内容进行完整性校验。消息前四个字符为RCmb,接着四个字符为消息的长度。当接收消息数据完整时,则调用clusterProcessPacket处理。

...
// 从集群中查找sender节点
sender = clusterLookupNode(hdr->sender);
if (type == CLUSTERMSG_TYPE_PING || type == CLUSTERMSG_TYPE_MEET) {
   serverLog(LL_DEBUG,"Ping packet received: %p", (void*)link->node);
    // 如果是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);
    }

    /* Anyway reply with a PONG */
    // 回复一个PONG消息
    clusterSendPing(link,CLUSTERMSG_TYPE_PONG);
}
...

这里只列出当前B节点处理meet消息的代码。

  • 第一次握手,在集群节点中,未发现发送节点(A节点),则在集群中创建一个标识为CLUSTER_NODE_HANDSHAKE的节点,在定时处理函数中去建立握手
  • 当有节点加入集群时(即发送meet消息),或者如果我们改变地址,这些节点将使用我们公开的地址来连接我们,所以在集群中,通过套接字来获取地址是一个简单的方法去discover / update我们自己的地址,而不是在配置中的硬设置
  • 给A节点回复PONG消息
2.3 A节点处理PONG消息

clusterReadHandler将消息读取到接收缓冲区link->rcvbuf,调用clusterProcessPacket处理。

  • 当前link连接的节点为B节点,且该link处于握手阶段,在握手初期,为该B节点创建时指定的时随机名字,现在收到B节点回复消息,则用hdr->sender重新命名
  • 取消B节点的握手标识
  • 如果link客户端已存在,link连接的B节点完成了握手,那么B节点的名字应该与消息体中的名字保持一致,否则消息不一致,该link连接无效
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消息响应时,更新最新发送的ping_sent时间为0。ping_sent表示发送PING消息且未收到回复时的时间点

if (link->node && type == CLUSTERMSG_TYPE_PONG) {
            // 更新接收到PONG的时间
            link->node->pong_received = mstime();

            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);
            }
        }

之后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包,来进行故障检测等工作。

回顾下握手的过程:
1、在A运行redis实例中,为B创建节点,该节点处于meet状态,随机名字。A运行实例中,定时处理函数,对处于meet状态节点,建立link连接,发送PING消息,取消meet状态
2、在B运行redis实例中,监听到连接,创建一个不指定的link连接,该link仅用来回复消息。收到A请求握手,为A创建节点,未命名,握手状态,回复PONG消息
3、在A运行redis实例中,收到B回复,更新B节点名称,取消握手状态。发送PING消息
4、A->B握手过程结束,在A运行实例已认识B节点。B运行实例在定时函数中处理,会向A发起握手,过程同上

3 Gossip协议

搭建redis-cluster集群时,首先通过cluster meet命令把所有节点加入某个集群节点中,在两两节点之间并没有执行cluster meet,是因为节点之间基于Gossip进行通信。Gossip协议基本思想就是:一个节点想要分享一些信息给网络中的其他的一些节点。于是,它周期性的随机选择一些节点,并把信息传递给这些节点。

3.1 Gossip分享传播节点信息

集群之间的通信是通过clusterSendPing封装消息发送的。一个消息对象clusterMsg结构体主要由头部和消息体构成:

  • 消息包头部包含签名、消息总大小、版本和发送消息节点的信息。
  • 消息数据则是一个联合体union clusterMsgData,联合体中又有不同的结构体来构建不同的消息。

在进行gossip通信时,联合体union clusterMsgData中可以存放多个clusterMsgDataGossip数据类型的消息数组。该消息数组记录的节点内容如下:节点名称、节点ip、port、节点标识,节点发送ping时间、节点回复ping时间。

typedef struct {
    char nodename[CLUSTER_NAMELEN];
    uint32_t ping_sent;
    uint32_t pong_received;
    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;

在这里插入图片描述

  • 在节点发送PINGPONGMEET消息的同时,会将当前redis服务器中建立正确连接的节点信息分享传播过去。
  • 消息中携带的并不是所有节点个数,而是随机取n个节点的信息,n的值如图。
  • 通过Gossip协议,每次能够将一些节点信息发送给目标节点,而每个节点都这么干,只要时间足够,理论上集群中所有的节点都会互相认识。
3.2 基于gossip的选择策略

1、随机节点的选择

  • 当然不会选择myself节点,因为,在包头中已经包含了myself节点也就是发送节点的信息。
  • 偏爱选择处于下线状态或疑似下线状态的节点,这样有利于进行故障检测。
  • 不选,处于握手状态或没有地址状态的节点,还有就是因为不负责任何槽而断开连接的节点。

2、十分之一
当集群中节点数量足够多时,在进行信息传播携带的节点个数为总数的十分之一

  • 在一个node_timeout时间内,会收到/回复4个消息包。因为在发送PING消息包,最迟在node_timeout/2会收到PONG消息包。所以在一个node_timeout时间内,会发送两个PING消息包,收到两个PONG消息包。
  • Redis Cluster中计算故障转移超时时间是cluster_node_timeout * 2,是两倍的node_timeout时间,那么当前节点会接收到8个消息包。因为传播节点个数为m/10,所以8*m/10,也就是会传播80%节点的信息包,这样就能收到大部分集群节点发送来的下线报告。
3.3 节点获取传播信息,处理消息包

clusterProcessPacket中,对消息进行处理,基于以下两种情况,会对携带节点消息包进行处理。

...
//从未知节点,握手阶段发来的meet消息,对携带的消息包进行处理
if (!sender && type == CLUSTERMSG_TYPE_MEET) 
	clusterProcessGossipSection(hdr,link);
...
//已知节点,发送PING/恢复PING消息,对携带的消息包进行处理
if (sender) 
	clusterProcessGossipSection(hdr,link);
...

clusterProcessGossipSection中,遍历节点信息,进行处理。
1、如果消息包中的g节点和发送节点sender,当前redis服务器都认识,则当前消息处理流程为,检测监控g节点的下线或正常状态。当前redis服务器为master节点,且无需处理消息包为当前myself的g节点。

  • 消息包中g节点处于下线状态,则在g节点的fail_reports故障列表中添加sender节点,并通过markNodeAsFailingIfNeeded对节点是否下线进行分析,当故障列表中有超过一半数量,则设置g节点下线状态,通过clusterSendFail广播所有可达到节点。
  • 消息包中g节点未处于下线状态,则将g节点故障列表中清除sender节点。

2、如果消息包中的g节点,当前redis服务器认识。g节点经历了下线后又重新上线,所以在当前redis服务器认识的g还处于下线状态,且同样的名称,ip或port发生了变化,则需要释放掉现有g->link,重置ip、port,在定时处理函数中,建立连接,重新握手。
3、如果sender发送节点认识,g节点不认识,(且不在集群黑名单),则需要创建新的随机节点,新建立握手clusterStartHandshake

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);
        // 如果标识表示节点处于正常状态
        } else {
            // 将sender从node的故障报告中删除
            if (clusterNodeDelFailureReport(node,sender)) {
                serverLog(LL_VERBOSE,
                    "Node %.40s reported node %.40s is back online.",
                    sender->name, node->name);
            }
        }
    }
    // 虽然node存在,但是node已经处于下线状态
    // 但是消息中的标识却反应该节点不处于下线状态,并且实际的地址和消息中的地址发生变化
    // 这些表明该节点换了新地址,尝试进行握手
    if (node->flags & (CLUSTER_NODE_FAIL|CLUSTER_NODE_PFAIL) &&
        !(flags & CLUSTER_NODE_NOADDR) &&
        !(flags & (CLUSTER_NODE_FAIL|CLUSTER_NODE_PFAIL)) &&
        (strcasecmp(node->ip,g->ip) || node->port != ntohs(g->port)))
    {
        // 释放原来的集群连接对象
        if (node->link) freeClusterLink(node->link);
        // 设置节点的地址为消息中的地址
        memcpy(node->ip,g->ip,NET_IP_STR_LEN);
        node->port = ntohs(g->port);
        // 清除无地址的标识
        node->flags &= ~CLUSTER_NODE_NOADDR;
    }
// node不存在,没有在当前集群中找到
} else {
    // 如果node不处于NOADDR状态,并且集群中没有该节点,那么向node发送一个握手的消息
    // 注意,当前sender节点必须是本集群的众所周知的节点(不在集群的黑名单中),否则有加入另一个集群的风险
    if (sender &&
        !(flags & CLUSTER_NODE_NOADDR) &&
        !clusterBlacklistExists(g->nodename))
    {
        // 开始进行握手
        clusterStartHandshake(g->ip,ntohs(g->port));
    }
}

4 槽位管理

Redis Cluster采用槽分区,所有的键根据哈希函数映射到0 ~ 16383,计算公式:slot = CRC16(key)&16383,具体算法可以参考: CRC16算法代码。每一个节点负责维护一部分槽位以及槽位所映射的键值数据。

当将所有节点组成集群后,还不能工作,因为集群的节点还没有分配槽位(slot)。

分配槽位的命令cluster addslots,假如我们为6379端口的myself节点指定{0..5461}的槽位,命令如下:

redis-cli -h 127.0.0.1 -p 6379 cluster addslots {0..5461}

4.1 槽位信息管理

1、在redis集群每个运行的redis服务器中,由管理集群状态clusterState的结构维护 槽位-节点管理者的映射关系,如下:

typedef struct clusterState {
	//CLUSTER_SLOTS : 16384 
    // 导出槽数据到目标节点,该数组记录这些节点
    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命令可以返回多个属于槽位的键,通过遍历跳跃表实现。

2、在节点字典中的每个节点,也维护着当前节点负责槽位的二进制序列。总共CLUSTER_SLOTS个节点,当前节点负责该槽,则置为1,否则置为0,一个char类型可以设置8位,故槽位图用slots[CLUSTER_SLOTS/8]
在这里插入图片描述

typedef struct clusterNode {
    // 节点的槽位图
    unsigned char slots[CLUSTER_SLOTS/8]; 
    // 当前节点负责槽的数量
    int numslots;  
} clusterNode;
4.2 分配槽

由客户端发起命cluster addslots <slot> [slot ...]

当redis服务器收到客户端请求时,调用clusterCommand函数处理该命令。

  • 确保参数有效且可执行:确保slot值正确、槽负责节点为空、slot值不重复
  • clusterAddSlot函数进行分配槽处理
if ((!strcasecmp(c->argv[1]->ptr,"addslots") ||
               !strcasecmp(c->argv[1]->ptr,"delslots")) && c->argc >= 3)
{
    /* CLUSTER ADDSLOTS <slot> [slot] ... */
    /* CLUSTER DELSLOTS <slot> [slot] ... */
    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);
}

4.1内容讲过,槽的信息管理设计两个数据结构,redis服务器的server.cluster->slots和各节点对应的二进制槽位图。

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;
}
4.3 广播节点的槽位信息

消息交互时,调用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);

更新处理槽中信息,存在两种情况:

  • 集群中当前槽负责节点为空,则直接指定槽负责节点即可
  • 发送消息包中的配置纪元>槽负责节点的配置纪元,即发送消息包配置信息版本更大
if (server.cluster->slots[j] == NULL ||
    server.cluster->slots[j]->configEpoch < senderConfigEpoch)
{
    /* Was this slot mine, and still contains keys? Mark it as
     * a dirty slot. */
    // 如果当前槽被当前节点所负责,而且槽中有数据,表示该槽发生冲突
    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);
}

对于第二种情况,处理会更复杂些,除了分配槽给sender节点,还存在如下情况:

  • 负责该槽位为当前redis对应主服务器,且当前redis对应主服务器负责的槽总数为0,表示发生了故障转移,则设置sender节点为当前redis服务器的主节点
  • 负责该槽位为当前redis服务器,且槽中有数据,则还需遍历所有的脏槽,删除槽中的键
if (newmaster && curmaster->numslots == 0) {
    serverLog(LL_WARNING,
    // 将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]);
}

5 集群伸缩

5.1 集群伸缩原理

集群扩容的步骤如下:

  • 准备新节点
  • 加入集群
  • 迁移槽和数据
5.1.1 准备新节点

准备新的节点,加入集群中,参考redis集群搭建

5.1.2 加入集群

如前面讲的,在客户端执行cluster meet命令,让集群认识该节点。

5.1.3 迁移槽数据

集群扩容的前两步和搭建集群很像,最后一步则是将集群节点中的槽和数据迁移到新的节点中,而不是为新的节点分配槽位。因此我们重点分析这一过程。

源节点槽位迁移到目标节点中,迁移单个槽位过程(A节点迁移到newD节点):
1、对目标节点newD发送 CLUSTER SETSLOT <slot> importing <A节点名称>,在目标节点newD中将<slot>设置为导入状态importing

2、对源节点A发送 CLUSTER SETSLOT <slot> migrating <newD节点名称> , 在源节点A中将<slot>设置为导出状态migrating

3、对源节点A发送CLUSTER GETKEYSINSLOT <slot> <count>命令, 获取<count>个属于<slot>的键,这些键要发送给目标节点newD。

4、对于第三步获得的键,发送MIGRATE host port "" dbid timeout [COPY | REPLACE] KEYS key1 key2 ... keyN命令,将选中的键从源节点A迁移到目标节点newD。

5、向集群中的任意节点发送CLUSTER SETSLOT <slot> node <target_name>,将<slot>指派给目标节点newD。指派信息会通过消息发送到整个集群中,然后最终所有的节点都会知道<slot>已经指派给了目标节点newD。

5.1.3.1 目标节点newD,设置成导入状态

接收客户端命令,处理函数clusterCommand,如果当前节点为从节点,则直接返回。获取源节点A,设置importing_slots_from导入节点为A。

...
else if (!strcasecmp(c->argv[3]->ptr,"importing") && c->argc == 5) {
	// 如果该槽已经是myself节点负责,那么不进行导入
	 if (server.cluster->slots[slot] == myself) {
	     addReplyErrorFormat(c,
	         "I'm already the owner of hash slot %u",slot);
	     return;
	 }
	 // 获取导入的目标节点
	 if ((n = clusterLookupNode(c->argv[4]->ptr)) == NULL) {
	     addReplyErrorFormat(c,"I don't know about node %s",
	         (char*)c->argv[3]->ptr);
	     return;
	 }
	 // 为该槽设置导入目标
	 server.cluster->importing_slots_from[slot] = n;
 } else if (!strcasecmp(c->argv[3]->ptr,"stable") && c->argc == 4) {
 ...
5.1.3.2 源节点A,设置成导出状态

A节点中,当前槽位负责节点需为myself主节点,获取目标节点newD,设置migrating_slots_to导出节点为newD。

...
if (!strcasecmp(c->argv[3]->ptr,"migrating") && c->argc == 5) {
    // 如果该槽不是myself主节点负责,那么就不能进行迁移
    if (server.cluster->slots[slot] != myself) {
        addReplyErrorFormat(c,"I'm not the owner of hash slot %u",slot);
        return;
    }
    // 获取迁移的目标节点
    if ((n = clusterLookupNode(c->argv[4]->ptr)) == NULL) {
        addReplyErrorFormat(c,"I don't know about node %s",
            (char*)c->argv[4]->ptr);
        return;
    }
    // 为该槽设置迁移的目标
    server.cluster->migrating_slots_to[slot] = n;
// 如果是importing
} else if (!strcasecmp(c->argv[3]->ptr,"importing") && c->argc == 5) {
...
5.1.3.3 获取槽中的键

CLUSTER GETKEYSINSLOT <slot> <count>,接收到客户端命令,处理函数clusterCommand,获取对应槽位count个键返回给客户端。

...
 else if (!strcasecmp(c->argv[1]->ptr,"getkeysinslot") && c->argc == 4) {
        long long maxkeys, slot;
        unsigned int numkeys, j;
        robj **keys;
        // 获取槽号
        if (getLongLongFromObjectOrReply(c,c->argv[2],&slot,NULL) != C_OK)
            return;
        // 获取打印键的个数
        if (getLongLongFromObjectOrReply(c,c->argv[3],&maxkeys,NULL)
            != C_OK)
            return;
        // 判断槽号和个数是否非法
        if (slot < 0 || slot >= CLUSTER_SLOTS || maxkeys < 0) {
            addReplyError(c,"Invalid slot or number of keys");
            return;
        }
        // 分配保存键的空间
        keys = zmalloc(sizeof(robj*)*maxkeys);
        // 将count个键保存到数组中
        numkeys = getKeysInSlot(slot, keys, maxkeys);
        // 添加回复键的个数
        addReplyMultiBulkLen(c,numkeys);
        // 添加回复每一个键
        for (j = 0; j < numkeys; j++) addReplyBulk(c,keys[j]);
        zfree(keys);
    // 从集群中删除<NODE ID>指定的节点
} else if (!strcasecmp(c->argv[1]->ptr,"forget") && c->argc == 3) {
...
5.1.3.4 迁移槽中的键

migrate命令如下,第一个为批量迁移命令,第二个为单个键迁移命令:

MIGRATE host port "" dbid timeout [COPY | REPLACE] KEYS key1 key2 ... keyN
MIGRATE host port key dbid timeout [COPY | REPLACE]

接收客户端命令,处理函数migrateCommand,对参数进行获取:

  • copy: 是否删除源节点上的key
  • replace:是否替换目标节点上已存在的key
  • num_keys: 迁移键的个数
  • kv/ov: 指针数组,数组大小为 num_keys,指向内存中key/values对象的地址
...
 for (j = 6; j < c->argc; j++) {
   // copy项:不删除源节点上的key
    if (!strcasecmp(c->argv[j]->ptr,"copy")) {
        copy = 1;
    // replace项:替换目标节点上已存在的key
    } else if (!strcasecmp(c->argv[j]->ptr,"replace")) {
        replace = 1;
    // keys项:指定多个迁移的键
    } else if (!strcasecmp(c->argv[j]->ptr,"keys")) {
        // 第三个参数必须是空字符串""
        if (sdslen(c->argv[3]->ptr) != 0) {
            addReplyError(c,
                "When using MIGRATE KEYS option, the key argument"
                " must be set to the empty string");
            return;
        }
        // 指定要迁移的键,第一个键的下标
        first_key = j+1;
        // 键的个数
        num_keys = c->argc - j - 1;
        break; /* All the remaining args are keys. */
    } else {
        addReply(c,shared.syntaxerr);
        return;
    }
 }
...
ov = zrealloc(ov,sizeof(robj*)*num_keys);
kv = zrealloc(kv,sizeof(robj*)*num_keys);
int oi = 0;
// 遍历所有指定的键
for (j = 0; j < num_keys; j++) {
    // 以读操作取出key的值对象,保存在ov中
    if ((ov[oi] = lookupKeyRead(c->db,c->argv[first_key+j])) != NULL) {
        // 将存在的key保存到kv中
        kv[oi] = c->argv[first_key+j];
        // 计数存在的键的个数
        oi++;
    }
}
...
  • 键数据封装发送

1、在源节点和目标节点之间建立TCP连接。为了避免频繁的创建释放连接,因此在服务器中的server.migrate_cached_sockets字典中缓存了最近的十个连接。该字典的键是host:ip,字典的值是一个指针,指向migrateCachedSocket结构。

typedef struct migrateCachedSocket {
    // TCP套接字
    int fd;
    // 上一次还原键的数据库ID
    long last_dbid;
    // 上一次使用的时间
    time_t last_use_time;
} migrateCachedSocket;

2、发送信息格式如下
在这里插入图片描述

for (j = 0; j < num_keys; j++) {
   long long ttl = 0;
   // 获取当前key的过期时间
   long long expireat = getExpire(c->db,kv[j]);

   if (expireat != -1) {
       // 计算key的生存时间
       ttl = expireat-mstime();
       if (ttl < 1) ttl = 1;
   }
   // 以"*<count>\r\n"格式为写如一个int整型的count
   // 如果指定了replace,则count值为5,否则为4
   // 写回复的个数
   serverAssertWithInfo(c,NULL,rioWriteBulkCount(&cmd,'*',replace ? 5 : 4));
   // 如果运行在进群模式下,写回复一个"RESTORE-ASKING"
   if (server.cluster_enabled)
       serverAssertWithInfo(c,NULL,
           rioWriteBulkString(&cmd,"RESTORE-ASKING",14));
   // 如果不是集群模式下,则写回复一个"RESTORE"
   else
       serverAssertWithInfo(c,NULL,rioWriteBulkString(&cmd,"RESTORE",7));
   // 检测键对象的编码
   serverAssertWithInfo(c,NULL,sdsEncodedObject(kv[j]));
   // 写回复一个键
   serverAssertWithInfo(c,NULL,rioWriteBulkString(&cmd,kv[j]->ptr,
           sdslen(kv[j]->ptr)));
   // 写回复一个键的生存时间
   serverAssertWithInfo(c,NULL,rioWriteBulkLongLong(&cmd,ttl));

   // 将值对象序列化
   createDumpPayload(&payload,ov[j]);
   // 将序列化的值对象写到回复中
   serverAssertWithInfo(c,NULL,
       rioWriteBulkString(&cmd,payload.io.buffer.ptr,
                          sdslen(payload.io.buffer.ptr)));
   sdsfree(payload.io.buffer.ptr);
   // 如果指定了replace,还要写回复一个REPLACE选项
   if (replace)
       serverAssertWithInfo(c,NULL,rioWriteBulkString(&cmd,"REPLACE",7));
}

3、发送给目标节点,调用syncWrite()函数同步将cmd的缓存写到连接的fd中,设置超时时间为timeout,每次写64K大小。如果发生写错误,会设置write_error= 1并且跳转到socket_err的错误处理代码。

  • 目标节点接收数据

无非就是解析restore-asking/restore key expireat value [repalce]命令,将键值保存在内存中,restore-askingrestore命令处理函数都是restoreCommand,进行反序列化,保存数据。
1、检查命令中replace标识,未有repalce,但该键在已存在,则报错
2、获取生存时间、键、值对象,如果有repalce,先删除键,再添加
3、更新脏键,并且回复一个+OK

  • 错误处理

1、如果出现套接字错误,第一个键就失败,跳转到socket_err代码,关闭连接端,可以进行重试,
2、如果出现套接字错误,非第一个键就可以进行重试,跳转到socket_err代码,关闭连接端,释放空间
3、键回复处理失败,释放空间
4、处理成功,更新该连接端last_dbid,关闭连接端,释放空间

5.3.5 迁移槽位
每个节点执行CLUSTER SETSLOT <slot> NODE <target_name>命令

  • 源节点执行该命令,需确保该槽中没有键,migrating_slots_to迁出置为空
  • 目标节点执行该命令,importing_slots_from迁入状态置为空,增大当前纪元

如果只对任意一节点执行该命令,其实也是可行的,就是会增加重定向

if (!strcasecmp(c->argv[3]->ptr,"node") && c->argc == 5) {
     /* CLUSTER SETSLOT <SLOT> NODE <NODE ID> */
     // 查找到目标节点
     clusterNode *n = clusterLookupNode(c->argv[4]->ptr);
     // 目标节点不存在,回复错误信息
     if (!n) {
         addReplyErrorFormat(c,"Unknown node %s",(char*)c->argv[4]->ptr);
         return;
     }
     // 如果这个槽已经由myself节点负责,但是目标节点不是myself节点
     if (server.cluster->slots[slot] == myself && n != myself) {
         // 保证该槽中没有键,否则不能指定给其他节点
         if (countKeysInSlot(slot) != 0) {
             addReplyErrorFormat(c,
                 "Can't assign hashslot %d to a different node "
                 "while I still hold keys for this hash slot.", slot);
             return;
         }
     }
     // 该槽处于被迁移的状态但是该槽中没有键
     if (countKeysInSlot(slot) == 0 && server.cluster->migrating_slots_to[slot])
         // 取消迁移的状态
         server.cluster->migrating_slots_to[slot] = NULL;

     // 如果该槽处于导入状态,且目标节点是myself节点
     if (n == myself &&
         server.cluster->importing_slots_from[slot])
     {
         // 手动迁移该槽,将该节点的配置纪元设置为一个新的纪元,以便集群可以传播新的版本。
         // 注意,如果这导致与另一个获得相同配置纪元的节点冲突,例如因为取消槽的同时发生执行故障转移的操作,则配置纪元冲突的解		决将修复它,指定不同节点有一个不同的纪元。
         if (clusterBumpConfigEpochWithoutConsensus() == C_OK) {
             serverLog(LL_WARNING,"configEpoch updated after importing slot %d", slot);
         }
         // 取消槽的导入状态
         server.cluster->importing_slots_from[slot] = NULL;
     }
     clusterDelSlot(slot);
     // 将slot槽指定给n节点
     clusterAddSlot(n,slot);
 } else {
     addReplyError(c,"Invalid CLUSTER SETSLOT action or number of arguments");
     return;
 }
 // 更新集群状态和保存配置
 clusterDoBeforeSleep(CLUSTER_TODO_SAVE_CONFIG|CLUSTER_TODO_UPDATE_STATE);
 addReply(c,shared.ok);

使用命令迁移键MIGRATE host port "" dbid timeout [COPY | REPLACE] KEYS key1 key2 ... keyN,当使用copy的场景,若你对源节点发送槽位迁移CLUSTER SETSLOT <slot> NODE <target_name>,则会报如下错误 ???:

  • (error) ERR Can’t assign hashslot 6918 to a different node while I still hold keys for this hash slot.
5.2 集群收缩原理

集群收缩过程,就是将即将下线节点槽位和键迁移到目标节点,然后在集群每个节点执行CLUSTER FORGET <NODE ID>命令来让集群中所有的节点都知道下线的节点,并且忘记他。

  • 调用clusterBlacklistAddNode函数将要下线的节点加入黑名单中,这个黑名单是一个字典,保存要下线的节点名字。然后将下线节点从集群中删除,并回复一个ok给客户端。
if (!strcasecmp(c->argv[1]->ptr,"forget") && c->argc == 3) {
    // 根据<NODE ID>查找节点ilil
    clusterNode *n = clusterLookupNode(c->argv[2]->ptr);
    // 没找到
    if (!n) {
        addReplyErrorFormat(c,"Unknown node %s", (char*)c->argv[2]->ptr);
        return;
    // 不能删除myself
    } else if (n == myself) {
        addReplyError(c,"I tried hard but I can't forget myself...");
        return;
    // 如果myself是从节点,且myself节点的主节点是被删除的目标键,回复错误信息
    } else if (nodeIsSlave(myself) && myself->slaveof == n) {
        addReplyError(c,"Can't forget my master!");
        return;
    }
    // 将n添加到黑名单中
    clusterBlacklistAddNode(n);
    // 从集群中删除该节点
    clusterDelNode(n);
    // 更新状态和保存配置
    clusterDoBeforeSleep(CLUSTER_TODO_UPDATE_STATE|
                         CLUSTER_TODO_SAVE_CONFIG);
    addReply(c,shared.ok);
}
  • 黑名单中的节点生存时间只有60s,每次加入节点都会先清理过期的节点,然后加入新的节点并且设置过期时间。加入黑名单中节点不会与其他节点进行消息交互,如果超过60s,该节点就会从黑名单中清理,再次发送消息时,就会重新进行握手,最终重新上线。因此只有60s的时间让所有集群节点忘记下线节点。
void clusterBlacklistAddNode(clusterNode *node) {
    dictEntry *de;
    // 获取node的ID
    sds id = sdsnewlen(node->name,CLUSTER_NAMELEN);
    // 先清理黑名单中过期的节点
    clusterBlacklistCleanup();
    // 然后将node添加到黑名单中
    if (dictAdd(server.cluster->nodes_black_list,id,NULL) == DICT_OK) {
        // 如果添加成功,创建一个id的复制品,以便能够在最后free
        id = sdsdup(id);
    }
    // 找到指定id的节点
    de = dictFind(server.cluster->nodes_black_list,id);
    // 为其设置过期时间
    dictSetUnsignedIntegerVal(de,time(NULL)+CLUSTER_BLACKLIST_TTL);
    sdsfree(id);
}

6 故障转移流程

Redis集群自身实现了高可用。高可用首先要解决集群部分失败的场景:当集群内少量节点出现故障时通过自动故障转移保证集群可以正常对外提供服务。接下来就介绍故障转移的细节,分析故障检测故障转移

6.1 故障检测
6.1.1 定时检测节点PING-PONG状态,主观下线判断

在定时函数中,循环迭代节点,发出的ping消息迟迟没有收到回复,这个时间间隔超过故障超时时长cluster_node_timeout,默认15s,则设置该节点主观下线状态CLUSTER_NODE_PFAIL

...
// 如果发出去的ping收到pong回复,则会把ping_sent设为0
if (node->ping_sent == 0) continue;
// 计算已经等待接收PONG回复的时长
delay = now - node->ping_sent;
// 如果等待的时间超过了限制
if (delay > server.cluster_node_timeout) {
    // 设置该节点为疑似下线的标识
    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;
    }
}
...
6.1.2 pingpong消息携带主观下线节点

a 、集群中发送ping的机制是每秒一次,在随机的5个节点中,选取一个满足以下条件的节点发送ping消息

  • ping-pong响应回复已完成
  • 当前随机节点中,pong回复时间距离当前最久

b 、当节点收到ping消息,就会回复pong消息

c、在基于gossip的选择策略中讲过,消息携带的节点随机节点选择,会优先选取主观下线的节点

6.1.3 客观下线判断,广播节点下线状态

在收到ping消息时,会调用clusterProcessGossipSection函数对消息中携带的节点信息进行判断,可能需要进行建立握手广播节点下线操作。当节点的fail_reports认定故障列表的节点数大于集群节点数量一半时,即认定为客观下线,调用clusterSendFail将信息同步给所有节点。

6.2 故障转移

当集群节点收到CLUSTERMSG_TYPE_FAIL消息后,会将故障节点标记为客观下线,并记录下线时间

...
 } else if (type == CLUSTERMSG_TYPE_FAIL) {
   clusterNode *failing;
   if (sender) {
       // 获取下线节点的地址
       failing = clusterLookupNode(hdr->data.fail.about.nodename);
       // 如果下线节点不是myself节点也不是处于下线状态
       if (failing &&
           !(failing->flags & (CLUSTER_NODE_FAIL|CLUSTER_NODE_MYSELF)))
       {
           serverLog(LL_NOTICE,
               "FAIL message received from %.40s about %.40s",
               hdr->sender, hdr->data.fail.about.nodename);
           // 设置FAIL标识
           failing->flags |= CLUSTER_NODE_FAIL;
           // 设置下线时间
           failing->fail_time = mstime();
           // 取消PFAIL标识
           failing->flags &= ~CLUSTER_NODE_PFAIL;
           clusterDoBeforeSleep(CLUSTER_TODO_SAVE_CONFIG|
                                CLUSTER_TODO_UPDATE_STATE);
       }
   } else {
       serverLog(LL_NOTICE,
           "Ignoring FAIL message from unknown node %.40s about %.40s",
           hdr->sender, hdr->data.fail.about.nodename);
   }
 ...

clucsterCorn函数中,只有从节点才开始调用clusterHandleSlaveFailover执行故障转移

...
if (nodeIsSlave(myself)) {
    // 设置手动故障转移的状态
    clusterHandleManualFailover();
    // 执行从节点的自动或手动故障转移,从节点获取其主节点的哈希槽,并传播新配置
    clusterHandleSlaveFailover();
    // 如果存在孤立的主节点,并且集群中的某一主节点有超过2个正常的从节点,并且该主节点正好是myself节点的主节点
    if (orphaned_masters && max_slaves >= 2 && this_slaves == max_slaves)
        // 给孤立的主节点迁移一个从节点
        clusterHandleSlaveMigration(max_slaves);
}
...
6.2.1 选举资格检测

在故障转移函数中,先对节点故障处理的资格进行检查,必要条件为:

  • 当前节点为从节点
  • 主节点存在,有指定负责槽位
  • 主节点处于客观下线状态或设置进行手动强制故障处理

从节点最后更新主节点的时间间隔不能太久,一般情况下不能超过cluster_slave_validity_factor* cluster_node_timeout + repl_ping_slave_period ,默认情况下,系数因子10,主向从发起ping时间为10

源码中1000*repl_ping_slave_period ,那不是10000s+???

...
if (nodeIsMaster(myself) ||myself->slaveof == NULL ||(!nodeFailed(myself->slaveof) && !manual_failover) ||
       myself->slaveof->numslots == 0)
   {
       // 设置故障转移失败的原因:CLUSTER_CANT_FAILOVER_NONE
       server.cluster->cant_failover_reason = CLUSTER_CANT_FAILOVER_NONE;
       return;
   }

   // 如果当前节点正在和主节点保持连接状态,计算从节点和主节点断开的时间
   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;
   }

   // 从数据时代删除节点超时,因为我们与我们的主服务器断开连接,至少在被标记为FAIL的时候,这是基准。
   // 从data_age删除一个cluster_node_timeout的时长,因为至少以从节点和主节点断开连接开始,因为超时的时间不算在内
   if (data_age > server.cluster_node_timeout)
       data_age -= server.cluster_node_timeout;

   // 检查这个从节点的数据是否比较新
   if (server.cluster_slave_validity_factor &&
       data_age >
       (((mstime_t)server.repl_ping_slave_period * 1000) +
        (server.cluster_node_timeout * server.cluster_slave_validity_factor)))
   {
       if (!manual_failover) {
           clusterLogCantFailover(CLUSTER_CANT_FAILOVER_DATA_AGE);
           return;
       }
   }
...
6.2.2 选举时间点,发起选举
  • 每个从节点,到了选举时间点,都有机会可以发起投票。主从复制偏移量是个衡量从节点性能的一个重要指标,当前节点根据主从复制偏移量的排名设置选举时间点,复制量越多,选举时间点则越靠前。

  • 当选举时间到了,即可发起第一次投票请求,将标识为CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST消息发送给集群所有节点

// 如果没有向其他节点发送投票请求
if (server.cluster->failover_auth_sent == 0) {
    // 增加当前纪元
    server.cluster->currentEpoch++;
    // 设置发其故障转移的纪元
    server.cluster->failover_auth_epoch = server.cluster->currentEpoch;
    serverLog(LL_WARNING,"Starting a failover election for epoch %llu.",
        (unsigned long long) server.cluster->currentEpoch);
    // 发送一个FAILOVE_AUTH_REQUEST消息给所有的节点,判断这些节点是否同意该从节点为它的主节点执行故障转移操作
    clusterRequestFailoverAuth();
    // 设置为真,表示本节点已经向其他节点发送了投票请求
    server.cluster->failover_auth_sent = 1;
    // 进入下一个事件循环执行的操作,保存配置文件,更新节点状态,同步配置
    clusterDoBeforeSleep(CLUSTER_TODO_SAVE_CONFIG|
                         CLUSTER_TODO_UPDATE_STATE|
                         CLUSTER_TODO_FSYNC_CONFIG);
    return; /* Wait for replies. */
}

在这里插入图片描述

6.2.3 选举投票

当集群中所有的节点接收到REQUEST消息后,会执行clusterProcessPacket函数的这部分代码:

 if (type == CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST) {
     if (!sender) return 1;  /* We don't know that node. */
     // 如果条件允许,向sender投票,支持它进行故障转移
     clusterSendFailoverAuthIfNeeded(sender,hdr);
 }

如果发送消息包的节点sender不是当前集群的节点,直接返回。否则调用clusterSendFailoverAuthIfNeeded函数对sender节点发起的投票进行处理。

  • 当前节点为有负责槽位的主节点,才有处理权限
  • 消息中集群纪元大于当前集群纪元
  • 如果集群最近一次投票的纪元lastVoteEpoch和当前集群的纪元相同,表示当前接收消息的节点已经投过票了,直接返回
  • 发送节点为从节点且主节点处于客观下线
  • cluster_node_timeout * 2时间内只能投1次票
  • 客观下线节点负责的槽位,在当前集群中的配置纪元不能大于消息中的配置纪元
  • 更新投票时间、投票纪元,调用clusterSendFailoverAuth发送FAILOVER_AUTH_ACK消息,表示支持该从节点进行故障转移操作
6.2.4 替换主节点
  • clusterProcessPacket中对CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消息进行处理,主要就是得票数server.failover_auth_count+1,在获得到其他集群节点的票数,票数足够时,调用clusterFailoverReplaceYourMaster函数进行故障转移,替换主节点。
  • 调用clusterBroadcastPong广播给所有节点
void clusterFailoverReplaceYourMaster(void) {
    int j;
    // 获取myself的主节点
    clusterNode *oldmaster = myself->slaveof;

    // 如果myself节点是主节点,直接返回
    if (nodeIsMaster(myself) || oldmaster == NULL) return;

    /* 1) Turn this node into a master. */
    // 将指定的myself节点重新配置为主节点
    clusterSetNodeAsMaster(myself);
    // 取消复制操作,设置myself为主节点
    replicationUnsetMaster();

    /* 2) Claim all the slots assigned to our master. */
    // 将所有之前主节点声明负责的槽位指定给现在的主节点myself节点。
    for (j = 0; j < CLUSTER_SLOTS; j++) {
        // 如果当前槽已经指定
        if (clusterNodeGetSlotBit(oldmaster,j)) {
            // 将该槽设置为未分配的
            clusterDelSlot(j);
            // 将该槽指定给myself节点
            clusterAddSlot(myself,j);
        }
    }
    /* 3) Update state and save config. */
    // 更新节点状态
    clusterUpdateState();
    // 写配置文件
    clusterSaveConfigOrDie(1);
    // 发送一个PONG消息包给所有已连接不处于握手状态的的节点
    // 以便能够其他节点更新状态
    clusterBroadcastPong(CLUSTER_BROADCAST_ALL);

    /* 5) If there was a manual failover in progress, clear the state. */
    // 重置与手动故障转移的状态
    resetManualFailover();
}
6.2.5 广播给所有节点

clusterProcessPacket中处理,

 if (sender) {
     // 如果消息头的slaveof为空名字,那么说明sender节点是主节点
     if (!memcmp(hdr->slaveof,CLUSTER_NODE_NULL_NAME,
         sizeof(hdr->slaveof)))
     {
         // 将指定的sender节点重新配置为主节点
         clusterSetNodeAsMaster(sender);
     // sender是从节点
     } else {
         // 根据名字从集群中查找并返回sender从节点的主节点
         clusterNode *master = clusterLookupNode(hdr->slaveof);
         // sender标识自己为主节点,但是消息中显示它为从节点
         if (nodeIsMaster(sender)) {
             // 删除主节点所负责的槽
             clusterDelNodeSlots(sender);
             // 消息主节点标识和导出标识
             sender->flags &= ~(CLUSTER_NODE_MASTER|
                                CLUSTER_NODE_MIGRATE_TO);
             // 设置为从节点标识
             sender->flags |= CLUSTER_NODE_SL
             // 更新配置和状态
             clusterDoBeforeSleep(CLUSTER_TODO_SAVE_CONFIG|
                                  CLUSTER_TODO_UPDATE_STATE);
         }

         // sender的主节点发生改变
         if (master && sender->slaveof != master) {
             // 如果sender有新的主节点,将sender从旧的主节点保存其从节点字典中删除
             if (sender->slaveof)
                 clusterNodeRemoveSlave(sender->slaveof,sender);
             // 将sender添加到新的主节点的从节点字典中
             clusterNodeAddSlave(master,sender);
             // 设置sender的主节点
             sender->slaveof = master;

             // 更新配置
             clusterDoBeforeSleep(CLUSTER_TODO_SAVE_CONFIG);
         }
     }
 }
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值