[Redis][17][集群]

第 17 章 集群

Redis集群是Redis提供的分布式数据库方案,集群通过分片来进行数据共享,并提供复制故障转移功能

17.1 节点

一个Redis集群通常由多个节点组成,在刚开始的时候,每个节点都是相互独立的,它们都处于一个只包含自己的集群当中,要组建一个真正可工作的集群,我们必须将各个独立的节点连接起来,构成一个包含多个节点的集群。

连接各个节点的工作可以使用CLUSTER MEET命令来完成,该命令的格式如下:

1
CLUSTER MEET <ip> <port>

向一个节点发送CLUSTER MEET命令,可以让该节点与ip+port所指定的节点进行握手,当握手成功时,该节点就会将ip+port所指定的节点添加到该节点当前所在的集群中。

下面的例子演示了如何构件一个有3个节点的集群




17.1.1 启动节点

个节点就是一个运行在集群模式下的Redis服务器, Redis服务器在启动时会根据cluster- enabled配置选项是否为yes来决定是否开启服务器的集群模式,流程如下图所示。

节点会继续使用所有在单机模式中使用的服务器组件,比如说:文件事件处理器、时间事件处理器、使用数据库保存键值对数据、使用RDB和AOF来持久化

17.1.2 集群数据结构

节点会继续使用redisServer结构来保存服务器的状态,使用redisClient结构来保存客户端的状态,至于那些只有在集群模式下才会用到的数据,节点将它们保存到了cluster.h/clusterNode结构、cluster.h/clusterLink结构,以及cluster.h/clusterState结构里面。

每个节点都会使用一个clusterNode结构来记录自己的状态,并为集群中的所有其他节点都创建一个相应的clusterNode结构,以此来记录其他节点的状态,状态如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 节点状态
struct clusterNode {

// 创建节点的时间
mstime_t ctime; /* Node object creation time. */

// 节点的名字,由 40 个十六进制字符组成
// 例如 68eef66df23420a5862208ef5b1a7005b806f2ff
char name[REDIS_CLUSTER_NAMELEN]; /* Node name, hex string, sha1-size */

// 节点标识
// 使用各种不同的标识值记录节点的角色(比如主节点或者从节点),
// 以及节点目前所处的状态(比如在线或者下线)。
int flags; /* REDIS_NODE_... */

// 节点当前的配置纪元,用于实现故障转移
uint64_t configEpoch; /* Last configEpoch observed for this node */

// 节点的 IP 地址
char ip[REDIS_IP_STR_LEN]; /* Latest known IP address of this node */

// 节点的端口号
int port; /* Latest known port of this node */

// 保存连接节点所需的有关信息
clusterLink *link; /* TCP/IP link with this node */

//...
};

clusterNode结构的link属性是一个 clusterLink结构,该结构保存了连接节点所需的有关信息,比如套接字描述符,输人缓冲区和输出缓冲区:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
typedef struct clusterLink {

// 连接的创建时间
mstime_t ctime; /* Link creation time */

// TCP 套接字描述符
int fd; /* TCP socket file descriptor */

// 输出缓冲区,保存着等待发送给其他节点的消息(message)。
sds sndbuf; /* Packet send buffer */

// 输入缓冲区,保存着从其他节点接收到的消息。
sds rcvbuf; /* Packet reception buffer */

// 与这个连接相关联的节点,如果没有的话就为 NULL
struct clusterNode *node; /* Node related to this link if any, or NULL */

} clusterLink;

redisServer中会保存一个clusterState结构,这个结构记录了集群的相关信息,例:集群是在线还是下线,集群包含多少个节点,集群当前的配置纪元,诸如此类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef struct clusterState{
//指向自己节点结构的指针
clusterNode *myself

//集群当前的配置单元
uint64_t currentEpoch;

//集群当前的状态:是上线还是下线
int state;

//集群中至少处理着一个槽的节点的数量
int size;

//集群节点名单
//字典的键为节点的名字,字典的值为节点对应的clusterNode结构
dict *nodes;
}

下图是一个clusterState状态的例子

17.1.3 CLUSTER MEET命令的实现

