redis之集群

Redis集群简介

Redis集群是Redis提供的分布式数据库方案,集群通过分片来进行数据共享,并提供复制和故障转移功能。换句话说,集群用来提供横向扩展能力,即当数据量增多之后,通过增加服务节点就可以扩展服务能力。背后理论思想是将数据通过某种算法分布到不同的服务节点,这样当节点越多,单台节点所需提供服务的数据就越少。

集群中数据分片之后由不同的节点提供服务,即每个节点的数据都不相同,此种情况下,为了确保没有单点故障,主服务必须挂载至少一个从服务。客户端请求时可以向任意一个主节点或者从节点发起,当向从节点发起请求时,从节点会返回MOVED信息重定向到相应的主节点。

Redis集群的数据结构

clusterNode结构保存了一个节点的当前状态,比如节点的创建时间、节点的名字、节点当前的配置纪元、节点的ip和端口号等,结构如下:

typedef struct clusterNode {
    mstime_t ctime; /* 创建节点时间*/
    char name[CLUSTER_NAMELEN]; /* 节点的名字*/
    int flags;      /* 节点标识。使用各种不同的标识值记录节点的角色(比如主节点或者从节点),以及节点目前所处的状态(比如在线或者下线) */
  ...
    unsigned char slots[CLUSTER_SLOTS/8]; /* slots handled by this node */
    int numslots;   /* Number of slots handled by this node */
    int numslaves;  /* Number of slave nodes, if this is a master */
    struct clusterNode **slaves; /* pointers to slave nodes */
    struct clusterNode *slaveof; /* pointer to the master node. Note that it may be NULL even if the node is a slave
                                    if we don't have the master node in our
                                    tables. */
  ...
    char ip[NET_IP_STR_LEN];  /* Latest known IP address of this node */
    int port;                   /* Latest known clients port of this node */
    int cport;                  /* Latest known cluster port of this node. */
    clusterLink *link;          /* TCP/IP link with this node */
  ...
} clusterNode;

每个节点都会使用一个clusterNode结构来记录自己的状态,并为集群中的所有其他节点(包括主节点和从节点)都创建一个相应的clusterNode结构,以此来记录其他节点的状态。

clusterNode结构有个clusterLink类型属性,该类型结构保存了连接节点所需的有关信息,比如套接字描述符,输入缓冲区和输出缓冲区。clusterLink结构和redisClient结构中都有套接字和输入、输出缓冲区,两者有什么区别呢?redisClient结构中的套接字和缓冲区是用于连接客户端的,而clusterLink结构中的套接字和缓冲区是用来连接节点的。

最后,每个节点都保存着一个clusterState结构,这个结构记录了在当前节点的视角下,集群目前所在的状态,例如集群是在线还是下线,集群包含多少个节点,集群当前的配置纪元等。clusterState这个结构很重要,下面讲槽指派时会再次提到,clusterState结构如下:

typedef struct clusterState {
    //指向当前节点的指针
    clusterNode *myself;
 
    //集群当前的配置纪元,用于实现故障转移
    uint64_t currentEpoch;
 
    //集群当前的状态:是在线还是下线
    int state;
 
    //集群中至少处理着一个槽的节点的数量
    int size;
 
    //集群节点名单(包括myself 节点)
    //字典的键为节点的名字,字典的值为节点对应的clusterNode 结构
    dict *nodes;
    // ...
} clusterState;

Redis集群的状态

我们知道Redis集群的这个数据库被分16384个槽,当数据库中的16384个槽都有节点在处理时,集群处于上线状态(OK);相反地,如果数据库中有任何一个槽没有得到处理,那么集群处于下线状态(fail)

例如,我们使用CLUSTER MEET命令将7000、7001、7002三个节点连接到了同一个集群里面,不过这个集群目前处于下线状态,因为集群中的三个节点都没有在处理任何槽。
通过向节点发送CLUSTER ADDSLOTS命令,我们可以将一个或多个槽指派给节点负责:

127.0.0.1:7000>CLUSTER ADDSLOTS 0 1 2 3 4...5000
OK

