redis模型(4):集群

一、集群节点

1、节点

一个节点就是一个运行在集群模式下的Redis服务器,Redis服务器在启动时会根据cluster-enabled配置选项是否为yes来决定是否开启服务器的集群模式。

运行在集群模式下的 Redis 服务器会继续使用所有在单机模式中使用的服务器组件:

  • 节点会继续使用文件事件处理器来处理命令请求和返回命令回复。
  • 节点会继续使用时间事件处理器来执行serverCron函数,而serverCron函数又会调用集群模式特有的clusterCron函数。clusterCron函数负责执行在集群模式下需要执行的常规操作,例如向集群中的其他节点发送Gossip消息,检查节点是否断线,或者检查是否需要对下线节点进行自动故障转移等。
  • 节点会继续使用数据库来保存键值对数据,键值对依然会是各种不同类型的对象。
  • 节点会继续使用RDB持久化模块和AOF持久化模块来执行持久化工作。
  • 节点会继续使用发布与订阅模块来执行PUBLISH、SUBSCRIBE等命令。
  • 节点会继续使用复制模块来进行节点的复制工作。
  • 节点会继续使用Lua脚本环境来执行客户端输入的Lua脚本。
  • 节点会继续使用redisServer结构来保存服务器的状态,使用redisClient结构来保存客户端的状态。

2、集群数据结构

那些只有在集群模式下才会用到的数据,节点将它们保存到了cluster.h/clusterNode结构、cluster.h/clusterLink结构,以及cluster.h/clusterState结构里。

(1)clusterState

每个节点都保存有一个clusterState结构,记录了在当前节点的视角下,集群目前所处的状态。

typedef struct clusterState {
    // 指向当前节点的指针
    clusterNode *myself;
    // 集群目前包含的节点(包括 myself 节点),字典的键为节点的名字,字典的值为节点对应的 clusterNode 结构
    dict *nodes;
    // 集群中至少处理着一个槽的节点的数量
    int size;

    // 集群当前的配置纪元,用于实现故障转移
    uint64_t currentEpoch;
    // 集群当前的状态:是在线还是下线
    int state;
    // ...
} clusterState;

(2)clusterNode

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

struct clusterNode {
    // 当前节点状态,如:主从角色,是否下线等
    int flags;
    // 最后一次与该节点发送ping消息的时间
    mstime_t ping_sent;
    // 最后一次接收到该节点pong消息的时间
    mstime_t pong_received;
    
    // 节点当前的配置纪元,用于实现故障转移
    uint64_t configEpoch;
    // 保存连接节点所需的有关信息,redisClient结构和clusterLink结构都有自己的套接字描述符和输入、输出缓冲区。redisClient结构中的套接字和缓冲区是用于连接客户端的,而clusterLink结构中的套接字和缓冲区则是用于连接节点的。
    clusterLink *link;
    // ...
};
flags对应节点状态:
CLUSTER_NODE_MASTER 1 /* 当前为主节点 */
CLUSTER_NODE_SLAVE 2 /* 当前为从节点 */
CLUSTER_NODE_PFAIL 4 /* 主观下线状态 */
CLUSTER_NODE_FAIL 8 /* 客观下线状态 */
CLUSTER_NODE_MYSELF 16 /* 表示自身状态 */
CLUSTER_NODE_HANDSHAKE 32 /* 握手状态,未与其他节点进行消息通信 */
CLUSTER_NODE_NOADDR 64 /* 无地址节点,用于第一次meet通信未完成或者通信失败 */
CLUSTER_NODE_MEET 128 /* 需要接受meet消息的节点状态 */
CLUSTER_NODE_MIGRATE_TO 256 /* 该节点被选中为新的主节点状态 */

3、节点连接

向节点 A 发送 CLUSTER MEET <ip> <port> 命令,将另一个节点 B 添加到节点 A 当前所在的集群里面,收到命令的节点 A 将与节点 B 进行握手,以此来确认彼此的存在:

  • 节点A会为节点B创建一个clusterNode结构,并将该结构添加到自己的clusterState.nodes字典里面。之后,节点A将根据CLUSTER MEET命令给定的IP地址和端口号,向节点 B 发送一条 MEET消息。
  • 节点B将接收到节点A发送的MEET消息并为节点A创建一个clusterNode结构并添加到自己的clusterState.nodes字典里。之后,节点B将向节点A返回一条 PONG消息。
  • 节点A将接收到节点B返回的PONG消息,节点A可以知道节点B已经成功地接收到了自己发送的MEET消息。之后,节点A将向节点B返回一条PING消息。
  • 节点B将接收到节点A返回的PING消息,节点B可以知道节点A已经成功地接收到了自己返回的PONG消息,握手完成。
  • 之后,节点A会将节点B的信息通过Gossip协议传播给集群中的其他节点,让其他节点也与节点B进行握手,最终经过一段时间之后,节点B会被集群中的所有节点认识。