下面我们来解释本节开头说的CLUSTER MEET的具体执行步骤,假设现在客户端向节点A发送命令,要求A加入节点B的集群中,假设节点A收到了命令,它会接着做这些事情:

  1. 节点A会为节点B创建一个clusterNode结构,并将该结构添加到自己的cluterState.nodes字典里面。
  2. 之后,节点A将根据CLUSTER MEET命令给定的IP地址和端口号,向节点B发送一条MEET消息
  3. 如果一切顺利,节点B将接收到节点A发送的MEET消息,节点B会为节点A创建一个clusterNode结构,并将该结构添加到自己的clusterState.nodes字典里面。
  4. 之后,节点B将向节点A返回一条PONG消息。
  5. 如果一切顺利,节点A将接收到节点B返回的PONG消息,通过这条PONG消息节点A可以知道节点B已经成功地接收到了自己发送的MEET消息。
  6. 之后,节点A将向节点B返回一条PING消息。
  7. 如果一切顺利,节点B将接收到节点A返回的PING消息,通过这条PING消息节点B可以知道节点A已经成功地接收到了自己返回的PONG消息,握手完成。

下图展示了上面步骤描述的握手过程

之后,节点A会将节点B的信息会通过Gossip协议传播给集群中的其他节点,让其他节点也与节点B握手

17.2 槽指派

Redis集群通过分片的方式来保存数据库中的键值对:集群的整个数据库被分为16384个槽,数据库中的每个键都属于这16384个槽的其中一个,集群中的每个节点可以处理0个或最多16384个槽。

当数据库中的16384个槽都有节点在处理时,集群处于上线状态;相反地,如果数据库中有任何一个槽没有得到处理,那么集群处于下线状态。

我们可以通过CLUSTER INFO命令查看集群相关的信息

如果想要把槽指派给某个节点负责,可以使用下面的命令

1
CLUSTER ADDSLOTS 0 1 2 3 4 ... 5000

17.2.1 记录节点的槽指派信息

clusterNode结构的slots属性和numslot属性记录了这个节点负责处理哪些槽:

1
2
3
4
5
6
struct clusterNode{
// ...
unsigned char slots[16384/8];
int numslots;
//...
}

s1ots属性是一个二进制位数组,这个数组的长度为16384/8=2048个字节,共包含16384个二进制位。

Redis以0为起始索引,16383为终止索引,对s1ots数组中的16384个二进制位进行编号,并根据索引i上的二进制位的值来判断节点是否负责处理槽i

下图展示了一个slots数组的实例

因为取出和设置s1ots数组中的任意一个二进制位的值的复杂度仅为O(1),所以对于一个给定节点的s1ots数组来说,程序检查节点是否负责处理某个槽,又或者将某个槽指派给节点负责,这两个动作的复杂度都是O(1)

17.2.2 传播节点的槽指派信息

这个节点不光会存储自己的槽指派信息,还将把它通过消息发送给其他节点,以此告诉其他节点自己目前负责哪些槽。事实上,我们要达到的是,让每个节点都知道所有16384个槽目前被谁处理。

下图是一个例子


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

我们除了将槽指派信息存储在cluterNode结构的slots属性中,还会在全局的clusterState结构的slots指针数组中记录所有16384个槽的指派信息

1
2
3
4
5
6
7
8
typedef struct clusterNode{
//...

//存储槽指派信息的指针数组
clusterNode *slot[16384]

//...
} clusterNode;

通过这种存储方式,我们可以以O(1)的复杂度得知指定槽目前被哪个节点负责

一个例子如下

17.2.4 CLUSTER ADDSLOTS命令的实现

CLUSTER ADDSLOTS命令接受一个或多个槽作为参数,并将所有输入的槽指派给接受该命令的节点负责,它的实现如下伪码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def cluster_addslots(*all input slots):
# 遍历所有输入槽,检查它们是否都是未指派槽
for i in all input slots:
# 如果有哪怕一个槽已经被指派给了某个节点排那么向客户端返回错误,并终止命令执行
if clusterState.slots[i] != NULL:
reply_error()
return

# 如果所有输入槽都是未指派槽
# 那么再次遍历所有输入槽,将这些槽指派给当前节点
for i in all input slots:
# 设置clusterState结构的slots数组
# 将s1ots[i]的指针指向代表当前节点的clusterNode结构
clusterState.slots[i] = clusterstate.myself
# 访问代表当前节点的clusterNode结构的slots数组
# 将数组在索引i上的二进制位设置为1
setSlotBit(clusterState.myself.slots, i)

下面我们举个例子,下图是一个节点,这个集群没有任何指派

接着我们执行槽指派