为了让7000、7001、7002三个节点所在的集群进入上线状态,我们还需要在7001和7002上执行类似的操作。
当以上三个CLUSTER ADDSLOTS命令都执行完毕之后,数据库中的16384个槽都已经被指派给了相应的节点,集群进入上线状态,可通过如下命令查看集群状态:

127.0.0.1:7000>CLUSTER INFO

Redis集群的槽指派

Redis集群通过分片的方式来保存数据库中的键值对:集群的这个数据库被分为16384个槽(slot),数据库中的每个键都属于这个16384个槽的其中一个,集群中的每个节点可以处理0个或最多16384个槽。

记录节点的槽指派信息

从上文中,我们知道了如何给主节点分配槽,现在我们介绍节点如何保存槽指派信息。
clusterNode结构的slots属性和numslot属性记录了节点负责处理哪些槽。
slots属性是一个二进制位数组(bit array),这个数组的长度为16384/8=2048个字节,供包含16384个二进制位。
Redis以0为索引,16383位终止索引,对slots数组中的16384个二进制位进行编号,并根据索引i上的二进制位的值来判断节点的是否负责处理槽i。

  • 如果slots数组在索引i上的二进制位的值为1,那么表示节点负责处理槽i。
  • 如果slots数组在索引i上的二进制位的值为0,那么表示节点不负责处理槽i。

因为取出和设置slots数组中的任意一个二进制位的值的复杂度仅为O(1),所以对于一个给定节点的slots数组来说,程序检查节点是否负责处理某个槽,又或者将某个槽指派给节点负责,这两个动作的复杂度都是O(1)。

至于numslots属性则记录节点负责处理的槽的数量,也就是slots数组中值为1的二进制位的数量。

传播节点的槽指派信息

一个节点除了会将自己负责处理的槽记录在clusterNode结构的slots属性和numslots属性之外,它还会将自己的slots数组通过消息发送给集群中的其他节点,以此来告知其他节点自己目前负责处理哪些槽。
当节点A通过消息从节点B那里接收到节点B的slots数组时,节点A会在自己的clusterState.nodes字典中查找节点B对应的clusterNode结构,并对结构中的slots数组进行保存或者更新。

因为集群中的每个节点都会将自己的slots数组通过消息发送给集群中的其他节点,并且每个接收到slots数组的节点都会将数组保存到相应节点的clusterNode结构里面,因此,集群中的每个节点都会知道数据库中的16384个槽分别被指派给了集群中的哪些节点。

记录集群所有槽的指派信息

clusterState结构中的slots数组记录了集群中所有16384个槽的指派信息:

typedef struct clusterState {
    //指向当前节点的指针
    clusterNode *myself;
 
    //集群当前的配置纪元,用于实现故障转移
    uint64_t currentEpoch;
 
    //集群当前的状态:是在线还是下线
    int state;
 
    //集群中至少处理着一个槽的节点的数量
    int size;
 
    //集群节点名单(包括myself 节点)
    //字典的键为节点的名字,字典的值为节点对应的clusterNode 结构
    dict *nodes;
    //集群中所有槽的指派信息
	clusterNode *slots[16384]
    // ...
} clusterState;

slots数组包含16384个项,每个数组项都是一个指向clusterNode结构的指针

  • 如果slots[i]指针指向NULL,那么表示槽i尚未指派给任何节点。
  • 如果slots[i]指针指向一个clusterNode结构,那么表示槽i已经指派给了clusterNode结构所代表的节点。

从上面的内容可知,每个节点中都记录了集群中所有节点的槽信息,那为什么还要把集群所有槽的指派信息再记录到clusterState结构中呢
这是因为如果只将槽指派信息保存在各个节点的clusterNode.slots数组里,会出现一些无法高效地解决的问题,而clusterState.slots数组的存在解决了这些问题:

  • 如果节点只使用clusterNode.slots数组来记录槽的指派信息,那么为了知道槽i是否已经被指派,或者槽i被指派给了哪个节点,程序需要遍历clusterState.nodes字典中的所有clusterNode结构,检查这些结构的slots数组,直到找到负责处理槽i的节点为止,这个过程的复杂度为O(N),其中N为clusterState.nodes字典保存的clusterNode结构的数量。
  • 而通过将所有槽的指派信息保存在clusterState.slots数组里面,程序要检查槽i是否已经被指派,又或者取得负责处理槽i的节点,只需要访问clusterState.slots[i]的值即可,这个操作的复杂度仅为O(1)

