【Redis】 cluster 集群

Metadata

title: 【Redis】 cluster 集群
date: 2023-07-09 08:47
tags:
  - 行动阶段/完成
  - 主题场景/数据存储
  - 笔记空间/KnowladgeSpace/ProgramSpace/BasicsSpace
  - 细化主题/数据存储/Redis
categories:
  - 数据存储
keywords:
  - 数据存储/Redis
description: 【Redis】 cluster 集群

概述

集群是 Redis 提供的分布式数据库方案,通过分片来进行数据共享并提供复制和故障转移的功能。主要对集群的节点,槽指派,命令执行,重新分片,转向,故障转移,消息进行介绍。

  • 节点通过握手来将其他节点添加到自己所处的集群当中。
  • 集群中的16384个可以分别指派给集群中的各个节点,每个节点都会记录哪些槽指派给了自己,而哪些槽又被指派给了其他节点。
  • 节点在接到一个命令请求时,会先检查这个命令请求要处理的键所在的槽是否由自己负责,如果不是的话,节点将向客户端返回一个MOVED错误,MOVED错误携带的信息可以指引客户端转向至正在负责相关槽的节点
  • 对Redis集群的重新分片工作是由redis-trib负责执行的,重新分片的关键是将属于某个槽的所有键值对从一个节点转移至另一个节点。
  • 如果节点A正在迁移槽i至节点B,那么当节点A没能在自己的数据库中找到命令指定的数据库键时,节点A会向客户端返回一个ASK错误,指引客户端到节点B继续查找指定的数据库键。
  • MOVED错误表示槽的负责权已经从一个节点转移到了另一个节点,而ASK错误只是两个节点在迁移槽的过程中使用的一种临时措施
  • 集群里的从节点用于复制主节点,并在主节点下线时,代替主节点继续处理命令请求。
  • 集群中的节点通过发送和接收消息来进行通信,常见的消息包括MEET、PING、PONG、PUBLISH、FAIL五种。

节点

集群由多个节点组成,通过CLUSTER MEET <ip> <port>可以将节点连接起来。这个命令主要是将目标节点加入到当前 Redis 所在的集群中。下面从启动节点,集群相关数据结构,命令实现来介绍节点内容。

启动节点

Redis 服务器在启动时会根据 cluster-enable 配置是否为 yes 来决定是否开启集群模式。集群中的节点除了使用 redisServer 之外,还用cluster.h/clusterNode结构、cluster.h/clusterLink结构、cluster.h/clusterState结构来保存集群数据。

集群数据结构

集群的每个节点都会用 clusterNode 来保存:

struct clusterNode {
    //创建节点的时间
    mstime_t ctime; .
    //节点的名字,由40个十六进制字符组成
    char name [REDIS_CLUSTER_NAMELEN] ; 
    //节点标识
    //使用各种不同的标识值记录节点的角色(比如主节点或者从节点),
    //以及节点目前所处的状态(比如在线或者下线)。
    int flags;
    //节点当前的配置纪元,用于实现故障转移
    uint64_t configEpoch;
    //节点的IP地址
    char ip[REDIS_IP_STR_LEN] ;
    //节点的端口号
    int port;
    //保存连接节点所需的有关信息
    clusterLink *link;
    ...
};
​

link 属性保存了连接节点所需的有关信息:

typedef struct clusterLink {
    //连接的创建时间
    mstime_t ctime ;
    // TCP 套接字描述符
    int fd;
    //输出缓冲区,保存着等待发送给其他节点的消息( message )。
    sds sndbuf;
    //输入缓冲区,保存着从其他节点接收到的消息。
    sds rcvbuf ;
    //与这个连接相关联的节点,如果没有的话就为NULL
    struct clusterNode node;
} clusterLink;
​

每个节点都保存一个集群状态,记录在当前节点下:

typedef struct clusterState {
    //指向当前节点的指针
    clusterNode *myself;
    //集群当前的配置纪元,用于实现故障转移
    uint64_t currentEpoch;
    //集群当前的状态:是在线还是下线
    int state;
    //集群中至少处理着一个槽的节点的数量(下一节谈)
    int size;
    //集群节点名单(包括myself节点)
    //键为节点名字,值为节点对应的clusterNode结构
    dict *nodes;
    ...
} clusterState;
​

CLUSTER MEET 命令的实现