4、节点通信

Redis集群采用P2P的Gossip(流言)协议,Gossip协议工作原理就是节点彼此不断通信交换信息,一段时间后所有的节点都会知道集群完整的信息。

(1)通信过程

  • 集群中的每个节点都会单独开辟一个TCP通道,用于节点之间彼此通信,通信端口号在基础端口上加10000。
  • 每个节点在固定周期内通过特定规则选择几个节点发送ping消息。
  • 接收到ping消息的节点用pong消息作为响应。

集群中每个节点通过一定规则挑选要通信的节点,每个节点可能知道全部节点,也可能仅知道部分节点,只要这些节点彼此可以正常通信,最终它们会达到一致的状态。

(2)Gossip消息

  • meet消息:用于通知新节点加入。消息发送者通知接收者加入到当前集群,meet消息通信正常完成后,接收节点会加入到集群中并进行周期性的ping、pong消息交换。
  • ping消息:集群内交换最频繁的消息,集群内每个节点每秒向多个其他节点发送ping消息,用于检测节点是否在线和交换彼此状态信息。ping消息发送封装了自身节点和部分其他节点的状态数据。
  • pong消息:当接收到ping、meet消息时,作为响应消息回复给发送方确认消息正常通信。pong消息内部封装了自身状态数据。
  • fail消息:当节点判定集群内另一个节点下线时,会向集群内广播一个fail消息,其他节点接收到fail消息之后把对应节点更新为下线状态。
  • publish消息:当节点接收到一个publish命令时,节点会执行这个命令,并向集群广播一条publish消息,所有接收到这条publish消息的节点都会执行相同的publish命令。

(3)由于内部需要频繁地进行节点信息交换,而ping/pong消息会携带当前节点和部分其他节点的状态数据,势必会加重带宽和计算的负担。Redis集群内节点通信采用固定频率(定时任务每秒执行10次),集群内每个节点维护定时任务默认每秒执行10次,每秒会随机选取5个节点找出最久没有通信的节点发送ping消息,用于保证Gossip信息交换的随机性。

二、槽

1、槽信息记录

    集群的整个数据库被分为16384个槽,数据库中的每个键都属于这16384个槽的其中一个,集群中的每个节点可以处理0个或最多16384个槽。如果数据库中有任何一个槽没有得到处理,那么集群处于下线状态。 

(1)clusterNode.slots

struct clusterNode{
    //...
    //Redis以0为起始索引,16383为终止索引,对slots数组中的16384个二进制位进行编号,索引i上为1表示节点负责处理槽i,为0表示节点不负责处理槽i。长度为16384/8=2048个字节。取出和设置slots数组中的任意一个二进制位的值的复杂度为O(1)。
    unsigned char slots[16384/8];
    //记录节点负责处理的槽的数量,即slots数组中值为1的二进制位的数量
    int numslots;
    //...
};

clusterNode结构的slots和numslots属性记录某个节点所负责的槽。

(2)clusterState.slots

typedef struct clusterState{
    //...
    //记录了集群中所有16384个槽的指派信息,每个数组项都是一个指向clusterNode结构的指针,若指向NULL表示槽i尚未指派给任何节点
    clusterNode *slots[16384];
    //...
} clusterState;

每个节点还会通过clusterState的slots属性记录集群所有槽的指派信息。

  • 如果节点只使用clusterNode.slots数组来记录槽的指派信息,那么为了知道槽i是否已经被指派,或者槽i被指派给了哪个节点,程序需要遍历clusterState.nodes字典中的所有 clusterNode结构,检查这些结构的slots数组,直到找到负责处理槽i的节点为止,这个过程的复杂度为O(N),通过clusterState.slots数组,只需要访问clusterState.slots[i]的值即可,这个操作的复杂度仅为O(1)。
  • 当程序需要将某个节点的槽指派信息通过消息发送给其他节点时,程序只需要将相应节点的clusterNode.slots数组整个发送出去就可以了,如果Redis不使用clusterNode.slots数组,而单独使用clusterState.slots数组的话,那么每次要将节点A的槽指派信息传播给其他节点时,程序必须先遍历整个clusterState.slots数组,记录节点A负责处理哪些槽,然后才能发送节点A的槽指派信息,这比直接发送clusterNode.slots数组要麻烦和低效得多。