需要强调的是,虽然clusterState.slots数组记录了集群中所有槽的指派信息,但是用clusterNode结构的slots数组记录单个节点的槽指派信息仍然是有必要的:

  • 因为当程序需要将某个节点的槽指派信息通过消息发送给其他节点时,程序只需要将相应节点的clusterNode.slots数组整个发送出去就可以了。
  • 另一方面,如果Redis不使用clusterNode.slots数组,而单独使用clusterState.slots数组的话,那么每次要将节点A的槽指派信息传播给其他节点时,程序必须先遍历整个clusterState.slots数组,记录节点A负责处理哪些槽,然后才能发送节点A的槽指派信息,这比直接发送clusterNode.slots数组要麻烦和低效得多。

Redis集群中执行命令

Redis将键空间分为了16384个slot,然后通过如下算法:

HASH_SLOT=CRC16(KEY) mod 16384

计算出每个key所属的slot。客户端可以请求任意一个节点,每个节点中都会保存所有16384个slot对应到哪一个节点的信息。如果一个key所属的slot正好由被请求的节点提供服务,则直接处理并返回结果,否则返回MOVED重定向信息,如下:

GET key
-MOVED slot IP:PORT

由-MOVED开头,接着是该key计算出的slot,然后是该slot对应到节点IP和PORT。客户端应该处理重定向信息,并且向拥有该key的节点发起请求。

然而,在实际应用中,Redis客户端可以通过向集群请求slot和节点的映射关系并缓存,然后通过本地计算要操作的key所属的slot,查询映射关系,直接向正确的节点发起请求,这样可以获得几乎等价于单节点部署的性能。
当集群由于节点故障或者扩容导致重新分片后,客户端通过重定向获取到数据,每次发生重定向后,客户端可以将新的映射关系进行缓存,下次仍然可以直接向正确的节点发起请求。
Redis集群执行命令的步骤,大致可以分为以下几个步骤:

  • 计算key属于哪个槽
    节点使用以下算法计算给定键key属于哪个槽:
def slot_number(key):
	return CRC16(key) & 16383

其中CRC16(key)语句用于计算键key的CRC-16校验和,而&16383语句则用于计算出一个介于0到16383之间的整数作为键key的槽号。

  • 判断槽由哪个节点负责
    当节点计算出键所属的槽i之后,节点就会检查自己在clusterState.slots数组中的项i,判断键所在的槽是否由自己负责:

    • 如果clusterState.slots[i]等于clusterState.myself,那么说明槽i由当前节点负责,节点可以执行客户端发送的命令。
    • 如果clusterState.slots[i]不等于clusterState.myself,那么说明槽i并非有当前节点负责,节点会根据clusterState.slots[i]指向的clusterNode结构所记录的节点IP和端口号,向客户端返回MOVED错误,指引客户端转向至正在处理槽i的节点。
  • MOVED错误
    当节点发现键所在的槽并非由自己负责处理的时候,节点就会向客户端返回一个MOVED错误,指引客户端转向至正在负责槽的节点。
    一个集群客户端通常会与集群中的多个节点创建套接字连接,而所谓的节点转向实际上就是换一个套接字来发送命令。
    如果客户端尚未与想要转向的节点建立套接字连接,那么客户端会先根据MOVED错误提供的IP地址和端口号来连接节点,然后再进行转向。

Redis集群重新分片

Redis集群的重新分片操作可以将任意数量已经指派给某个节点(源节点)的槽改为指派给另一个节点(目标节点),并且相关槽所属的键值对也会从源节点被移动到目标节点。
重新分片操作可以在线进行,在重新分片的过程中,集群不需要下线,并且源节点和目标节点都可以继续处理命令请求。

