第 17 章 集群
Redis集群是Redis提供的分布式数据库方案,集群通过分片来进行数据共享,并提供复制和故障转移功能
17.1 节点
一个Redis集群通常由多个节点组成,在刚开始的时候,每个节点都是相互独立的,它们都处于一个只包含自己的集群当中,要组建一个真正可工作的集群,我们必须将各个独立的节点连接起来,构成一个包含多个节点的集群。
连接各个节点的工作可以使用CLUSTER MEET
命令来完成,该命令的格式如下:
1 | CLUSTER MEET <ip> <port> |
向一个节点发送CLUSTER MEET
命令,可以让该节点与ip+port
所指定的节点进行握手,当握手成功时,该节点就会将ip+port
所指定的节点添加到该节点当前所在的集群中。
下面的例子演示了如何构件一个有3个节点的集群
17.1.1 启动节点
个节点就是一个运行在集群模式下的Redis服务器, Redis服务器在启动时会根据cluster- enabled
配置选项是否为yes
来决定是否开启服务器的集群模式,流程如下图所示。
节点会继续使用所有在单机模式中使用的服务器组件,比如说:文件事件处理器、时间事件处理器、使用数据库保存键值对数据、使用RDB和AOF来持久化
17.1.2 集群数据结构
节点会继续使用redisServer
结构来保存服务器的状态,使用redisClient
结构来保存客户端的状态,至于那些只有在集群模式下才会用到的数据,节点将它们保存到了cluster.h/clusterNode
结构、cluster.h/clusterLink
结构,以及cluster.h/clusterState
结构里面。
每个节点都会使用一个clusterNode
结构来记录自己的状态,并为集群中的所有其他节点都创建一个相应的clusterNode
结构,以此来记录其他节点的状态,状态如下所示
1 | // 节点状态 |
clusterNode
结构的link
属性是一个 clusterLink
结构,该结构保存了连接节点所需的有关信息,比如套接字描述符,输人缓冲区和输出缓冲区:
1 | typedef struct clusterLink { |
在redisServer
中会保存一个clusterState
结构,这个结构记录了集群的相关信息,例:集群是在线还是下线,集群包含多少个节点,集群当前的配置纪元,诸如此类
1 | typedef struct clusterState{ |
下图是一个clusterState
状态的例子
17.1.3 CLUSTER MEET命令的实现
下面我们来解释本节开头说的CLUSTER MEET
的具体执行步骤,假设现在客户端向节点A发送命令,要求A加入节点B的集群中,假设节点A收到了命令,它会接着做这些事情:
- 节点A会为节点B创建一个
clusterNode
结构,并将该结构添加到自己的cluterState.nodes
字典里面。 - 之后,节点A将根据
CLUSTER MEET
命令给定的IP地址和端口号,向节点B发送一条MEET
消息 - 如果一切顺利,节点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握手
17.2 槽指派
Redis集群通过分片的方式来保存数据库中的键值对:集群的整个数据库被分为16384个槽,数据库中的每个键都属于这16384个槽的其中一个,集群中的每个节点可以处理0个或最多16384个槽。
当数据库中的16384个槽都有节点在处理时,集群处于上线状态;相反地,如果数据库中有任何一个槽没有得到处理,那么集群处于下线状态。
我们可以通过CLUSTER INFO
命令查看集群相关的信息
如果想要把槽指派给某个节点负责,可以使用下面的命令
1 | CLUSTER ADDSLOTS 0 1 2 3 4 ... 5000 |
17.2.1 记录节点的槽指派信息
clusterNode
结构的slots
属性和numslot
属性记录了这个节点负责处理哪些槽:
1 | struct clusterNode{ |
s1ots
属性是一个二进制位数组,这个数组的长度为16384/8=2048
个字节,共包含16384
个二进制位。
Redis以0
为起始索引,16383
为终止索引,对s1ots
数组中的16384
个二进制位进行编号,并根据索引i
上的二进制位的值来判断节点是否负责处理槽i
下图展示了一个slots
数组的实例
因为取出和设置s1ots
数组中的任意一个二进制位的值的复杂度仅为O(1)
,所以对于一个给定节点的s1ots
数组来说,程序检查节点是否负责处理某个槽,又或者将某个槽指派给节点负责,这两个动作的复杂度都是O(1)
。
17.2.2 传播节点的槽指派信息
这个节点不光会存储自己的槽指派信息,还将把它通过消息发送给其他节点,以此告诉其他节点自己目前负责哪些槽。事实上,我们要达到的是,让每个节点都知道所有16384个槽目前被谁处理。
下图是一个例子
17.2.3 记录集群所有槽的指派信息
我们除了将槽指派信息存储在cluterNode
结构的slots
属性中,还会在全局的clusterState
结构的slots
指针数组中记录所有16384个槽的指派信息
1 | typedef struct clusterNode{ |
通过这种存储方式,我们可以以O(1)
的复杂度得知指定槽目前被哪个节点负责
一个例子如下
17.2.4 CLUSTER ADDSLOTS命令的实现
CLUSTER ADDSLOTS
命令接受一个或多个槽作为参数,并将所有输入的槽指派给接受该命令的节点负责,它的实现如下伪码
1 | def cluster_addslots(*all input slots): |
下面我们举个例子,下图是一个节点,这个集群没有任何指派
接着我们执行槽指派
1 | CLUSTER ADDSLOTS 1 2 |
槽指派后会变成如下结构
最后,在CLUSTER ADDSLOTS
命令执行完毕之后,节点会通过发送消息告知集群中的其他节点,自己目前正在负责处理哪些槽。
17.3 在集群中执行命令
在对数据库中的16384个槽都进行了指派之后,集群就会进入上线状态,这时客户端就可以向集群中的节点发送数据命令了。
当客户端向节点发送与数据库键有关的命令时,接收命令的节点会计算出命令要处理的数据库键属于哪个槽,并检查这个槽是否指派给了自己
- 如果键所在的槽正好就指派给了当前节点,那么节点直接执行这个命令。
- 如果键所在的槽并没有指派给当前节点,那么节点会向客户端返回一个MOVED错误,指引客户端转向至正确的节点,并再次发送之前想要执行的命令。
流程如下图
17.3.1 节点数据库的实现
节点和单机服务器在数据库方面的一个区别是,节点只能使用0号数据库,而单机Redis服务器则没有这一限制。
此外,除了将键值对保存在数据库里面之外,节点还会用clusterState
结构中的slots_to_keys
跳跃表来保存槽和键之间的关系:
1 | typedef struct clusterState{ |
slots_to_keys
跳跃表每个节点的分值都是一个槽号,而每个节点的成员都是一个数据库键。通过在slots_to_keys
跳跃表中记录各个数据库键所属的槽,节点可以很方便地对属于某个或某些槽的所有数据库键进行批量操作。
下面是一个跳跃表的例子
17.4 重新分片
Redis集群的重新分片操作可以将任意数量已经指派给某个节点(源节点)的槽改为指派给另一个节点(目标节点),并且相关槽所属的键值对也会从源节点被移动到目标节点。
Redis集群的重新分片操作是由Redis的集群管理软件redis-trib
负责执行的,Redis提供了进行重新分片所需的所有命令,而redis-trib
则通过向源节点和目标节点发送命令来进行重新分片操作。重新分片步骤如下
-
redis-trib
对目标节点发送CLUSTER SETSLOT <slot> IMPORTING <source_id>
命令,让目标节点准备好从源节点导人属于槽slot
的键值对。 -
redis-trib
对源节点发送CLUSTER SETSLOT <slot> MIGRATING <target_id>
命令,让源节点准备好将属于槽slot
的键值对迁移至目标节点。 -
redis-trib
向源节点发送CLUSTER GETKEYSINSLOT <s1ot> <count>
命令,获得最多count
个属于槽slot
的键值对的键名 -
对于步骤3获得的每个键名,
redis-trib
都向源节点发送一个MIGRATE <target Ip> <target port> <key name> 0 <timeout>
命令,将被选中的键原子地从源节点迁移至目标节点 -
重复执行步骤3和步骤4,直到源节点保存的所有属于槽s1ot的键值对都被迁移至目标节点为止。每次迁移键的过程下图所示
-
redis-trib向集群中的任意一个节点发送
CLUSTER SETSLOT <slot> NODE <target_id>
命令,将槽slot
指派给目标节点,这一指派信息会通过消息发送至整个集群最终集群中的所有节点都会知道槽s1ot
已经指派给了目标节点。
流程图如下
17.5 ASK错误
在进行重新分片期间,源节点向目标节点迁移一个槽的过程中,可能会出现这样一种情况:属于被迁移槽的一部分键值对保存在源节点里面,而另一部分键值对则保存在目标节点里面。
当客户端向源节点发送某个与数据库键有关的命令,并且命令要处理的数据库键恰好就属于正在被迁移的槽时:
- 源节点会先在自己的数据库里面查找指定的键,如果找到的话,就直接执行客户端发送的命令。
- 相反地,如果源节点没能在自己的数据库里面找到指定的键,那么这个键有可能已经被迁移到了目标节点,源节点将向客户端返回一个
ASK
错误,指引客户端转向正在导入槽的目标节点,并再次发送之前想要执行的命令。
流程图如下
17.6 复制与故障转移
Redis集群中的节点分为主节点和从节点,其中主节点用于处理槽,而从节点则用于复制某个主节点,并在被复制的主节点下线时,代替下线主节点继续处理命令请求。
下面我们举一个发生故障的例子
-
首先集群中包含如下6个节点
-
如果这时,节点7000进入下线状态,那么集群中仍在正常运作的几个主节点将在节点7000的两个从节点中选出一个节点作为新的主节点,这个新的主节点将接管原来节点7000负责处理的槽,并继续处理客户端发送的命令请求。
如果在故障转移完成之后,下线的节点7000重新上线,那么它将成为节点7004的从节点,如下图
17.6.1 设置从节点
向一个节点发送如下命令可以让接收命令的节点成为node_id
所指定节点的从节点,并开始对主节点进行复制
1 | CLUSTER REPLICATE <node_id> |
这个命令的执行步骤如下
- 接收到该命令的节点首先会在自己的
clusterState.nodes
字典中找到node_id
所对应节点的clusterNode
结构,并将自己的clusterState.myself.slaveof
指针指向这个结构,以此来记录这个节点正在复制的主节点 - 然后节点会修改自己在
cluterState.myself.flags
中的属性,关闭原本的REDIS_NODE_MASTER
标识,打开REDIS_NODE_SLAVE
标识,表示这个节点已经由原来的主节点变成了从节点。 - 最后,节点会调用复制代码,并根据
clusterState.myself.slaves
指向的clusterNode
结构所保存的IP地址和端口号,对主节点进行复制。
某个节点成为从节点,并开始复制某个主节点这一信息会通过gossip协议消息发送给集群中的其他节点,最终集群中的所有节点都会知道某个从节点隶属于了某个主节点
17.6.2 故障检测
集群中的每个节点都会定期地向集群中的其他节点发送PING消息,以此来检测对方是否在线,如果接收PING消息的节点没有在规定的时间内,向发送PING消息的节点返回PONG消息,那么发送PING消息的节点就会将接收PING消息的节点标记为疑似下线
此外,PING和PONG消息还用于集群内节点以gossip的方式互相交换各个节点的状态,详见下一节
当一个主节点A通过互发消息的方式得知了主节点B也认为主节点C进入了疑似下线,它会将主节点B的下线报告加入到下线报告链表中
举个例子,如果主节点7001在收到主节点7002、主节点7003发送的消息后得知,主节点7002和主节点7003都认为主节点7000进入了疑似下线状态,那么主节点7001将为主节点7000创建下图所示的下线报告。
如果节点A发现自己积累了超过半数以上的关于节点C的下线报告,也就是半数以上的节点都认为C下线了,它就会将C设置为已下线,并广播给所有节点一条FAIL消息,所有节点收到FAIL后,会立刻设置C为已下线状态
例如下图
17.6.3 故障转移
当一个从节点发现自己正在复制的主节点进入了已下线状态时,从节点将开始对下线主节点进行故障转移,以下是故障转移的执行步骤
- 复制下线主节点的所有从节点里面,会有一个从节点被选中。
- 被选中的从节点会执行
SLAVEOF no one
命令,成为新的主节点。 - 新的主节点会撤销所有对已下线主节点的槽指派,并将这些槽全部指派给自己。
- 新的主节点向集群广播一条PONG消息,这条PONG消息可以让集群中的其他节点立即知道这个节点已经由从节点变成了主节点,并且这个主节点已经接管了原本由已下线节点负责处理的槽,新的主节点开始接收和自己负责处理的槽有关的命令请求,故障转移完成。
17.6.4 选举新的主节点
新的主节点是通过选举产生的,选举采用Raft算法的领头选举,类似于第16章选举哨兵主节点
- 集群的配置纪元是一个自增计数器,它的初始值为0.当集群里的某个节点开始一次故障转移操作时,集群配置纪元的值会被增一
- 对于每个配置纪元,集群里每个负责处理槽的主节点都有一次投票的机会,而第一个向主节点要求投票的从节点将获得主节点的投票。
- 当从节点发现自己正在复制的主节点进入已下线状态时,从节点会向集群广播一条
CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST
消息,要求所有收到这条消息、并且具有投票权的主节点向这个从节点投票。 - 如果一个主节点具有投票权,并且这个主节点尚未投票给其他从节点,那么主节点将向要求投票的从节点返回一条
CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK
消息,表示这个主节点支持从节点成为新的主节点。 - 每个参与选举的从节点都会接收
CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK
消息,并根据自己收到了多少条这种消息来统计自己获得了多少主节点的支持。 - 如果集群里有N个具有投票权的主节点,那么当一个从节点收集到大于等于
N/2+1
张支持票时,这个从节点就会当选为新的主节点 - 如果在一个配置纪元里面没有从节点能收集到足够多的支持票,那么集群进入一个新的配置纪元,并再次进行选举,直到选出新的主节点为止。
17.7 消息
节点发送的消息主要有以下五种
-
MEET消息:当发送者接到客户端发送的
CLUSTER MEET
命令时,发送者会向接收者发送MEET消息,请求接收者加入到发送者当前所处的集群里面。 -
PING消息:集群里的每个节点默认每隔一秒钟就会从已知节点列表中随机选出五个节点,然后对这五个节点中最长时间没有发送过PING消息的节点发送PING消息,以此来检测被选中的节点是否在线。
-
PONG消息:当接收者收到发送者发来的MEET消息或者PONG消息时,为了向发送者确认这条MEET消息或者PING消息已到达,接收者会向发送者返回一条PONG消息。
-
FAIL消息:当一个主节点A判断另一个主节点B已经进入FAIL状态时,节点A会向集群广播一条关于节点B的FAIL消息,所有收到这条消息的节点都会立即将节点B标记为已下线。
-
PUBLISH消息:当节点接收到一个PUBLISH命令时,节点会执行这个命令,并向集群广播一条PUBLISH消息,所有接收到这条PUBLISH消息的节点都会执行相同的PUBLISH命令。
其实,在Redis集群中,关于消息的发送有两种方式
- 直接广播:这种方式比较快捷暴力,它直接将消息广播给集群中所有节点,这样所有节点都快速得知了这条消息中的内容,例如
FAIL
和PUBLISH
就是使用这个方式 - 基于Gossip协议的消息传播:Gossip协议是一个基于传染病模型的分布式通信协议,所有节点不会立即都收到这条消息,但是它可以保证在一定的时间范围内,所有节点都收到了这个消息,它可以节省通信成本,因为不用一次通知所有节点。关于Gossip协议,可以继续查看这篇文章。下面使用图片举例。