Redis集群是Redis提供的分布式数据库方案,采用去中心化的P2P架构,通过哈希槽分片、Gossip协议通信和哨兵机制,实现高可用、横向扩展和自动故障转移。以下是Redis集群底层实现原理的详细介绍:
一、Redis集群概念
Redis基于主从复制和哨兵模式的实现基础,提供了更优的Redis分布式部署方案:Cluster模式,实现了分布式存储方案,支持水平横向扩展,解决在线扩容问题。
Redis集群是一个由多个主从节点群组成的分布式服务集群,需要将每个节点设置成集群模式,这种集群模式没有中心节点,可水平扩展,可以线性扩展到上万个节点,但官方推荐不超过1000个节点。以下是Redis Cluster部署的架构图,支持多个客户端Client通过JedisCluster API同时访问Redis集群,集群下可水平扩展master+slave主从结构的数量,每一个master下的slave节点数量也可以视情况部署若干个。
二、集群节点
一个Redis集群通常由多个节点(node)组成,在刚开始的时候,每个节点都是相互独立的,它们都处于一个只包含自己的集群当中,要组建一个真正可工作的集群,我们必须将各个独立的节点连接起来,构成一个包含多个节点的集群。
1. 节点如何启动
一个节点就是一个运行在集群模式下的Redis服务器,Redis服务器在启动时会根据cluster-enabled
配置选项是否为yes来决定是否开启服务器的集群模式。
每一个节点服务器启动后都具有正常的Redis单机服务功能,当他们加入到集群模式之后,节点服务器会将集群相关的信息保存到了cluster.h/clusterNode
结构、cluster.h/clusterLink
结构,以及cluster.h/clusterState
结构里面,依此来提供集群相关的功能。
2. 节点的集群数据结构
2.1 clusterNode
结构
每个节点都会使用一个clusterNode
结构来记录自己的状态,并为集群中的所有其他节点(包括主节点和从节点)都创建一个相应的clusterNode
结构,以此来记录其他节点的状态。具体结构如下:
typedef struct clusterNode {
mstime_t ctime; // 节点创建时间
char name[REDIS_CLUSTER_NAMELEN]; // 节点ID(40位十六进制字符串)
int flags; // 节点标志(主/从、在线/下线等)
uint64_t configEpoch; // 配置纪元(故障转移时使用)
char ip[REDIS_IP_STR_LEN]; // 节点IP地址
int port; // 节点端口
clusterLink *link; // 与该节点的TCP连接信息
unsigned char slots[REDIS_CLUSTER_SLOTS/8]; // 槽位分配位图
int numslots; // 节点负责的槽位总数
int numslaves; // 从节点数量
struct clusterNode **slaves; // 从节点列表
struct clusterNode *slaveof; // 指向主节点的指针
// ...其他字段(心跳时间、故障报告等)
} clusterNode;
ctime
:记录节点创建时间,用于追踪节点的启动时间。name
:节点唯一ID,由40位十六进制字符串组成(如68eef66df23420a5862208ef5b1a7005b806f2ff
),通过CLUSTER NODES
命令可查看。flags
:节点标志位,通过位掩码表示节点角色和状态:REDIS_NODE_MASTER
:主节点REDIS_NODE_SLAVE
:从节点REDIS_NODE_PFAIL
:疑似下线REDIS_NODE_FAIL
:确认下线REDIS_NODE_HANDSHAKE
:握手阶段
configEpoch
:配置纪元,用于故障转移时的版本控制。每次主节点切换或配置变更时递增,确保节点状态一致性。ip
&port
:节点的IP地址和端口,用于节点间通信。link
:指向clusterLink
结构的指针,记录与该节点的TCP连接信息。slots
:二进制位数组,长度为16384/8 = 2048
字节,每位代表一个槽位(0~16383)。若某位为1,表示该节点负责对应槽位。比如:slots[0] = 0b11111111
表示槽位0~7由该节点负责。numslots
:节点负责的槽位总数,用于快速统计。slaves
&numslaves
:从节点列表及数量,仅在主节点中有效。slaveof
:指向主节点的指针,若为从节点,则指向其主节点。ping_sent
&pong_received
:记录最后一次发送PING和接收PONG的时间戳,用于心跳检测。若超时未响应,则标记为疑似下线(PFAIL
)。fail_time
:节点被标记为确认下线(FAIL
)的时间。
2.2 clusterLink
结构
clusterNode
结构的link属性是一个clusterLink
结构,该结构保存了连接节点所需的有关信息,比如套接字描述符,输入缓冲区和输出缓冲区,具体结构如下:
typedef struct clusterLink {
//连接的创建时间
mstime_t ctime;
// TCP套接字描述符
int fd;
//输出缓冲区,保存着等待发送给其他节点的消息(message)。
sds sndbuf;
//输入缓冲区,保存着从其他节点接收到的消息。
sds rcvbuf;
//与这个连接相关联的节点,如果没有的话就为NULL
struct clusterNode *node;
} clusterLink;
redisClient结构和clusterLink结构的相同和不同之处:redisClient结构和clusterLink结构都有自己的套接字描述符和输入、输出缓冲区,这两个结构的区别在于,redisClient结构中的套接字和缓冲区是用于连接客户端的,而clusterLink结构中的套接字和缓冲区则是用于连接节点的。
2.3 clusterState
结构
每个节点都保存着一个clusterState
结构,这个结构记录了在当前节点的视角下,集群目前所处的状态,例如集群是在线还是下线,集群包含多少个节点,集群当前的配置纪元。具体结构如下:
typedef struct clusterState {
clusterNode *myself; // 指向自身节点的clusterNode结构
dict *nodes; // 节点ID到clusterNode的映射字典
clusterNode *slots[REDIS_CLUSTER_SLOTS]; // 槽位到节点的直接映射
uint64_t currentEpoch; // 当前配置纪元(用于故障转移)
int state; // 集群整体状态(OK/FAIL)
int size; // 集群节点总数(主+从)
int dir; // 集群配置目录(用于持久化)
// ...其他字段(如迁移状态、故障报告等)
} clusterState;
myself
:自身节点指针,指向当前节点对应的clusterNode
结构,通过该指针可快速访问自身节点的详细信息(如IP、端口、槽位分配等)。nodes
:节点字典,一个字典(dict
类型),键为节点ID(40位十六进制字符串),值为对应的clusterNode
结构指针。存储集群中所有节点的信息,包括主节点和从节点。slots
数组:槽位映射,长度为16384
的数组,索引对应槽位编号(0~16383),值指向负责该槽位的clusterNode
结构。通过槽位编号直接获取负责节点,时间复杂度为O(1),是Redis集群高效路由的关键。currentEpoch
:配置纪元,无符号64位整数,用于标记集群配置的版本号。在故障转移、槽位迁移等操作时递增,确保所有节点对最新配置达成一致。state
:集群状态,整数,表示集群整体状态。客户端可通过CLUSTER INFO
命令查询该字段,判断集群是否可用。REDIS_CLUSTER_OK
:集群正常(所有主节点可用)。REDIS_CLUSTER_FAIL
:集群不可用(部分主节点下线且无足够从节点晋升)。
size
:集群节点数量,整数。若值为0,表示集群目前没有任何节点在处理槽。
3. 节点如何加入集群
Redis通过向节点A发送
CLUSTER MEET
命令,客户端可以让接收命令的节点A将另一个节点B添加到节点A当前所在的集群里面。CLUSTER MEET <ip> <port>
收到
CLUSTER MEET
命令的节点A将与节点B进行握手(handshake),以此来确认彼此的存在,并为将来的进一步通信打好基础。以下是节点A和节点B之间的握手过程的详细步骤:- 节点A会为节点B创建一个clusterNode结构,并将该结构添加到自己的clusterState.nodes字典里面。
- 之后,节点A将根据CLUSTER MEET命令给定的IP地址和端口号,向节点B发送一条MEET消息(message. 。
- 如果一切顺利,节点B将接收到节点A发送的MEET消息,节点B会为节点A创建一个clusterNode结构,并将该结构添加到自己的clusterState.nodes字典里面。
- 之后,节点B将向节点A返回一条PONG消息。
- 如果一切顺利,节点A将接收到节点B返回的PONG消息,通过这条PONG消息节点A可以知道节点B已经成功地接收到了自己发送的MEET消息。
- 之后,节点A将向节点B返回一条PING消息。
- 如果一切顺利,节点B将接收到节点A返回的PING消息,通过这条PING消息节点B可以知道节点A已经成功地接收到了自己返回的PONG消息,握手完成。
- 节点A和节点B握手完成完成后,节点A会将节点B的信息通过Gossip协议传播给集群中的其他节点,让其他节点也与节点B进行握手,最终,经过一段时间之后,节点B会被集群中的所有节点认识。
三、数据分片机制
Redis集群通过分片的方式来保存数据库中的键值对:集群的整个数据库被分为16384个固定哈希槽位(Slot,编号0~16383),数据库中的每个键都属于这16384个哈希槽的其中一个,集群中的每个节点可以处理0个或最多16384个哈希槽。
当Redis中的16384个哈希槽都有节点在处理时,集群处于上线状态(ok);相反地,如果数据库中有任何一个哈希槽没有得到处理,那么集群处于下线状态(fail)。
1. 记录节点的槽指派信息
集群的每个节点负责处理哪些槽会记录在clusterNode
结构的slots
属性和numslot
属性:
slots
属性是一个二进制位数组(bit array),这个数组的长度为16384/8=2048个字节,共包含16384个二进制位。numslots
属性则记录节点负责处理的槽的数量,也即是slots数组中值为1的二进制位的数量。
Redis以0为起始索引,16383为终止索引,对slots数组中的16384个二进制位进行编号,并根据索引i
上的二进制位的值来判断节点是否负责处理槽i
:
- 如果slots数组在索引
i
上的二进制位的值为1
,那么表示节点负责处理槽i
。 - 如果slots数组在索引
i
上的二进制位的值为0
,那么表示节点不负责处理槽i
。
如下图当前节点的哈希槽slots数组记录值,这个数组索引1、3、5、8、9、10上的二进制位的值都为1,而其余所有二进制位的值都为0,这表示节点负责处理槽1、3、5、8、9、10。
2. 传播节点的槽指派信息
一个集群节点除了会将自己负责处理的槽记录在clusterNode
结构的slots
属性和numslots
属性之外,它还会将自己的slots数组通过消息发送给集群中的其他节点,以此来告知集群其他节点自己目前负责处理哪些槽。
当节点A通过消息从节点B那里接收到节点B的slots数组时,节点A会在自己的clusterState.nodes
字典中查找节点B对应的clusterNode
结构,并对结构中的slots数组进行保存或者更新。通过传播记录节点的槽信息,集群中的每个节点都会知道数据库中的16384个槽分别被指派给了集群中的哪些节点。
3. 记录集群所有槽的指派信息
Reids集群中所有16384个槽的指派信息会记录在clusterState
结构中的slots
数组。slots
数组包含16384个项,每个数组项都是一个指向clusterNode
结构的指针:
- 如果
slots[i]
指针指向NULL
,那么表示槽i
尚未指派给任何节点。 - 如果
slots[i]
指针指向一个clusterNode
结构,那么表示槽i
已经指派给了clusterNode
结构所代表的节点。
Redis集群使用clusterState.slots
数组记录了集群中所有槽的指派信息,但使用clusterNode.slots
数组来记录单个节点的槽指派信息的原因:
- 当程序需要将某个节点的槽指派信息通过消息发送给其他节点时,程序只需要将相应节点的
clusterNode.slots
数组整个发送出去就可以了。 - 如果Redis不使用
clusterNode.slots
数组,而单独使用clusterState.slots
数组的话,那么每次要将节点A的槽指派信息传播给其他节点时,程序必须先遍历整个clusterState.slots
数组,记录节点A负责处理哪些槽,然后才能发送节点A的槽指派信息,这比直接发送clusterNode.slots
数组要麻烦和低效得多。
4. 节点的槽指派命令
集群的每一个节点都可以使用CLUSTER ADDSLOTS <slot> [slot...]
命令接受一个或多个槽作为参数,并将所有输入的槽指派给接收该命令的节点负责。比如在集群某个节点上执行CLUSTER ADDSLOTS 1 2
命令,将为该节点增加槽1和槽2,更新节点的clusterState、clusterNode
结构槽记录信息,并通知其他节点自己负责的槽信息。
以下是执行CLUSTER ADDSLOTS命令的伪代码:
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数组,将slots[i]的指针指向代表当前节点的clusterNode结构clusterState.slots[i] = clusterState.myself
#访问代表当前节点的clusterNode结构的slots数组,将数组在索引i上的二进制位设置为1
setSlotBit(clusterState.myself.slots, i)
四、客户端命令解析
当Reids集群将所有的16384个槽都指派给了对于的集群节点后,集群才可以正常对外提供服务。当客户端向节点发送与数据库键有关的命令时,接收命令的节点会计算出命令要处理的数据库键属于哪个槽,并检查这个槽是否指派给了自己:
- 如果键所在的槽正好就指派给了当前节点,那么节点直接执行这个命令。
- 如果键所在的槽并没有指派给当前节点,那么节点会向客户端返回一个MOVED错误,指引客户端转向(redirect)至正确的节点,并再次发送之前想要执行的命令。
1. 槽位分配算法
节点使用以下算法来计算给定键key属于哪个槽:
def slot_number(key):
return CRC16(key) & 16384
首先是CRC16(key)
语句对键(Key)计算CRC16哈希值,在通过&16384
语句对16384取模,计算出一个介于0至16383之间的整数作为键key的槽号。比如键"date"
的CRC16值为45678
,则槽位为45678 % 16384 = 12910
。
可以通过命令CLUSTER KEYSLOT<key>
查看一个给定键属于哪个槽:
127.0.0.1:7000> CLUSTER KEYSLOT "date"
(integer) 12910
127.0.0.1:7000> CLUSTER KEYSLOT "msg"
(integer) 6257
127.0.0.1:7000> CLUSTER KEYSLOT "name"
(integer) 5798
2. 判断槽是否由当前节点负责处理
当节点计算出键所属的槽i之后,节点就会检查自己在clusterState.slots
数组中的项i
,判断键所在的槽是否由自己负责:
- 如果
clusterState.slots[i]
等于clusterState.myself
,那么说明槽i
由当前节点负责,节点可以执行客户端发送的命令。 - 如果
clusterState.slots[i]
不等于clusterState.myself
,那么说明槽i
并非由当前节点负责,节点会根据clusterState.slots[i]
指向的clusterNode
结构所记录的节点IP和端口号,向客户端返回MOVED
错误,指引客户端转向至正在处理槽i的节点。
3. MOVED错误
当节点发现键所在的槽并非由自己负责处理的时候,节点就会向客户端返回一个MOVED错误,指引客户端转向至正在负责槽的节点。MOVED错误的格式如下,其中slot为键所在的槽,而ip和port则是负责处理槽slot的节点的IP地址和端口号。
// MOVED 10086 127.0.0.1:7002
MOVED <slot> <ip>:<port>
当客户端接收到节点返回的MOVED错误时,客户端会根据MOVED错误中提供的IP地址和端口号,转向至负责处理槽slot的节点,并向该节点重新发送之前想要执行的命令。如果两个节点之间未连接过,则会根据ip和端口号先建立节点连接。
$ redis-cli -c -p 7001 ...
# 集群模式
127.0.0.1:7001>SET msg "happy new year!"
->Redirected to slot [6257] located at 127.0.0.1:7002
OK
127.0.0.1:7002>
值得注意的是,单机模式的redis-cli客户端,再次向节点7001发送相同的命令,那么MOVED错误就会被客户端打印出来:
$ redis-cli -p 7001 ...
# 单机模式
127.0.0.1:7001>SET msg "happy new year!"
(error)MOVED 6257 127.0.0.1:7002
127.0.0.1:7001>
这是因为单机模式的redis-cli客户端不清楚MOVED错误的作用,所以它只会直接将MOVED错误直接打印出来,而不会进行自动转向。
4. 节点数据库的实现
Redis集群节点服务器和单机服务器在数据库方面的区别是:集群节点只能使用0号数据库,而单机Redis服务器则没有这一限制。
集群节点服务器保存键值对以及键值对过期时间的方式与单机服务器完全相同。但是集群节点服务器除了将键值对保存在数据库里面之外,集群节点服务器还会用
clusterState
结构中的slots_to_keys
跳跃表来保存槽和键之间的关系。比如一个简单的slots_to_keys
跳跃表结构图如下:
slots_to_keys
跳跃表每个节点的分值(score)都是一个槽号,而每个节点的成员(member)都是一个数据库键:- 每当节点往数据库中添加一个新的键值对时,节点就会将这个键以及键的槽号关联到
slots_to_keys
跳跃表。 - 当节点删除数据库中的某个键值对时,节点就会在
slots_to_keys
跳跃表解除被删除键与槽号的关联。
- 每当节点往数据库中添加一个新的键值对时,节点就会将这个键以及键的槽号关联到
通过在
slots_to_keys
跳跃表中记录各个数据库键所属的槽,节点可以很方便地对属于某个或某些槽的所有数据库键进行批量操作,例如命令CLUSTER GETKEYSINSLOT <slot> <count>
命令可以返回最多count个属于槽slot的数据库键,而这个命令就是通过遍历slots_to_keys
跳跃表来实现的。
五、重新分片
Redis集群的重新分片操作可以将任意数量已经指派给某个节点(源节点)的槽改为指派给另一个节点(目标节点),并且相关槽所属的键值对也会从源节点被移动到目标节点。
重新新分片操作可以在线(online)进行,在重新分片的过程中,集群不需要下线,并且源节点和目标节点都可以继续处理命令请求。
1. 手动重新分片(redis-trib.rb
/ redis-cli
)
Redis集群的重新分片操作在Redis 3.x~5.x 使用redis-trib.rb
工具,在Redis 6.x 及以上推荐使用 redis-cli
。以下是redis-trib.rb/redis-cli
对集群的单个槽slot进行重新分片的详细步骤如下:
目标节点准备导入数据:
redis-trib.rb/redis-cli
通知目标节点(Target Node)准备接收属于该槽的所有键值对。目标节点会记录该槽的导入状态,并在后续迁移过程中处理来自源节点的数据。使用如下命令:CLUSTER SETSLOT <slot> IMPORTING <source_node_id>
源节点准备迁移数据:
redis-trib.rb/redis-cli
通知源节点(Source Node)准备将该槽的键值对迁移至目标节点。源节点会记录该槽的迁移状态,并在后续处理客户端请求时返回ASK
重定向。使用如下命令:CLUSTER SETSLOT <slot> MIGRATING <target_node_id>
获取槽中的键名列表:
redis-trib.rb/redis-cli
向源节点发送请求,获取最多count
个属于该槽的键名(Key Name)。此命令用于分批迁移键值对,避免一次性传输大量数据导致性能问题。使用如下命令:CLUSTER GETKEYSINSLOT <slot> <count>
逐个迁移键值对:对于步骤3获得的每个键名,
redis-trib.rb/redis-cli
都向源节点发送MIGRATE
命令,将键值对原子性地从源节点迁移至目标节点。迁移过程中:- 源节点会锁定键并删除本地数据。
- 目标节点接收键并写入本地存储。
- 迁移是原子的,确保键在任意时刻只存在于源节点或目标节点之一。
MIGRATE <target_ip> <target_port> <key_name> 0 <timeout>
重复迁移直到完成:重复执行步骤3和步骤4,直到源节点保存的所有属于槽slot的键值对都被迁移至目标节点为止。
更新槽指派给目标节点:
redis-trib.rb/redis-cli
向集群中的任意节点发送命令,将该槽的归属权更新为目标节点。此信息会通过 Gossip 协议传播到整个集群,确保所有节点同步最新槽分配。使用如下命令:CLUSTER SETSLOT <slot> NODE <target_node_id>
如果重新分片涉及多个槽,那么
redis-trib.rb/redis-cli
将对每个给定的槽分别执行1~6给出的步骤。
单个槽手动重新分片完整的伪代码:
#假设需将槽 `0` 从节点 `A`(IP: 192.168.1.101:6379)迁移到节点 `B`(IP:192.168.1.102:6379)
# 1.标记目标节点为导入状态
redis-cli -h 192.168.1.102 -p 6379 CLUSTER SETSLOT 0 IMPORTING a1b2c3d4...
# 2.标记源节点为迁移状态
redis-cli -h 192.168.1.101 -p 6379 CLUSTER SETSLOT 0 MIGRATING b3a4c5d6...
# 3.获取槽中的键名
redis-cli -h 192.168.1.101 -p 6379 CLUSTER GETKEYSINSLOT 0 100
# 4~5.逐个迁移键
for key in $(redis-cli -h 192.168.1.101 -p 6379 CLUSTER GETKEYSINSLOT 0 100); do
redis-cli -h 192.168.1.101 -p 6379 MIGRATE 192.168.1.102 6379 $key 0 5000
done
# 6.更新槽归属
redis-cli -h 192.168.1.101 -p 6379 CLUSTER SETSLOT 0 NODE b3a4c5d6...
2. 自动重新分片(redis-cli --cluster reshard
)
Redis 6.x 推荐使用 redis-cli --cluster reshard
自动完成槽迁移,支持批量迁移和交互式操作。以下是参考步骤:
查看槽分配
redis-cli -p 7001 CLUSTER SLOTS | grep 1000
执行重新分片(假设槽1000在节点A,需迁移到节点B)
redis-cli --cluster reshard 127.0.0.1:7001 --cluster-from "a1b2c3d4" --cluster-to "e5f6g7h8" --cluster-slots 1
输入交互式参数
#输入你要迁移的槽数量(1~16384)。 How many slots do you want to move (from 1 to 16384)? 1 #输入目标节点的 Node ID。 #可以通过 redis-cli --cluster nodes <host>:<port> 查看集群节点的ID。 What is the destination node ID? e5f6g7h8 #输入源节点的 Node ID。可以是一个或多个,输入`all`时表示从所有节点迁移。 Please enter all the source node IDs. a1b2c3d4 #输入 `yes` 确认。 Do you want to proceed with the proposed reshard plan (yes/no)? yes
验证结果
redis-cli -p 7001 CLUSTER SLOTS | grep 1000
3. ASK错误
在进行重新分片期间,源节点向目标节点迁移一个槽的过程中,可能会出现这样一种情况:属于被迁移槽的一部分键值对保存在源节点里面,而另一部分键值对则保存在目标节点里面。当客户端向源节点发送一个与数据库键有关的命令,并且命令要处理的数据库键恰好就属于正在被迁移的槽时:
- 源节点会先在自己的数据库里面查找指定的键,如果找到的话,就直接执行客户端发送的命令。
- 相反地,如果源节点没能在自己的数据库里面找到指定的键,那么这个键有可能已经被迁移到了目标节点,源节点将向客户端返回一个ASK错误,指引客户端转向正在导入槽的目标节点,并再次发送之前想要执行的命令。
ASK错误和MOVED错误都会导致客户端转向,它们的区别在于:
- MOVED错误:MOVED错误代表槽的负责权已经从一个节点转移到了另一个节点。在客户端收到关于槽i的MOVED错误之后,客户端每次遇到关于槽i的命令请求时,都可以直接将命令请求发送至MOVED错误所指向的节点,因为该节点就是目前负责槽i的节点。
- ASK错误:ASK错误只是两个节点在迁移槽的过程中使用的一种临时措施。在客户端收到关于槽i的ASK错误之后,客户端只会在接下来的一次命令请求中将关于槽i的命令请求发送至ASK错误所指示的节点,但这种转向不会对客户端今后发送关于槽i的命令请求产生任何影响,客户端仍然会将关于槽i的命令请求发送至目前负责处理槽i的节点,除非ASK错误再次出现。
六、复制与故障转移
Redis集群中的节点分为主节点(master)和从节点(slave),其中主节点用于处理槽,而从节点则用于复制某个主节点,并在被复制的主节点下线时,代替下线主节点继续处理命令请求。
1. 节点复制
向一个节点发送命令,可以让接收命令的节点成为node_id所指定节点的从节点,并开始对主节点进行复制:
CLUSTER REPLICATE <node_id>
与主从复制原理一样,集群节点会在clusterState.nodes
内保存主从节点标识、主从节点ip和端口等信息,通过这些信息建立主从关系复制。当一个从节点开始复制某个主节点时,这一信息会通过消息发送给集群中的其他节点,最终集群中的所有节点都会在记录该主节点的clusterNode
结构记录并保存从节点正在复制该主节点相关的信息。
2. 故障检测
集群中的每个节点都会定期地向集群中的其他节点发送PING消息,以此来检测对方是否在线,如果接收PING消息的节点没有在规定的时间内,向发送PING消息的节点返回PONG消息,那么发送PING消息的节点就会将接收PING消息的节点标记为疑似下线(probable fail,PFAIL)。
当一个主节点A通过消息得知主节点B认为主节点C进入了疑似下线状态时,主节点A会在自己的clusterState.nodes
字典中找到主节点C所对应的clusterNode
结构,并将主节点B的下线报告(每个下线报告由一个clusterNodeFailReport
结构表示)添加到clusterNode
结构的fail_reports
链表里面。
如果在一个集群里面,半数以上负责处理槽的主节点都将某个主节点x报告为疑似下线,那么这个主节点x将被标记为已下线(FAIL),将主节点x标记为已下线的节点会向集群广播一条关于主节点x的FAIL消息,所有收到这条FAIL消息的节点都会立即将主节点x标记为已下线。
3. 故障转移
当一个从节点发现自己正在复制的主节点进入了已下线状态时,从节点将开始对下线主节点进行故障转移,以下是故障转移的执行步骤:
- 复制下线主节点的所有从节点里面,会有一个从节点被选中。
- 被选中的从节点会执行
SLAVEOF no one
命令,成为新的主节点。 - 新的主节点会撤销所有对已下线主节点的槽指派,并将这些槽全部指派给自己。
- 新的主节点向集群广播一条PONG消息,这条PONG消息可以让集群中的其他节点立即知道这个节点已经由从节点变成了主节点,并且这个主节点已经接管了原本由已下线节点负责处理的槽。
- 新的主节点开始接收和自己负责处理的槽有关的命令请求,故障转移完成。
4. 选举新的主节点
与哨兵领头选举机制原理类似,新的主节点也是基于Raft
算法的领头选举产生的。以下是集群选举新的主节点的方法:
- 集群的配置纪元是一个自增计数器,它的初始值为0。
- 当集群里的某个节点开始一次故障转移操作时,集群配置纪元的值会被增一。
- 对于每个配置纪元,集群里每个负责处理槽的主节点都有一次投票的机会,而第一个向主节点要求投票的从节点将获得主节点的投票。
- 当从节点发现自己正在复制的主节点进入已下线状态时,从节点会向集群广播一条
CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST
消息,要求所有向这个从节点投票。 - 如果一个主节点具有投票权(它正在负责处理槽),并且这个主节点尚未投票给其他从节点,那么主节点将向要求投票的从节点返回一条
CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK
消息,表示这个主节点支持从节点成为新的主节点。 - 每个参与选举的从节点都会接收
CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK
消息,并根据自己收到了多少条这种消息来统计自己获得了多少主节点的支持。 - 如果集群里有N个具有投票权的主节点,那么当一个从节点收集到大于等于N/2+1张支持票时,这个从节点就会当选为新的主节点。
- 因为在每一个配置纪元里面,每个具有投票权的主节点只能投一次票,所以如果有N个主节点进行投票,那么具有大于等于N/2+1张支持票的从节点只会有一个,这确保了新的主节点只会有一个。
- 如果在一个配置纪元里面没有从节点能收集到足够多的支持票,那么集群进入一个新的配置纪元,并再次进行选举,直到选出新的主节点为止。
七、消息
集群中的各个节点通过发送和接收消息(message)来进行通信,我们称发送消息的节点为发送者(sender),接收消息的节点为接收者(receiver)。节点发送的消息主要有以下五种:
- MEET消息:当发送者接到客户端发送的CLUSTER MEET命令时,发送者会向接收者发送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消息来让集群中的其他节点立即刷新关于这个节点的认识,例如当一次故障转移操作成功执行之后,新的主节点会向集群广播一条PONG消息,以此来让集群中的其他节点立即知道这个节点已经变成了主节点,并且接管了已下线节点负责的槽。
- FAIL消息:当一个主节点A判断另一个主节点B已经进入FAIL状态时,节点A会向集群广播一条关于节点B的FAIL消息,所有收到这条消息的节点都会立即将节点B标记为已下线。
- PUBLISH消息:当节点接收到一个PUBLISH命令时,节点会执行这个命令,并向集群广播一条PUBLISH消息,所有接收到这条PUBLISH消息的节点都会执行相同的PUBLISH命令。
每条消息由由消息头(header)和消息正文(data)组成,每个消息头都由一个cluster.h/clusterMsg
结构表示,每个消息头内的clusterMsg.data
属性指向联合cluster.h/clusterMsgData
,这个联合就是消息的正文。以下是消息头和消息正文的结构体:
typedef struct {
uint32_t totlen; // 消息总长度(含头和正文)
uint16_t type; // 消息类型(如MEET、PING、PONG等)
uint16_t count; // 正文包含的节点信息数量(仅Gossip协议使用)
uint64_t currentEpoch; // 发送者的配置纪元(用于故障转移)
uint64_t configEpoch; // 主节点配置纪元或从节点复制的主节点纪元
char sender[REDIS_CLUSTER_NAMELEN]; // 发送者ID
unsigned char myslots[REDIS_CLUSTER_SLOTS/8]; // 槽位指派位图
char slaveof[REDIS_CLUSTER_NAMELEN]; // 从节点复制的主节点ID
uint16_t port; // 发送者端口
uint16_t flags; // 节点标识(如主/从、在线状态)
unsigned char state; // 集群状态
union clusterMsgData data; // 消息正文(联合体)
} clusterMsg;
union clusterMsgData {
// Gossip协议消息(MEET、PING、PONG)
struct {
clusterMsgDataGossip gossip[1]; // 携带两个节点信息
} ping;
// 故障消息(FAIL)
struct {
clusterMsgDataFail about; // 故障节点信息
} fail;
// 发布订阅消息(PUBLISH)
struct {
clusterMsgDataPublish msg; // 发布内容
} publish;
// 主节点选举请求(FAILOVER_AUTH_REQUEST)
struct {
clusterMsgDataAuthRequest req; // 选举请求
} authRequest;
// 主节点选举确认(FAILOVER_AUTH_ACK)
struct {
clusterMsgDataAuthAck ack; // 选举确认
} authAck;
};
typedef struct {
char nodename[REDIS_CLUSTER_NAMELEN]; // 节点ID
uint32_t ping_sent; // 上次发送PING的时间戳
uint32_t pong_received; // 上次接收PONG的时间戳
char ip[NET_IP_STR_LEN]; // 节点IP
uint16_t port; // 节点端口
uint16_t cport; // 集群总线端口
uint16_t flags; // 节点标识
uint32_t notused1; // 保留字段
} clusterMsgDataGossip;
typedef struct {
char nodename[REDIS_CLUSTER_NAMELEN]; // 故障节点ID
uint64_t configEpoch; // 故障节点的配置纪元
} clusterMsgDataFail;
typedef struct {
char channel[REDIS_CLUSTER_NAMELEN]; // 订阅的频道名称
char message[REDIS_CLUSTER_MSGSIZE]; // 发布的消息内容
uint64_t sender_epoch; // 发送者的配置纪元(用于版本控制)
char sender_id[REDIS_CLUSTER_NAMELEN]; // 发送者节点ID
} clusterMsgDataPublish;
typedef struct {
uint64_t configEpoch; // 从节点当前配置纪元(或其主节点的配置纪元)
char slave_id[REDIS_CLUSTER_NAMELEN]; // 从节点ID
uint64_t offset; // 从节点复制主节点的偏移量(数据同步进度)
uint16_t priority; // 从节点优先级(配置项`slave-priority`)
} clusterMsgDataAuthRequest;
typedef struct {
uint64_t configEpoch; // 选举的配置纪元
char slave_id[REDIS_CLUSTER_NAMELEN]; // 确认选举的从节点ID
} clusterMsgDataAuthAck;
总结
Redis集群通过哈希槽(16384个)实现数据分片将数据分布到多个节点,利用Gossip协议实现节点间通信与状态同步,结合主从复制与自动故障转移保障高可用,并通过迁移哈希槽实现动态扩容与负载均衡。