赞
踩
随着业务系统功能、模块、规模、复杂性的增加,我们对Redis的要求越来越高,尤其是在高低峰场景的动态伸缩能力,比如:电商平台平日流量较低且平稳,双十一大促流量是平日的数倍,两种情况下对于各系统的数量要求必然不同。如果始终配备高峰时的硬件及中间件配置,必然带来大量的资源浪费。
Redis作为业界优秀的缓存产品,成为了各类系统的必备中间件。哨兵模式虽然优秀,但由于其不具备动态水平伸缩能力,无法满足日益复杂的应用场景。在官方推出集群模式之前,业界就已经推出了各种优秀实践,比如:Codis、twemproxy等。
为了弥补这一缺陷,自3.0版本起,Redis官方推出了一种新的运行模式——Redis Cluster。
Redis Cluster采用无中心结构,具备多个节点之间自动进行数据分片的能力,支持节点动态添加与移除,可以在部分节点不可用时进行自动故障转移,确保系统高可用的一种集群化运行模式。按照官方的阐述,Redis Cluster有以下设计目标:
简单概述。结合以上三个目标,我认为Redis Cluster最大的特点在于可扩展性,多个主节点通过分片机制存储所有数据,即每个主从复制结构单元管理部分key。因为在主从复制、哨兵模式下,同样具备其他优点。当系统容量足够大时,读请求可以通过增加从节点进行分摊压力,但是写请求只能通过主节点,这样存在以下风险点:
所以,动态伸缩能力是Redis Cluster最耀眼的特色。好了,开始步入正题,本文将结合实例从整体上对Redis Cluster进行介绍,在后续文章深入剖析其工作原理。
还是延续之前的风格,通过实例的搭建与演示,给大家建立对集群结构的直观感受;然后再以源码为基础梳理其中的逻辑关系;最后详细阐述集群构建的过程,循序渐进。
按照官方文档的说明,在Redis版本5以上,集群搭建比较简单。本文使用6个Redis实例(版本是6.2.0),三个主节点,三个从节点,每个主节点有一个副本。
- port 7000
- cluster-enabled yes
- cluster-config-file nodes.conf
- cluster-node-timeout 5000
- appendonly yes
- 复制代码
- redis-cli --cluster create 127.0.0.1:7000 127.0.0.1:7001 \
- 127.0.0.1:7002 127.0.0.1:7003 127.0.0.1:7004 127.0.0.1:7005 \
- --cluster-replicas 1
- 复制代码
通过terminal看到输入如下图所示的内容:
上图以>>>是redis-cli创建集群时进行的一些核心操作,当然也还有一些日志中没有输出的部分,最终将建立起如下图所示的集群关系。
上图从两中视角对集群节点关系进行了描述:左侧是在不考虑节点角色情况下的物理结构,节点之间双向箭头代表了集群总线;右侧考虑了节点角色及主从分组,其中体现了主从复制关系及集群总线(集群总线仅绘制了主节点之间的,大家自行脑补,全都画出显得过于凌乱)。
在redis-cli的帮助下,Redis Cluster的搭建还是比较简单的,一条命令便解决了所有问题。从上面的过程我们可以清楚的了解到,在集群创建过程中,redis-cli是一个管理者,负责检查节点状态、主从关系建立以、数据分片以及协调节点间通过握手组建集群,但是这都离不了Redis Cluster能力的支持。
为了深入理解集群建立的过程,并为接下来其他部分的理解打好基础,接下来我将介绍Redis Cluster有关一些概念或结构,然后把集群建立的过程进行详细说明。
上述示例集群中,有6个Redis实例构成了三主三从的集群结构,并且明确了每组主从节点负责的哈希槽范围,那Redis Cluster是如何描述这种关系的呢?有了上图的直观感受,我们还是要回归到数据结构,看看Redis是如何描述这种关系的。按照Redis源码数据结构之间的关系,我绘制了与Redis Cluster相关的重要数据结构的组织关系,如下图所示(以节点A的视角):
集群状态(clusterState)
我们知道,Redis Cluster是Redis的一种运行模式,一切都要归属于Redis内最核心的数据结构redisServer,以下仅摘取关于集群模式部分字段。
- struct redisServer {
- /* Cluster */
- // 是否以集群模式运行
- int cluster_enabled; /* Is cluster enabled? */
-
- // 集群节点通信超时参数
- mstime_t cluster_node_timeout; /* Cluster node timeout. */
-
- // 自动生成的配置文件(nodes.conf),用户不能修改,存储了集群状态
- char *cluster_configfile; /* Cluster auto-generated config file name. */
-
- // 集群状态,从当前redis实例视角来看当前集群的状态
- struct clusterState *cluster; /* State of the cluster */
- }
- 复制代码
由此可知,集群模式下每个redisServer通过clusterState来描述在它看来整个集群中所有节点的信息与状态。clusterState不仅包含当前节点本身的状态(myself),而且还包含集群内其他节点的状态(nodes)。
另外,比较关键的一点是“在它看来”,因为集群是一个无中心的分布式系统,节点之间通过网络传播信息,而网络并不是百分百可靠的,可能存在分区或断连等问题,所以每个节点维护的集群状态可能不准确或者更新不及时。
以下为clusterState的完整结构,我们在这里先做简单的了解,在稍后的章节中会陆续涉及到这里的内容。
- // 这个结构存储的是从当前节点视角,整个集群所处的状态
- typedef struct clusterState {
- // 当前节点信息
- clusterNode *myself; /* This node */
- // 集群的配置纪元
- uint64_t currentEpoch;
- // 集群状态
- int state; /* CLUSTER_OK, CLUSTER_FAIL, ... */
- // 负责哈希槽主节点的数量
- int size; /* Num of master nodes with at least one slot */
- // 节点字典:name->clusterNode
- dict *nodes; /* Hash table of name -> clusterNode structures */
- // 黑名单
- dict *nodes_black_list; /* Nodes we don't re-add for a few seconds. */
- // 正在执行迁出的哈希槽及目标节点
- clusterNode *migrating_slots_to[CLUSTER_SLOTS];
- // 正在执行导入的哈希槽及源节点
- clusterNode *importing_slots_from[CLUSTER_SLOTS];
- // 哈希槽与节点的映射关系
- clusterNode *slots[CLUSTER_SLOTS];
- // 每个哈希槽中存储key的数量
- uint64_t slots_keys_count[CLUSTER_SLOTS];
- rax *slots_to_keys;
- /* The following fields are used to take the slave state on elections. */
- // 故障转移授权时间
- mstime_t failover_auth_time; /* Time of previous or next election. */
- // 故障转移获得投票数
- int failover_auth_count; /* Number of votes received so far. */
- // 是否发起投票
- int failover_auth_sent; /* True if we already asked for votes. */
- //
- int failover_auth_rank; /* This slave rank for current auth request. */
- // 当前故障转移的配置纪元
- uint64_t failover_auth_epoch; /* Epoch of the current election. */
- int cant_failover_reason; /* Why a slave is currently not able to
- failover. See the CANT_FAILOVER_* macros. */
- /* Manual failover state in common. */
- mstime_t mf_end; /* Manual failover time limit (ms unixtime).
- It is zero if there is no MF in progress. */
- /* Manual failover state of master. */
- clusterNode *mf_slave; /* Slave performing the manual failover. */
- /* Manual failover state of slave. */
- long long mf_master_offset; /* Master offset the slave needs to start MF
- or zero if still not received. */
- int mf_can_start; /* If non-zero signal that the manual failover
- can start requesting masters vote. */
- /* The following fields are used by masters to take state on elections. */
- // 最近一次投票的配置纪元
- uint64_t lastVoteEpoch; /* Epoch of the last vote granted. */
- int todo_before_sleep; /* Things to do in clusterBeforeSleep(). */
- /* Messages received and sent by type. */
- long long stats_bus_messages_sent[CLUSTERMSG_TYPE_COUNT];
- long long stats_bus_messages_received[CLUSTERMSG_TYPE_COUNT];
- // 达到PFAIL的节点数量
- long long stats_pfail_nodes; /* Number of nodes in PFAIL status,
- excluding nodes without address. */
- } clusterState;
- 复制代码
简单说下几个字段,方便理解集群的基础字段:
后面三个字段描述了节点自身的状态,也记录了集群中的其他兄弟节点,同时保存了集群内哈希槽的分配情况。在节点初次启动时,只会存在节点自身的情况,需要等待其他节点加入或者加入已有的集群才会有兄弟节点和哈希槽分配信息。这三个字段都与clusterNode结构有关。
节点属性(clusterNode)
Redis Cluster通过数据结构clusterNode来描述一个集群节点信息与状态。从不同视角来看,它既可以来描述节点自身的状态,也可以用来描述其他节点的状态。
无论是自身还是其他节点,都会存储在由Redis核心数据结构redisServer维护的clusterState中,随着集群状态的变化而不断更新。
clusterNode维护的信息有些是比较稳定或者是静态的,比如节点ID、ip和端口;也有一些会随着集群状态发生改变,比如节点负责的哈希槽范围、节点状态等。我们以源码+注释的方式来认识一下这个数据结构:
- // 这是对集群节点的描述,是集群运作的基础
- typedef struct clusterNode {
- // 节点创建时间
- mstime_t ctime; /* Node object creation time. */
- // 节点名称,也叫做节点ID,启动后会存储在node.conf中,除非文件删除,否则不会改变
- char name[CLUSTER_NAMELEN]; /* Node name, hex string, sha1-size */
- // 节点状态,以状态机驱动集群运作
- int flags; /* CLUSTER_NODE_... */
- // 节点的配置纪元
- uint64_t configEpoch; /* Last configEpoch observed for this node */
- // 代表节点负责的哈希槽
- 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. */
- // 最近一次发送ping请求的时间
- mstime_t ping_sent; /* Unix time we sent latest ping */
- // 最近一次接收pong回复的时间
- mstime_t pong_received; /* Unix time we received the pong */
- // 最近一次接收到数据的时间
- mstime_t data_received; /* Unix time we received any data */
- // 节点达到FAIL状态的时间
- mstime_t fail_time; /* Unix time when FAIL flag was set */
- // 故障转移过程中最近一次投票的时间
- mstime_t voted_time; /* Last time we voted for a slave of this master */
- // 复制偏移更新时间
- mstime_t repl_offset_time; /* Unix time we received offset for this node */
- mstime_t orphaned_time; /* Starting time of orphaned master condition */
- // 节点的复制偏移量
- long long repl_offset; /* Last known repl offset for this node. */
- // 节点的ip地址
- 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 */
- // 报告此节点宕机的节点列表
- list *fail_reports; /* List of nodes signaling this as failing */
- } clusterNode;
- 复制代码
再来重点了解几个关键的字段。
通过对clusterState和clusterNode两个数据结构的了解,我们基本可以从代码层面建立其集群节点中,这种“你中有我,我中有你”、主从复制的逻辑关系,相信你对本节开头结构图中的关系认识更加深刻。
集群总线(Cluster Bus)
集群总线是Redis Cluster内部用于集群治理的专用链路,它由节点与节点之间一条条TCP链接构成。集群内的每个节点都会主动与其他所有节点建立链接,所以每个节点也会被其他所有节点连接。
假如集群有N个节点,那么将会存在N*(N-1)个网络连接,从图论来讲,这就构成了具有N个顶点的有向完全图。用三个节点举例,节点与集群总线的关系如下图所示:
通过前面对集群搭建、节点属性与集群状态的了解,我们可以知道Redis Cluster是一个无中心分布式系统,所以它需要节点之间不断通过信息交换来实现状态一致,而集群总线便是节点之间信息交换的通道。这里的“通道”就是clusterNode中的link,其数据结构为clusterLink。
- /* clusterLink encapsulates everything needed to talk with a remote node. */
- typedef struct clusterLink {
- mstime_t ctime; /* Link creation time */
- // 与远程节点的网络链接
- connection *conn; /* Connection to remote node */
- sds sndbuf; /* Packet send buffer */
- char *rcvbuf; /* Packet reception buffer */
- size_t rcvbuf_len; /* Used size of rcvbuf */
- size_t rcvbuf_alloc; /* Used size of rcvbuf */
- struct clusterNode *node; /* Node related to this link if any, or NULL */
- } clusterLink;
- 复制代码
clusterLink封装了远程节点实例,以及与其的网络链接、接收和发送数据包的信息,基于它两个节点之间就可以保持实时通信了。
需要注意的是:集群总线中所使用的端口并不是我们之前熟悉的6379这样服务于客户端的端口,而是专用的;它不是我们手动设置的,而是由服务于客户端的端口通过偏移计算(+10000)而来。比如,服务于客户端的端口为6379,那么集群总线监听的端口就为16379。
所以,若需要以集群模式部署Redis实例,我们必须保证主机上两个端口都是非占用状态,否则实例会启动失败。
到目前为止,我们已经了解了集群节点、集群状态及集群总线。它们为集群的运行提供了基础,接下来就是让节点与节点“动起来”,让他们认识对方、介绍各自的朋友……一切都需要沟通,集群总线已经提供了沟通的通道,我们再来认识一下它们的“语言”。
消息结构
集群消息结构包含消息头和消息体两部分,所有类型消息采用通用的消息头,消息头中包含消息类型字段,根据消息类型追加不同的消息体对象。结合源码和注释了解集群消息结构:
- typedef struct {
- // 固定消息头,魔数“RCmb”
- char sig[4];
- // 消息总长度:头+消息体
- uint32_t totlen;
- // 消息版本,目前是1
- uint16_t ver;
- // 对外部客户端提供服务的端口,如6379
- uint16_t port;
- // 消息类型,比如PING、PONG、MEET等,节点需要根据该值追加或解析消息体
- uint16_t type;
- //
- uint16_t count;
- // 从发送消息的节点来看,当前集群纪元
- uint64_t currentEpoch;
- // 发送消息节点的配置纪元或其主节点的配置纪元
- uint64_t configEpoch;
- // 复制偏移量:对于主节点,是命令传播的复制偏移量;对于从节点,是已处理的来自其主节点的复制偏移
- uint64_t offset;
- // 发送消息节点的名称/ID
- char sender[CLUSTER_NAMELEN];
- // 发送消息节点负责的哈希槽
- unsigned char myslots[CLUSTER_SLOTS/8];
- // 如果是从节点,该字段放置其主节点的节点名称/ID
- char slaveof[CLUSTER_NAMELEN];
- // 发送消息节点的IP
- char myip[NET_IP_STR_LEN];
- char notused1[34];
- // 集群总线监听端口
- uint16_t cport;
- // 发送消息节点的状态
- uint16_t flags;
- // 从发送消息节点的视角来看,当前集群的状态,OK or FAIL
- unsigned char state;
- unsigned char mflags[3];
- // 消息体,根据上面消息类型type,决定该字段存放什么内容
- union clusterMsgData data;
- } clusterMsg;
- 复制代码
消息头主要包含了消息发送方节点的状态,这样消息接收方可以解析消息并更新本地集群状态中的节点信息。消息体由消息头中的type字段决定,在消息结构中消息体使用了联合体类型clusterMsgData。
消息体结构clusterMsgData是联合体,根据type为对应的字段赋值或解析。联合体是C语言的一种数据结构,大家可以把这个字段看作是Java的泛型,由运行时动态指定。
- union clusterMsgData {
- /*用于 PING, MEET and PONG三种类型消息 */
- struct {
- /* Array of N clusterMsgDataGossip structures */
- clusterMsgDataGossip gossip[1];
- } ping;
-
- /* 用于广播节点故障 FAIL */
- struct {
- clusterMsgDataFail about;
- } fail;
-
- /* PUBLISH */
- struct {
- clusterMsgDataPublish msg;
- } publish;
-
- /* 用于广播节点哈希槽最新状态UPDATE */
- struct {
- clusterMsgDataUpdate nodecfg;
- } update;
-
- /* MODULE */
- struct {
- clusterMsgModule msg;
- } module;
- };
- 复制代码
消息类型
Redis Cluster提供了几种不同的消息类型,然后把几种不同的类型组合使用完成某项功能,比如心跳、握手、配置更新等。我们首先了解几个重要的消息类型及其对应的数据结构,注意:这里说的数据结构仅指整个消息结构中的data部分。
以上三种消息类型共用一种消息结构clusterMsgDataGossip,通过消息头中的type字段来确定是何种类型,该结构描述了一个节点的基础信息及状态,数据结构及说明如下:
- typedef struct {
- // 节点ID
- char nodename[CLUSTER_NAMELEN];
- // 消息发送节点对该节点最近一次发送ping请求的时间
- uint32_t ping_sent;
- // 消息发送节点从该节点最近一次接收pong回复的时间
- uint32_t pong_received;
- // 节点IP
- char ip[NET_IP_STR_LEN]; /* IP address last time it was seen */
- // 对外服务端口
- uint16_t port; /* base port last time it was seen */
- // 集群总线端口
- uint16_t cport; /* cluster port last time it was seen */
- // 在消息发送节点视角该节点的状态
- uint16_t flags; /* node->flags copy */
- // 预留字段
- uint32_t notused1;
- } clusterMsgDataGossip;
- 复制代码
MEET消息仅在节点握手加入集群时用到(后面通过集群建立过程详细说明),PING-PONG组合用于节点间心跳交互(集群容错部分详细说明)。
- typedef struct {
- // 发生故障节点的名称
- char nodename[CLUSTER_NAMELEN];
- } clusterMsgDataFail;
- 复制代码
- typedef struct {
- uint64_t configEpoch; /* Config epoch of the specified instance. */
- char nodename[CLUSTER_NAMELEN]; /* Name of the slots owner. */
- unsigned char slots[CLUSTER_SLOTS/8]; /* Slots bitmap. */
- } clusterMsgDataUpdate;
- 复制代码
以上两者配合使用,用于从节点选举交互流程,是集群模式故障转移的基础。
简单的集群示例系统搭建完毕,通过控制台输出我们大体可以了解集群创建的过程;前面也对集群有关的基本概念从理论到代码结构进行了说明。接下来总结一下整个过程,并对节点握手过程详细说明。
总体过程
结合源码流程(函数
clusterManagerCommandCreate)及控制台输出,把集群创建的主要过程总结如下:
接下来,我们对数据分片、主从分配、配置纪元、节点握手几个核心步骤做进一步分析。
主从分配及数据分片
根据输入的节点信息及从节点数量要求,redis-cli计算主节点的数量,然后把所有节点按照一主N从进行分组。假设输入节点的数量为n,要求每个主节点的副本数为r,则理论上可以分为:
其中,m为右边计算结果向下取整。按照集群模式的要求,至少需要3个主节点。如果m<3,则提示创建失败。
redis-cli根据输入节点的ip分布,优先考虑把主节点分配在不同的主机上,选择m个主节点,然后按照从节点数量r为主节点分配从节点。按照指定的从节点数量r分配完成后,如果还有剩余的节点,则再次执行从节点分配。
由于新节点启动之后,默认是主节点,主从节点分配完成后,redis-cli只需要为从节点设置主节点,等待执行配置即可。
数据分片时默认会把16384个哈希槽均分给主节点,不再详细展开了。
配置下发
主从分配及数据分片完成后,redis-cli已经在本地为节点保存了主从配置及数据分片配置信息,得到管理员的许可后,会遍历节点列表把配置下发至对应的节点。
如果是主节点,则执行数据分片配置。redis-cli使用CLUSTER ADDSLOTS命令设置节点负责的哈希槽;主节点接收后,会进行如下修改操作:
如果是从节点,则为其设置主节点。redis-cli向从节点发送CLUSTER REPLICATE命令;从节点接收后,执行主从复制,不再赘述。
升级纪元
经过以上配置,各个节点已经不是刚刚启动时的状态,为了表明这种变化,redis-cli把各个节点的配置纪元升级,该命令为cluster set-config-epoch。
节点接收命令后,将修改myself的configEpoch,并确保集群的currentEpoch不低于此值。
节点握手
到这里,每个单独的节点已经配置完成,接下来redis-cli会向节点发起握手命令,从零开始把节点逐个加入集群。为了安全考虑,目前节点间的握手仅能通过管理员发起,握手过程通过集群总线完成。
节点启动后监听集群总线端口,会接受一切外来的网络链接并接收其发送的消息,但是如果发现消息来源节点不是集群已知节点,其发送的所有消息将被丢弃。集群已有节点接受新节点加入集群只有两种方式:
结合以上两种方式,我们只需要从第二个节点以后的节点依次与第一个节点握手,再通过自动发现即可实现所有节点加入集群。
好了,我们了解一下握手过程是如何实现的,为了简单,我们仅以两个节点为例描述其过程。假设节点信息如下:
通过redis-cli发起meet命令,让节点B与节点A握手,命令为cluster meet 127.0.0.1 7000。
B节点接收命令后,开始与A节点的握手过程,为了方便清晰的了解握手过程中节点状态的变化,通过下图进行说明。
图示显示了握手过程中,两个节点经过“MEET-PONG-PING-PONG”两次交互完成握手,状态变化一目了然。用文字描述一下过程:
这部分主要是打基础,把集群的一些基本概念介绍以下,同时介绍了Redis Cluster的物理结构和逻辑结构,通过实例与集群建立过程的说明,给大家一个比较直观的理解。
通过上一节的内容,我们已经知道了Redis Cluster结构、设计理念以及从无到有创建一个集群,总体上来讲对于Redis Cluster有了一个初步的认识。本节将重点解析Redis Cluster数据分片的更多细节,帮助大家更好的理解与使用。
不同于单机版Redis及Sentinel模式中一个节点负责所有key的管理工作,Redis Cluster采用了类似于一致性哈希算法的哈希槽(hash slot)机制、由多个主节点共同分担所有key的管理工作。
Redis Cluster使用CRC16算法把key空间分布在16384个哈希槽内,哈希槽是按照序号从0~16383标号的,每组主从节点只负责一部分哈希槽管理操作;而且通过集群状态维护哈希槽与节点之间的映射关系,随着集群运行随时更新。如上面我们示例中,哈希槽与节点关系如下:
每当我们通过Redis Cluster对某个key执行操作时,接收请求的节点会首先对key执行计算,得到该key对应的哈希槽,然后再从哈希槽与节点的映射关系中找到负责该哈希槽的节点。如果是节点自身,则直接进行处理;如果是其他节点,则通过重定向告知客户端连接至正确的节点进行处理。
- HASH_SLOT = CRC16(key) mod 16384
- 复制代码
由于数据分片机制的存在,不同的key可能存储在不同的节点上,这就导致普通Redis中的一些多key之间的计算命令无法支持。因为key不同,其对应的哈希槽可能不同,导致这些数据存储在不同的节点上,如果一个命令涉及到多个节点的key,性能较低。所以,Redis Cluster实现了所有在普通Redis版本中的单一key的命令,那些使用多个key的复杂操作,比如set的union、intersection操作只有当这些key在同一个哈希槽时才可用。
但是,实际应用中,我们确实存单个命令涉及多个key的情况,基于此问题Redis Cluster提供了哈希标签在一定程度上满足使用需求。
Redis Cluster提供了哈希标签(Hash Tags)来强制多个key存储到同一个哈希槽内,哈希标签通过匹配key中“{”、“}”之间的字符串提取真正用于计算哈希槽的key。比如:客户端输入{abcd}test,那么将只把abcd用于哈希槽的计算;这样{abcd}test、{abcd}prod就会被存储到同一个哈希槽内。但是,客户端输入的key可能存在多个“{”或“}”,此时Redis Cluster将会如下规则处理:
满足以上两个条件,Redis Cluster将把“{”与“}”之间的内容作为真正的key进行哈希槽计算,否则还是使用原来的输入执行计算。需要注意:“{”和“}”的匹配遵循最左匹配原则。举例看下:
当集群中节点压力过大时,我们会考虑通过扩容,让新增节点分担其他节点的哈希槽;当集群中节点压力不平衡时,我们会考虑把部分哈希槽从压力较大的节点转移至压力较小的节点。
Redis Cluster支持在不停机的情况下添加或移除节点,以及节点间哈希槽的迁出和导入,这种动态扩容或配置的方式对于我们的生产实践好处多多。比如:电商场景中,日常流量比较稳定,只要按需分配资源确保安全水位即可;当遇到大促时,流量较大,我们可以新增资源,以不停机、不影响业务的方式实现服务能力的水平扩展。以上两种情况我们称之为重新分片(Resharding)或者在线重配置(Live Reconfiguration),我们来分析下Redis是如何实现的。
通过前面了解集群状态的数据结构,我们知道哈希槽的分配其实是一个数组,数组索引序号对应哈希槽,数组值为负责哈希槽的节点。理论上,哈希槽的重新分配实质上是根据数组索引修改对应的节点对象,然后通过状态传播在集群所有节点达到最终一致。如下图中,把负责哈希槽1001的节点从7000修改为7001。
实际中,为了实现上面的过程,还需要考虑更多方面。
我们知道,哈希槽是由key经过CRC16计算而来的,哈希槽只是为了把key存储到真正节点时一个虚拟的存在,一切的操作还得回归到key上。当把哈希槽负责的节点从旧节点改为新节点时,需要考虑旧节点存量key的迁移问题,也就是要把旧节点哈希槽中的key全部转移至新的节点。
但是,无论哈希槽对应多少个key,key中存储了多少数据,把key从一个节点迁移至另外一个节点总是消耗时间的,同时需要保证原子性;而且,重新分片过程中,客户端的请求并没有停止,Redis还需要正确响应客户端请求,使之不受影响。
接下来,我们利用示例集群做一次重新分片的实践,并且结合源码深入剖析一下Redis的实现过程。以下示例是把7002节点的两个哈希槽迁移至7000节点,过程简述如下:
执行过程截图如下:
以上过程对应的源码为文件redis-cli.c中
clusterManagerCommandReshard函数,代码比较多,我们关注的是哈希槽是如何在节点间迁移的,所以我们仅贴出哈希槽迁移部分代码进行分析:
- static int clusterManagerCommandReshard(int argc, char **argv) {
- /* 省略代码 */
- int opts = CLUSTER_MANAGER_OPT_VERBOSE;
- listRewind(table, &li);
- // 逐个哈希槽迁移
- while ((ln = listNext(&li)) != NULL) {
- clusterManagerReshardTableItem *item = ln->value;
- char *err = NULL;
- // 把哈希槽从source节点迁移至target节点
- result = clusterManagerMoveSlot(item->source, target, item->slot,
- opts, &err);
- /* 省略代码 */
- }
- }
-
- /* Move slots between source and target nodes using MIGRATE.*/
- static int clusterManagerMoveSlot(clusterManagerNode *source, clusterManagerNode *target, int slot, int opts, char**err)
- {
- if (!(opts & CLUSTER_MANAGER_OPT_QUIET)) {
- printf("Moving slot %d from %s:%d to %s:%d: ", slot, source->ip,
- source->port, target->ip, target->port);
- fflush(stdout);
- }
- if (err != NULL) *err = NULL;
- int pipeline = config.cluster_manager_command.pipeline,
- timeout = config.cluster_manager_command.timeout,
- print_dots = (opts & CLUSTER_MANAGER_OPT_VERBOSE),
- option_cold = (opts & CLUSTER_MANAGER_OPT_COLD),
- success = 1;
- if (!option_cold) {
- // 设置target节点哈希槽为importing状态
- success = clusterManagerSetSlot(target, source, slot, "importing", err);
- if (!success) return 0;
- // 设置source节点哈希槽为migrating状态
- success = clusterManagerSetSlot(source, target, slot, "migrating", err);
- if (!success) return 0;
- }
- // 迁移哈希槽中的key
- success = clusterManagerMigrateKeysInSlot(source, target, slot, timeout, pipeline, print_dots, err);
- if (!(opts & CLUSTER_MANAGER_OPT_QUIET)) printf("\n");
- if (!success) return 0;
- /* Set the new node as the owner of the slot in all the known nodes. */
- /* 依次通知所有节点:负责这个哈希槽的节点变更了 */
- if (!option_cold) {
- listIter li;
- listNode *ln;
- listRewind(cluster_manager.nodes, &li);
- while ((ln = listNext(&li)) != NULL) {
- clusterManagerNode *n = ln->value;
- if (n->flags & CLUSTER_MANAGER_FLAG_SLAVE) continue;
- // 向节点发送命令:CLUSTER SETSLOT
- redisReply *r = CLUSTER_MANAGER_COMMAND(n, "CLUSTER SETSLOT %d %s %s", slot, "node", target->name);
- /* 省略代码 */
- }
- }
- /* Update the node logical config */
- if (opts & CLUSTER_MANAGER_OPT_UPDATE) {
- source->slots[slot] = 0;
- target->slots[slot] = 1;
- }
- return 1;
- }
- 复制代码
clusterManagerCommandReshard函数首先根据集群中哈希槽分配情况及迁移计划,找到需要迁移的哈希槽列表,然后使用clusterManagerMoveSlot函数逐个哈希槽进行迁移,它是迁移哈希槽的核心方法,主要包含几个步骤。大家可以结合示意图和文字说明了解一下(每幅图上面为源节点,下面为目标节点):
上图是把哈希槽1000,从7000节点迁移至7001节点的集群状态变化过程,步骤说明:
好了,重新分片的过程就介绍完了。
数据分片使得所有的key分散存储在不同的节点,而且随着重新分片或者故障转移,哈希槽与节点之间的映射关系会发生改变,那么当客户端发起对一个可以的操作时,集群节点与客户端是如何处理的呢?我们解析来了解一下两种重定向机制。
MOVED重定向
由于数据分片机制,Redis集群中每个节点仅负责一部分哈希槽,也就是一部分key的存储及管理工作。客户端可以随意向集群中任何一个节点发起命令请求,此时节点会计算当前请求key对应的哈希槽,并通过哈希槽与节点的映射关系查询负责该哈希槽的节点,根据查询结果Redis会有如下操作:
举个例子来看,首先通过常规方式使用redis-cli连接至7000端口节点,然后执行get TestKey命令,如下所示:
- redis-cli -p 7000
- 127.0.0.1:7000> GET TestKey
- (error) MOVED 15013 127.0.0.1:7002
- 复制代码
返回结果告诉我们,TestKey对应的哈希槽为15013,应该由7002节点负责。客户端可以根据返回结果中的MOVED错误信息,解析出负责该key的节点ip和端口,并与之建立连接,然后重新执行即可。做下测试,效果如下:
- redis-cli -p 7002
- 127.0.0.1:7002> GET TestKey
- (nil)
- 复制代码
为什么会这样呢?
因为Redis Cluster每个节点都保存了哈希槽与节点的映射关系,当客户端请求的key不在当前节点的负责范围之内时,节点不会充当目标节点的代理,而是以错误的方式告知客户端在它看来应该由那个节点负责该key。当然,如果正好赶上哈希槽迁移,节点返回的信息不一定准确,客户端可能还会收到MOVED或ASK错误。
所以,这就要求客户端具备这种重定向的能力,及时连接之正确的节点重新发起命令请求。如果客户端与节点之间总是通过重定向的方式处理命令,性能必然不如普通Redis模式高。
怎么办呢?Redis官方提出了两种可选的缓存办法:
在集群稳定运行期间,当然大部分时间也是稳定运行的,以上方式都能够大大提高命令执行的效率。但是,由于集群运行期间可能发生重新分片,客户端维护的信息就会变得不准确,所以当客户端哈希槽对应的节点发生改变时,客户端应该及时修正。
自5.0版本起,redis-cli已经具备了MOVED重定向能力。再以集群客户端的方式连接至7000节点,执行上述命令,效果图如下:
- redis-cli -c -p 7000
- 127.0.0.1:7000> GET TestKey
- -> Redirected to slot [15013] located at 127.0.0.1:7002
- (nil)
- 127.0.0.1:7002>
- 复制代码
虽然向7000节点发起请求,但是客户端在接收到7000的返回结果后,自动连接至7002并重新执行了请求。
结合以上示例,在重新分片的过程中,客户端向节点请求key(CRC16=1000)命令,会不会有影响呢?带着这个问题,我们一起来看下ASK重定向。
ASK重定向
在重新分片时,源节点向目标节点迁移哈希槽的过程中,该哈希槽所存储的key有的在源节点,有的已经迁移至目标节点。此时客户端向源节点发起命令请求(尤其是多key的情况),MOVED重定向就无法正常的工作了。下图为此时集群的状态示意图,我们来分析下:
为了全面完整的说明ASK重定向过程,本部分所阐述的对节点发起的命令中将包含多个具有相同哈希槽的key,比如{test}1、{test}2,用复数keys表示,并假设test对应的哈希槽为1000。
如前文所述,按照“MOVED重定向”原理,当客户端向节点发起keys的请求时,会首先计算CRC16得到keys对应的哈希槽,然后通过哈希槽与节点的映射关系找到负责该哈希槽的节点,最后决定时立即执行还是返回MOVED错误。
但是,如果集群正处于重新分片过程中,客户端请求的keys可能还未迁移,也可能已经迁移,我们看下会发生什么?
因此,在这种情况下MOVED重定向是不适用的。为此,Redis Cluster引入了ASK重定向,我们来看下ASK重定向的工作原理。
客户端根据本地缓存的哈希槽与节点的映射关系,向7000节点发起keys请求,根据keys的迁移进度,7000节点的执行流程如下:
- (error) -ASK <slot> <ip>:<port>
- # 对应示例结果为:
- (error) -ASK 1000 127.0.0.1:7001
- 复制代码
这样,如果客户端请求的keys处于迁移过程,节点将以ASK重定向错误的方式返回客户端,客户端再向新的节点发起请求。当然,会有一定的概率由于keys未迁移完成而导致请求失败,此时节点将回复“TRYAGAIN”,客户端可以稍后重试。
一旦哈希槽迁移完成,客户端将收到节点回复的MOVED重定向错误,意味着哈希槽的管理权已经转移至新的节点,此时客户端可修改本地的哈希槽与节点映射关系,采用“MOVED重定向”逻辑向新节点发起请求。
MOVED重定向与ASK重定向
通过前面部分的介绍,相信大家已经对两者的区别有了一定的了解,简单总结一下。
集群扩容或缩容期间可以正常提供服务吗?
这个是面试中经常遇到的问题,如果你理解问题的本质,这个问题就不难回答了。我们来分析一下:
对主节点的扩容或者缩容本质上是一个重新分片的过程,重新分片涉及哈希槽迁移,也就是哈希槽内key的迁移。Redis Cluster提供了ASK重定向来告知客户端目前集群发生的状况,以便客户端进行调整:ASKING重定向或者重试。
所以,整体上来讲,扩容或者缩容期间,集群是可以正常提供服务的。
数据分片是Redis Cluster动态收缩,具备可扩展性的根基,虽然本文内容写的比较啰嗦,但是原理还是比较简单的。大家重点理解扩容的过程与本质,就可以以不变应万变。
哨兵模式的自动故障转移能力为其提供高可用保障,同样的,为了提供集群的可用性,Redis Cluster提供了自动故障检测及故障转移能力。两者在设计思想上有很大的相似之处,本节将围绕这个话题进行剖析。
Redis Cluster作为无中心的分布式系统,集群容错机制依靠各个节点共同协作,在节点检测到某个节点故障时,通过传播节点故障并达成共识,然后触发一系列的从节点选举及故障转移工作。这一工作完成的基础是节点之间通过心跳机制对集群状态的维护。
下图是从节点A视角来看集群的状态示意图(仅绘制与集群容错有关的内容),myself指向A节点本身,它是节点A对自身状态的描述;nodes[B]指向节点B,它是从A节点来看B节点的状态;还有集群当前纪元、哈希槽与节点映射关系等。
在集群中,每两个节点之间通过PING和PONG两种类型的消息保持心跳,由前文可知这两种消息采用完全相同的结构(消息头和消息体都相同),仅消息头中的type字段不同,我们称这个消息对为心跳消息。
心跳消息在集群节点两两之间以“我知道的给你,你知道的给我”这样“瘟疫传播”的方式传播、交换信息,可以保证在短时间内节点状态达成一致。我们从心跳触发的时机、消息体的构成、应用几个方面深入理解心跳机制。
触发时机
在集群模式下,心跳动作是由周期性函数clusterCron()触发的,该函数每个100毫秒执行一次。为了控制集群内消息的规模,同时兼顾与节点之间心跳的时效性,Redis Cluster采取了不同的处理策略。
正常情况下,clusterCron()每隔一秒(该函数每执行10次)向一个节点发送PING消息。这个节点的选择是随机的,随机方式为:
补充说明Redis Cluster心跳消息发送与接收的检查依据,这对后续故障检测也是非常重要的:
当源节点向目标节点发送PING命令后,将设置目标节点的ping_sent为当前时间。当源节点接收到目标节点的PONG回复后,将设置目标节点的ping_sent为0,同时更新pong_received为当前时间。
也就是说, ping_sent> 为0,说明已收到> PONG> 回复并等待下次发送;> ping_sent> 不为0,说明正在等待> PONG> 回复。
我们在集群配置文件中设置了超时参数cluster-node-timeout,对应变量NODE_TIMEOUT,节点将以此参数作为目标节点心跳超时的依据。为了确保PING-PONG消息不超时并保留重试余地,Redis Cluster将以NODE_TIMEOUT/2为界限进行心跳补偿。
clusterCron()每次执行时(100毫秒)会依次检查每个节点:
总体来讲,集群内每秒的心跳消息收发数量是稳定的,即使集群有很多节点也不会导致瞬时网络I/O过大,给集群带来负担。集群中每两个节点之间都在保持心跳,按照N个节点的有向完全图,整个集群会有N*(N-1)个链接,每个链接都需要保持心跳,心跳消息成对出现。
假如集群有100个节点,NODE_TIMEOUT为60秒,那就意味着每个节点在30秒内要发送99条PING消息,平均每秒发送3.3条。100个节点每秒发送总计330条消息,这个数量级的网络流量压力还是可以接受的。
不过,我们需要注意节点数确定的情况下,需要合理设置NODE_TIMEOUT参数。如果过小,会导致心跳消息对网络带来较大压力;如果太大,可能会影响及时发现节点故障。
消息构成
PING/PONG消息采用一致的数据结构。其中,消息头的内容来自集群状态的myself,这点很容易理解;而消息体需要追加若干节点的状态,但是集群中有很多节点,到底应该添加哪几个节点呢?
按照Redis Cluster的设计,每个消息体将会包含正常节点和PFAIL状态节点,具体获取方式如下(该部分源码位于cluster.c函数clusterSendPing中):
消息应用
节点接收到PING或PONG消息后,将按照消息头及消息体中的内容对本地维护的节点状态进行更新。顺着源码说明的话,其中涉及的字段和逻辑还是比较复杂的,我将从应用场景角度来说明消息的处理过程(结合源码函数clusterProcessPacket)。
集群纪元和配置纪元
心跳消息头包含了源节点的配置纪元(configEpoch)及在他看来的集群当前纪元(currentEpoch),目标节点接收后将检查自身维护的源节点的配置纪元和集群当前纪元。具体方式为:
哈希槽变更检测
消息头包含了源节点当前负责的哈希槽列表,目标节点会检查本地缓存的哈希槽与节点的映射关系,看是否存在与映射关系不一致的哈希槽。当发现不一致的映射关系时,将按照以下情况进行处理:
新节点发现
在节点握手过程中,我们知道,新节点加入集群仅需与集群中任意一个节点通过握手加入集群,但是其他节点并不知道有新节点加入,新节点也不知道其他节点的存在;对于两者而言,都是新节点的发现过程。
在心跳过程中,源节点会把对方未知的新节点信息加入消息体,通知目标节点。目标节点将执行以下流程:
节点故障发现
节点故障发现是心跳的核心功能,该部分在下一节单独介绍。
PFAIL与FAIL概念
Redis Cluster使用两个概念PFAIL、FAIL来描述节点的运行状态,这与哨兵模式中的SDOWN、ODOWN类似。
当一个节点在超过 NODE_TIMEOUT 时间后仍无法访问某个节点,那么它会用 PFAIL 来标识这个不可达的节点。无论节点类型是什么,主节点和从节点都能标识其他的节点为 PFAIL。
Redis集群节点的不可达性是指:源节点向目标节点发送PING命令后,超过 NODE_TIMEOUT 时间仍未得到它的PONG回复,那么就认为目标节点具有不可达性。
这是由心跳引出的一个概念。为了让PFAIL尽可能,NODE_TIMEOUT 必须比两节点间的网络往返时间大;为了确保可靠性,当在经过一半 NODE_TIMEOUT 时间还没收到目标节点对于 PONG 命令的回复时,源节点就会马上尝试重连接该目标节点。
所以,PFAIL是从源节点对目标节点心跳检测的结果,具有一定的主观性。
PFAIL状态具有一定的主观性,此时不代表目标节点真正的宕机,只有达到FAIL状态,才意味着节点真正宕机。
不过我们已经知道,在心跳过程中,每个节点都会把检测到PFAIL的节点告知其他节点。所以,如果某个节点宕机是客观存在的,那其他节点也必然会检测到PFAIL状态。
在一定的时间窗口内,当集群中一定数量的节点都认为目标节点为PFAIL状态时,节点就会将该节点的状态提升为FAIL(宕机)状态。
节点故障检测
本节将详细说明节点故障检测的实现原理,还是以下图为例(A、B、C节点为主节点,以B节点宕机为例),重点关注集群状态节点列表(nodes)的ping_sent、pong_received、fail_reports几个字段。
节点如何达到PFAIL状态?
每个节点维护的集群状态中包含节点列表,节点信息如上图节点B所示,其中字段ping_sent代表了B节点对A节点的心跳状态:如果值为0,说明A节点与B节点心跳正常;如果值不是0,说明A节点已经向B节点发送了PING,正在等待B节点回复PONG。
集群节点每隔100毫秒执行一次clusterCron()函数,其中会检查与每个节点的心跳及数据交互状态,若A节点在NODE_TIMEOUT时间内未收到B节点的任何数据,则视为B节点发生故障,A节点设置节点状态为PFAIL。具体代码如下所示:
- void clusterCron(void) {
- /* 省略…… */
-
- // ping消息已经发送的时间
- mstime_t ping_delay = now - node->ping_sent;
- // 已经多久没有收到节点的数据了
- mstime_t data_delay = now - node->data_received;
-
- // 两者取较早的
- mstime_t node_delay = (ping_delay < data_delay) ? ping_delay : data_delay;
-
- // 判断超时
- if (node_delay > server.cluster_node_timeout) {
- /* 节点超时,如果当前节点不是PFAIL或FAIL状态,则设置为PFAIL状态 */
- if (!(node->flags & (CLUSTER_NODE_PFAIL|CLUSTER_NODE_FAIL))) {
- serverLog(LL_DEBUG,"*** NODE %.40s possibly failing", node->name);
- node->flags |= CLUSTER_NODE_PFAIL;
- update_state = 1;
- }
- }
- /* 省略…… */
- }
- 复制代码
PFAIL状态传播
由“心跳机制——消息构成”可知,PFAIL状态的节点将会随着心跳传播至集群内所有可达节点,不再赘述。
PFAIL状态切换至FAIL状态
PFAIL到FAIL的状态切换需要集群内过半数主节点的认可,集群节点通过心跳消息收集节点的PFAIL的标志。如果B节点发生故障,A、C节点都将检测到B节点故障并标记B节点为PFAIL;那么A、C节点之间的心跳消息都会包含B节点已经PFAIL的状态。以A节点来看,Redis Cluster是如何处理。
由于其他节点的状态在心跳消息的消息体内,消息接收方通过
clusterProcessGossipSection函数进行处理,C节点是主节点,并且声明B节点为PFAIL状态。从源码可知,将执行以下流程:
fail_reports为clusterNodeFailReport列表,保存了所有认为B节点故障的节点列表。结构如下所示,其中time字段代表其被加入的时间,即声明该节点故障的最新时间,当再次报告该节点状态时,仅刷新time字段。
- typedef struct clusterNodeFailReport {
- /* 报告节点故障的节点 */
- struct clusterNode *node; /* Node reporting the failure condition. */
- /* 故障报告的时间 */
- mstime_t time; /* Time of the last report from this node. */
- } clusterNodeFailReport;
- 复制代码
详细的代码过程如下函数所示:
- void markNodeAsFailingIfNeeded(clusterNode *node) {
- int failures;
- // 计算判定节点宕机的法定数量
- int needed_quorum = (server.cluster->size / 2) + 1;
- // 判断当前节点是否认为该节点已经超时
- if (!nodeTimedOut(node)) return; /* We can reach it. */
- if (nodeFailed(node)) return; /* Already FAILing. */
-
- failures = clusterNodeFailureReportsCount(node);
- /* 当前节点也认可该节点宕机 */
- if (nodeIsMaster(myself)) failures++;
- if (failures < needed_quorum) return; /* No weak agreement from masters. */
-
- serverLog(LL_NOTICE, "Marking node %.40s as failing (quorum reached).", node->name);
-
- /* 设置节点为FAIL状态 */
- node->flags &= ~CLUSTER_NODE_PFAIL;
- node->flags |= CLUSTER_NODE_FAIL;
- node->fail_time = mstime();
-
- /* 向所有可达节点广播节点的FAIL状态,所有节点接收后将被强制接收认可 */
- clusterSendFail(node->name);
- clusterDoBeforeSleep(CLUSTER_TODO_UPDATE_STATE|CLUSTER_TODO_SAVE_CONFIG);
- }
- 复制代码
需要注意的是:fail_reports中的记录是有有效期的,默认是2倍的NODE_TIMEOUT,超过该时间限制记录会被移除。也就是说,必须在一定的时间窗口内收集足够的记录才能完成PFAIL到FAIL的状态转移。如果某个主节点对该节点的心跳恢复正常,会立刻从fail_reports移除。
节点A把节点B设置为FAIL状态后,将向所有可达节点发送关于B节点FAIL的消息,对应的消息类型为CLUSTERMSG_TYPE_FAIL。其他节点一旦收到FAIL消息,将立即设置节点B为FAIL状态,无论在他们看来节点B是否处于PFAIL状态。
主节点故障后,关于它的FAIL消息被传播至集群内的所有可达节点,这些节点标记其为FAIL状态。为了保证集群的可用性,该主节点的从节点们将启动故障转移动作,选择最优的从节点提升为主节点,Redis Cluster的故障转移包含两个关键过程:从节点选举和从节点提升。
从节点选举
若主节点故障,该主节点的所有从节点都会启动一个选举流程,在其他主节点的投票表决下,只有投票胜出的从节点才有机会提升为主节点。从节点选举的准备与执行过程是在clusterCron中进行的。
发起选举的条件与时机
从节点发起选举流程必须满足以下条件(选举流程发起前的检查工作位于函数
clusterHandleSlaveFailover(void)):
- /* 数据有效时间 */
- mstime_t data_age;
-
- /* 取从节点与主节点断开的时间间隔 */
- if (server.repl_state == REPL_STATE_CONNECTED) {
- data_age = (mstime_t)(server.unixtime - server.master->lastinteraction) * 1000;
- } else {
- data_age = (mstime_t)(server.unixtime - server.repl_down_since) * 1000;
- }
-
- /* */
- if (data_age > server.cluster_node_timeout)
- data_age -= server.cluster_node_timeout;
-
- data_age >
- (((mstime_t)server.repl_ping_slave_period * 1000)
- + (server.cluster_node_timeout * server.cluster_slave_validity_factor)
- 复制代码
如果FAIL状态的主节点拥有多个从节点,Redis Cluster总是希望数据最完整的从节点被提升为新的主节点。然而,假如所有从节点同时启动选举流程,所有从节点公平竞争,无法保证数据最完整的节点优先被提升。为了提高该节点的优先级,Redis Cluster在启动选举流程时引入了延迟启动机制。结合源码,每个从节点会计算一个延迟值并据此计算该节点选举流程启动的时间,计算公式如下:
- /* 选举流程启动延迟值 */
- DELAY = 500 + random(0,500) + SLAVE_RANK * 1000
-
- /* 计算得出从节点启动选举流程的时间 */
- server.cluster->failover_auth_time = DELAY + mstime()
- 复制代码
解释一下这个公式:
所以,数据完整程度最高的节点将最先启动选举流程,如果后续一切顺利,它将被提升为新的主节点。
设置failover_auth_time后,当clusterCron()再次运行时,如果系统时间达到这个预设值(并且failover_auth_sent=0)就会进入选举流程。
从节点选举流程
从节点启动选举流程,先把自身维护的集群当前纪元(currentEpoch)加1,并设置failover_auth_sent=1以表示已经启动选举流程;然后通过FAILOVER_AUTH_REQUEST类型的消息向集群内的所有主节点发起选举请求,并在2倍NODE_TIMEOUT(至少2秒)时间内等待主节点的投票回复。
集群内的其他主节点是从节点选举的决策者,投票前需要做出严格的检查。为了避免多个从节点在选举中同时胜出,并且保证选举过程合法性,主节点接收到FAILOVER_AUTH_REQUEST命令消息后,将会做以下条件校验:
主节点投票完成将记录信息,并安全持久化保存到配置文件:
保存上次投票的集群当前纪元:lastVoteEpoch 。
保存投票时间,存储在集群节点列表的voted_time中。
为了避免把上一轮投票计入本轮投票,从节点会检查FAILOVER_AUTH_ACK消息所声明的currentEpoch,若该值小于从节点的集群当前纪元,该选票会被丢弃。确认投票有效,从节点将通过cluster->failover_auth_count进行计数。
在得到大多数主节点的投票认可后,从节点将从选举中胜出。如果在2倍NODE_TIMEOUT(至少2秒)时间内未得到大多数节点的投票认可,选举流程将会终止,并且在4倍NODE_TIMEOUT(至少4秒)时间后启动新的选举流程。
从节点提升
从节点获得有效选票后,将把投票计数器failover_auth_count加1,并通过从节点选举与提升处理函数
clusterHandleSlaveFailover进行周期性检查,如果从节点得到大多数(法定数量)主节点的认可,将触发从节点提升流程。
这里选举通过法定数量与触发FAIL状态的法定数量一致,即(server.cluster->size / 2) + 1,半数以上的主节点。
从节点启动提升流程,将会对自身的状态信息进行一系列的修改,最终把自己提升为主节点,具体内容如下:
从节点把自己设置为主节点以后,就会通过PONG命令向集群所有节点广播自己状态的变化,以便其他节点及时修改状态,接受该从节点的角色提升。
从节点在选举中获胜后,自身的角色提升过程是比较简单的,更为关键的是被集群内其他所有节点认可。结合其他节点在集群中角色,需要考虑三种情况:
通用处理逻辑
新晋主节点被提升后,向集群内所有可达节点发送了PONG消息。其他节点收到该PONG消息,除了进行通用的处理逻辑(如提升配置纪元等)外,会检测到该节点的角色变化(从节点提升为主节点),从而进行本地集群状态cluterState更新。具体的更新内容为:
兄弟从节点切换主从复制
以上过程是“其他节点”、“兄弟从节点”通用的处理过程,“旧主节点”暂时失联,无法被通知到。基于此PONG消息,“其他节点”已经认可新晋主节点的角色变更信息。但是“兄弟节点”仍然是把旧的主节点作为自己的主节点,按照故障迁移的思想,它应该以新晋主节点作为自己的主从复制对象,怎么实现呢?
在哈希槽冲突处理过程中,“兄弟从节点”会发现,冲突的哈希槽是原来它的主节点负责的,“兄弟从节点”检测到这一变化,就会把新晋主节点作为自己的主节点,并以它为新的主节点进行主从复制。
旧主节点重新加入
旧主节点恢复后,将以宕机前的配置信息(集群当前纪元、配置纪元、哈希槽等等)与其他节点保持心跳。
当集群内任一节点收到它的PING消息后,会发现它的配置信息已经过时(节点配置纪元),并且哈希槽的分配情况存在冲突,此时节点将通过UPDATE消息通知它更新配置。
UPDATE消息包含了冲突哈希槽的负责权节点信息,旧主节点接收后会发现自身的节点配置纪元已经过时,从而把UPDATE消息的节点作为自己的主节点,并切换自己的身份为从节点,然后更新本地的哈希槽映射关系。
在后续的心跳中,其他节点将把旧主节点作为新晋主节点的从节点进行更新。
至此,故障转移完成。
从节点迁移
为了提供集群系统的可用性,Redis Cluster实现了从节点迁移机制:集群建立时,每个主节点都有若干从节点,如果在运行过程中因为几次独立的节点故障事件,导致某个主节点没有正常状态的从节点(被孤立),那么该主节点一旦宕机,集群将无法工作。Redis Cluster会及时发现被孤立的主节点和从节点数量最大的主节点,然后挑选合适的从节点迁移至被孤立的主节点,使得其能够再抵御一次宕机事件,从而提高整个系统的可用性。
以下图为例进行说明:初始状态时,集群有7个节点,其中A、B、C为主节点,A有两个从节点A1、A2,B、C各有一个从节点,分别时B1、C1。
通过以下过程,阐述节点故障时,从节点迁移的作用:
集群脑裂
作为分布式系统,必须解决网络分区带来的各种复杂问题。在Redis Cluster中,由于网络分区问题,导致集群节点分布在两个分区,使得集群发生“脑裂”。此时从节点的选举与提升在两个网络分区是如何工作的呢?
如上图所示,节点A及其从节点A1,由于网络分区与其他节点失联。我们来看下两个分区内的节点是如何工作的?
该分区内的节点将检测到节点A的PFAIL状态,然后经过传播确认节点A达到FAIL状态;A2节点将触发选举流程并胜出,提升为新的主节点,继续工作。经过故障转移,含有大部分节点的网络分区可以继续工作。
位于少数节点分区的节点A、A1,会检测到其他节点B、C的PFAIL状态,但是由于无法得到大多数主节点的确认,B、C无法达到FAIL状态,进而导致不能发生后续的故障转移工作。
本文从三个主要部分介绍了Redis Cluster的工作原理:集群结构、数据分片、容错机制,差不多覆盖了Redis Cluster的所有内容,希望能够给大家学习Redis Cluster带来帮助。
在研究官方文档、系统源码的过程中,确实遇到了好多不解的内容,通过反复梳理代码流程,逐个揭开各个谜底,最终建立起了整个知识体系。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。