《Redis设计与实现》 集群

本文最后更新于 2025年8月30日 晚上

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

节点

一个Redis集群通常有多个节点(node,也就是一个Redis服务器)组成,他们最开始时都是相互独立的,需要通过连接来构成一个包含多个节点的集群。连接节点的命令为:

1
CLUSTER MEET <ip> <port>

该命令发送给一个节点之后,该节点就会与目标ip:port节点进行握手。握手成功后,该节点便会将目标节点添加当该节点所在的集群中。

节点(集群模式下的Redis服务器)会继续使用所有在单机模式中使用的服务器组件,但也有些不同:

  • 在时间事件处理器执行的serverCron函数中,会调用集群特有的clusterCron函数。该函数用于执行在集群模式下所需要的常规操作,比如向节点发送消息,检查其他节点的状态,和检查是否需要自动进行故障转移等。
  • 节点的底层代码中会多一些关于集群模式下的特有结构:
    • cluster.h/clusterNode:保存了节点当前状态,如创建时间,名字,当前的配置纪元,IP地址,端口等等
    • cluster.h/clusterLink:在clusterNodelink属性下,保存了连接节点所需的有关信息,如套接字描述符,输入缓冲区和输出缓冲区
    • cluster.h/clusterState每一个节点都有一个这个结构。记录了在当前节点的视角下,集群目前所处的状态,如集群在线状态,集群中有多少个节点,集群当前的配置纪元等

这些结构的底层代码大致长这样:

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
struct clusterNode {

// 创建节点的时间
mstime_t ctime;

// 节点的名字,由 40 个十六进制字符组成
// 例如 68eef66df23420a5862208ef5b1a7005b806f2ff
char name[REDIS_CLUSTER_NAMELEN];

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

// 节点当前的配置纪元,用于实现故障转移
uint64_t configEpoch;

// 节点的 IP 地址
char ip[REDIS_IP_STR_LEN];

// 节点的端口号
int port;

// 保存连接节点所需的有关信息
clusterLink *link;

// 一个二进制位数组,长度为2048个字节(186384/8 = 2048),共包含16384个二进制位
unsigned char slots[16384/8];

// 该节点负责处理的槽的数量,也就是slots数组中值为1的二进制位数量
int numslots;

// 如果这是一个从节点,那么该属性将指向主节点
struct clusterNode *slaveof;
// ...

};

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;

// 指向自己或其他节点的指针数组,表示某个节点负责哪个槽
clusterNode *slots[16384];

// 槽与键之间的关系,该关系构成了一个有序跳表
zkiplist *slots_to_keys;
// ...

} clusterState;

CLUSTER MEET命令

这里简单画一个图,用来讲述从客户发送CLUSTER MEET命令开始一直到握手完成的流程:

sequenceDiagram
	autonumber
	participant Client as 客户端
	participant NodeA as 节点A
	participant NodeB as 节点B
	Client ->> NodeA: CLUSTER MEET <B_ip> <B_port>
	NodeA ->> NodeA: 创建一个关于节点B的clusterNode结构<br/>将其添加到clusterState.node字典中
	NodeA ->> NodeB: MEET
	NodeB ->> NodeB: 创建一个关于节点A的clusterNode结构<br/>将其添加到clusterState.node字典中
	NodeB ->> NodeA: PONG
	NodeA ->> NodeB: PING
	Note over NodeA,NodeB: 握手完成

在节点A与B握手完成之后,节点A会将节点B的信息通过Gossip[1]协议传播给集群中的其他节点,以让其他节点也与节点B握手。这样,做种就可以形成一个完全连接的节点网以构成集群。

槽指派 (Assign)

Redis集群的主要目的就是为了实现分布式数据库,而做分布式数据库最常见的就是通过分片的方式,Redis集群也就是这么做的:集群的整个数据库被分为16384个槽,数据库中的每个键都属于这些槽的其中一个,集群中的每个节点可以处理0-16384个槽。

当数据库中的16384个槽都有节点在处理时,集群处于上线状态(ok),反之,如果任意一个槽没有得到处理,则为下线状态(fail)。

用户可以通过向节点发送CLUSTER ADDSLOTS <slot> [slot ...]命令,将一个或多个槽指派给节点负责。

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

  • slots:一个16384个二进制位的二进制数组(长度为16384/8=204816384/8=2048),如果索引i上的二进制位的值为1,则表示该节点负责处理槽i,反之就不处理。
  • numslots:记录节点负责处理的槽的数量

节点在将自己负责处理的槽记录下来之后,会将自己的slots数组通过消息发送给其他节点,然后其他节点将更新自己在clusterState结构下的slots属性:

  • clusterState.slots:这是一个具有16384个指针的数组,每一个指针都指向一个clusterNode结构或NULL。指向的clusterNode结构代表槽i已经指派给了clusterNode结构所代表的节点

clusterNodeclusterState中都有关于slots的信息是出于一个性能方面的考量。如果没有前者的存在,那每一次发送指派信息时,都需要遍历一次后者的slots,这样会低效许多。