通过向节点发送CLUSTER MEET命令,让目标节点加入集群,进行握手,执行过程如下:

  1. 客户端发送该命令给节点 A,节点 A 会创建一个节点 B 的 clusterNode 结构,添加到 clusterState.nodes 中。
  2. 解析 IP 地址和端口号,向节点 B 发送 MEET 消息(最后一节会讲消息)。
  3. 同理,节点 B 收到后,会为 A 创建 clusterNode 结构并添加到 nodes。
  4. 节点 B 向 A 发送 PONG 消息。
  5. 节点 A 收到后向 B 发送一条 PING 消息。
  6. 节点 B 收到后直到 A 成功感知到 B,握手完成。

之后,节点 A 将节点 B 的信息通过 Gossip 协议(最后一节消息中将提到)传播给集群中的其他节点。

槽指派

Redis 通过分片方式保存键值对,集群的整个数据库被分为 16384 个槽(slot),数据库的每个键都属于某一个槽,每个节点可处理 0~16384 个槽。当集群中的每个槽都归某个节点管理,集群处于上线状态;但凡有一个没人管,集群处于下线状态。

发送CLUSTER ADDSLOTS <slot> [slot...]命令,可以将槽委派给某个节点负责。下面介绍槽的实现。

记录节点的槽指派信息

clusterNode 有 slots 和 numslot 属性记录了节点负责处理的槽:

struct clusterNode{
    //二进制位数组,长度为16384/8=2048字节,
    //每个索引8位,根据0和1判断槽是否被该节点负责
    unsigned char slot[16384/8];
    //负责处理的槽数量
    int numslots;
    ...
}

比如该节点负责 0~7 的槽,存储结构如下:

通过这种设计,检查某节点是否负责处理某个槽或者让节点负责某个槽的操作的时间复杂度都为 **O(1)**。

传播节点的槽指派信息

节点除了将自己负责的槽记录在clusterNode.slots中,还会将这个数组通过消息发送给其他节点,让他们都知道自己负责什么槽。其他节点接收消息后,会对clusterStaste.nodes字典中对应的clusterNode.slots数据进行更新。

记录集群所有槽的指派信息

clusterState 会维护集群中每个节点管理槽的情况:

typedef struct slusterState{
    clusterNode *slots[16384];
    ...
}

如果 slot[i] 指向空,说明该槽没有被节点管理;如果指向具体某个 clusterNode 的指针,说明由这个节点管理。

总结一下,clusterState.slots数组记录集群中所有槽的信息clusterNode.slots数组记录当前节点负责槽的信息。前者方便知道某个槽指派给谁,后者方便知道某个槽是否指派给自己,或者发送自己槽的指派信息。(因为被指派后,还需要向其他节点发送消息告知)。

CLUSTER ADDSLOTS 命令的实现

这个命令的执行其实就是把上面讲的几小节知识给串起来。主要是用来指派槽给节点负责的,接收该命令后,首先会遍历所有传入的槽(命令入参)是否都是未指派,如果有一个被指派了就报错。如果都未指派,将这些槽委派给当前节点,更新clusterState.slots数组指向当前节点的 clusterNode;然后将clusterNode.slots数组中对应的索引二进制位设置为 1。最后,发送消息告诉集群中其他节点,自己负责这些槽。

在集群中执行命令

当客户端对节点发送与数据库键有关的命令时,接收命令的节点会计算属于哪个槽,检查这个槽是否指派给自己(根据 key 的 CRC-16 校验和 & 16383 来确定槽号 i,再根据 clusterState.slots[i] 是否指向当前节点的 clusterNode 判断是否自己负责的)。如果不是指派给自己的,就(找负责该槽的 ip 和端口,指引客户端转向它)向客户端返回 MOVED 错误,引导客户端指向正确的节点并再次发送命令。

注:通过CLUSTER KEYSLOT <key>可查看某个 key 对应的槽号。

MOVED 错误

MOVED 错误格式为:MOVED <slot> <ip>:<port>

在集群模式下,会被隐藏,客户端会进行自动转向并重发命令。节点的转向其实就是换对应套接字来发送命令。下面演示了对 7000 端口的节点操作键命令并被引导转向到真正存储该键的服务器(7001 端口)的过程:

127.0.0.1: 7000> SET msg "hello"
-> Redirected to slot [6257] located at 127.0.0.1: 7001
OK
127.0.0.1:7001> GET msg
"hello"

节点数据库的实现

节点对数据的存储和单机 Redis 的实现是一样的,只不过节点只能使用 0 号库。还需要维护一个slots_to_keys跳跃表关联槽号和键。分数是槽号,节点就是键。当节点往数据库添加新键时,节点就会在slots_to_keys中进行关联,反之则删除关联。