重新分片的原理

Redis集群的重新分片操作是由Redis的集群管理软件redis-trib负责执行的,Redis提供了进行重新分片所需的所有命令,而redis-trib则通过向源节点和目标节点发送命令来进行重新分片操作。

ASK错误

在进行重新分片期间,源节点向目标节点迁移一个槽的过程中,可能会出现这样一种情况:属于被迁移槽的一部分键值对保存在源节点里面,而另一部分键值对则保存在目标节点里面。
当客户端向某节点发送一个与数据库键有关的命令,并且命令要处理的数据库键恰好就属于正在被迁移的槽时:

  • 源节点会先在自己的数据库里面查找指定的键,如果找到的话,就直接执行客户端发送的命令。
  • 相反地,如果源节点没能在自己的数据库中找到指定的键,那么这个键有可能已经被迁移到了目标节点,源节点将向客户端返回一个ASK错误,指引客户端转向正在导入槽的目标节点,并再次发送之前想要执行的命令。

Redis的时间任务函数

类似哨兵,Redis时间任务函数serverCron中会调度集群的周期性函数,如下:

serverCron(){
	if (server.cluster_enabled) clusterCron();
}

其中clusterCron函数执行如下操作:

  • 向其他节点发送MEET消息,将其加入集群。
  • 每1s会随机选择一个节点,发送ping消息,如果一个节点在超市时间之内仍未收到ping包的响应,则将其标记为pfail。
  • 检查是否需要进行主从切换,如果需要则执行切换。
  • 检查是否需要进行副本漂移,如果需要,执行副本漂移操作。

Redis除了在serverCron函数中进行调度之外,在每次进入时间循环之前,会在beforeSleep函数中执行一些操作,如下:

  • 检查主从切换状态,如果需要,执行主从切换相关操作。
  • 更新集群状态,通过检查是否所有slot都有相应的节点提供服务以及是否大部分主服务都是可用状态,来决定集群处于正常状态还是失败状态。
  • 刷新集群状态到配置文件。

Redis集群的主从切换

Redis集群中节点有两种状态:pfailfail。当集群中节点通过错误检测机制发现某个节点处于fail状态时,会自动执行主从切换。
Redis中还提供一种手动执行切换的方法,即通过执行cluster failover命令。所以Redis主从切换分为两种:自动切换手动切换

故障检测

  • 集群中的每个节点都会定期的向集群中的其他节点发送PING消息,以此来检测对方是否在线,如果接收PING消息的节点没有在规定的时间内,向发送PING消息的节点返回PONG消息,那么发送PING消息的节点就会将接收PING消息的节点标记为疑似下线(pfail)
  • 如果在一个集群里面,半数以上负责处理槽的主节点都将某个主节点X报告为疑似下线,那么这个主节点X将被标记为已下线(fail),将主节点x标记为已下线的节点会向集群广播一条关于主节点X的FAIL消息,所有收到这条FAIL消息的节点都会立即将主节点X标记为已下线。

故障转移

集群中节点之间会相互发送心跳包,心跳包中会包括从发送方视角所记录的关于其他节点的状态信息。当一个节点收到心跳包之后,如果检测到发送方(假设为A)标记某个节点(假设为B)处于pfail状态,则接收节点(假设为C)会检测B节点是否已被大多数主节点标记为pfail状态。如果是,则C节点会向集群中所有节点发送一个fail包,通知其他节点B已经处于fail状态。

当一个从节点发现自己正在复制的主节点进入了已下线状态时,从节点将开始对下线主节点进行故障转移,大致步骤如下:

  • 复制下线主节点的所有从节点里面,会有一个从节点被选中。
  • 被选中的从节点会执行SLAVEOF no one命令,成为新的主节点。
  • 新的主节点会撤销所有对已下线主节点的槽指派,并将这些槽全部指派给自己。
  • 新的主节点向集群广播一条PONG消息,这条PONG消息可以让集群中的其他节点立即知道这个节点已经由从节点变成了主节点,并且这个主节点已经接管了原本由已下线节点负责处理的槽。
  • 新的主节点开始接收和自己负责处理的槽有关的命令请求,故障转移完成。