在客户端向服务器发送CLUSTER ADDSLOTS之后,服务器会检查是否指定的所有槽都没有被指派给任意节点,如果发现了这种情况,则将返回错误,并终止命令。

集群的命令执行

在集群进入上线状态后,客户端才能向集群中的节点发送数据命令。当客户端发送与数据库键有关的命令时,接受命令的节点会计算出命令要处理的数据库建属于哪个槽,并检查该槽是否指派给了自己:

  • 如果该槽是指派给了自己,那就正常执行并返回命令
  • 如果该槽没有指派给自己,节点奖项客户端返回一个MOVED错误,指引客户端重定向到正确的节点,并再次发送之前想要执行的命令。

集群计算键属于哪一个槽的方法为(这里用python写了):

1
2
def slot_number(key):
return CRC16(key) & 16384

很明显了,先用CRC-16计算key的校验和,然后用& 16384计算出一个介于0到16384之间的槽号。

MOVED错误

当节点发现命令键不在自己负责的槽中,将会返回给客户端一个MOVE错误,具体格式为:

1
MOVED <slot> <ip>:<port>
  • slot:命令键所在的槽
  • ip:port:负责处理槽的节点的IP与端口号

然而,在我们实际操作的时候,我们有时候不会看到MOVED错误信息,而是会看到重定向的信息。下面给一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 假如该集群中有7000,7001,7002三个端口号的节点,他们的IP地址都是127.0.0.1
$ redis-cli -c -p 7000 # 集群模式

127.0.0.17000> SET msg "message"
-> Redirected to slot [6257] located at 127.0.0.1:7001
OK

127.0.0.1:7001>

#========下面是另一个情况=========#
$ redis-cli -p 7000 # 单机模式

127.0.0.1:7000> SET msg "message"
(error) MOVED 6257 127.0.0.1:7001

127.0.0.1:7000>

这个例子中可以清晰地看到,当客户端使用集群模式,便会自动进行节点转向,并答应出转向信息。而在单机模式下,MOVED错误会被打印出来。

从这里也可以明显地看出来,在集群模式下的服务器,如果有任意一个槽位没有得到分配,集群都应该是被关闭的。

节点数据库

我们在之前文章中讲过,单机服务器中可以有很多个数据库。在节点(集群模式下的服务器)中,只能使用0号数据库。而其余的大部分机制,都与单机数据库相同。不同的是,在节点中有一个clusterState.slots_to_keys属性。这是一个跳表,每一个跳表节点的分值都是一个槽号,节点成员是数据库键。在对节点数据库进行写操作时,会有如下情况:

  • 添加新键值:节点会将这个件以及键的槽号关联到slots_to_keys跳表
  • 删除键:节点会在slots_to_keys跳表中删除这个键与槽号的关系

通过这个跳表,节点可以很方便地对属于某个或某些槽的所有数据库键进行批量操作。

重新分片

Redis集群的重新分片操作,旨在将某个或某批已经分配的槽重新分配给另一个节点,并且相关槽内的数据都会被转移到目标节点。

重分片操作是由Redis的集群管理软件redis-trib负责执行的,具体流程如下:

sequenceDiagram
	autonumber
	participant rt as redis-trib
	participant target as 目标节点
	participant source as 源节点
	rt ->> target: CLUSTER SETSLOT <slot> IMPORTING <source_id>
	target ->> target: 将clusterState.importing_slots_from[i]<br/>的值设置为source_id所代表节点的clusterNode结构
	rt ->> source: CLUSTER SETSLOT <slot> MIGRATING <target_id>
	source ->> source: 将clusterState.migrating_slots_to[i]<br/>的值设置为target_id所代表节点的clusterNode结构
	loop 源节点没有迁移完在槽slot中的键值对
	rt ->> source: CLUSTER GETKEYSINSLOT <slot> <count>
	source ->> rt: 发送count个属于槽slot的键值对的键名
	loop 对获得的每个键名key_name
	rt ->> source: MIGRATE <target_ip> <target_port> <key_name> 0 <timeout>
	source ->> target: 发送指定key_name的键值
	target ->> target: 接受键值,将其存入数据库
	target ->> source: OK
	source ->> source: 删除本地数据库中key_name的键值
	source ->> rt: OK
	end
	end
	target ->> target: 完成分配槽slot
  • 如果涉及到多个槽,则redis-trib将对每个给定的槽分别执行以上步骤
  • 1-4步中,我少写了一些返回信息的过程,但是这并不是很重要。
  • 7-12步中,我添加了一些书中没有直接讲到的内容(消息返回与源节点删除键值对,redis-trib发送MIGRATE的时机)

ASK错误

在执行重新分片的期间(上面时序图的5-12期间),客户端向源节点发送了一个与数据库键有关的命令,且该键在正在迁移的槽中时:

  • 源节点查找指定键,找到了就执行客户端发送的命令
  • 没找到的话,取决于这个键是否正在迁移槽中:
    • 没有,则用正常流程处理信息(返回信息不存在错误什么的)
    • 有,则返回ASK错误,并指引客户端转向正在导入槽的目标节点