2、传播节点的槽指派信息

     一个节点除了会将自己负责处理的槽记录在clusterNode结构的slots和numslots属性外,还会将自己的slots数组通过消息发送给集群中的其他节点,每个接收到信息的节点会在自己的clusterState.nodes中找到发送方对应的clusterNode并保存slots信息,因此集群中的每个节点都会知道数据库中的16384个槽分别被指派给了集群中的哪些节点。

3、CLUSTER ADDSLOTS命令将槽指派给节点

  • 遍历所有输入槽,检查它们是否都是未指派槽,如果其中存在已被指派的槽,会向客户端返回错误。
  • 遍历所有输入槽,设置clusterState结构的slots数组,将slot[i]的指针指向代表当前节点的clusterNode。
  • 访问代表当前节点的clusterNode结构的slots数组,将数组在索引i上的二进制位设置为1。
  • CLUSTER ADDSLOTS命令执行完毕之后,节点通过发送消息告知集群中的其他节点自己目前正在负责处理哪些槽。

4、命令执行

在对数据库中的16384个槽都进行了指派之后,集群就会进入上线状态,这时客户端就可以向集群中的节点发送数据命令了。

集群中的节点只能使用0号数据库,节点会用clusterState结构中的slots_to_keys来保存槽和键之间的关系。

当客户端向节点发送与数据库键有关的命令时,接收命令的节点会计算出命令要处理的键属于哪个槽:

  • 使用算法 CRC16(key) & 16383 计算出给定键key属于哪个槽,当节点计算出键键所属的槽i之后,节点会检查自己在clusterState.slots数组中的项i是否指向clusterState.myself。
  • 如果键所在的槽正好就指派给了当前节点,那么节点直接执行这个命令。
  • 如果键所在的槽并没有指派给当前节点,那么节点会向客户端返回一个MOVED错误。当客户端接收到节点返回的MOVED错误时,客户端会根据MOVED错误中提供的IP地址和端口号,转向至负责处理槽slot的节点,并向该节点重新发送之前想要执行的命令。(一个集群客户端通常会与集群中的多个节点创建套接字连接,节点转向实际上就是换一个套接字来发送命令)。 

5、Redis虚拟槽分区的特点:

  • 解耦数据和节点之间的关系,简化了节点扩容和收缩难度。
  • 节点自身维护槽的映射关系,不需要客户端或者代理服务维护槽分区元数据。
  • 支持节点、槽、键之间的映射查询,用于数据路由,在线伸缩等场景。

6、重新分片(集群扩容或收缩的场景)

Redis集群的重新分片操作可以将任意数量已经指派给某个节点(源节点)的槽改为指派给另一个节点(目标节点),并且相关槽所属的键值对也会从源节点被移动到目标节点。数据迁移过程是逐个槽进行的,由redis-trib执行,redis-trib对集群的单个槽slot进行重新分片的步骤如下:

  • 对目标节点发送cluster setslot {slot} importing {sourceNodeId}命令,让目标节点准备导入槽的数据。
  • 对源节点发送cluster setslot {slot} migrating {targetNodeId}命令,让源节点准备迁出槽的数据。
  • 源节点循环执行cluster getkeysinslot {slot} {count}命令,获取count个属于槽(slot)的键。
  • 在源节点上执行migrate {targetIp} {targetPort} "" 0 {timeout} keys {key...}命令,把获取的键通过流水线(pipeline)机制批量迁移到目标节点,批量迁移版本的migrate命令在Redis3.0.6以上版本提供,之前的migrate命令只能单个键迁移。对于大量key的场景,批量键迁移将极大降低节点之间网络IO次数。
  • 重复执行步骤3和步骤4直到槽下所有的键值数据迁移到目标节点。
  • 向集群内所有主节点发送cluster setslot {slot} node {targetNodeId}命令,通知槽分配给目标节点。为了保证槽节点映射变更及时传播,需要遍历发送给所有主节点更新被迁移的槽指向新节点。

当客户端向源节点发送一个与数据库键有关的命令,并且命令要处理的数据库键恰好正在被迁移时,会向客户端返回一个ASK错误。

三、集群功能的限制

1、Redis集群相对单机在功能上存在一些限制:

  • key批量操作支持有限。如mset、mget,目前只支持具有相同slot值的key执行批量操作。对于映射为不同slot值的key由于执行mget、mset等操作可能存在于多个节点上因此不被支持。
  • key事务操作支持有限。同理只支持多key在同一节点上的事务操作,当多个key分布在不同的节点上时无法使用事务功能。
  • key作为数据分区的最小粒度,因此不能将一个大的键值对象如hash、list等映射到不同的节点。
  • 不支持多数据库空间。单机下的Redis可以支持16个数据库,集群模式下只能使用一个数据库空间,即db0。
  • 复制结构只支持一层,从节点只能复制主节点,不支持嵌套树状复制结构。