typedef struct clusterState{
    zskiplist *slots_to_keys;
    ...
} clusterState;

重新分片

Redis 集群的重新分片操作可以将任意数量已经指派给某个节点 (源节点) 的槽改为指派给另一个节点(目标节点),并且相关槽所属的键值对也会从源节点被移动到目标节点。

重新分片通过集群管理软件 redis-trib 执行,步骤如下:

  1. redis-trib 对目标节点发送CLUSTER SETSLOT <slot> IMPORTING <source_id>命令,让目标节点准备好,要导入键值对了。
  2. redis-trib 对源节点发送CLUSTER SETSLOT <slot> MIGRATING <target_id> 命令,让源节点准备好,要转移键值对了。
  3. redis-trib 向源节点发送CLUSTER GETKEYSINSLOT <slot> <count>命令, 获得最多 count 个属于槽 slot 的键值对的键名 (key name),这实际上就是个批量分片的意思。
  4. 对于步骤 3 获得的每个键名,redis-trib 都向源节点发送一个MIGRATE <target_ip> <target_ port> <key_name> 0 <timeout>命令,将被选中的键原子地从源节点迁移至目标节点。
  5. 重复执行步骤 3 和步骤 4,直到源节点保存的所有属于槽 slot 的键值对都被迁移至目标节点。
  6. redis-trib 向集群中的任意一个节点发送CLUSTER SETSLOT <slot> NODE <target_ id>命令,将槽 slot 指派给目标节点,通过消息发送至整个集群,让所有节点感知。

整体迁移的流程图:

ASK 错误

当客户端向源节点发送与数据库键相关的命令,并且该键恰好属于被迁移的槽时,源节点会先查自己有没有,有就返回;没有则返回 ASK 错误,指引客户端向正在导入槽的目标节点发送命令。这个命令和 MOVED 类似,不会直接打印错误。比如端口 7000 是源节点,”love” 键的槽(16198 槽)正在被迁移到 7001 就会这样:

127.0.0.1: 7000> GET "love"
-> Redirected to slot [16198] located at 127.0.0.1: 7001
"you get the key 'love'"
127.0.0.1: 7001>

CLUSTER SETSLOT IMPORTING 的实现

clusterState.importing_slots_from 数组记录当前节点正在从其他节点导入的槽:

typedef struct clusterState{
    clusterNode *importing_slots_from[16384];
    ...
}

一般情况下指向空,当执行CLUSTER SETSLOT <i> IMPORTING <source_id>时,会将目标节点(当前节点)clusterState.importing_slots_from[i]设置为 source_id 所代表节点的 clusterNode。

CLUSTER SETSLOT MIGRATING 的实现

clusterState 结构的 migrating_slot_to 数组记录了当前节点正在迁移至其他节点的槽:

typedef struct clusterState{
    clusterNode *migrating_slots_to[16384];
}

一般情况下指向空,当执行CLUSTER SERSLOT <i> MIGRATING <target_id>时,会将源节点clusterState.migrating_slots_to[i]设置为 target_id 所代表节点的 clusterNode。

ASK 错误

如果收到 key 命令请求的节点所属 key 的槽正好指派给了该节点,找到则返回,如果没找到则检查clusterState.migrating_slots_to[i],是否正在迁移,如果正在迁移,就向客户端返回 ASK 错误,引导其去导入槽的节点查询

客户端接收到 ASK 错误后,根据 IP 和端口,转向目标节点,然后先向目标节点发送 ASKING 命令,再重新发送要执行的命令。

ASKING 命令

这个命令的唯一作用就是打开发送该命令客户端的REDIS_ASKING标识。有了这个标识后,节点会为正在导入的键执行命令。这个标识是一次性的,如果再对刚才的 key 执行相关操作,该节点会返回 MOVED 错误(因为重分片未结束,它不是负责该槽的节点)。下面表示相关判断过程:

ASK 错误与 MOVED 错误的区别

这两个错误都会客户端转向:

  • MOVED 错误代表槽的负责权已经从一个节点到了另一个节点
  • ASK 错误只是两个节点再迁移槽过程中使用的临时措施

复制与故障转移

Redis 集群中的节点分主节点和从节点,主节点用于处理槽,从节点用于复制某个主节点(相当于备份,不处理读请求),并在被复制节点下线时,代替下线主节点继续处理命令请求。

接下来介绍节点的复制方法,检测节点是否下线方法及对下线节点故障转移方法。

设置从节点

