Redis 集群是 Redis 提供的分布式数据库方案,集群通过分片(sharding) 来进行数据共享,并提供复制和故障转移功能
节点
一个 Redis 集群通常由多个节点(node)组成,在刚开始的时候,每个节点都是相互独立的,他们都处于一个只包含自己的集群当中,要组建一个真正可工作的集群,我们可以通过CLUSTER MEET <ip> <port>
将各个独立的节点连接起来。
向一个节点 node 发送 CLUSTER MEET
命令,可以让 node 节点与 ip 和 port 所制定的节点进行握手,当握手成功时,node 节点就会将 ip 和 port 所指定的节点添加到 node 节点当前所在的集群中。
启动节点
一个节点就是一个运行在集群模式下的 Redis 服务器, Redis 服务器在启动时会根据 cluster-enabled 配置选项是否为 yes 来决定是否开启服务器的集群模式。
节点(运行在集群模式下的 Redis 服务器) 会继续使用所有在单机模式中的服务器组件,比如说
- 继续使用文件事件处理器来处理命令请求和返回命令回复
- 继续使用事件事件处理器来执行 serverCron 函数,而 serverCron 函数又会调用集群模式下特有的 clusterCron 函数。clusterCron 函数负责执行在集群模式下需要执行的常规操作。比如,检查节点是否断线,或者检查是否需要对下线节点进行自动故障转移等。
- 继续使用数据库来保存键值对数据,键值对依然会是各种不同类型的对象
- 继续使用 RDB 持久化和 AOF 持久化
- 继续使用复制模块来进行节点的复制工作
- 继续使用发布订阅模块进行 pub/sub
- 继续使用 Lua 脚本环境来执行客户端输入的 Lua 脚本
除此之外,节点会继续使用 redisServer 结构来保存服务器的状态,redisClient 结构来保存客户端的状态。只有在集群模式下才会用到的数据,保存到了cluster.h/clusterNode、cluster.h/clusterLink、cluster.h/clusterState
三种结构中。
集群数据结构
clusterNode
:保存节点当前状态,比如节点创建时间、节点名字、节点当前配置、节点 IP 地址和端口等等。
每个节点都会使用一个clusterNode
来记录自己的状态,并未集群中的所有其他节点都创建一个相应的clusterNode
结构,以此来记录其他节点状态。
typedef struct clusterNode {
mstime_t ctime; /* 节点创建时间 */
char name[CLUSTER_NAMELEN]; /* 节点的名称,由 40 位 16 进制字符组成 */
int flags; /* 节点标识。使用各种不同的标识记录节点角色以及节点所处状态 */
uint64_t configEpoch; /* 节点当前的配置纪元,用于实现故障转移 */
char ip[NET_IP_STR_LEN]; /* 节点的ip地址 */
int port; /* 节点的端口号 */
clusterLink *link; /* 保存连接节点所需的有关信息 */
// ...
} clusterNode;
clusterNode
结构中的 link
属性是一个 clusterLink
结构,该结构保存了连接节点所需的有关信息。
typedef struct clusterLink {
mstime_t ctime; /* 连接创建时间 */
connection *conn; /* 连接 */
sds sndbuf; /* 输出缓冲区,保存着等待发送给其他节点的信息 */
char *rcvbuf; /* 接受缓冲区,保存着从其他节点接收到的消息 */
size_t rcvbuf_len; /* Used size of rcvbuf */
size_t rcvbuf_alloc; /* Used size of rcvbuf */
struct clusterNode *node; /* 与这个连接相关联的节点,没有为 NULL */
} clusterLink;
每个节点都保存着一个clusterState
结构,这个结构记录在当前节点的视角下,集群目前所处的状态。
typedef struct clusterState {
clusterNode *myself; /* 指向当前节点 */
uint64_t currentEpoch; /* 集群当前配置纪元,用于实现故障转移 */
int state; /* 集群当前状态 */
int size; /* 集群中至少处理着一个槽的节点数量 */
dict *nodes; /* 集群节点名单(包括myself)
key:节点名称
value:节点对应的 clusterNode 结构
*/
// ...
} clusterState;
CLUSTER MEET 命令的实现
收到CLUSTER MEET <ip> <port>
节点 A 会和节点 B 进行握手,以此来确认彼此的存在。
- 节点 A 会为节点 B 创建一个 clusterNode 结构,并将该结构添加到自己的 clusterState.nodes 字典中
- 节点 A 将根据
CLUSTER MEET
命令给定的 IP 和 PORT, 向节点 B 发送一个 MEET 消息 - 节点 B 接受节点 A 发送的 MEET 消息,节点 B 会为节点 A 创建一个 clusterNode 结构,并将该结构添加到自己的 clusterState.nodes 字典中
- 节点 B 将向节点 A 返回一个 PONG 消息
- 节点 A 接受节点 B 发送的 PONG 消息,通过这条消息知道节点 B 已经成功接收到了自己发送的 MEET 消息
- 节点 A 将向节点 B 返回一条 PING 消息
- 节点 B 接受节点 A 发送的 PING 消息,通过这条消息知道节点 A 已经成功接收到了自己发送的 PONG 消息, 握手完成
- 节点 A 会将节点 B 的信息通过 Gossip 协议传播给集群中的其他节点,让其他节点也与节点 B 进行握手,最终通过一段时间后,节点 B 会被集群中的所有节点认识
槽指派
Redis 集群通过分片的方式来保存数据库中的键值对:集群的整个数据库被分为 16384 个槽(slot),数据库中的每个键都属于这 16384 个槽的其中一个,集群中的每个节点可以处理 0 个或最多 16384 个槽。
当数据库中的 16384 个槽都有节点在处理时,集群处于上线状态;相反的,如果数据库中有一个槽没有得到处理,那么集群处于下线状态。
可以通过 CLUSTER ADDSLOTS <slot> [slot ...]
命令,将一个或者多个槽指派给节点负责。
记录节点的槽指派信息
clusterNode
结构中的 slots 属性和 numslot 属性记录了节点负责处理那些槽
typedef struct clusterNode {
unsigned char slots[CLUSTER_SLOTS/8]; /* slots handled by this node */
int numslots; /* Number of slots handled by this node */
// ...
} clusterNode;
-
slots 是一个二进制位数组,这个数组的长度为
16384 / 8 = 2048
个字节。Redis 以 0 为起始索引, 16383 为终止索引,对 slots 数组中的 16384 个二进制位进行编号,并根据所以 i 上的二进制位的值来判断节点是否负责处理槽 i:
- 如果 slots 数组在索引 i 上的二进制位的值为 1, 那么表示节点负责处理槽 i
- 如果 slots 数组在索引 i 上的二进制位的值为 0, 那么表示节点不负责处理槽 i
-
numslots 记录节点负责处理的槽点数量,即 slots 数组中值为 1 的二进制数量
传播节点的槽指派信息
一个节点除了会将自己负责处理的槽记录在 cluserNode 结构的 slots 数组和 numslots 属性外,他还会将自己的 slots 数组通过消息发送给集群中的其他节点。
记录集群所有槽的指派信息
clusterState 结构中 slots 数组记录了集群中所有 16384 个槽的指派信息
- 如果
slots[i]
指针指向 NULL, 那么表示尚未指派给任何节点 - 如果
slots[i]
指针指向一个 clusterNode 结构,表示已经指派给 clusterNode 所代表的节点
如果只将槽指派信息保存在各个节点的 clusterNode.slots 数组里,会出现一些无法高效的解决的问题,而 clusterNode.slots 数组的存在解决了这些问题。
- 如果节点只使用 clusterNode.slots 数组来记录槽的指派信息,那么为了知道槽 i 是否已经被指派,或者槽 i 被指派给了哪个节点,程序需要遍历 clusterState.nodes 字典中的所有 clusterNode 结构, 检查这些结构的 slots 数组,直到找到负责处理槽 i 的节点为止,时间复杂度为
O(N)
. - 如果使用 clusterState.slots 数组的话,那么通过槽 i 的索引可以直接获取到槽 i 被指派的节点。时间复杂度为
O(1)
复制与故障转移
Redis 集群中的节点分为主节点(master)和从节点(slave),其中主节点用于处理槽,从节点用于复制某个主节点,并在被复制的主节点下线时,代替下线的主节点继续处理命令请求.
设置从节点
向一个节点发送命令 CLUSTER REPLICATE <node_id>
可以让接收命令的节点成为 node_id 所指定节点的从节点,并开始对主节点进行复制。
- 接收到该命令的节点首先会在自己的 clusterState.nodes 字典中找到 node_id 所对应节点的 clusterNode 结构,并将自己的 clusterState.myself.slaveof 指针指向这个结构,以此来记录这个节点正在复制的主节点。
- 节点会修改自己在 clusterState.myself.flags 中的属性,关闭原来的
REDIS_NODE_MASTER
标识,打开REDIS_NODE_SLAVEOF
标识。 - 节点调用复制方法,对主节点进行复制。采用了和单机一样的复制方式
故障检测
集群中的每个节点都会定期的向集群中的其他节点发送 PING 消息。以此来检测对方是否在线,如果接收到 PING 消息的节点没有在规定的时间内,向发送 PING 的节点返回 PONG 消息,那么发送 PING 的节点会将接受 PING 消息的节点标记为疑似下线。
如果一个集群里面,半数以上负责处理操的主节点都将某个主节点 x 报告为疑似下线,那么主节点 x 将被标记为已下线,并将广播一条关于主节点 x 下线的消息,接收到消息的节点会将主节点 x 标记为已下线。
故障转移
- 复制下线主节点的所有从节点里面,会有一个节点被选中。
- 被选中的从节点会执行 SLAVEOF no one 命令,成为新的主节点
- 新的主节点会撤销所有对已下线主节点的槽指派,并将这些槽全部指派给自己
- 新的主节点向集群广播一条 PONG 消息,这条 PONG 消息可以让集群中的其他节点立即知道。
- 新的主节点开始接收和自己负责处理的槽有关的命令请求,故障转移完成。
选举新的主节点
和选举 Sentinel 的方法相似,基于 Raft 算法的领头选举方法实现的
- 集群的配置纪元是一个自增计数器,他的初始值为 0
- 当集群里的某个节点开始一次故障转移操作时,集群配置纪元的值会被增加一
- 对于每个配置纪元,集群里每个负责处理槽的主节点都有一次投票机会,而第一个向主节点要求投票的从节点将获得主节点的投票
- 当从节点发现自己正在复制的主节点进入已下线状态时,从节点会广播一条
CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST
消息,要求所有收到这条消息,并且具备投票权的主节点向这个从节点投票 - 如果一个主节点具有投票权,并且这个主节点尚未投票给其他从节点,那么主节点将向要求投票的从节点返回一条
CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK
消息,表示支持 - 每个参与选举的从节点统计自己接收到
CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK
消息的个数 - 如果当一个从节点获得
N / 2 + 1
张支持票,当选新的主节点。 - 如果在一个配置纪元里面没有从节点能收集到足够多的支持票,那么集群进入一个新的配置纪元。并再次选举,直到选出为止。