Redis集群是Redis提供的分布式数据库方案,集群通过分片来进行数据共享,并提供复制和故障转移功能。
目录
节点
槽
集群命令的实现
重新分片
复制和故障转移
节点消息传递
总结
一个 Redis 集群通常由多个节点(node)组成, 在刚开始的时候, 每个节点都是相互独立的, 它们都处于一个只包含自己的集群当中, 要组建一个真正可工作的集群, 我们必须将各个独立的节点连接起来, 构成一个包含多个节点的集群。
连接各个节点的工作可以使用 CLUSTER MEET 命令来完成, 该命令的格式如下:
CLUSTER MEET
向一个节点 node
发送 CLUSTER MEET 命令, 可以让 node
节点与 ip
和 port
所指定的节点进行握手(handshake), 当握手成功时, node
节点就会将 ip
和 port
所指定的节点添加到 node
节点当前所在的集群中。
1、启动节点
一个节点就是一个运行在集群模式下的 Redis 服务器, Redis 服务器在启动时会根据 cluster-enabled
配置选项的是否为 yes
来决定是否开启服务器的集群模式。
节点(运行在集群模式下的 Redis 服务器)会继续使用所有在单机模式中使用的服务器组件, 比如说:
serverCron
函数, 而 serverCron
函数又会调用集群模式特有的 clusterCron
函数: clusterCron
函数负责执行在集群模式下需要执行的常规操作, 比如向集群中的其他节点发送 Gossip 消息, 检查节点是否断线; 又或者检查是否需要对下线节点进行自动故障转移, 等等。除此之外, 节点会继续使用 redisServer
结构来保存服务器的状态, 使用 redisClient
结构来保存客户端的状态, 至于那些只有在集群模式下才会用到的数据, 节点将它们保存到了 clusterNode
结构,clusterLink
结构,以及 clusterState
结构里面。
2、集群数据结构
clusterNode
结构保存了一个节点的当前状态,如节点的创建时间,节点的名字,节点当前的配置纪元,节点的 IP和端口号等。
每个节点都会使用一个 clusterNode
结构来记录自己的状态,并为集群中的所有其他节点(包括主节点和从节点)都创建一个相应的 clusterNode
结构, 以此来记录其他节点的状态:
struct clusterNode {
// 创建节点的时间
mstime_t ctime;
// 节点的名字,由 40 个十六进制字符组成
// 例如 68eef66df23420a5862208ef5b1a7005b806f2ff
char name[REDIS_CLUSTER_NAMELEN];
// 节点标识
// 使用各种不同的标识值记录节点的角色(比如主节点或者从节点),
// 以及节点目前所处的状态(比如在线或者下线)。
int flags;
// 节点当前的配置纪元,用于实现故障转移
uint64_t configEpoch;
// 节点的 IP 地址
char ip[REDIS_IP_STR_LEN];
// 节点的端口号
int port;
// 保存连接节点所需的有关信息
clusterLink *link;
// ...
};
其中,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
结构中的套接字和缓冲区则是用于连接节点。
每个节点都保存着一个 clusterState
结构, 这个结构记录了在当前节点的视角下,集群目前所处的状态 —— 比如集群是在线还是下线,集群包含多少个节点,集群当前的配置纪元, 诸如此类:
typedef struct clusterState {
// 指向当前节点的指针
clusterNode *myself;
// 集群当前的配置纪元,用于实现故障转移
uint64_t currentEpoch;
// 集群当前的状态:是在线还是下线
int state;
// 集群中至少处理着一个槽的节点的数量
int size;
// 集群节点名单(包括 myself 节点)
// 字典的键为节点的名字,字典的值为节点对应的 clusterNode 结构
dict *nodes;
// ...
} clusterState;
以下面的7000 、7001 、7002三个节点为例,展示了节点 7000 创建的 clusterState
结构,这个结构从节点 7000 的视角角度(myself指针指向自己的clusterNode结构)记录了集群以及集群包含的三个节点的当前状态。
3、CLUSTER MEET 命令的实现
通过向节点 A 发送 CLUSTER MEET 命令,客户端可以让接收命令的节点 A 将另一个节点B添加到节点 A当前所在的集群里面,收到命令的节点 A 将与节点 B 进行握手(handshake), 以此来确认彼此的存在, 并为将来的进一步通信打好基础:
clusterNode
结构, 并将该结构添加到自己的 clusterState.nodes
字典里面。MEET
消息(message)。MEET
消息, 节点 B 会为节点 A 创建一个 clusterNode
结构, 并将该结构添加到自己的 clusterState.nodes
字典里面。PONG
消息。PONG
消息, 通过这条 PONG
消息节点 A 可以知道节点 B 已经成功地接收到了自己发送的 MEET
消息。PING
消息。PING
消息, 通过这条 PING
消息节点 B 可以知道节点 A 已经成功地接收到了自己返回的 PONG
消息, 握手完成。之后, 节点 A 会将节点 B 的信息通过 Gossip 协议传播给集群中的其他节点, 让其他节点也与节点 B 进行握手,最终,经过一段时间之后, 节点 B 会被集群中的所有节点认识。这里的握手更TCP三次握手基本类似。
redis集群通过分片的方式,来保存数据库中的键值对。集群的整个数据库被分为16384个槽(slot),数据库中的每个键,都属于16384个槽中的一个,集群每个节点可以处理0~16384个槽。
当集群中,每一个槽都有节点在处理时,则这个集群是上线(ok)的状态;任意一个槽没有节点处理,则该集群下线(fail)。
采用命令cluster info,可以查看当前集群的状态,ok是上线,fail是下线。
通过向节点发送cluster addslots
在哪个节点输入cluster addslots,则对该节点指派槽。
1、记录节点指派信息
节点指派的槽的信息,记录在clusterNode结构体中:
struct clusterNode{
//….其他信息
unsigned char slots[16384/8];//二进制数组
int numslots;//节点处理的槽数量
};
slots是一个二进制位数组,长度是16384/8 = 2048个字节byte = 2kb,共包含16384个二进制位。每一个下标代表8个槽,用二进制位表示。如果节点负责某个槽,则数组下标对应的二进制位的相应位置的值是1,否则是0。
例如,下图中的数组,表示节点负责的槽是1、3、5、8、9、10这几个。
使用二进制的方式,目的是便于获取、修改节点负责的槽,因为时间复杂度都是O(1)。
2、传播节点槽指派信息
节点被分配了槽,不仅会记录在节点自身的clusterNode结构体,还会将信息传播给集群的其他节点。
节点a收到节点b的槽分配信息,会从自身的clusterState.nodes字典查找记录节点b信息clusterNode的结构中,并对结构中相应的属性slots与numslots记录槽的位置与槽的数量进行更新。(也就是A节点更新对于B节点的认知)
因为集群中的每个节点都会将自己的slots数组发送给其他节点,其他节点则会将接收到的slots数组保存其节点字典的源相应节点的clusterNode结构里从而刷新对于此节点的认知状态,所以集群中的每个节点都会知道数据库中的16384(2^14)个槽被分配给了集群的哪些节点。
3、记录集群所有槽指派信息
在clusterState结构体中slots数组记录了集群中所有16384个槽的指派信息:
typedef stuct clusterState{
//….其他信息
clusterNode *slots[16384];
}clusterState;
这个结构体中,数组每个下标表示一个槽,下标的值都是指针,指向负责该槽的节点clusterNode。如果某个数组下标是null,表示目前没有节点负责该槽。
clusterState.slots数组记录了集群中所有槽的指派信息,而clusterNode.slots数组只记录了clusterNode结构代表的节点的槽指派信息。
Redis的设计非常巧妙,clusterState.slots可以快速找到每个槽所属的负责节点,而节点内部clusterNode.slots数组可以快速查找、改变某个节点负责的槽,且获取某个节点负责的全部槽的速度比从clusterState的slots中快得多。
4、槽指派的实现
cluster addslots 命令接受了一个或多个槽作为参数,并将所有输入的槽指派给接受该命令的节点负责,槽指派之前,会先检查槽是否已经有节点负责,如果一个或以上的槽已经有节点负责,则停止指派,并且报错。
如果所有槽都没有节点负责,则修改clusterState的slots数组,将每个槽下标的值指向该节点的clusterNode结构;并修改该clusterNode的slots数组,将槽对应的二进制位置设置成1。
例如,执行命令clusteraddslots 1 2,节点变化如下:
完成上述命令写入后,节点会发消息通知集群的其他节点。
1、节点对命令的判断
当对集群的16384个槽都完成指派后,集群就上线,可以对集群进行操作。当客户端向节点发送数据库键有关的命令,接收命令的节点,会计算命令属于哪个槽,并检查槽是否指派给自己。
2、计算键属于哪个槽
假设键名为key,计算方法如下:
crc16(key) & 16383
其中,crc16是一种算法,将key用crc16算法获取的结果,在与16383进行与操作(即对16384取余),获取一个介于0~16383的整数。可以采用cluster keyslot key,查看键属于哪个槽。
3、判断槽是否由当前节点处理
根据上述算法计算出键所述的槽后,节点会与clusterState.slots数组相应下标的指针比对,判断键所在的槽是否是自己负责:
4、moved错误介绍
move命令为:moved
当客户端收到moved命令,就会解析里面的ip和端口号,并重新连上相应的节点,再执行命令。
一个客户端通常会与集群中的多个节点创建套接字连接,所以所谓的节点转向实际上就是换一个套接字连接来发送命令。
不过,由于moved错误的时候,处于集群状态下的redis-cli客户端会自动重定向,显示的也是redirect,因此客户端上看不到报错信息。
但是,如果是单机模式下的redis-cli客户端,则会直接报错,因为其并不知道moved命令的含义,也不会自动连接上新的节点。
5、节点数据的实现
集群节点有个限制,只能用0号数据库。键值对的保存方式和单机一样。节点的clusterState结构,还会使用跳跃表slots_to_keys保存槽和键的关系。
typedef struct clusterState{
//….其他内容
zskiplist *slots_to_keys;
}clusterState;
节点保存槽和键的关系,用的是zskiplist,其分值(score)是槽的编号,每个阶节点成员(member)是数据库的键。如下:
redis集群的重新分片功能,可以将任意数量已经指派给某个节点的槽,修改为指派给另一个节点,且相关槽对应的数据库的键值对数据也迁移到另一个节点。
重新分片工作可以在线进行,集群不需要下线,并且源节点和目标节点都可以正常处理客户端的其他命令。
1、重新分片的实现
redis重新分片,是由redis集群管理软件redis-trib负责执行的,redis提供了进行重新分配所需的所有命令。而redis-trib软件通过向源节点和目标节点发送命令,来完成重新分片的工作。
对单个槽进行重新分片,步骤如下:
如果是多个槽重新分片,则每个槽都会经历上述的步骤进行重新分片。
2、ASK错误
在进行重新分片的期间,源节点向目标节点迁移一个槽的过程中,可能会有属于被迁移槽的一部分键值对保存在源节点黎明,另一部分已被迁移完成的键值对保存在目标节点里。
当客户端向源节点发送一个与数据库键有关的命令,并且命令要处理的数据库键恰好就属于正在被迁移的槽时,源节点会在自己的数据库里面查找指定的键,如果找到就直接执行客户端发送的命令;否则,这个键有可能已经被迁移到目标节点了,源节点将向客户端返回一个ASK错误,指引客户端转向正在导入槽的目标节点,并再次发送之前想要执行的命令。
需要注意的是,ASK跟MOVED错误一样,对于客户端来说同样是隐藏的,单机模式的redis-cli客户端可以看见。
2、cluster setslot importing 命令的实现
clusterState结构的 importing_slots_from 数组记录了当前节点正在从其他节点导入的槽:
typedef struct clusterState{
//….其他内容
clusterNode *importing_slots_from[16384];
}clusterState;
如果importing_slots_from[i] 的值不为NULL,而是指向一个 clusterNode结构,那么表示当前节点正在从clusterNode节点导入槽 i。
3、cluster setslot migrating 命令的实现
clusterState结构的 importing_slots_to 数组记录了当前节点正在迁移至其他节点的槽:
typedef struct clusterState{
//….其他内容
clusterNode *importing_slots_to[16384];
}clusterState;
如果importing_slots_to[i] 的值不为NULL,而是指向一个 clusterNode结构,那么表示当前节点正在将槽 i 迁移至clusterNode节点。
4、ASK错误与MOVED错误的区别
ASK错误和MOVED错误都会导致客户端转向,但其区别在于:
redis集群的节点,分为主节点和从节点,主节点负责处理槽,从节点复制主节点的数据,并且在复制的主节点下线时,替代主节点继续处理命令请求。集群中可以有多个主节点。
如上图,7000~7003都是主节点,7004和7005作为从节点复制7000节点,当7000下线则7004和7005有一个会被选为主节点,替代7000。例如7004被选为主节点,则7004替代7000管理7000原先负责的槽,而7005则复制7004。当7000重新上线,也会成为7004的从节点。
1、设置从节点
命令如下:cluster replicate
设置过程如下:
1)接收到该命令的节点,会先在自身结构的clusterState结构体的nodes数组中,找到对应的节点的clusterNode,使用slaveof属性来记录主节点。
structclusterNode{
//….其他内容
struct clusterNode *slaveof;
};
2)修改自身的flags属性,关闭原本的标识redis_node_master,打开redis_node_slave标识,表示变成从属节点。
3)调用复制的代码,根据上述slaveof保存的主节点的信息中的ip和端口号,去获取数据。
因此,cluster replicate命令等同于单机情况下的主从复制中的slaveof命令。
命令执行完毕后,该节点会在集群中发送消息给其他节点告知。
其他节点都会在各自保存主节点结构的结构体中,对应的numslaves属性记录该节点的从节点个数,以及slaves数组,该数组每个属性记录的是指向该节点从节点的clusterNode指针。
struct clusterNode{
//….其他内容
int numslaves;
struct clusterNode **slaves;
};
2、故障检测
每个节点都会定期给其他节点发送ping信息,以此检测对方是否在线,如果在规定时间内ping没有得到返回pong,则认为疑似下线。如果节点认为某个节点疑似下线,就会在保存那个节点的clusterNode结构体中,flags属性加上redis_node_pfail属性。
每个节点自身的clusterNode结构体中,都有一个属性是fail_reports,该属性是一个链表,用于记录其他节点的下线报告。
structclusterNode{
//….其他内容
list *fail_reports;
};
list中的每一个元素,都是一个clusterNodeFailReport结构体,结构如下:
struct clusterNodeFailReport{
//指向下线节点的指针
struct clusterNode *node;
//最后一次从node节点收到的下线报告时间,程序使用这个时间戳来检查下线报告是否过期
//如果与当前时间相差太久,该结构体会被删除
mstime_t time;
}typedef clusterNodeFailReport;
在一个集群中,半数以上的节点认为某个节点疑似下线,则该节点被认定为已下线。并且会广播给集群的所有节点。
3、故障转移
当一个从节点发现自己正在复制的主节点已下线状态,则会开始进行故障转移。步骤如下:
4、选举新主节点方式
新主节点是通过选举产生,方法如下:
主节点的选取方式和选取领头sentinel的方式很相似,都是raft算法的领头选举。
1、发送消息类型
集群中节点通过发送与接收消息进行通信。发送消息的节点称为消息发送者,接收消息节点称为接收者。消息发送类型如下:
2、消息头
所有消息都由消息头包裹,消息头可以认为是消息的一部分。消息头由cluster.h/clusterMsg结构记录,如下:
structclusterMsg{
uint32_t totlen;//消息总长度,包括消息头长度和正文长度
uint16_t type;//消息类型
uint16_t count;//消息正文包含节点信息数量,只有在meet、ping、pong这三种涉及到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];//主节点时记录的是40位长的都是0的字节数组,从节点时记录的是复制的主节点的名字
uint16_t port;//发送者端口号
uint16_t flag;//发送者标识值
unsigned char state;//发送者所处的集群状态
union clusterMsgData data;//消息的正文
}clusterMsg;
消息的正文是一个联合体,共有三种类型结构体,包括ping、fail、publish,其中pong、meet类型都和ping一样。
这里需要注意的是,发送心跳包信息的信息头里最占空间的是发送者的槽信息myslots[REDIS_CLUSTER_SLOTS/8],16384个槽的位图bitmap共2kb大小,这也是为什么Redis设计为16384个槽的原因。
虽然槽分配是根据公式 HASH_SLOT=CRC16(key) mod 16384 按理说这里对于键的hash算法产生的hash值有16bit,该算法可以产生2^16=65536个值。换句话说,值是分布在0~65535之间。那作者在做mod
运算的时候,为什么不mod
65536,而选择mod
16384?
真正的原因是65536个值的位图为8kb的内存,发送的心跳包过于庞大,浪费带宽。而Redis的集群主节点数量由于设计原因基本不可能超过1000个,所以16384个槽位够用了,能确保每个节点都有槽可以处理。更详细可见这篇文章。
3、meet、ping、pong 消息的实现
这三个的类型一样,都是记录在联合体clusterMsgData中的结构体。因为这三种消息有相同的正文,节点是通过消息头的type判断是这三种的哪一种。
这三种消息的类型是clusterMsgDataGossip,这是一个结构体,记录节点名字、最后给该节点发送ping的时间戳、最后收到节点pong的时间戳、节点ip、端口号、标识等。
当节点接收到信息时,如果不认识里面的节点,则会与节点进行握手,如果认识则更新对应的信息。
4、fail 消息的实现
由于使用gossip协议会有延迟,fail是用来表示该节点下线,需要尽快传达,因此不用gossip协议,而是立即让集群中的全部节点知情。其正文就是下线节点的名字。
5、publish 消息的实现
客户端向集群某个节点发送publish
publish用结构体clusterMsgDataPublish记录,内容是包括频道长度,消息长度,以及具体内容。
其中,bulk_data的前channel_len字节,记录channel参数;剩余字节记录message参数。
例如,发送publish“news.it” “hello” 如下:
其实也可以直接向集群广播publish命令,但是由于其不符合redis设计的各节点通过消息发送和接收来传播消息的做法,因此采用对某一节点进行消息发送。
16384
个槽可以分别指派给集群中的各个节点, 每个节点都会记录哪些槽指派给了自己, 而哪些槽又被指派给了其他节点。MOVED
错误, MOVED
错误携带的信息可以指引客户端转向至正在负责相关槽的节点。i
至节点 B , 那么当节点 A 没能在自己的数据库中找到命令指定的数据库键时, 节点 A 会向客户端返回一个 ASK
错误, 指引客户端到节点 B 继续查找指定的数据库键。MOVED
错误表示槽的负责权已经从一个节点转移到了另一个节点, 而 ASK
错误只是两个节点在迁移槽的过程中使用的一种临时措施。MEET
、 PING
、 PONG
、 PUBLISH
、 FAIL
五种。
参考文章:
《Redis设计与实现》