1
CLUSTER ADDSLOTS 1 2

槽指派后会变成如下结构

最后,在CLUSTER ADDSLOTS命令执行完毕之后,节点会通过发送消息告知集群中的其他节点,自己目前正在负责处理哪些槽。

17.3 在集群中执行命令

对数据库中的16384个槽都进行了指派之后,集群就会进入上线状态,这时客户端就可以向集群中的节点发送数据命令了。

当客户端向节点发送与数据库键有关的命令时,接收命令的节点会计算出命令要处理的数据库键属于哪个槽,并检查这个槽是否指派给了自己

  • 如果键所在的槽正好就指派给了当前节点,那么节点直接执行这个命令。
  • 如果键所在的槽并没有指派给当前节点,那么节点会向客户端返回一个MOVED错误,指引客户端转向至正确的节点,并再次发送之前想要执行的命令。

流程如下图

17.3.1 节点数据库的实现

节点和单机服务器在数据库方面的一个区别是,节点只能使用0号数据库,而单机Redis服务器则没有这一限制。

此外,除了将键值对保存在数据库里面之外,节点还会用clusterState结构中的slots_to_keys跳跃表来保存槽和键之间的关系:

1
2
3
4
5
typedef struct clusterState{
//...
zskiplist *slots_to_key;
//...
} clusterState;

slots_to_keys跳跃表每个节点的分值都是一个槽号,而每个节点的成员都是一个数据库键。通过在slots_to_keys跳跃表中记录各个数据库键所属的槽,节点可以很方便地对属于某个或某些槽的所有数据库键进行批量操作。

下面是一个跳跃表的例子

17.4 重新分片

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

Redis集群的重新分片操作是由Redis的集群管理软件redis-trib负责执行的,Redis提供了进行重新分片所需的所有命令,而redis-trib则通过向源节点和目标节点发送命令来进行重新分片操作。重新分片步骤如下

  1. redis-trib对目标节点发送CLUSTER SETSLOT <slot> IMPORTING <source_id>命令,让目标节点准备好从源节点导人属于槽slot的键值对。

  2. redis-trib对源节点发送CLUSTER SETSLOT <slot> MIGRATING <target_id>命令,让源节点准备好将属于槽slot的键值对迁移至目标节点。

  3. redis-trib向源节点发送CLUSTER GETKEYSINSLOT <s1ot> <count>命令,获得最多count个属于槽slot的键值对的键名

  4. 对于步骤3获得的每个键名,redis-trib都向源节点发送一个MIGRATE <target Ip> <target port> <key name> 0 <timeout>命令,将被选中的键原子地从源节点迁移至目标节点

  5. 重复执行步骤3和步骤4,直到源节点保存的所有属于槽s1ot的键值对都被迁移至目标节点为止。每次迁移键的过程下图所示

  6. redis-trib向集群中的任意一个节点发送CLUSTER SETSLOT <slot> NODE <target_id>命令,将槽slot指派给目标节点,这一指派信息会通过消息发送至整个集群最终集群中的所有节点都会知道槽s1ot已经指派给了目标节点。

流程图如下

17.5 ASK错误

在进行重新分片期间,源节点向目标节点迁移一个槽的过程中,可能会出现这样一种情况:属于被迁移槽的一部分键值对保存在源节点里面,而另一部分键值对则保存在目标节点里面。

当客户端向源节点发送某个与数据库键有关的命令,并且命令要处理的数据库键恰好就属于正在被迁移的槽时:

  • 源节点会先在自己的数据库里面查找指定的键,如果找到的话,就直接执行客户端发送的命令。
  • 相反地,如果源节点没能在自己的数据库里面找到指定的键,那么这个键有可能已经被迁移到了目标节点,源节点将向客户端返回一个ASK错误指引客户端转向正在导入槽的目标节点,并再次发送之前想要执行的命令。

流程图如下

17.6 复制与故障转移

Redis集群中的节点分为主节点从节点,其中主节点用于处理槽,而从节点则用于复制某个主节点,并在被复制的主节点下线时,代替下线主节点继续处理命令请求。

下面我们举一个发生故障的例子

  1. 首先集群中包含如下6个节点

  2. 如果这时,节点7000进入下线状态,那么集群中仍在正常运作的几个主节点将在节点7000的两个从节点中选出一个节点作为新的主节点,这个新的主节点将接管原来节点7000负责处理的槽,并继续处理客户端发送的命令请求。

