《Redis设计与实现》学习笔记-集群
《Redis设计与实现》学习笔记-集群
Redis集群是Redis提供的分布式数据库方案,集群通过分片(sharding)来进行数据共享,并提供复制和故障转移功能。
下面会对集群的节点、槽指派、命令执行、重新分片、转向、故障转移、消息等各个方面进行介绍
一、节点
1. 连接节点
一个Redis集群通常由多个节点(node)组成,在刚开始的时候,每个节点都是相互独立的,它们都处于一个只包含自己的集群当中,要组建一个真正可工作的集群,我们必须将各个独立的节点连接起来,构成一个包含多个节点的集群。
连接各个节点的工作可以使用 CLUSTER MEET命令来完成。该命令的格式如下:
CLUSTER MEET <ip> <port>
向一个节点node发送CLUSTER MEET命令,可以让node节点与ip和port所指定的节点进行握手(handshake),当握手成功时,node节点就会将ip和port所指定的节点添加到node节点当前所在的集群中。
例如:通过向节点7000发送以下命令,我们可以将节点7001添加到节点7000所在的集群里面
127.0.0.1:7000> CLUSTER MEET 127.0.0.1 7001
握手过程如下图:
节点会继续使用redisServer结构来保存服务器的状态,使用redisClient结构来保存客户端的状态,至于那些只有在集群模式下才会用到的数据,节点将他们保存到了cluster.h/clusterNode结构、cluster.h/clusterLink结构,以及cluster.h/clusterState结构里面。
2. 集群数据结构
1)clusterNode结构
clusterNode结构保存了一个节点的当前状态,比如节点的创建时间、节点的名字、节点当前的配置纪元、节点的IP地址和端口号等等。
每个节点都会使用一个clusterNode结构来记录自己的状态,并为集群中所有其他节点(包括主节点和从节点)都创建一个相应的clusterNode结构,以此来记录其他节点的状态。
1 // 这是对集群节点的描述,是集群运作的基础 2 typedef struct clusterNode { 3 // 节点创建时间 4 mstime_t ctime; 5 6 // 节点名称,也叫做节点ID,由40个十六进制字符组成 7 // 启动后会存储在node.conf中,除非文件删除,否则不会改变 8 char name[REDIS_CLUSTER_NAMELEN]; 9 10 // 节点标识 11 // 使用各种不同的标识值记录节点的角色(比如主节点或者从节点) 12 // 以及节点目前所处的状态(比如在线或者下线) 13 int flags; 14 15 // 节点当前的配置纪元,用于实现故障转移 16 uint64_t configEpoch; 17 18 // 节点的ip地址 19 char ip[NET_IP_STR_LEN]; 20 21 // 节点端口号 22 int port; 23 24 // 保存连接节点所需的有关信息 25 clusterLink *link; 26 27 // ... 28 29 }
2)clusterLink结构
clusterNode结构的 link 属性是一个clusterLink结构,该结构保存了连接节点所需的有关信息,比如套接字描述符,输入缓冲区和输出缓冲区。
1 typedef struct clusterLink { 2 // 连接的创建时间 3 mstime_t ctime; 4 5 // TCP 套接字描述符 6 int fd; 7 8 // 与远程节点的网络链接 9 connection *conn; 10 11 // 输出缓冲区,保存着等待发送给其他节点的消息(message) 12 sds sndbuf; 13 14 // 输入缓冲区,保存着从其他节点接收到的消息 15 sds rcvbuf; 16 17 // 与这个连接相关联的节点,如果没有的话就为NULL 18 struct clusterNode *node; 19 20 } clusterLink;
clusterLink封装了远程节点实例,以及与其的网络链接、接收和发送数据包的信息,基于它两个节点之间就可以保持实时通信了。
需要注意的是:集群总线中所使用的端口并不是我们之前熟悉的6379这样服务于客户端的端口,而是专用的;它不是我们手动设置的,而是由服务于客户端的端口通过偏移计算(+10000)而来。比如,服务于客户端的端口为6379,那么集群总线监听的端口就为16379。
所以,若需要以集群模式部署Redis实例,我们必须保证主机上两个端口都是非占用状态,否则实例会启动失败。
3) clusterState结构
每个节点都保存着一个clusterState 结构,这个结构记录了在当前节点的视角下,集群目前所处的状态,例如:集群是在线还是下线,集群包含多少个节点,集群当前的配置纪元,诸如此类:
1 // 这个结构存储的是从当前节点视角,整个集群所处的状态 2 typedef struct clusterState { 3 // 指向当前节点的指针 4 clusterNode *myself; 5 6 // 集群当前的配置纪元,用于实现故障转移 7 uint64_t currentEpoch; 8 9 // 集群当前的状态:是在线还是下线 10 int state; 11 12 // 集群中至少处理着一个槽的节点的数量 13 int size; 14 15 // 集群节点名单(包括myself节点) 16 // 节点字典:name->clusterNode(字典的键为节点的名字,字典的值为节点对应的clusterNode结构) 17 dict *nodes; 18 19 //... 20 }clusterState;
二、槽指派
Redis集群通过分片的方式来保存数据库中的键值对:集群的整个数据库被分为16384个槽(slot),数据库中的每个键都属于这16384个槽的其中一个,集群中的每个节点可以处理0个或最多16384个槽。
当数据库中的16384个槽都有节点在处理时,集群处于上线状态(ok); 相反地,如果数据库中有任何一个槽没有得到处理,那么集群处于下线状态(fail)。通过向节点发送CLUSTER ADDSLOTS命令, 我们可以将一个或多个槽指派(assign)给节点负责。例如:执行以下命令可以将槽0至槽5000指派给节点7000负责:
127.0.0.1:7000> CLUSTER ADDSLOTS 0 1 2 3 4 ... 5000
1. 记录节点的槽指派信息
clusterNode结构的slots属性和numslot 属性记录了节点负责处理那些槽:
1 typedef struct clusterNode { 2 3 // ... 4 5 // 代表节点负责的哈希槽 6 unsigned char slots[16384/8]; 7 8 // 节点负责哈希槽的数量 9 int numslots; 10 11 // ... 12 }
1)slots
slots属性是一个二进制位数组(bit array),这个数组的长度为 16384/8=2048个字节,共包含16384个二进制位。
Redis以0为起始索引,16383为终止索引,对slots数组中的16384个二进制位进行编号,并根据索引 i 上的二进制位的值来判断节点是否负责处理槽 i:
- 如果slots数组在索引i上的二进制位的值为1,那么表示节点负责处理槽i。
- 如果slots 数组在索引i上的二进制位的值为0,那么表示节点不负责处理槽i。
如下图:
说明:上图展示了一个slots数组示例∶这个数组索引0至索引7上的二进制位的值都为1,其余所有二进制位的值都为0,这表示节点负责处理槽0至槽7
2)numslots
numslots属性则记录节点负责处理的槽的数量,也即是slots数组中值为1的二进制位的数量。对于上图所示的slots数组来说,节点处理的槽数量为8。
2. 传播节点的槽指派信息
一个节点除了会将自己处理的槽记录在clusterNode
结构中的slots
和numslots
属性之外,还会将自己的slots
数组通过消息发送给集群中的其它节点。
如下图:
节点A通过消息从节点B接收到节点B的slots
数组,会在自己的clusterState.nodes
字典中查找节点B对应的clusterNode
结构,并对结构中的slots
数组进行更新。
最终,集群中的每个节点都知道数据库中的16384个槽分别被指派给了哪些节点。
3. 记录集群所有槽的指派信息
clusterState结构中的 slots 数组记录了集群中所有16384个槽的指派信息:
1 typedef struct clusterState { 2 3 //...
4
5 6 clusterNode *slots[16384]; 7 8 //... 9 10 } clusterState;
slot数组包含16384个项,每个数组项都是个指向 clusterNode结构的指针:
- 如果slots[i]指针指向NULL,那么表示槽i尚未指派给任何节点。
- 如果slots[i]指针指向一个clusterNode结构。那么表示槽已经指派给了clusterNode结构所代表的节点。
如下图:
4. clusterState.slots 数组与clusterNode.slots数组的区别
clusterState.slots 数组记录了集群中所有槽的指派信息,而clusterNode.slots数组只记录了clusterNode结构所代表的节点的槽指派信息,这是两个slots数组的关键区别所在。
三、在集群中执行命令
当客户端向节点发送与数据库键有关的命令时,接收命令的节点会计算出命令要处理的数据库键属于哪个槽,并检查这个槽是否指派给了自己:
- 如果键所在的槽正好就指派给了当前节点,那么节点直接执行这个命令。
- 如果键所在的槽并没有指派给当前节点,那么节点会向客户端返回一个MOVED错误,指引客户端转向(redirect)至正确的节点,并再次发送之前想要执行的命令。
1. 计算键属于哪个槽
节点使用以下算法来计算给定键key属于哪个槽:
def slot_number(key):
return CRC16(key) & 16383
其中 CRC16(key)语句用于计算键key的 CRC-16 校验和,而 & 16383 语句则用于计算出一个介于 0 至16383 之间的整数作为键 key的槽号。
使用CLUSTER KEYSLOT <key> 命令可以查看一个给定键属于哪个槽:
127.0.0.1:7000> CLUSTER KEYSLOTS "date"
(integer) 6257
2. 判断槽是否由当前节点负责处理
当节点计算出健所属的槽i之后,节点就会检查自己在clusterState.slots数组中的项i,判断键所在的槽是否由自己负责:
1) 如果clusterState.slots[i]等于clusterState.myself,那么说明槽i由当前节点负责,节点可以执行客户端发送的命令。
2) 如果clusterState..slots[i]不等于clusterState.myself,那么说明槽i并非由当前节点负责,节点会根据clusterState.slots[i]指向的clusterNode结构所记录的节点IP和端口号,向客户端返回MOVED错误,指引客户端转向至正在处理槽i的节点。
1. MOVED 错误
MOVED 错误的格式为:
MOVED <slot> <ip>:<port>
客户端通常会与集群中的多个节点创建套接字连接,所谓的节点转向就是换一个套接字来发送命令。
1)集群模式 - 被隐藏的MOVED错误
集群模式的redis-cli 客户端在接收到MOVED错误时,并不会打印出MOVED错误,而是根据MOVED错误自动进行节点转向,并打印出转向信息,所以我们是看不见节点返回的MOVED错误的。
1 ./redis-cli -h 192.24.54.1 -p 6379 -a '123456' -c 2 3 192.24.54.1:6379> get name 4 5 -> Redirected to slot [5798] located at 192.24.54.2:6379 6 7 "yayun"
2) 单机模式 - 将MOVED错误直接打印出来
这是因为单机模式下的 redis-cli 客户端不清楚MOVED错误的作用,所以它只会将MOVED错误直接打印出来,而不会进行自动转向。
2. 重新分片
Redis集群的重新分片操作可以将任意数量已经指派给某个节点(源节点)的槽改为指派给另一个节点(目标节点),并且相关槽所属的键值对也会从源节点被移动到目标节点。
重新分片操作可以在线(online)进行,在重新分片的过程中,集群不需要下线,并且源节点和目标节点都可以继续处理命令请求。
重新分片是由Redis的集群管理软件redis-trib负责的,Redis提供了重新分片所需的所有命令,redis-trib则通过向源节点和目标节点发送命令来实现重新分片。redis-trib对集群的单个槽slot进行重新分片
的步骤如下:
1)向目标节点发送CLUSTER SETSLOT <slot> IMPORTING <source_id>命令,让目标节点准备好导入源节点中属于槽slot的键值对。
2)向源节点发送CLUSTER SETSLOT <slot> MIGRATING <target_id>命令,让源节点准备好迁移键值对。
3)向源节点发送CLUSTER GETKEYINSLOT <slot> <count>命令,获得最多count个属于槽slot的键值对的键名。
4)对于步骤3获得的每个键名,向源节点发送一个MIGRATE <target_ip> <target_port> <key_name> 0 <timeout>命令,将选中的键原子地从原子地从源节点迁移到目标节点。
5)重复执行步骤3和4,直到源节点保存的所有属于槽slot的键值对都被迁移至目标节点为止。
6)向集群中的任一节点发送CLUSTER SETSLOT <slot> NODE <target_id>命令,将槽slot指派给目标节点,这一指派信息通过消息传送至整个集群。最终集群中的所有节点都会知道槽slot已经指派给了目标节点。
迁移键的过程如下图:
3. ASK错误
在进行重新分片期间,源节点向目标节点迁移一个槽的过程中,可能会出现这样一种情况:属于被迁移槽的一部分键值对保存在源节点里面,而另一部分键值对则保存在目标节点里面。
当客户端向源节点发送一个与数据库键有关的命令,并且命令要处理的数据库键恰好就属于正在被迁移的槽时:
- 源节点会先在自己的数据库里面查找指定的键,如果找到的话,就直接执行客户端发送的命令。
- 相反地,如果源节点没能在自己的数据库里面找到指定的键,那么这个键有可能已经被迁移到了目标节点,源节点将向客户端返回一个ASK错误,指引客户端转向正在导入槽的目标节点,并再次发送之前想要执行的命令。
1) 集群模式 - 被隐藏的ASK错误
举个例子,假设节点7002正在向节点7003迁移槽 16198,这个槽包含"is"和"1ove"两个键,其中键"is"还留在节点7002,而键"Iove"已经被迁移到了节点7003
如果我们向节点7002发送关于键"is"的命令,那么这个命令会直接被节点7002执行:
1 127.0.0.1:7002> GET "is" 2 3 "you get the key1s
而如果我们向节点7002发送关于键"love"的命令,那么客户端会先被转向至节点7003,然后再次执行命令:
1 127.0.0.1:7002> GET "1ove" 2 -> Redirected to slot [16198] located at 127.0.0.1:7003 3 "you get the key 'love'"
2)单机模式 - 将ASK错误直接打印出来
比如下面的命令:
1 redis-cli -p 7002 2 127.0.0.1:7002> GET "1ove" 3 (error) ASK 16198 127.0.0.1:7003
4. ASK错误和MOVED错误的区别
ASK错误和MOVED错误都会导致客户端转向,它们的区别在于:
1)MOVED错误代表槽的负责权已经从一个节点转移到了另一个节点:在客户端收到关于槽 i 的MOVED错误之后,客户端每次遇到关于槽 i 的命令请求时,都可以直接将命令请求发送至MOVED错误所指向的节点,因为该节点就是目前负责槽 i 的节点。
2) 与此相反,ASK错误只是两个节点在迁移槽的过程中使用的一种临时措施:在客户端收到关于槽i的ASK错误之后,客户端只会在接下来的一次命令请求中将关于槽i的命令请求发送至ASK错误所指示的节点,但这种转向不会对客户端今后发送关于槽 i 的命令请求产生任何影响,客户端仍然会将关于槽 i 的命令请求发送至目前负责处理槽 i 的节点,除非ASK错误再次出现。
四、复制与故障转移
Redis集群中的节点分为主节点(master)和从节点(slave),其中主节点用于处理槽,而从节点则用于复制某个主节点,并在被复制的主节点下线时,代替下线主节点继续处理命令请求。
1. 设置从节点
向一个节点发送命令:
CLUSTER REPLICATE <node_id>
可以让接受命令的节点成为node_id
所指定节点的slave,并开始对master进行复制:
1 struct clusterNode { 2 3 // ... 4 5 // 正在复制这个master的slave数量 6 int numslaves; 7 8 // 如果这是一个从节点,那么指向主节点 9 struct clusterNode *slaveof; 10 11 // ... 12 13 };
2. 故障检测
集群中的每个节点都会定期地向集群中的其他节点发送PING消息,以此来检测对方是否在线,如果接收PING消息的节点没有在规定的时间内,向发送PING消息的节点返回PONG消息,那么发送PING消息的节点就会将接收PING消息的节点标记为疑似下线(probable fail,PFAIL)。
集群中的各个节点都会通过互相发送消息的方式来交换集群中各个节点的状态信息,例如某个节点是处于在线状态,疑似下线状态(PFAIL),还是已下线状态(FAIL)。
当一个主节点A通过消息得知主节点B认为主节点C进入疑似下线状态,A会在自己的clusterState.nodes
字典中找到C对应的clusterNode
结构,并将B的下线报告添加到clusterNode
结构的fail_reposts
链表中:
1 struct clusterNode { 2 3 // ... 4 5 // 一个链表,记录了所有其它节点对该节点的下线报告 6 list *fail_reports; 7 8 //... 9 };
每个下线报告由一个clusterNodeFailReport
结构表示:
1 struct clusterNodeFailReport { 2 // 报告目标节点已经下线的节点 3 struct clusterNode *node; 4 5 // 最后一次从node节点收到下线报告的时间 6 // 程序使用这个时间戳来检查下线报告是否过期 7 // (与当前时间相差太久的下线报告会被删除) 8 mstime_t time; 9 10 } typedef clusterNodeFailReports;
节点的下线报告示例:
如果在一个集群里面,半数以上负责处理槽的主节点都将某个主节点x报告为疑似下线,那么这个主节点x将被标记为已下线(FAIL),将主节点x标记为已下线的节点会向集群广播一条关于主节点x的FAIL消息,所有收到这条FAIL消息的节点都会立即将主节点x标记为已下线。
节点向集群广播FAIL消息,如下图:
3. 故障转移
当一个从节点发现自己正在复制的主节点进入了已下线状态时,从节点将开始对下线主节点进行故障转移,以下是故障转移的执行步骤:
1) 复制下线主节点的所有从节点里面,会有一个从节点被选中。(基于Raft算法leader election方法实现)
2) 被选中的从节点会执行 SLAVEOF no one命令,成为新的主节点。
3) 新的主节点会撤销所有对已下线主节点的槽指派,并将这些槽全部指派给自己。
4) 新的主节点向集群广播一条PONG消息,这条PONG消息可以让集群中的其他节点立即知道这个节点已经由从节点变成了主节点,并且这个主节点已经接管了原本由已下线节点负责处理的槽。
5) 新的主节点开始接收和自己负责处理的槽有关的命令请求,故障转移完成。
4. 选举新的master
新的master是选举产生的:
1) 集群中的配置纪元是一个自增计数器,初始值为0。
2) 集群中的某个节点开始一次故障转移操作时,集群配置纪元的值+1。
3) 对于每个配置纪元,集群中每个负责处理槽的master都有一次投票机会,而第一个向master要求投票的slave将获得投票权。
4) 当slave发现自己正在复制的master已下线,会广播一条CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST
,要求收到消息的master给自己投票。
5) 如果一个master有投票权(正在处理槽),且未投票给其它slave,那么master会向要求投票的slave返回一条CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK
消息,表示支持它成为新master。
6) 每个参与选举的slave都会接收到CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK
消息,根据消息的个数来统计自己获得几票。
7) 一个slave收集到大于N/2+1的支持票后,会当选新master。
8) 因为每个配置纪元里,拥有投票权的master只有一票,因此新的master只会有一个。
9) 如果一个配置纪元中没有选举出新master,那么集群进入一个新的配置纪元,继续选举。
5. 消息
集群中的节点通过消息来通信,消息主要分为以下5种:
MEET
消息:加入当前集群PING
消息:检测在线PONG
消息:回复MEET
和PING
FAIL
消息:进入FAIL
状态PUBLISH
消息:节点接收到PUBLISH
消息,会执行这个命令,并向集群广播一条PUBLISH
消息,所有接收到这条PUBLISH
消息的节点都会执行相同的PUBLISH
命令。
如下图:
一个消息由消息头(header)和消息正文(body)组成。
参考资料:
《Redis设计与实现》