2、hash_tag

    如果键内容包含{和}大括号字符,则计算槽的有效部分是括号内的内容;否则采用键的全内容计算槽。其中键内部使用大括号包含的内容又叫做hash_tag,它提供不同的键可以具备相同slot的功能,常用于Redis IO优化。

  • 集群模式下使用mget等命令优化批量调用时,键列表必须具有相同的slot,否则会报错。这时可以利用hash_tag让不同的键具有相同的slot达到优化的目的。
  • Pipeline同样可以受益于hash_tag,由于Pipeline只能向一个节点批量发送执行命令,而相同slot必然会对应到唯一的节点,降低了集群使用Pipeline的门槛。

四、故障转移

redis集群自身实现了高可用,当集群内少量节点出现故障时通过自动故障转移保证集群可以正常对外提供服务。作为一个完整的集群,每个负责处理槽的节点应该具有从节点,保证当它出现故障时可以自动进行故障转移。首次启动的节点和被分配槽的节点都是主节点,从节点负责复制主节点槽信息和相关的数据。

1、故障发现

故障发现也是通过消息传播机制实现的,主要环节包括:

(1)主观下线(pfail)。

  • 集群中每个节点都会定期向其他节点发送ping消息,接收节点回复pong消息作为响应。如果在cluster-node-timeout时间内通信一直失败,则发送节点会认为接收节点存在故障,把接收节点标记为主观下线(pfail)状态。

(2)客观下线(fail)

  • 当某个节点判断另一个节点主观下线后,相应的节点状态会跟随消息在集群内传播。当接受节点发现消息体中含有主观下线的节点状态且发送节点是主节点时,会在本地找到故障节点的ClusterNode结构,更新下线报告链表。
  • 集群中的节点每次接收到其他节点的pfail状态,都会尝试触发客观下线。首先统计有效的下线报告数量,当下线报告数量大于槽主节点数量一半时,标记对应故障节点为客观下线状态。
  • 向集群广播一条fail消息,通知所有的节点将故障节点标记为客观下线,fail消息的消息体只包含故障节点的ID。通知故障节点的从节点触发故障转移流程。
struct clusterNode { /* 认为是主观下线的clusterNode结构 */
    list *fail_reports; /* 记录了所有其他节点对该节点的下线报告 */
};

只有负责槽的主节点参与故障发现决策,因为集群模式下只有处理槽的主节点才负责读写请求和集群槽等关键信息维护,而从节点只进行主机诶单数据和状态信息的复制。

2、故障恢复

故障节点变为客观下线后,如果下线节点是持有槽的主节点则需要在它的从节点中选出一个替换它,从而保证集群的高可用。下线主节点的所有从节点承担故障恢复的义务,当从节点通过内部定时任务发现自身复制的主节点进入客观下线时,将会触发故障恢复流程:

(1) 资格检查

每个从节点都要检查最后与主节点断线时间,判断是否有资格替换故障的主节点。如果从节点与主节点断线时间超过cluster-node-time * cluster-slave-validity-factor,则当前从节点不具备故障转移资格。

(2)准备选举时间

当从节点符合故障转移资格后,更新触发故障选举的时间,只有到达该时间后才能执行后续流程。这里之所以采用延迟触发机制,主要是通过对多个从节点使用不同的延迟选举时间来支持优先级问题。复制偏移量越大说明从节点延迟越低,那么它应该具有更高的优先级来替换故障主节点。

struct clusterState {
    mstime_t failover_auth_time; /* 记录之前或者下次将要执行故障选举时间 */
    int failover_auth_rank; /* 记录当前从节点排名 */
}

(3)发起选举

当从节点定时任务检测到达故障选举时间(failover_auth_time)到达后,发起选举流程如下:会先更新配置纪元,再在集群内广播选举消息,并记录已发送过消息的状态,保证该从节点在一个配置纪元内只能发起一次选举。 

配置纪元的主要作用:

  • 标示集群内每个主节点的不同版本和当前集群最大的版本。
  • 每次集群发生重要事件时,这里的重要事件指出现新的主节点(新加入的或者由从节点转换而来),从节点竞争选举。都会递增集群全局的配置纪元并赋值给相关主节点,用于记录这一关键事件。
  • 主节点具有更大的配置纪元代表了更新的集群状态,因此当节点间进行ping/pong消息交换时,如出现slots等关键信息不一致时,以配置纪元更大的一方为准,防止过时的消息状态污染集群。

 配置纪元的应用场景有:新节点加入、槽节点映射冲突检测、从节点投票选举冲突检测。