向节点发送命令CLUSTER REPLICATE <node_id>可以让接收命令的节点成为指定节点的从节点并对主节点开始复制。主要过程是:

  1. 接收命令节点在lusterState.node字典中找到 node_id 对应节点的 clusterNode,然后将clusterState.myself.slaveof指向这个节点。
  2. 修改clusterState.myself.flags属性,关闭REDIS_NODE_MASTER标识,打开REDIS_NODE_SLAVE标识,标识该节点成为从节点。
  3. 调用复制代码,对主节点复制。

当节点成为从节点并开始复制时,这个信息会通过消息发送给集群中其他节点。

故障检测

集群中每个节点都会定期向其他节点发送 PING 消息,如果没有在规定时间返回 PONG 消息,就会被标记位疑似下线。集群中各个节点会互相发送消息来交换各个节点的状态,当一个主节点 A 通过消息得知主节点 B 认为主节点 C 进入疑似下线状态,A 会将 B 的下线报告添加clusterNode.fail_reports链表中。

链表中每个元素都由 clusterNodeFailReport 组成:

struct clusterNodeFailReport{
    //报告目标节点已经下线的节点
    struct clusterNode *node;
    //最后一次从node节点收到下线报告的时间
    //程序使用这个时间戳来检查下线报告是否过期
    // (与当前时间相差太久的下线报告会被删除)
    mstime_t time;
} typedef clusterNodeFailReport;

在一个集群中,半数以上负责处理槽的主节点将某个主节点报告为疑似下线后,这个主节点将被标记为已下线。并向集群广播一条关于该主节点 FAIL 的消息,所有收到消息的节点都会将其标记为已下线。

故障转移

当一个从发现主节点下线后,开始故障转移。具体步骤:

  1. 下线的主节点的所有从节点里面,会有一个从节点被选中。
  2. 被选中的从节点会执行SLAVEOF no one命令,成为新的主节点。
  3. 新的主节点会撤销并指派给自己对已下线主节点的槽指派。
  4. 新的主节点向集群广播一条 PONG 消息,让其他节点立即知道新的主节点。
  5. 新的主节点开始接收和自己负责处理的槽有关的命令请求,故障转移完成。

选举新的主节点

集群选举新主节点的具体过程:

  1. 通过集群的配置纪元确定是哪一次选举,它是一个自增计数器,初始值为 0。
  2. 当集群里的某个节点开始一次故障转移操作时,集群配置纪元的值会被加一。
  3. 集群里每个负责处理槽的主节点都有一次投票的机会,第一个向主节点要求投票的从节点将获得主节点的投票。
  4. 当从节点发现自己正在复制的主节点进入已下线状态时,从节点会向集群广播一条 CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST消息,要求所有收到这条消息并且具有投票权的主节点向这个从节点投票。
  5. 如果一个主节点具有投票权 (它正在负责处理槽),并且这个主节点尚未投票给其他从节点,那么主节点将向要求投票的从节点返回一条CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消息,表示这个主节点支持从节点成为新的主节点。
  6. 每个参与选举的从节点都会接收CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消息,并根据自己收到消息的条数来统计自己获得多少主节点的支持。
  7. 如果集群里有 N 个具有投票权的主节点,那么当一个从节点收集到大于等于 N/2+1 支持票时,这个从节点就会当选为新的主节点。
  8. 配置纪元确定每个具有投票权的主节点只能投一次,所以如果有 N 个主节点进行投票,那么具有大于等于 N/2+1 张支持票的从节点只会有一个,这确保了新的主节点只会有一个。
  9. 如果在一个配置纪元里没有从节点得到足够的票,那么集群进入一个新的配置纪元,并再次进行选举,直到选出新的主节点为止。

消息

集群中节点主要通过发送消息来传递信息,主要有 5 种:

  • MEET 消息:发送者接收到客户端的 MEET 消息时,发送者向接收者发送 MEET 消息,请求加入发送者所在集群。
  • PING 消息:集群每个节点每隔一秒从已知节点随机选出 5 个节点,然后对最长时间没发 PING 消息的节点进行发送。除此之外,还会将最后一次收到 PONG 消息的节点中,如果时间超过配置的cluster-node-timeout选项的一半时,也会发送 PING 消息。
  • PONG 消息:应答 MEET 或 PING 消息。还可以通过 PONG,告诉其他节点,刷新该节点的相关信息。
  • FAIL 消息:当某个主节点判断另一个主节点已经进入 FAIL 状态时,当前主节点会向集群广播一条关于已下线节点的 FAIL 消息。
  • PUBLISH 消息:当节点接收到 PUBLISH 命令时,执行这个命令,并向集群广播一条 PUBLISH 消息,所有接收到这条 PUBLISH 消息的节点都会执行相同的 PUBLISH 命令。