选举新的主节点

新的主节点是通过选举产生的,基于Raft算法的领头选举方法来实现的。
Redis集群选取新的主节点的方法:

  • 集群的配置纪元是一个自增计数器,它的初始值为0.
  • 当集群里的某个节点开始一次故障转移操作时,集群配置纪元的值会被增一。
  • 对于每个配置纪元,集群里每个负责处理槽的主节点都有一个投票的机会,而第一个向主节点要求投票的从节点将获得主节点的投票。
  • 当从节点发现自己正在复制的主节点进入已下线状态时,此从节点会向集群广播一条消息,要求所有收到这条消息、并且具有投票权的主节点给自己投票。
  • 如果一个主节点具有投票权(它正在负责处理槽),并且这个主节点尚未投票给其他从节点,那么主节点将向要求投票的从节点返回一条消息,表示这个主节点支持从节点成为新的主节点。
  • 每个参与选举的从节点都会接收消息,并根据自己收到了多少条这种消息来统计自己获得了多少主节点的支持。
  • 如果集群里有N个具有投票权的主节点,那么当一个从节点收集到大于等于N/2+1张支持票时,这个从节点就会当选为新的主节点。
  • 因为在每一个配置纪元里面,每个具有投票权的主节点只能投一次票,所以如果有N个主节点进行投票,那么具有大于等于N/2+1张支持票的从节点只会有一个,这确保了新的主节点只会有一个。
  • 如果在一个配置纪元里面没有从节点能收集到足够多的支持票,那么集群进入下一个新的配置纪元,并再次进行选举,直到选出新的主节点为止。

Redis集群的副本漂移

考虑如下场景:假设我们有一个由三个主节点三个从节点组成的集群,为了提高可靠性,我们可以在每个主节点下边各挂载两个从节点,共需要增加3个实例。但假设若集群中有100个主节点,为了更高的可靠性,就需要增加100个实例。是否存在一种方法既能提高可靠性,又可以做到不随集群规模线性增加从节点实例的数量呢?答案是肯定的,Redis中提供了一种副本漂移的方法。
什么是副本漂移呢?距离说明,我们有一个三个主节点(A、B、C)和三个从节点(A1、B1、C1)组成的Redis集群,现在我们在主节点C下再增加两个从节点(C2、C3)。假设主A发生故障,主A的从A1会执行切换,切换完成之后从A1变为主A1,此时主A1会出现单点问题,当检测到该单点问题后,集群会主动从主C的从服务中漂移一个给有单点问题的主A1做为从服务,这个过程就叫副本漂移

下面我们详细介绍Redis中如何实现副本漂移。
在周期性调度函数clusterCron中会定期检测如下条件:

  • 是否存在单点的主节点,即主节点没有任何一台可用的从节点。
  • 是否存在有两台及以上可用从节点的主节点。
    如果以上两个条件都满足,则从有最多可用从节点的主节点中选择一台从节点执行副本漂移。选择标准为按节点名称的字母序从小到大,选择最靠前的一台从节点执行漂移。漂移具体过程如下:
  • 从C的记录中将C1移除;
  • 将C1所记录的主节点更改为A1;
  • 在A1中添加C1为从节点;
  • 将C1的数据同步源设置为A1;
    可以看到,漂移过程只是更改一些节点所记录的信息,之后会通过心跳包将该信息同步到所有的集群节点。

最后,当一条命令需要操作的key分属于不同的节点时,Redis会报错。Redis提供了一种称为hash tags的机制,由业务方保证当需要进行多个key的处理时,将所有key分布到同一个节点,该机制实现原理如下:
如果一个key包括{substring}这种模式,则计算slot时只计算“{“和”}”之间的子字符串。即keys{sub}1、keys{sub}2、keys{sub}3计算slot时都会按照sub串进行。这样保证这3个字符串都会分布到同一个节点。

学习链接

Redis之详解
Redis5.0之集群搭建

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值