type
status
date
slug
summary
tags
category
icon
password
Property
目录
节点启动节点集群数据结构CLUSTER MEET 命令的实现槽指派记录节点的槽指派信息传播节点的槽指派信息记录集群所有槽的指派信息CLUSTER ADDSLOTS命令的实现在集群中执行命令计算键所属槽判断槽所属节点(struct clusterState.slots数组)MOVED错误节点数据库的实现重新分片CLUSTER SETSLOT IMPORTING命令的实现CLUSTER SETSLOT MIGRATING命令的实现ASK错误ASKING命令复制与故障转移复制的命令(设置从节点)故障检测故障转移消息消息的结构MEET、PING、PONG消息的实现(Gossip协议)FAIL消息的实现PUBLISH消息的实现
节点
一个
Redis
集群通常由多个节点(node)组成, 在刚开始的时候, 每个节点都是相互独立的, 它们都处于一个只包含自己的集群当中, 要组建一个真正可工作的集群, 必须将各个独立的节点连接起来, 构成一个包含多个节点的集群。连接各个节点的工作可以使用
CLUSTER MEET
命令来完成:向一个节点
node
发送 CLUSTER MEET
命令, 可以让node
节点与ip
和port
所指定的节点进行握手(handshake), 当握手成功时, node
节点就会将 ip
和 port
所指定的节点添加到node
节点当前所在的集群中。 假设现在有三个独立的节点
127.0.0.1:7000
、 127.0.0.1:7001
、 127.0.0.1:7002
, 首先使用客户端连上节点7000 , 通过发送CLUSTER NODE
命令可以看到, 集群目前只包含 7000 自己一个节点:通过向节点 7000 发送以下命令, 可以将节点 7001 添加到节点 7000 所在的集群里面:
继续向节点7000发送以下命令, 将节点 7002 也添加到节点 7000 和节点 7001 所在的集群里面:
启动节点
一个节点就是一个运行在集群模式下的
Redis
服务器, Redis
服务器在启动时会根据cluster-enabled
配置选项的是否为 yes
来决定是否开启服务器的集群模式:节点(运行在集群模式下的
Redis
服务器)会继续使用所有在单机模式中使用的服务器组件, 比如说:- 节点会继续使用文件事件处理器来处理命令请求和返回命令回复
- 节点会继续使用时间事件处理器来执行
serverCron
函数, 而serverCron
函数又会调用集群模式特有的clusterCron
函数:clusterCron
函数负责执行在集群模式下需要执行的常规操作, 比如向集群中的其他节点发送Gossip
消息, 检查节点是否断线; 又或者检查是否需要对下线节点进行自动故障转移, 等等。
- 节点会继续使用数据库来保存键值对数据,键值对依然会是各种不同类型的对象。
- 节点会继续使用 RDB 持久化模块和 AOF 持久化模块来执行持久化工作。
- 节点会继续使用发布与订阅模块来执行 PUBLISH 、 SUBSCRIBE 等命令。
- 节点会继续使用复制模块来进行节点的复制工作。
- 节点会继续使用 Lua 脚本环境来执行客户端输入的 Lua 脚本。
除此之外, 节点会继续使用
redisServer
结构来保存服务器的状态, 使用 redisClient
结构来保存客户端的状态, 至于那些只有在集群模式下才会用到的数据, 节点将它们保存到了 cluster.h/clusterNode
结构, cluster.h/clusterLink
结构, 以及 cluster.h/clusterState
结构里面集群数据结构
clusterNode
结构保存了一个节点的当前状态, 比如节点的创建时间, 节点的名字, 节点当前的配置纪元, 节点的 IP 和地址, 等等。每个节点都会使用一个
clusterNode
结构来记录自己的状态, 并为集群中的所有其他节点(包括主节点和从节点)都创建一个相应的clusterNode
结构, 以此来记录其他节点的状态:clusterNode
结构的link
属性是一个clusterLink
结构, 该结构保存了连接节点所需的有关信息, 比如套接字描述符, 输入缓冲区和输出缓冲区:redisClient
结构和 clusterLink
结构的相同和不同之处:redisClient
结构和 clusterLink
结构都有自己的套接字描述符和输入、输出缓冲区, 这两个结构的区别在于, redisClient
结构中的套接字和缓冲区是用于连接客户端的, 而 clusterLink
结构中的套接字和缓冲区则是用于连接节点的。每个节点都保存着一个
clusterState
结构, 这个结构记录了在当前节点的视角下, 集群目前所处的状态 —— 比如集群是在线还是下线, 集群包含多少个节点, 集群当前的配置纪元, 诸如此类:节点 7000 创建的
clusterState
结构, 这个结构从节点 7000 的角度记录了集群、以及集群包含的三个节点的当前状态:- 结构的
currentEpoch
属性的值为0
, 表示集群当前的配置纪元为0
- 结构的
size
属性的值为0
, 表示集群目前没有任何节点在处理槽: 因此结构的state
属性的值为REDIS_CLUSTER_FAIL
—— 这表示集群目前处于下线状态
- 结构的
nodes
字典记录了集群目前包含的三个节点, 这三个节点分别由三个clusterNode
结构表示: 其中myself
指针指向代表节点 7000 的clusterNode
结构, 而字典中的另外两个指针则分别指向代表节点 7001 和代表节点 7002 的clusterNode
结构, 这两个节点是节点 7000 已知的在集群中的其他节点
- 三个节点的
clusterNode
结构的flags
属性都是REDIS_NODE_MASTER
,说明三个节点都是主节点。
节点 7001 和节点 7002 也会创建类似的
clusterState
结构:- 不过在节点 7001 创建的
clusterState
结构中,myself
指针将指向代表节点 7001 的clusterNode
结构, 而节点 7000 和节点 7002 则是集群中的其他节点。
- 而在节点 7002 创建的
clusterState
结构中,myself
指针将指向代表节点 7002 的clusterNode
结构, 而节点 7000 和节点 7001 则是集群中的其他节点。
CLUSTER MEET 命令的实现
通过向节点
A
发送CLUSTER MEET
命令, 客户端可以让接收命令的节点A
将另一个节点B
添加到节点A
当前所在的集群里面:收到命令的节点 A 将与节点 B 进行握手(handshake), 以此来确认彼此的存在, 并为将来的进一步通信打好基础:
- 节点 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 的信息通过 Gossip 协议传播给集群中的其他节点, 让其他节点也与节点 B 进行握手, 最终, 经过一段时间之后, 节点 B 会被集群中的所有节点认识
槽指派
Redis
集群通过分片的方式来保存数据库中的键值对:集群的整个数据库被分为16384个槽,数据库中的每个键都属于这16384个槽的其中一个,集群中的每个节点可以处理0个或最多16384个槽。当数据库中的16384个槽都有节点在处理时,集群处于上线状态(ok);相反地,如果数据库中有任何一个槽没有得到处理,那么集群处于下线状态(fail)。
通过向节点发送
CLUSTER ADDSLOTS
命令,可以将一个或多个槽指派(assign)给节点负责:将槽0至槽5000指派给节点7000:
记录节点的槽指派信息
clusterNode
结构的slots
属性和numslot
属性记录了节点负责处理哪些槽:slots
属性是一个二进制位数组(bit array),这个数组的长度为16384/8=2048
个字节,共包含16384个二进制位。以0
为起始索引,16383
为终止索引,对slots
数组中的16384
个二进制位进行编号, 并根据索引i
上的二进制位的值来判断节点是否负责处理槽i
:- 如果
slots
数组在索引i
上的二进制位的值为1
,那么表示节点负责处理槽i
- 如果
slots
数组在索引i
上的二进制位的值为0
,那么表示节点不负责处理槽i
numslots
属性记录节点负责处理的槽的数量,也即是slots
数组中值为1
的二进制位的数量传播节点的槽指派信息
一个节点除了会将自己负责处理的槽记录在
clusterNode
结构的slots
属性和numslots
属性之 外,它还会将自己的slots
数组通过消息发送给集群中的其他节点,以此来告知其他节点自己目前负责处理哪些槽当节点
A
通过消息从节点B
那里接收到节点B
的slots
数组时,节点A
会在自己的clusterState.nodes
字典中查找节点B
对应的clusterNode
结构,并对结构中的slots
数组进行保存或者更新。因为集群中的每个节点都会将自己的
slots
数组通过消息发送给集群中的其他节点,并且每个接收到slots
数组的节点都会将数组保存到相应节点的clusterNode
结构里面,因此,集群中的每个节点都会知道数据库中的16384
个槽分别被指派给了集群中的哪些节点。记录集群所有槽的指派信息
clusterState
结构中的slots
数组记录了集群中所有16384个槽的指派信息:slots
数组包含16384个项,每个数组项都是一个指向clusterNode
结构的指针:- 如果
slots[i]
指针指向NULL,那么表示槽i尚未指派给任何节点
- 如果
slots[i]
指针指向一个clusterNode
结构,那么表示槽i
已经指派给了clusterNode
结构所代表的节点
struct clusterState.slots数组减少了复杂度
如果只将槽指派信息保存在各个节点的
clusterNode.slots
数组里,会出现一些无法高效地解决的问题,而clusterState.slots
数组的存在解决了这些问题:- 如果节点只使用
clusterNode.slots
数组来记录槽的指派信息,那么为了知道槽i是否已经被指派,或者槽i被指派给了哪个节点,程序需要遍历clusterState.nodes
字典中的所有clusterNode
结构,检查这些结构的slots
数组,直到找到负责处理槽i
的节点为止,这个过程的复杂度为 ,其中为clusterState.nodes
字典保存的clusterNode
结构的数量
- 而通过将所有槽的指派信息保存在
clusterState.slots
数组里面,程序要检查槽i是否已经被指派,又或者取得负责处理槽E的节点,只需要访问clusterState.slots[i]
的值即可,这个操作的复杂度仅为
clusterState.slots
数组与clusterNode.slots
数组的区别与关系虽然
clusterState.slots
数组记录了集群中所有槽的指派信息,但使用clusterNode
结构的slots
数组来记录单个节点的槽指派信息仍然是有必要的:- 因为当程序需要将某个节点的槽指派信息通过消息发送给其他节点时,程序只需要将相 应节点的
clusterNode.slots
数组整个发送出去就可以了
- 另一方面,如果
Redis
不使用clusterNode.slots
数组,而单独使用clusterState.slots
数组的 话,那么每次要将节点A的槽指派信息传播给其他节点时,程序必须先遍历整个clusterState.slots
数组,记录节点A
负责处理哪些槽,然后才能发送节点A
的槽指派信息,这比 直接发送clusterNode.slots
数组要麻烦和低效得多
clusterState.slots
数组记录了集群中所有槽的指派信息,而clusterNode.slots
数组只记录了clusterNode
结构所代表的节点的槽指派信息,这是两个slots
数组的关键区别所在
CLUSTER ADDSLOTS命令的实现
CLUSTER ADDSLOTS
命令接受一个或多个槽作为参数,并将所有输入的槽指派给接收该命令的节点负责:CLUSTER ADDSLOTS
命令的实现可以用以下伪代码来表示:一个节点的
clusterState
结构,clusterState.slots
数组中的所有指针都指向NULL
,并且clusterNode.slots
数组中的所有二进制位的值都是0,这说明当前节点没有被指派任何槽,并且集群中的所有槽都是未指派的:当客户端对上图所示的节点执行命令:
将槽1和槽2指派给节点之后,节点的
clusterState
结构将被更新成下面的样子:在命令执行完毕之后,节点会通过发送消息告知集群中的其他节点,自己目前正在负责处理哪些槽
在集群中执行命令
在对数据库中的
16384
个槽都进行了指派之后,集群就会进入上线状态,这时客户端就可以向集群中的节点发送数据命令了:当客户端向节点发送与数据库键有关的命令时,接收命令的节点会计算出命令要处理的数据库键属于哪个槽,并检查这个槽是否指派给了自己:
- 如果键所在的槽正好就指派给了当前节点,那么节点直接执行这个命令
- 如果键所在的槽并没有指派给当前节点,那么节点会向客户端返回一个MOVED错误, 指引客户端转向(redirect)至正确的节点,并再次发送之前想要执行的命令
如果我,由7000、7001、7002三个节点组成的集群中,用客户端连上节点7000,并发送以下命令,键date所在的槽2022正是由节点7000负责处理的,那么命令会直接被节点7000执行:
但是,如果执行以下命令,那么客户端会先被转向至节点7001,然后再执行命令:
这是因为键
msg
所在的槽6257
是由节点7001
负责处理的,而不是由最初接收命令的节点7000
负责处理。计算键所属槽
节点使用以下伪代码算法来计算给定键
key
属于哪个槽:CRC16(key)语句用于计算键
key
的CRC-16
校验和,而&16383
语句则用于计算出 一个介于0至16383之间的整数作为键key
的槽号使用“
CLUSTER KEYSLOT <key>
命令”可以查看一个给定键属于哪个槽:CLUSTER KEYSLOT
命令就是通过调用上面给出的槽分配算法来实现的,以下是该命令的伪代码实现:判断槽所属节点(struct clusterState.slots数组)
当节点计算出键所属的槽
i
之后,节点就会检查自己在clusterState.slots
数组中的项i
,判断键所在的槽是否由自己负责:- 如果
clusterState.slots[i]
等于clusterState.myself
,那么说明槽i由当前节点负责,节点可以执行客户端发送的命令
- 如果
clusterState.slots[i]
不等于clusterState.myself
,那么说明槽i并非由当前节点负责,节点会根据clusterState.slots[i]
指向的clusterNode
结构所记录的节点IP和端口号,向客户端返回MOVED
错误,指引客户端转向至正在处理槽i的节点
假设下图为节点
7000
的clusterState
结构:- 当客户端向节点7000发送命令
SET date"2013-12-31"
的时候,节点首先计算出键date
属于槽2022
,然后检查得出clusterState.slots[2022]
等于clusterState.myself
,这说明槽2022
正是由节点7000
负责,于是节点7000
直接执行这个SET
命令,并将结果返回给发送命令的客户端
- 当客户端向节点
7000
发送命令SET msg"happy new year!"
的时候,节点首先计算出键msg
属于槽6257,然后检查clusterState.slots[6257]
是否等于clusterState.myself
,结果发现两者并不相等:这说明槽6257
并非由节点7000
负责处理,于是节点7000访问clusterState.slots[6257]
所指向的clusterNode
结构,并根据结构中记录的IP地址127.0.0.1
和端口号7001
,向客户端返回错误MOVED 62 127.0.0.1:7001
,指引节点转向至正在负责处理槽6257
的节点7001
MOVED错误
何时出现?当节点发现键所在的槽并非由自己负责处理的时候,节点就会向客户端返回一个 MOVED错误,指引客户端转向至正在负责槽的节点
MOVED错误的格式为:
其中
slot
为键所在的槽,而ip
和port
则是负责处理槽slot
的节点的IP
地址和端口号- 当客户端接收到节点返回的
MOVED
错误时,客户端会根据MOVED
错误中提供的IP
地址和端口号,转向至负责处理槽slot
的节点,并向该节点重新发送之前想要执行的命令
- 一个集群客户端通常会与集群中的多个节点创建套接字连接,而所谓的节点转向实际上就是换一个套接字来发送命令
- 如果客户端尚未与想要转向的节点创建套接字连接,那么客户端会先根据
MOVED
错误提供的IP地址和端口号来连接节点,然后再进行转向
节点数据库的实现
集群模式下与单机模式下数据库的异同:
- 相同点:集群节点保存键值对以及键值对过期时间的方式,与单机
Redis
服务器保存键值对以及键值对过期时间的方式完全相同
- 不同点:节点只能使用0号数据库,而单机
Redis
服务器则没有这一限制
节点7000的数据库状态,数据库中包含列表键"lst",哈希 键"book",以及字符串键"date",其中键"lst"和键"book"带有过期时间:
除了将键值对保存在数据库里面之外,节点还会用
clusterState
结构中的 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)进行,在重新分片的过程中,集群不需要下线,并且源节点和目标节点都可以继续处理命令请求。
Redis
集群的重新分片操作是由Redis
的集群管理软件redis-trib
负责执行的,Redis
提供了进行重新分片所需的所有命令,而redis-trib
则通过向源节点和目标节点发送命令来进行重新分片操作:重新分片的步骤:
redis-trib
对目标节点发送“CLUSTER SETSLOT <slot> IMPORTING <source_id>
”命令, 让目标节点准备好从源节点导入(import)属于槽slot的键值对
redis-trib
对源节点发送“CLUSTER SETSLOT <slot> MIGRATING <target_id>
”命令,让源节点准备好将属于槽slot的键值对迁移(migrate)至目标节点
redis-trib
向源节点发送“CLUSTER GETKEYSINSLOT <slot> <count>
”命令,获得最多count
个属于槽slot的键值对的键名(key name)
- 对于步骤3获得的每个键名,
redis-trib
都向源节点发送一个“MIGRATE <target_ip> <target_port> <key_name> 0 <time out>
”命令,将被选中的键原子地从源节点迁移至目标节点
- 重复执行步骤3和步骤4,直到源节点保存的所有属于槽
slot
的键值对都被迁移至目标节点为止。每次迁移键的过程如下:
redis-trib
向集群中的任意一个节点发送CLUSTER SETSLOTNODE
命令,将槽slot
指派给目标节点,这一指派信息会通过消息发送至整个集群,最终集群中的所有节点都会知道槽slot
已经指派给了目标节点
CLUSTER SETSLOT IMPORTING命令的实现
clusterState
结构的importing_slots_from
数组记录了当前节点正在从其他节点导入的槽:如果importing_slots_from[i]
的值不为NULL,而是指向一个clusterNode
结构,那么表示当前节点正在从clusterNode
所代表的节点导入槽i
在对集群进行重新分片的时候,向目标节点发送命令,可以将目标节点
clusterState.importing_slots_from[i]
的值设置为source_id
所代表节点的clusterNode
结构:如果客户端向节点7003发送以下命令:
那么节点7003的
clusterState.importing_slots_from
数组将变成:CLUSTER SETSLOT MIGRATING命令的实现
clusterState
结构的migrating_slots_to
数组记录了当前节点正在迁移至其他节点的槽:如果migrating_slots_to[i]
的值不为NULL,而是指向一个clusterNode
结构,那么表示当前 节点正在将槽i
迁移至clusterNode
所代表的节点在对集群进行重新分片的时候,向源节点发送命令,可以将源节点
clusterState.migrating_slots_to[i]
的值设置为target_id
所代表节点的clusterNode
结构:CLUSTER SETSLOT <i> MIGRATING <target_id>
。如果客户端向节点7002发送以下命令:
那么节点7002的
clusterState.migrating_slots_to
数组将变成下图所示的样子:ASK错误
在进行重新分片期间,源节点向目标节点迁移一个槽的过程中,可能会出现这样一种情况:属于被迁移槽的一部分键值对保存在源节点里面,而另一部分键值对则保存在目标节点里面
当客户端向源节点发送一个与数据库键有关的命令,并且命令要处理的数据库键恰好就属于正在被迁移的槽时:
- 源节点会先在自己的数据库里面查找指定的键,如果找到的话,就直接执行客户端发送的命令(底层实现:如果节点收到一个关于键
key
的命令请求,并且键key
所属的槽i正好就指派给了这个节点,那么节点会尝试在自己的数据库里查找键key
,如果找到了的话,节点就直接执行客户端发送的命令)
- 相反地,如果源节点没能在自己的数据库里面找到指定的键,那么这个键有可能已经被迁移到了目标节点,源节点将向客户端返回一个ASK错误,指引客户端转向正在导入槽的目标节点,并再次发送之前想要执行的命令(底层实现:如果节点没有在自己的数据库里找到键
key
,那么节点会检查自己的clusterState.migrating_slots_to[i]
,看键key
所属的槽i
是否正在进行迁移,如果槽i的确在进行 迁移的话,那么节点会向客户端发送一个ASK错误,引导客户端到正在导入槽i的节点去查找键key)
与MOVED错误情况类似:
- 集群模式的redis-cli在接到ASK错误时也不会打印错误,而是自动根据错误提供的IP地址和端口进行转向动作
- 而单机模式的redis-cli客户端会打印ASK错误
注:集群模式的redis-cli并未支持ASK自动转向,上面展示的ASK自动转向行为实际上是根据MOVED自动转向行为虚构出来的。因此,当集群模式的redis-cli真正支持ASK自动转向时,它的行为和上面展示的行为可能会有所不同
假设在节点7002向节点7003迁移槽16198期间,有一个客户端向节点7002发送命令:
GET "love”
因为键"love"正好属于槽16198,所以节点7002会首先在自己的数据库中查找键"love", 但并没有找到,通过检查自己的
clusterState.migrating_slots_to[16198]
,节点7002发现自己正 在将槽16198迁移至节点7003,于是它向客户端返回错误:ASK 16198 127.0.0.1:7003
这个错误表示客户端可以尝试到IP为
127.0.0.1
,端口号为7003
的节点去执行和槽16198
有关的操作:接到ASK错误的客户端会根据错误提供的IP地址和端口号,转向至正在导入槽的目标节 点,然后首先向目标节点发送一个ASKING命令,之后再重新发送原本想要执行的命令
当客户端接收到节点7002返回的以下错误时:
ASK 16198 127.0.0.1:7003
客户端会转向至节点7003,首先发送命令:
ASKING
然后再次发送命令:
GET "love”
并获得回复:
"you get the key 'love'”
整个过程如下图所示:
ASKING命令
ASKING命令功能:唯一要做的就是打开发送该命令的客户端的REDIS_ASKING标识
伪代码实现:
REDIS_ASKING
标识:在一般情况下,如果客户端向节点发送一个关于槽i的命令,而槽i又没有指派给这个节点的话,那么节点将向客户端返回一个MOVED
错误;但是,如果节点的 clusterState.importing_slots_from[i]
显示节点正在导入槽i
,并且发送命令的客户端带有REDIS_ASKING
标识,那么节点将破例执行这个关于槽i
的命令一次:当客户端接收到ASK错误并转向至正在导入槽的节点时,客户端会先向节点发送一个 ASKING命令,然后才重新发送想要执行的命令,这是因为如果客户端不发送ASKING命令,而直接发送想要执行的命令的话,那么客户端发送的命令将被节点拒绝执行,并返回 MOVED错误
客户端的
REDIS_ASKING
标识是一个一次性标识,当节点执行了一个带有REDIS_ASKING
标识的客户端发送的命令之后,客户端的REDIS_ASKING
标识就会被移除。如果在成功执行GET
命令之后,再次向节点7003发送GET
命令,那么第二次发送的GET命令将执行失败,因为这时客户端的REDIS_ASKING
标识已经被移除:ASK错误和MOVED错误都会导致客户端转向,它们的区别在于:
MOVED
错误代表槽的负责权已经从一个节点转移到了另一个节点:在客户端收到关于槽i
的MOVED
错误之后,客户端每次遇到关于槽i的命令请求时,都可以直接将命令请求发送 至MOVED
错误所指向的节点,因为该节点就是目前负责槽i
的节点
- 与此相反,ASK错误只是两个节点在迁移槽的过程中使用的一种临时措施:在客户端收到关于槽i的ASK错误之后,客户端只会在接下来的一次命令请求中将关于槽
i
的命令请求发送至ASK
错误所指示的节点,但这种转向不会对客户端今后发送关于槽i的命令请求产生任何影响,客户端仍然会将关于槽i
的命令请求发送至目前负责处理槽i
的节点,除非ASK
错误再次出现
复制与故障转移
Redis
集群中的节点分为主节点(master)和从节点(slave):- 主节点用于处理槽
- 从节点则用于复制某个主节点,并在被复制的主节点下线时,代替下线主节点继续处理命令请求
对于包含7000、7001、7002、7003四个主节点的集群来说,可以将7004、7005两个节点添加到集群里面,并将这两个节点设定为节点7000的从节点,如下图所示(图中以双圆形表示主节点,单圆形表示从节点)
如果这时,节点7000进入下线状态,那么集群中仍在正常运作的几个主节点将在节点7000的两个从节点——节点7004和节点7005中选出一个节点作为新的主节点,这个新的主节点将接管原来节点7000负责处理的槽,并继续处理客户端发送的命令请求
如果节点7004被选中为新的主节点,那么节点7004将接管原来由节点7000负责处理的槽0至槽5000,节点7005也会从原来的复制节点7000,改为复制节点7004,如下图所示(图中用虚线包围的节点为已下线节点)
如果在故障转移完成之后,下线的节点7000重新上线,那么它将成为节点7004的从节点:
复制的命令(设置从节点)
向一个节点发送命令,可以让接收命令的节点成为
node_id
所指定节点的从节点,并开始对主节点进行复制:CLUSTER REPLICATE <node_id>
接收到该命令的节点首先会在自己的
clusterState.nodes
字典中找到node_id
所对应节点的clusterNode
结构,并将自己的clusterState.my self.slaveof
指针指向这个结构,以此来记录这个节点正在复制的主节点然后节点会修改自己在
clusterState.myself.flags
中的属性,关闭原本的REDIS_NODE_MASTER
标识,打开REDIS_NODE_SLAVE
标识,表示这个节点已经由原来 的主节点变成了从节点。最后,节点会调用复制代码,并根据
clusterState.myself.slaveof
指向的clusterNode
结构所保存的IP地址和端口号,对主节点进行复制。因为节点的复制功能和单机Redis
服务器的复制功能使用了相同的代码,所以让从节点复制主节点相当于向从节点发送命令SLAVEOF
。
节点7004在复制节点7000时的
clusterState
结构:clusterState.myself.flags
属性的值为REDIS_NODE_SLAVE
,表示节点7004是一个从节点
clusterState.myself.slaveof
指针指向代表节点7000的结构,表示节点7004正在复制的主节点为节点7000
复制信息的传递
一个节点成为从节点,并开始复制某个主节点这一信息会通过消息发送给集群中的其他节点,最终集群中的所有节点都会知道某个从节点正在复制某个主节点。
集群中的所有节点都会在代表主节点的
clusterNode
结构的slaves
属性和numslaves
属性中记录正在复制这个主节点的从节点名单:下图记录了节点7004和节点7005成为节点7000的从节点之后,集群中的各个节点为节点7000创建的
clusterNode
结构的样子:- 代表节点7000的
clusterNode
结构的numslaves
属性的值为2,这说明有两个从节点正在复制节点7000
- 代表节点7000的
clusterNode
结构的slaves
数组的两个项分别指向代表节点7004和代表节点7005的clusterNode
结构,这说明节点7000的两个从节点分别是节点7004和节点7005
故障检测
集群中的每个节点都会定期地向集群中的其他节点发送PING消息,以此来检测对方是否在线,如果接收
PING
消息的节点没有在规定的时间内,向发送PING
消息的节点返回 PONG
消息,那么发送PING
消息的节点就会将接收PING
消息的节点标记为疑似下线 (probable fail,PFAIL)
如果节点7001向节点7000发送了一条PING消息,但是节点7000没有在规定的时间内,向节点7001返回一条PONG消息,那么节点7001就会在自己的
clusterState.nodes
字 典中找到节点7000所对应的clusterNode
结构,并在结构的flags
属性中打开 REDIS_NODE_PFAIL
标识,以此表示节点7000进入了疑似下线状态:集群中的各个节点会通过互相发送消息的方式来交换集群中各个节点的状态信息,例如 某个节点是处于在线状态、疑似下线状态(PFAIL),还是已下线状态(FAIL)
当一个主节点A通过消息得知主节点B认为主节点C进入了疑似下线状态时,主节点A会在自己的
clusterState.nodes
字典中找到主节点C所对应的clusterNode
结构,并将主节点B的下线报告(failure report)添加到clusterNode
结构的fail_reports
链表里面:如果主节点7001在收到主节点7002、主节点
7003
发送的消息后得知,主节点 7002
和主节点7003
都认为主节点7000
进入了疑似下线状态,那么主节点7001将为主节点7000创建如下图所示的下线报告每个下线报告由一个
clusterNodeFailReport
结构表示:如果在一个集群里面,半数以上负责处理槽的主节点都将某个主节点x报告为疑似下 线,那么这个主节点
x
将被标记为已下线(FAIL),将主节点x
标记为已下线的节点会向集群广播一条关于主节点x
的FAIL消息,所有收到这条FAIL消息的节点都会立即将主节点x标记为已下线对于上图所示的下线报告来说,主节点7002和主节点7003都认为主节点 7000进入了下线状态,并且主节点7001也认为主节点7000进入了疑似下线状态(代表主节点 7000的结构打开了REDIS_NODE_PFAIL标识),综合起来,在集群四个负责处理槽的主节 点里面,有三个都将主节点7000标记为下线,数量已经超过了半数,所以主节点7001会将主 节点7000标记为已下线,并向集群广播一条关于主节点7000的FAIL消息:
故障转移
当一个从节点发现自己正在复制的主节点进入了已下线状态时,从节点将开始对下线主节点进行故障转移,以下是故障转移的执行步骤:
- 复制下线主节点的所有从节点里面,会有一个从节点被选中
- 被选中的从节点会执行
SLAVEOF no one
命令,成为新的主节点
- 新的主节点会撤销所有对已下线主节点的槽指派,并将这些槽全部指派给自己
- 新的主节点向集群广播一条
PONG
消息,这条PONG
消息可以让集群中的其他节点立即知道这个节点已经由从节点变成了主节点,并且这个主节点已经接管了原本由已下线节点负责处理的槽
- 新的主节点开始接收和自己负责处理的槽有关的命令请求,故障转移完成
新的主节点是通过选举产生的,集群选举新的主节点的方法:
- 集群的配置纪元是一个自增计数器,它的初始值为0
- 当集群里的某个节点开始一次故障转移操作时,集群配置纪元的值会被增一
- 对于每个配置纪元,集群里每个负责处理槽的主节点都有一次投票的机会,而第一 个向主节点要求投票的从节点将获得主节点的投票
- 当从节点发现自己正在复制的主节点进入已下线状态时,从节点会向集群广播一条 CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST消息,要求所有收到这条消息、并且具 有投票权的主节点向这个从节点投票
- 如果一个主节点具有投票权(它正在负责处理槽),并且这个主节点尚未投票给其 他从节点,那么主节点将向要求投票的从节点返回一条 CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消息,表示这个主节点支持从节点成为新的 主节点
- 每个参与选举的从节点都会接收CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消 息,并根据自己收到了多少条这种消息来统计自己获得了多少主节点的支持
- 如果集群里有N个具有投票权的主节点,那么当一个从节点收集到大于等于N/2+1张 支持票时,这个从节点就会当选为新的主节点
- 因为在每一个配置纪元里面,每个具有投票权的主节点只能投一次票,所以如果有N 个主节点进行投票,那么具有大于等于N/2+1张支持票的从节点只会有一个,这确保了新的 主节点只会有一个
- 如果在一个配置纪元里面没有从节点能收集到足够多的支持票,那么集群进入一个 新的配置纪元,并再次进行选举,直到选出新的主节点为止
选举新主节点的方法和前面文章介绍的选举领头
Sentinel
的方法非常相似,因为两者都 是基于Raft算法的领头选举(leader election)方法来实现的消息
集群中的各个节点通过发送和接收消息(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
,这个联合就是消息的正文:clusterMsg结构的currentEpoch、sender、myslots等属性记录了发送者自身的节点信息, 接收者会根据这些信息,在自己的clusterState.nodes字典里找到发送者对应的clusterNode结 构,并对结构进行更新
例如:通过对比接收者为发送者记录的槽指派信息,以及发送者在消息头的
my slots
属性记录的槽指派信息,接收者可以知道发送者的槽指派信息是否发生了变化。又或者说,通过对比接收者为发送者记录的标识值,以及发送者在消息头的flags属性记录的标识值,接收者可以知道发送者的状态和角色是否发生了变化,例如节点状态由原来的在线变成了下线,或者由主节点变成了从节点等等MEET、PING、PONG消息的实现(Gossip协议)
Redis
集群中的各个节点通过Gossip协议来交换各自关于不同节点的状态信息,其中Gossip
协议由MEET、PING、PONG三种消息实现,这三种消息的正文都由两个cluster.h/clusterMsgDataGossip
结构组成:因为MEET、PING、PONG三种消息都使用相同的消息正文,所以节点通过消息头clusterMsg结构的type属性来判断一条消息是MEET消息、PING消息还是PONG消息。
clusterMsgDataGossip结构
每次发送MEET、PING、PONG消息时,发送者都从自己的已知节点列表中随机选出两个节点(可以是主节点或者从节点),并将这两个被选中节点的信息分别保存到两个
clusterMsgDataGossip
结构里面。clusterMsgDataGossip
结构记录了被选中节点的名字,发送者与被选中节点最后一次发送和接收PING消息和PONG消息的时间戳,被选中节点的IP地址和端口号,以及被选中节点的标识值:当接收者收到MEET、PING、PONG消息时,接收者会访问消息正文中的两个
clusterMsgDataGossip
结构,并根据自己是否认识clusterMsgDataGossip
结构中记录的被选中节点来选择进行哪种操作:- 如果被选中节点不存在于接收者的已知节点列表,那么说明接收者是第一次接触到被选中节点,接收者将根据结构中记录的IP地址和端口号等信息,与被选中节点进行握手
- 如果被选中节点已经存在于接收者的已知节点列表,那么说明接收者之前已经与被选中节点进行过接触,接收者将根据
clusterMsgDataGossip
结构记录的信息,对被选中节点所对应的clusterNode
结构进行更新
假设在一个包含A、B、C、D、E、F六个节点的集群里:
- 节点A向节点D发送PING消息,并且消息里面包含了节点B和节点C的信息,当节点D收到这条PING消息时,它将更新自己对节点B和节点C的认识
- 之后,节点D将向节点A返回一条PONG消息,并且消息里面包含了节点E和节点F的消息,当节点A收到这条PONG消息时,它将更新自己对节点E和节点F的认识
FAIL消息的实现
当集群里的主节点A将主节点B标记为已下线(FAIL)时,主节点A将向集群广播一条关于主节点B的FAIL消息,所有接收到这条FAIL消息的节点都会将主节点B标记为已下线
在集群的节点数量比较大的情况下,单纯使用Gossip协议来传播节点的已下线信息会给节点的信息更新带来一定延迟,因为Gossip协议消息通常需要一段时间才能传播至整个集群,而发送FAIL消息可以让集群里的所有节点立即知道某个主节点已下线,从而尽快判断是否需要将集群标记为下线,又或者对下线主节点进行故障转移
FAIL消息的正文由
cluster.h/clusterMsgDataFail
结构表示,这个结构只包含一个nodename
属性,该属性记录了已下线节点的名字: 因为集群里的所有节点都有一个独一无二的名字,所以FAIL消息里面只需要保存下线节点的名字,接收到消息的节点就可以根据这个名字来判断是哪个节点下线了
对于包含7000、7001、7002、7003四个主节点的集群来说:
• 如果主节点7001发现主节点7000已下线,那么主节点7001将向主节点7002和主节点7003 发送FAIL消息,其中FAIL消息中包含的节点名字为主节点7000的名字,以此来表示主节点 7000已下线
• 当主节点7002和主节点7003都接收到主节点7001发送的FAIL消息时,它们也会将主节 点7000标记为已下线
• 因为这时集群已经有超过一半的主节点认为主节点7000已下线,所以集群剩下的几个主 节点可以判断是否需要将集群标记为下线,又或者开始对主节点7000进行故障转移
PUBLISH消息的实现
当客户端向集群中的某个节点发送命令:
PUBLISH <channel> <message>
接收到
PUBLISH
命令的节点不仅会向channel
频道发送消息message
,它还会向集群广播一条PUBLISH
消息,所有接收到这条PUBLISH
消息的节点都会向channel
频道发送 message
消息。换句话说,向集群中的某个节点发送PUBLISH
命令,将导致集群中的所有节点都向channel
频道发送message
消息
为什么不直接向节点广播
PUBLISH
命令:实际上,要让集群的所有节点都执行相同的PUBLISH
命令,最简单的方法就是向所有节点广播相同的PUBLISH
命令,这也是Redis
在复制PUBLISH
命令时所使用的方法, 不过因为这种做法并不符合Redis
集群的“各个节点通过发送和接收消息来进行通信”这一规则,所以节点没有采取广播PUBLISH
命令的做法。
对于包含7000、7001、7002、7003四个节点的集群来说,如果节点7000收到 了客户端发送的PUBLISH命令,那么节点7000将向7001、7002、7003三个节点发送 PUBLISH消息:
PUBLISH消息的正文由
cluster.h/clusterMsgDataPublish
结构表示:bulk_data
属性是一个字节数组,这个字节数组保存了客户端通过PUBLISH
命令发送给节点的channel
参数和message
参数channel_len和
message_len
则分别保存了channel参数的长度和message参数的长度- 其中
bulk_data
的0字节至channel_len-1
字节保存的是channel
参数
- 而
bulk_data
的channel_len
字节至channel_len+message_len-1
字节保存的则是message
参数
如果节点收到的PUBLISH命令为:
那么节点发送的
PUBLISH
消息的clusterMsgDataPublish
结构:bulk_data
数组的前七个字节保存了channel
参数的值"news.it"
,而bulk_data
数组的后五个字节则保存了message
参数的值"hello"