(4)选举投票

只有持有槽的主节点才会处理故障选举消息,因为每个持有槽的节点在一个配置纪元内都有唯一的一张选票,当接到第一个请求投票的从节点消息时回复FAILOVER_AUTH_ACK消息作为投票,之后相同配置纪元内其他从节点的选举消息将忽略。当从节点收集到N/2+1个持有槽的主节点投票时,从节点可以执行替换主机点操作。

Redis集群没有直接使用从节点进行领导者选举,主要因为从节点数必须大于等于3个才能保证凑够N/2+1个节点,将导致从节点资源浪费。使用集群内所有持有槽的主节点进行领导者选举,即使只有一个从节点也可以完成选举过程。

投票作废:每个配置纪元代表了一次选举周期,如果在开始投票之后的cluster-node-timeout * 2时间内从节点没有获取足够数量的投票,则本次选举作废。从节点对配置纪元自增并发起下一轮投票,直到选举成功为止。

(5)替换主节点

当从节点收集到足够的选票之后,触发替换主节点操作:

  • 当前从节点取消复制变为主节点。
  • 执行clusterDelSlot操作撤销故障主节点负责的槽,并执行clusterAddSlot把这些槽委派给自己。
  • 向集群广播自己的pong消息,通知集群内所有的节点当前从节点变为主节点并接管了故障主节点的槽信息。 

(6)故障转移时间预估

  • 主观下线(pfail)识别时间 = cluster-node-timeout
  • 主观下线状态消息传播时间 <= cluster-node-timeout/2。消息通信机制对超过cluster-node-timeout/2未通信节点会发起ping消息,消息体在选择包含哪些节点时会优先选取下线状态节点,所以通常这段时间内能够收集到半数以上主节点的pfail报告从而完成故障发现。
  • 从节点转移时间 <= 1000毫秒。由于存在延迟发起选举机制,偏移量最大的从节点会最多延迟1秒发起选举。通常第一次选举就会成功,所以从节点执行转移时间在1秒以内。
  • 根据以上分析可以预估出故障转移时间,如下:failover-time(毫秒) <= cluster-node-timeout + cluster-node-timeout/2 + 1000

当节点发现与其他节点最后通信时间超过cluster-node-timeout/2时会直接发送ping消息,适当提高cluster-node-timeout可以降低消息发送频率,但同时cluster-node-timeout还影响故障转移的速度,因此需要根据自身业务场景兼顾二者的平衡。

五、数据分布算法

把整个数据集按照分区规则划分到多个节点上,有3种规则:节点取余分区、一致性哈希分区、虚拟槽分区。Redis Cluster采用的是虚拟槽分区,将3种规则进行对比一下。

1、节点取余分区

使用特定的数据,如Redis的键或用户ID,再根据节点数量N使用公式:hash(key)%N 来决定数据映射到哪一个节点上。这种方案存在一个问题:当节点数量变化时,如扩容或收缩节点,数据节点映射关系需要重新计算,会导致数据的重新迁移。

2、一致性哈希分区

实现思路是为系统中每个节点分配一个token,范围一般在0~2^32,这些token构成一个哈希环。数据读写执行节点查找操作时,先根据key计算hash值,然后顺时针找到第一个大于等于该哈希值的token节点。这种方式相比节点取余最大的好处在于加入和删除节点只影响哈希环中相邻的节点,对其他节点无影响。但一致性哈希分区存在几个问题:

  • 加减节点会造成哈希环中部分数据无法命中,需要手动处理或者忽略这部分数据,因此一致性哈希常用于缓存场景。
  • 当使用少量节点时,节点变化将大范围影响哈希环中数据映射,因此这种方式不适合少量数据节点的分布式方案。
  • 普通的一致性哈希分区在增减节点时需要增加一倍或减去一半节点才能保证数据和负载的均衡。

3、虚拟槽分区

虚拟槽分区巧妙地使用了哈希空间,使用分散度良好的哈希函数把所有数据映射到一个固定范围的整数集合中,整数定义为槽(slot)。这个范围一般远远大于节点数,比如Redis Cluster槽范围是0~16383。槽是集群内数据管理和迁移的基本单位。采用大范围槽的主要目的是为了方便数据拆分和集群扩展,每个节点会负责一定数量的槽。 

posted @ 2020-03-18 09:07  湮天霸神666  阅读(323)  评论(0编辑  收藏  举报