如果在故障转移完成之后,下线的节点7000重新上线,那么它将成为节点7004的从节点,如下图

17.6.1 设置从节点

向一个节点发送如下命令可以让接收命令的节点成为node_id所指定节点的从节点,并开始对主节点进行复制

1
CLUSTER REPLICATE <node_id>

这个命令的执行步骤如下

  • 接收到该命令的节点首先会在自己的clusterState.nodes字典中找到 node_id所对应节点的clusterNode结构,并将自己的clusterState.myself.slaveof指针指向这个结构,以此来记录这个节点正在复制的主节点
  • 然后节点会修改自己在cluterState.myself.flags中的属性,关闭原本的REDIS_NODE_MASTER标识,打开REDIS_NODE_SLAVE标识,表示这个节点已经由原来的主节点变成了从节点。
  • 最后,节点会调用复制代码,并根据clusterState.myself.slaves指向的clusterNode结构所保存的IP地址和端口号,对主节点进行复制。

某个节点成为从节点,并开始复制某个主节点这一信息会通过gossip协议消息发送给集群中的其他节点,最终集群中的所有节点都会知道某个从节点隶属于了某个主节点

17.6.2 故障检测

集群中的每个节点都会定期地向集群中的其他节点发送PING消息,以此来检测对方是否在线,如果接收PING消息的节点没有在规定的时间内,向发送PING消息的节点返回PONG消息,那么发送PING消息的节点就会将接收PING消息的节点标记为疑似下线

此外,PING和PONG消息还用于集群内节点以gossip的方式互相交换各个节点的状态,详见下一节

当一个主节点A通过互发消息的方式得知了主节点B也认为主节点C进入了疑似下线,它会将主节点B的下线报告加入到下线报告链表

举个例子,如果主节点7001在收到主节点7002、主节点7003发送的消息后得知,主节点7002和主节点7003都认为主节点7000进入了疑似下线状态,那么主节点7001将为主节点7000创建下图所示的下线报告。

如果节点A发现自己积累了超过半数以上关于节点C的下线报告,也就是半数以上的节点都认为C下线了,它就会将C设置为已下线,并广播给所有节点一条FAIL消息,所有节点收到FAIL后,会立刻设置C为已下线状态

例如下图

17.6.3 故障转移

当一个从节点发现自己正在复制的主节点进入了已下线状态时,从节点将开始对下线主节点进行故障转移,以下是故障转移的执行步骤

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

17.6.4 选举新的主节点

新的主节点是通过选举产生的,选举采用Raft算法的领头选举,类似于第16章选举哨兵主节点

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

17.7 消息

节点发送的消息主要有以下五种

  • MEET消息:当发送者接到客户端发送的CLUSTER MEET命令时,发送者会向接收者发送MEET消息,请求接收者加入到发送者当前所处的集群里面。

  • PING消息:集群里的每个节点默认每隔一秒钟就会从已知节点列表中随机选出五个节点,然后对这五个节点中最长时间没有发送过PING消息的节点发送PING消息,以此来检测被选中的节点是否在线。

  • PONG消息:当接收者收到发送者发来的MEET消息或者PONG消息时,为了向发送者确认这条MEET消息或者PING消息已到达,接收者会向发送者返回一条PONG消息。

  • FAIL消息:当一个主节点A判断另一个主节点B已经进入FAIL状态时,节点A会向集群广播一条关于节点B的FAIL消息,所有收到这条消息的节点都会立即将节点B标记为已下线。

  • PUBLISH消息:当节点接收到一个PUBLISH命令时,节点会执行这个命令,并向集群广播一条PUBLISH消息,所有接收到这条PUBLISH消息的节点都会执行相同的PUBLISH命令。

其实,在Redis集群中,关于消息的发送有两种方式

  1. 直接广播:这种方式比较快捷暴力,它直接将消息广播给集群中所有节点,这样所有节点都快速得知了这条消息中的内容,例如FAILPUBLISH就是使用这个方式
  2. 基于Gossip协议的消息传播:Gossip协议是一个基于传染病模型的分布式通信协议,所有节点不会立即都收到这条消息,但是它可以保证在一定的时间范围内,所有节点都收到了这个消息,它可以节省通信成本,因为不用一次通知所有节点。关于Gossip协议,可以继续查看这篇文章。下面使用图片举例。