一条消息由消息头和消息正文组成。

消息头

记录信息发送者的一些信息。比如发送者的当前纪元,发送者名字,发送者的槽指派信息等。接收者可根据发送者的信息来更新发送者的状态。消息头是一个cluster.h/clusterMsg:

typedef struct{
    //消息的长度(包括这个消息头的长度和消息正文的长度)
    uint32_ t totlen;
    //消息的类型
    uint16_ t type;
    //消息正文包含的节点信息数量
    //只在发送MEET. PING、PONG这三种Gossip协议消息时使用
    uint16_ t count;
    //发送者所处的配置纪元
    uint64_ t currentEpoch;
    //如果发送者是一个主节点,那么这里记录的是发送者的配置纪元
    //如果发送者是一个从节点,那么这里记录的是发送者正在复制的主节点的配置纪元.
    uint64_ t configEpoch;
    //发送者的名字(ID)
    char sender [REDIS_ CLUSTER_ NAMELEN] ;
    //发送者目前的槽指派信息
    unsigned char myslots [REDIS_ CLUSTER_ SLOTS/8];
    //如果发送者是一个从节点,那么这里记录的是发送者正在复制的主节点的名字
    //如果发送者是一个主节点,那么这里记录的是REDIS_ NODE NULL NAME
    // (一个40字节长,值全为0的字节数组)
    char slaveof [REDIS_ CLUSTER_ NAMELEN] ;
    //发送者的端口号
    uint16_ t port;
    //发送者的标识值
    uint16_ t flags;
    //发送者所处集群的状态
    unsigned char state;
    //消息的正文(或者说,内容)
    union clusterMsgData data;
} clusterMsg;

其中消息正文是一个cluster.h/clusterMsgData结构:

union clusterMsgData{
    // MEET、 PING、PONG消息的正文
    struct{
        //每条MEET、PING、pONG消息都包含两个
        //clusterMsgDataGossip结构
        clusterMsgDataGossip[1];
    } ping;
    
    // FAIL消息的正文
    struct {
    clusterMsgDatafail about;
    }fail;
​
    //PUBLISH消息的正文
    struct{
        clusterMsgDataPublish msg;
    }publish;
    
    //其他消息的正文...
};
​

MEET、PING、PONG 消息的实现

集群中的各个节点通过 Gossip 协议来交换各自关于不同节点的状态信息,Gossip 协议由 MEET、PING、PONG 这三种消息实现,他们的的正文就是上面的 ping 结构体。

因为共用消息正文,所以需要消息头的 type 属性来区分。每次发送这类消息时,发送者都从已知节点中随机选择两个节点保存到 clusterMsgDataGossip,因此正文包含两个 clusterMsgDataGossip 结构:

typedef struct (
    //节点的名字
    char nodename [REDIS_CLUSTER_NAMELEN]; .
    //最后一次向该节点发送PING消息的时间戳
    uint32_ t ping_sent;
    //最后一次从该节点接收PONG消息的时间戳
    uint32_t pong_received;
    //节点的IP地址
    char ip[16];
    //节点的墙口号
    uint16_t port;
    //节点的标识值
    uint16_t flags;
}clusterMsgDataGossip;
​

消息接收者会根据 clusterMsgDataGossip 包含的节点,看是否为第一次接触,如果是的话,需要进行一次握手,记录节点信息;如果已经存在于已知节点中,则对相关节点信息更新。

FAIL 消息的实现

在集群节点较多的情况下,单纯使用 Gossip 会带来一些延迟,FAIL 消息需要所有节点立刻知道某个主节点下线了,从而尽快判断是否需要标记为下线或故障转移。消息正文是一个cluster.h/clusterMsgDatafail结构:

typedef struct{
    //记录下线节点的名字
    char nodename[REDIS_CLUSTER_NAMELEN]
}clusterMsgDataFail;

因为名字都是集群内唯一的,所以可以这么保存。

PUBLISH 消息的实现

当集群的某个节点发送PUBLISH <channel> <message>时,会引发集群中所有节点都向 channel 发送消息。消息正文是一个cluster.h/clusterMsgDataPublish结构:

typedef struct {
    uint32_ t channel_len;
    uint32_ t message_len;
    //定义为8字节只是为了对齐其他消息结构
    //实际的长度由保存的内容决定
    unsigned char bulk_data[8] ;
} clusterMsgDataPublish;
​

bulk_data 保存消息的 channel 和 message 参数。具体是根据对应参数长度识别的。