ASKING命令

该命令唯一要做的就是打开发送改名的客户端的REDIS_ASKING标识。然而,该表示只会生效一次,生效之后会自动关闭。具体功能如下:

1
2
3
4
5
6
7
8
127.0.0.1:7000> ASKING # 打开REDIS_ASKING标识
OK

127.0.0.1:7000> GET "love" # 移除REDIS_ASKING标识
"you get the key 'love'"

127.0.0.1:7000> GET "love" # REDIS_ASKING标识未打开,执行失败
(error) MOVE 16198 127.0.0.1:7002
  • 该标识的作用是会让Redis服务器强制执行这个关于槽i的命令一次,即便这个槽i没有被指派给这个节点。

复制与故障转移

Redis集群中的节点也被分为主节点和从节点。概念与主从服务器一样,主节点用于处理槽,从服务器用于复制主节点。一样的,当被复制的主节点下线时,从节点需要代替下线的主节点继续处理命令请求。
在向一个节点发送命令:

1
CLUSTER REPLICATE <node_id>

可以让接收命令的节点成为node_id所指定节点的从节点。节点接收到这个命令后:

  • 将自己的clusterState.myself.slaveof指针指向这个结构
  • 将自己的clusterState.myself.flags中的属性,关闭REDIS_NODE_MASTER并打开REDIS_NODE_SLAVE
  • 根据clusterState.myself.slaveof中的信息,复制主节点。

在成为从节点后且开始复制某个主节点时,该节点会将消息发送给集群中的其他节点,因此集群中所有节点都会知道某个从节点正在复制某个主节点。

在Redis集群中,观测一个节点是否下线,和选举出新主节点的方式都与Redis哨兵中的机制很像。接下来会描述一些细节上的不同

故障检测

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

集群中的各个节点互相发送的消息中也会包含在它的视角中各个节点的状态信息:在线状态,疑似下线状态(PFAIL),还是已下线状态(FAIL)。

如果在一个集群中,半数以上的负责处理槽的主节点都将某个主节点x报告为疑似下线,那么这个主节点x将被标记为已下线(FAIL)。观察到这一现象的节点会向集群广播一条关于主节点x的FAIL消息,并且所有收到消息的节点都会这个主节点x标记为已下线。

选举 -> 故障转移

选举中与哨兵机制不一样的地方:

  • 只有集群中的主节点有投票机会,只有下线的主节点x的从节点才会向广播CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST消息,要求所有收到消息且有投票权的主节点向这个从节点投票
  • 当有一个从节点收集到半数以上的可投票的主节点的支持票时,这个从节点就会被当成新的主节点

故障转移中与哨兵机制不一样的地方:

  • 新的主节点会撤销所有对已下线主节点的槽指派,并将这些槽全部指派给自己
  • 显得主节点会在指派完成之后,像集群广播一条PONG消息,让其他节点知道这个节点已经变成了主节点,并更新他们自己的结构。

消息

集群中的各个节点通过发送与接收消息来进行通信。主要发送的消息有以下五种:

  • MEET消息:用于请求加入发送者当前所属集群中
  • PING消息:集群里的每个节点默认每隔一秒钟就会从已知节点列表中随机选出五个节点,然后对这五个节点中最长时间没有发送过PING消息的节点发送PING消息:以此来检测被选中的节点是否在线。除此之外,如果节点A最后一次收到节点B发送的PONG消息的时间,距离当前时间已经超过了节点A的cluster-node-timeout选项设置时长的一半,那么节点A也会向节点B发送PING消息,这可以防止节点A因为长时间没有随机选中节点B作为PING消息的发送对象而导致对节点B的信息更新滞后。
  • PONG消息:当接收者收到发送者发来的MEET消息或者PING消息时,为了向发送者确认这条MEET消息或者PING消息已到达,接收者会向发送者返回一条PONG消息。另外,一个节点也可以通过向集群广播自己的PONG消息来让集群中的其他节点立即刷新关于这个节点的认识。
  • FAIL消息:当一个主节点A判断另一个主节点B已经进入FAIL状态时,节点A会向集群广播一条关于节点B的FAIL消息,所有收到这条消息的节点都会立即将节点B标记为已下线。
  • PUBLISH消息:当节点接收到一个PUBLISH命令时,节点会执行这个命令,并向集群广播一条PUBLISH消息,所有接收到这条PUBLISH消息的节点都会执行相同的PUBLISH命令。

Redis集群中的各个节点通过Gossip协议来交换各自关于不同节点的状态信息。

原书详细介绍了每一个消息的具体内容,这里不做详细赘述,有兴趣的可以去原著看看。


《Redis设计与实现》 集群
http://example.com/2025/08/29/Redis学习/《Redis设计与实现》集群/
作者
Clain Chen
发布于
2025年8月29日
许可协议