汉扬编程 编程大纲 Redis专题:了解Redis集群,这篇就够了

Redis专题:了解Redis集群,这篇就够了

随着业务系统功能、模块、规模、复杂性的增加,我们对Redis的要求越来越高,尤其是在高低峰场景的动态伸缩能力,比如:电商平台平日流量较低且平稳,双十一大促流量是平日的数倍,两种情况下对于各系统的数量要求必然不同。如果始终配备高峰时的硬件及中间件配置,必然带来大量的资源浪费。

Redis专题:了解Redis集群,这篇就够了

Redis作为业界优秀的缓存产品,成为了各类系统的必备中间件。哨兵模式虽然优秀,但由于其不具备动态水平伸缩能力,无法满足日益复杂的应用场景。在官方推出集群模式之前,业界就已经推出了各种优秀实践,比如:Codis、twemproxy等。

Redis专题:了解Redis集群,这篇就够了

为了弥补这一缺陷,自3.0版本起,Redis官方推出了一种新的运行模式——Redis Cluster。

Redis专题:了解Redis集群,这篇就够了

Redis Cluster采用无中心结构,具备多个节点之间自动进行数据分片的能力,支持节点动态添加与移除,可以在部分节点不可用时进行自动故障转移,确保系统高可用的一种集群化运行模式。按照官方的阐述,Redis Cluster有以下设计目标:

Redis专题:了解Redis集群,这篇就够了

高性能可扩展,支持扩展到1000个节点。多个节点之间数据分片,采用异步复制模式完成主从同步,无代理方式完成重定向。一定程度内可接受的写入安全:系统将尽可能去保留客户端通过大多数主节点所在网络分区所有的写入操作,通常情况下存在写入命令已确认却丢失的较短时间窗口。如果客户端连接至少量节点所处的网络分区,这个时间窗口可能较大。可用性:如果大多数节点是可达的,并且不可达主节点至少存在一个可达的从节点,那么Redis Cluster可以在网络分区下工作。而且,如果某个主节点A无从节点,但是某些主节点B拥有多个(大于1)从节点,可以通过从节点迁移操作,把B的某个从节点转移至A。简单概述。结合以上三个目标,我认为Redis Cluster最大的特点在于可扩展性,多个主节点通过分片机制存储所有数据,即每个主从复制结构单元管理部分key。因为在主从复制、哨兵模式下,同样具备其他优点。当系统容量足够大时,读请求可以通过增加从节点进行分摊压力,但是写请求只能通过主节点,这样存在以下风险点:

Redis专题:了解Redis集群,这篇就够了

所有写入请求集中在一个Redis实例,随着请求的增加,单个主节点可能出现写入延迟。每个节点都保存系统的全量数据,如果存储数据过多,执行rdb备份或aof重写时fork耗时增加,主从复制传输及数据恢复耗时增加,甚至失败;如果该主节点故障,在故障转移期间可能导致所有服务短时的数据丢失或不可用。所以,动态伸缩能力是Redis Cluster最耀眼的特色。好了,开始步入正题,本文将结合实例从整体上对Redis Cluster进行介绍,在后续文章深入剖析其工作原理。

Redis专题:了解Redis集群,这篇就够了

集群结构还是延续之前的风格,通过实例的搭建与演示,给大家建立对集群结构的直观感受;然后再以源码为基础梳理其中的逻辑关系;最后详细阐述集群构建的过程,循序渐进。

Redis专题:了解Redis集群,这篇就够了

动手实践按照官方文档的说明,在Redis版本5以上,集群搭建比较简单。本文使用6个Redis实例(版本是6.2.0),三个主节点,三个从节点,每个主节点有一个副本。

Redis专题:了解Redis集群,这篇就够了

准备配置文件:在目录cluster-demo下创建6个文件夹,以Redis将要监听的端口号命名,依次为7000、7001、7002、7003、7004、7005。在每个目录放置一份Redis Cluster所需的最小化配置文件,命名为:cluster.conf,内容如下所示(注意修改端口):port 7000cluster-enabled yescluster-config-file nodes.confcluster-node-timeout 5000appendonly yes复制代码启动Redis实例:依次切换到6个目录,执行指令redis-server cluster.conf,以Cluster模式启动Redis实例。以7000为例,如下图:

Redis专题:了解Redis集群,这篇就够了

Redis专题:了解Redis集群,这篇就够了

创建集群:我使用的Redis版本为6.2.0,所以可以直接使用redis-cli。打开terminal输入–cluster create指令,使用我们刚刚开启的Redis实例创建集群,三主三从。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专题:了解Redis集群,这篇就够了

Redis专题:了解Redis集群,这篇就够了

​ 上图以>>>是redis-cli创建集群时进行的一些核心操作,当然也还有一些日志中没有输出的部分,最终将建立起如下图所示的集群关系。

Redis专题:了解Redis集群,这篇就够了

Redis专题:了解Redis集群,这篇就够了

​ 上图从两中视角对集群节点关系进行了描述:左侧是在不考虑节点角色情况下的物理结构,节点之间双向箭头代表了集群总线;右侧考虑了节点角色及主从分组,其中体现了主从复制关系及集群总线(集群总线仅绘制了主节点之间的,大家自行脑补,全都画出显得过于凌乱)。

Redis专题:了解Redis集群,这篇就够了

在redis-cli的帮助下,Redis Cluster的搭建还是比较简单的,一条命令便解决了所有问题。从上面的过程我们可以清楚的了解到,在集群创建过程中,redis-cli是一个管理者,负责检查节点状态、主从关系建立以、数据分片以及协调节点间通过握手组建集群,但是这都离不了Redis Cluster能力的支持。

Redis专题:了解Redis集群,这篇就够了

为了深入理解集群建立的过程,并为接下来其他部分的理解打好基础,接下来我将介绍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;复制代码简单说下几个字段,方便理解集群的基础字段:

currentEpoch:集群当前纪元,相当于是集群所处的时代,由于重新分片、故障转移等会引起当前纪元升级;myself:数据类型为clusterNode,存储当前节点的状态,稍后解释;nodes:字典类型,以k-v结构存储集群内所有的节点信息,k为节点名称(也叫节点ID),v的数据类型也是clusterNode。slots:哈希槽与节点的映射关系,clusterNode数组,以哈希槽编号为索引,指向负责节点;后面三个字段描述了节点自身的状态,也记录了集群中的其他兄弟节点,同时保存了集群内哈希槽的分配情况。在节点初次启动时,只会存在节点自身的情况,需要等待其他节点加入或者加入已有的集群才会有兄弟节点和哈希槽分配信息。这三个字段都与clusterNode结构有关。

节点属性(clusterNode)

Redis Cluster通过数据结构clusterNode来描述一个集群节点信息与状态。从不同视角来看,它既可以来描述节点自身的状态,也可以用来描述其他节点的状态。

当Redis以集群模式启动后,就会初始化一个clusterNode对象,来维护自身状态。当节点通过握手或者心跳过程发现其他节点后,也会创建一个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;复制代码再来重点了解几个关键的字段。

节点名称/ID:name,每个节点都有一个唯一的ID,是识别节点的唯一依据。节点状态:flags。如果你也学习了Redis源码,你会发现很多过程都是通过状态机来驱动的,Redis Cluster中的每个节点为了描述自身或其他节点的状态,以节点状态驱动系统流程,它虽然为int类型,但是其实只使用了低10位,每一位对应一个状态,先来了解一下每一位的作用,在随后的内容会涉及到:CLUSTER_NODE_NULL_NAME,0,对应一个全0的二进制序列,一个新的节点通过握手加入集群时,默认是没有名称的,依次来标识该节点还没有唯一的ID。CLUSTER_NODE_MASTER,1,二进制表示左起第一位是1,表明该节点是主节点;CLUSTER_NODE_SLAVE,2,二进制表示左起第二位是1,表明该节点是从节点;CLUSTER_NODE_PFAIL,4,二进制表示左起第3位是1,表明该节点可能发生宕机,需要其他节点确认;CLUSTER_NODE_FAIL,8,二进制表示左起第4位是1,表明该节点发生宕机;CLUSTER_NODE_MYSELF,16,二进制表示左起第5位是1,表明该节点是存储该对象本身;CLUSTER_NODE_HANDSHAKE,32,二进制表示左起第6位是1,表明该节点处于握手过程中的第一个ping交互;CLUSTER_NODE_NOADDR,64,二进制表示左起第7位是1,表明不知道该节点的网络地址;CLUSTER_NODE_MEET,128,二进制表示左起第8位是1,表明向该节点发送了MEET命令;CLUSTER_NODE_MIGRATE_TO,256,二进制表示左起第9位是1,表明该节点适合做复制迁移;CLUSTER_NODE_NOFAILOVER,512,二进制表示左起第10位是1,表明该节点不会执行故障转移;配置纪元:configEpoch。与哨兵模式中的“epoch”类似的概念,这里configEpoch描述的是节点的纪元,它与集群的当前纪元currentEpoch可能不同。节点负责的哈希槽:slots。以bitmap方式存储了节点或者其主节点负责的哈希槽。主从关系:如果是主节点,slaves存储其副本数量;如果是从节点,slaveof存储其主节点。集群链接:link,用于维护当前节点与其他节点之间的网络链接。它把clusterState中一个个孤立的节点链接起来,形成网络,是集群总线的基础。通过对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部分。

PING:用于节点间的心跳请求;PONG:对节点间心跳请求PING的回复;MEET:节点握手请求,是一个特殊的PING类型;以上三种消息类型共用一种消息结构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组合用于节点间心跳交互(集群容错部分详细说明)。

FAIL:用于告知其他节点我(消息发送方)发现nodename节点发生了故障。如果发现某个节点发生故障,源节点将会通过此类型命令向集群内其他所有可达节点发送广播消息。typedef struct { // 发生故障节点的名称 char nodename[CLUSTER_NAMELEN];} clusterMsgDataFail;复制代码UPDATE:用于告知其他集群节点哈希槽的分配发生变化,节点接收到后需要更新本地集群状态中的哈希槽与节点的映射关系。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;复制代码FAILOVER_AUTH_REQUEST:从节点发起故障转移投票。FAILOVER_AUTH_ACK:主节点对从节点发起的投票请求确认。以上两者配合使用,用于从节点选举交互流程,是集群模式故障转移的基础。

集群建立过程简单的集群示例系统搭建完毕,通过控制台输出我们大体可以了解集群创建的过程;前面也对集群有关的基本概念从理论到代码结构进行了说明。接下来总结一下整个过程,并对节点握手过程详细说明。

总体过程

结合源码流程(函数clusterManagerCommandCreate)及控制台输出,把集群创建的主要过程总结如下:

根据输入参数,redis-ci依次创建集群管理节点,并与每个节点建立网络链接,获取节点及已有集群信息;依次检查输入的节点,如是否为集群已有节点、节点是否为空;主从节点分配、哈希槽分配,判断节点是否满足集群创建的条件:至少三个主节点;输出哈希槽分片及主从节点分配,并在得到用户许可后执行节点配置:针对主节点:通过CLUSTER ADDSLOTS命令,添加主节点负责的哈希槽范围;针对从节点:通过CLUSTER REPLICATE命令,创建主从复制关系;针对所有节点:通过cluster set-config-epoch命令,使其配置纪元(config epoch)加1;redis-cli通过cluster meet命令触发节点握手过程,节点之间通过集群总线(Cluster Bus)传递MEET、PING、PONG等信息,逐步建立起集群关系;通过7000端口的节点检查集群节点及哈希槽分配情况。还有非常重要的一点上图没有体现到:当集群建立以后,节点之间就会通过集群总线,使用二进制协议Gossip不断进行“闲聊”,以此完成节点发现、健康检查、故障检测、故障转移、配置更新、从节点迁移等工作。接下来,我们对数据分片、主从分配、配置纪元、节点握手几个核心步骤做进一步分析。

主从分配及数据分片

计算主节点数量。根据输入的节点信息及从节点数量要求,redis-cli计算主节点的数量,然后把所有节点按照一主N从进行分组。假设输入节点的数量为n,要求每个主节点的副本数为r,则理论上可以分为:

​ 其中,m为右边计算结果向下取整。按照集群模式的要求,至少需要3个主节点。如果m<3,则提示创建失败。

分配主从节点。redis-cli根据输入节点的ip分布,优先考虑把主节点分配在不同的主机上,选择m个主节点,然后按照从节点数量r为主节点分配从节点。按照指定的从节点数量r分配完成后,如果还有剩余的节点,则再次执行从节点分配。

由于新节点启动之后,默认是主节点,主从节点分配完成后,redis-cli只需要为从节点设置主节点,等待执行配置即可。

数据分片数据分片时默认会把16384个哈希槽均分给主节点,不再详细展开了。

配置下发

主从分配及数据分片完成后,redis-cli已经在本地为节点保存了主从配置及数据分片配置信息,得到管理员的许可后,会遍历节点列表把配置下发至对应的节点。

如果是主节点,则执行数据分片配置。redis-cli使用CLUSTER ADDSLOTS命令设置节点负责的哈希槽;主节点接收后,会进行如下修改操作:

如果集群状态clusterState->importing_slots不为空,则设置为NULL;修改myself的slots字段,以bitmap方式设置当前节点负责的哈希槽范围;如果是从节点,则为其设置主节点。redis-cli向从节点发送CLUSTER REPLICATE命令;从节点接收后,执行主从复制,不再赘述。

升级纪元

经过以上配置,各个节点已经不是刚刚启动时的状态,为了表明这种变化,redis-cli把各个节点的配置纪元升级,该命令为cluster set-config-epoch。

节点接收命令后,将修改myself的configEpoch,并确保集群的currentEpoch不低于此值。

节点握手

到这里,每个单独的节点已经配置完成,接下来redis-cli会向节点发起握手命令,从零开始把节点逐个加入集群。为了安全考虑,目前节点间的握手仅能通过管理员发起,握手过程通过集群总线完成。

节点启动后监听集群总线端口,会接受一切外来的网络链接并接收其发送的消息,但是如果发现消息来源节点不是集群已知节点,其发送的所有消息将被丢弃。集群已有节点接受新节点加入集群只有两种方式:

MEET请求:新节点以MEET消息发送请求,说明是由管理员发起的扩容命令,通过握手过程加入集群。自动发现:如果一个节点被集群中某个节点认可其是集群的有效节点,然后通过节点间的心跳告知其他节点,其他节点也会认为其是集群的有效节点。比如已知集群中由A、B、C三个节点,通过MEET请求A认可了D,通过一段时间的心跳,B、C也会接受D作为集群的节点。结合以上两种方式,我们只需要从第二个节点以后的节点依次与第一个节点握手,再通过自动发现即可实现所有节点加入集群。

好了,我们了解一下握手过程是如何实现的,为了简单,我们仅以两个节点为例描述其过程。假设节点信息如下:

节点A:127.0.0.1 7000节点B:127.0.0.1 7001通过redis-cli发起meet命令,让节点B与节点A握手,命令为cluster meet 127.0.0.1 7000。

B节点接收命令后,开始与A节点的握手过程,为了方便清晰的了解握手过程中节点状态的变化,通过下图进行说明。

​ 图示显示了握手过程中,两个节点经过“MEET-PONG-PING-PONG”两次交互完成握手,状态变化一目了然。用文字描述一下过程:

B节点创建握手节点A的节点信息,初始化时无名称,状态为MEET和HANDSHAKE。B节点创建与A节点的集群总线连接,主动发起MEET请求,而后A节点回复PONG。此时:对于B,取消A的MEET状态,获取A的名称;对于A,B以HANDSHAKE、无名称的状态加入节点列表。A节点创建与B节点的集群总线连接,主动发起PING请求。而后B节点回复PONG。此时:对于B,取消A的HANDSHAKE状态对于A,取消B的HANDSHAKE状态,设置名称。至此,A、B节点握手完成,之后进入正常的心跳保持过程。集群结构总结这部分主要是打基础,把集群的一些基本概念介绍以下,同时介绍了Redis Cluster的物理结构和逻辑结构,通过实例与集群建立过程的说明,给大家一个比较直观的理解。

通过上一节的内容,我们已经知道了Redis Cluster结构、设计理念以及从无到有创建一个集群,总体上来讲对于Redis Cluster有了一个初步的认识。本节将重点解析Redis Cluster数据分片的更多细节,帮助大家更好的理解与使用。

数据分片机制数据分片不同于单机版Redis及Sentinel模式中一个节点负责所有key的管理工作,Redis Cluster采用了类似于一致性哈希算法的哈希槽(hash slot)机制、由多个主节点共同分担所有key的管理工作。

Redis Cluster使用CRC16算法把key空间分布在16384个哈希槽内,哈希槽是按照序号从0~16383标号的,每组主从节点只负责一部分哈希槽管理操作;而且通过集群状态维护哈希槽与节点之间的映射关系,随着集群运行随时更新。如上面我们示例中,哈希槽与节点关系如下:

Master[0]负责Slots:0 – 5460Master[1]负责Slots:5461 – 10922Master[2]负责Slots:10923 – 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将会如下规则处理:

key中存在“{”字符,并且“{”的右侧存在“}”;“{”与“}”之间存在一个或多个字符;满足以上两个条件,Redis Cluster将把“{”与“}”之间的内容作为真正的key进行哈希槽计算,否则还是使用原来的输入执行计算。需要注意:“{”和“}”的匹配遵循最左匹配原则。举例看下:

{user1000}.following 和{user1000}.followers:最终采用user1000;foo{}{bar}:最终采用foo{}{bar};foo{{bar}}zap:最终采用{bar;foo{bar}{zap}:最终采用bar;重新分片当集群中节点压力过大时,我们会考虑通过扩容,让新增节点分担其他节点的哈希槽;当集群中节点压力不平衡时,我们会考虑把部分哈希槽从压力较大的节点转移至压力较小的节点。

Redis Cluster支持在不停机的情况下添加或移除节点,以及节点间哈希槽的迁出和导入,这种动态扩容或配置的方式对于我们的生产实践好处多多。比如:电商场景中,日常流量比较稳定,只要按需分配资源确保安全水位即可;当遇到大促时,流量较大,我们可以新增资源,以不停机、不影响业务的方式实现服务能力的水平扩展。以上两种情况我们称之为重新分片(Resharding)或者在线重配置(Live Reconfiguration),我们来分析下Redis是如何实现的。

通过前面了解集群状态的数据结构,我们知道哈希槽的分配其实是一个数组,数组索引序号对应哈希槽,数组值为负责哈希槽的节点。理论上,哈希槽的重新分配实质上是根据数组索引修改对应的节点对象,然后通过状态传播在集群所有节点达到最终一致。如下图中,把负责哈希槽1001的节点从7000修改为7001。

​ 实际中,为了实现上面的过程,还需要考虑更多方面。

我们知道,哈希槽是由key经过CRC16计算而来的,哈希槽只是为了把key存储到真正节点时一个虚拟的存在,一切的操作还得回归到key上。当把哈希槽负责的节点从旧节点改为新节点时,需要考虑旧节点存量key的迁移问题,也就是要把旧节点哈希槽中的key全部转移至新的节点。

但是,无论哈希槽对应多少个key,key中存储了多少数据,把key从一个节点迁移至另外一个节点总是消耗时间的,同时需要保证原子性;而且,重新分片过程中,客户端的请求并没有停止,Redis还需要正确响应客户端请求,使之不受影响。

接下来,我们利用示例集群做一次重新分片的实践,并且结合源码深入剖析一下Redis的实现过程。以下示例是把7002节点的两个哈希槽迁移至7000节点,过程简述如下:

使用命令redis-cli –cluster reshard 127.0.0.1:7000对集群发起重新分片的请求;redis-cli输出集群当前哈希槽分配情况后,询问迁移哈希槽的数量How many slots do you want to move (from 1 to 16384)?,输入数字2,回车确认;redis-cli询问由哪个节点接收迁移的哈希槽:What is the receiving node ID? ,输入节点7000的ID,回车确认;redis-cli询问迁移哈希槽的来源:输入all代表从其他所有节点中均分,逐行输入节点ID以done结束代表从输入节点迁移哈希槽,这里我输入了7002的节点ID;redis-cli输出本次重新分片的计划,源节点、目标节点以及迁移哈希槽的编号等内容;输出yes确认执行,输入no停止;输入yes后,redis-cli执行哈希槽迁移;执行过程截图如下:

以上过程对应的源码为文件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节点的集群状态变化过程,步骤说明:

修改源节点和目标节点的迁移状态,对应第一幅图,其中:通知目标节点,把指定slot设置为importing状态;通知源节点,把指定哈希槽设置为migrating状态;迁移源节点slot中的key到目标节点,对应第二、三幅图(这一步可能会耗时,key迁移过程中节点的命令处理线程是被占用的)使用命令CLUSTER GETKEYSINSLOT <slog> <pipeline>从源节点查询slot中所有的keys;使用MIGRATE程序把keys从源节点迁移至目标节点,逐个迁移key,每个key的迁移是原子操作,期间会锁定双方节点。通知所有节点,把负责slot的节点设置为最新节点,同时移除源节点、目标节点中的importing、migrating状态,对应第四幅图。好了,重新分片的过程就介绍完了。

重定向数据分片使得所有的key分散存储在不同的节点,而且随着重新分片或者故障转移,哈希槽与节点之间的映射关系会发生改变,那么当客户端发起对一个可以的操作时,集群节点与客户端是如何处理的呢?我们解析来了解一下两种重定向机制。

MOVED重定向

由于数据分片机制,Redis集群中每个节点仅负责一部分哈希槽,也就是一部分key的存储及管理工作。客户端可以随意向集群中任何一个节点发起命令请求,此时节点会计算当前请求key对应的哈希槽,并通过哈希槽与节点的映射关系查询负责该哈希槽的节点,根据查询结果Redis会有如下操作:

如果是当前节点负责该key,那么节点就会立即执行命令;如果是其他节点负责该key,那么节点就会向客户端返回一个MOVED错误。举个例子来看,首先通过常规方式使用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 7002127.0.0.1:7002> GET TestKey(nil)复制代码为什么会这样呢?

因为Redis Cluster每个节点都保存了哈希槽与节点的映射关系,当客户端请求的key不在当前节点的负责范围之内时,节点不会充当目标节点的代理,而是以错误的方式告知客户端在它看来应该由那个节点负责该key。当然,如果正好赶上哈希槽迁移,节点返回的信息不一定准确,客户端可能还会收到MOVED或ASK错误。

所以,这就要求客户端具备这种重定向的能力,及时连接之正确的节点重新发起命令请求。如果客户端与节点之间总是通过重定向的方式处理命令,性能必然不如普通Redis模式高。

怎么办呢?Redis官方提出了两种可选的缓存办法:

执行请求前,客户端首先根据输入的key计算哈希槽。若当前连接对应的节点可以处理该请求,则把哈希槽与节点(ip和端口)映射关系保存起来;若发生重定向,则连接至新的节点,重新请求,直到可以执行成功,最后把哈希槽与节点的关系保存起来。这样,当客户端就可以在先查询缓存,再执行请求,提高效率。通过命令CLUSTER NODES查询集群节点状态,从中获取哈希槽与节点的映射关系,在客户端本地缓存起来。每次请求时,先计算key的哈希槽,再查询节点,最后执行请求,更加高效。在集群稳定运行期间,当然大部分时间也是稳定运行的,以上方式都能够大大提高命令执行的效率。但是,由于集群运行期间可能发生重新分片,客户端维护的信息就会变得不准确,所以当客户端哈希槽对应的节点发生改变时,客户端应该及时修正。

自5.0版本起,redis-cli已经具备了MOVED重定向能力。再以集群客户端的方式连接至7000节点,执行上述命令,效果图如下:

redis-cli -c -p 7000127.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重定向请求至7000节点,7000节点检查后需要自己处理请求,并且keys存储在自己节点内,可以正常处理请求;已迁移:客户端直接或者通过MOVED重定向请求至7000节点,7000节点检查后需要自己处理请求,但是此时keys已经被完全或部分迁移至7001节点,所以执行时无法找到keys,无法正常处理请求;因此,在这种情况下MOVED重定向是不适用的。为此,Redis Cluster引入了ASK重定向,我们来看下ASK重定向的工作原理。

客户端根据本地缓存的哈希槽与节点的映射关系,向7000节点发起keys请求,根据keys的迁移进度,7000节点的执行流程如下:

keys对应的哈希槽slot是由7000节点负责,如果:哈希槽1000不在迁移过程中(migrating),则当前请求由7000节点执行并返回执行结果;哈希槽1000在迁移过程中(migrating),但是keys对应的key都未迁移走,说明此时7000节点可以执行当前请求,则当前请求由7000节点处理并返回执行结果;哈希槽1000在迁移过程中(migrating),但是keys对应的key已经完全或部分迁移至7001,则以ASK重定向错误告知客户端需要请求的节点,格式如下:(error) -ASK <slot> <ip>:<port># 对应示例结果为:(error) -ASK 1000 127.0.0.1:7001复制代码客户端接收到ASK重定向错误信息后,将为该哈希槽(1000)设置强制指向新的节点(7001)的一次性标识,然后执行以下操作:向7001节点发送ASKING命令,并移除一次性标识;紧接着向7001节点发送真正需要请求的命令;7001节点接收客户端ASKING请求后,如果:哈希槽1000正在导入中(importing),当前请求的keys对应的key已经全部导入完成,则7001节点执行该请求并返回执行结果;哈希槽1000正在导入中(importing),当前请求的key对应的key未完全导入完成,则返回重试错误(TRYAGAIN);这样,如果客户端请求的keys处于迁移过程,节点将以ASK重定向错误的方式返回客户端,客户端再向新的节点发起请求。当然,会有一定的概率由于keys未迁移完成而导致请求失败,此时节点将回复“TRYAGAIN”,客户端可以稍后重试。

一旦哈希槽迁移完成,客户端将收到节点回复的MOVED重定向错误,意味着哈希槽的管理权已经转移至新的节点,此时客户端可修改本地的哈希槽与节点映射关系,采用“MOVED重定向”逻辑向新节点发起请求。

MOVED重定向与ASK重定向

通过前面部分的介绍,相信大家已经对两者的区别有了一定的了解,简单总结一下。

两者都是以错误的方式告知客户端应该向其他节点发起目标请求;MOVED重定向:告知客户端当前哈希槽是由哪个节点负责,它是以哈希槽与节点的映射关系为基础的。如果客户端接收到此错误,可以直接更新本地的哈希槽与节点的映射关系缓存。这是一种相对稳定的状态。ASK重定向:告知客户端,它所请求的keys对应的哈希槽当前正在迁移至新的节点,当前节点已经无法完成请求,应该向新节点发起请求。客户端接收到此错误,将会临时(一次性)重定向,以询问(ASKING)的方式向新节点发起请求尝试。该错误不会影响接下来客户端对相同哈希槽的请求,除非它再次收到ASK重定向错误。集群扩容或缩容期间可以正常提供服务吗?

这个是面试中经常遇到的问题,如果你理解问题的本质,这个问题就不难回答了。我们来分析一下:

集群扩容:如果增加主节点:增加主节点后,刚开始它是不负责任何哈希槽的。为了能够分摊系统压力,我们要进行重新分片,把一部分哈希槽转移到新加入的节点,所以这实质上是一个重新分片的过程。如果增加从节点,只需要与指定的主节点进行主从复制过程。集群缩容:缩容意味着从集群中摘除节点。如果摘除主节点,正常情况下,主节点负责一部分哈希槽的读写,若要安全摘除,需要先把该哈希槽负责的节点转移至其他节点,这也是一个重新分片过程。如果摘除从节点,直接摘除即可。对主节点的扩容或者缩容本质上是一个重新分片的过程,重新分片涉及哈希槽迁移,也就是哈希槽内key的迁移。Redis Cluster提供了ASK重定向来告知客户端目前集群发生的状况,以便客户端进行调整:ASKING重定向或者重试。

所以,整体上来讲,扩容或者缩容期间,集群是可以正常提供服务的。

数据分片总结数据分片是Redis Cluster动态收缩,具备可扩展性的根基,虽然本文内容写的比较啰嗦,但是原理还是比较简单的。大家重点理解扩容的过程与本质,就可以以不变应万变。

集群容错机制哨兵模式的自动故障转移能力为其提供高可用保障,同样的,为了提供集群的可用性,Redis Cluster提供了自动故障检测及故障转移能力。两者在设计思想上有很大的相似之处,本节将围绕这个话题进行剖析。

心跳机制Redis Cluster作为无中心的分布式系统,集群容错机制依靠各个节点共同协作,在节点检测到某个节点故障时,通过传播节点故障并达成共识,然后触发一系列的从节点选举及故障转移工作。这一工作完成的基础是节点之间通过心跳机制对集群状态的维护。

下图是从节点A视角来看集群的状态示意图(仅绘制与集群容错有关的内容),myself指向A节点本身,它是节点A对自身状态的描述;nodes[B]指向节点B,它是从A节点来看B节点的状态;还有集群当前纪元、哈希槽与节点映射关系等。

​ 在集群中,每两个节点之间通过PING和PONG两种类型的消息保持心跳,由前文可知这两种消息采用完全相同的结构(消息头和消息体都相同),仅消息头中的type字段不同,我们称这个消息对为心跳消息。

PING/PONG消息头包含了消息源节点的配置纪元(configEpoch)、复制偏移量(offset)、节点名称(sender)、负责的哈希槽(myslots)、节点状态(flags)等,这些内容与目标节点所维护的nodes中节点信息一一对应;另外还包含在源节点看来集群纪元(currentEpoch)、集群状态(state)。PING/PONG消息体包含若干clusterMsgDataGossip,每个clusterMsgDataGossip对应一个集群节点状态,它描述了源节点与之的心跳状态及源节点对它运行状态的判断。心跳消息在集群节点两两之间以“我知道的给你,你知道的给我”这样“瘟疫传播”的方式传播、交换信息,可以保证在短时间内节点状态达成一致。我们从心跳触发的时机、消息体的构成、应用几个方面深入理解心跳机制。

触发时机

在集群模式下,心跳动作是由周期性函数clusterCron()触发的,该函数每个100毫秒执行一次。为了控制集群内消息的规模,同时兼顾与节点之间心跳的时效性,Redis Cluster采取了不同的处理策略。

正常情况下,clusterCron()每隔一秒(该函数每执行10次)向一个节点发送PING消息。这个节点的选择是随机的,随机方式为:

随机5次,每次从节点列表nodes中随机选择一个节点,如果节点满足条件(集群总线链接存在、非等待PONG回复、非握手状态、非本节点),则作为备选节点;如果备选节点不为空,则从备选节点中选择PONG回复最早的节点;补充说明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毫秒)会依次检查每个节点:

如果在收到目标节点PONG消息NODE_TIMEOUT/2还未发送PING,源节点会立刻向目标节点发送一次PING。如果已经向目标节点发送PING消息,但是在NODE_TIMEOUT/2内未收到目标节点的PONG回复,源节点会尝试断开网络链接,通过重连排除网络链接故障对心跳的影响。总体来讲,集群内每秒的心跳消息收发数量是稳定的,即使集群有很多节点也不会导致瞬时网络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中):

确定心跳消息需要包含正常节点的理论最大值(之所以是理论值,是因为接下来的流程还需要考虑节点状态,会移除在握手中或宕机的节点):条件1:最大数量=集群节点数 – 2,“减2”是指去掉源节点与目标节点;条件2:最大数量为节点数量的10分之一且不小于3;以上结果两者取最小值,得到所需的理论数量wanted。确定心跳信息需要包含PFAIL状态节点的数量pfail_wanted:集群状态中获取所有PFAIL节点的数量(server.cluster->stats_pfail_nodes)。添加正常节点:从集群节点列表随机获取wanted个节点,创建gossip消息片段,加入消息体。其中节点需要满足以下条件:不是源节点自身;不是PFAIL状态,因为后面单独添加PFAIL节点;节点不是握手状态及无地址状态,或者节点集群总线链接存在且负责的哈希槽数量不为0;添加PFAIL状态节点:遍历获取PFAIL状态节点,创建gossip片段,加入消息体,此时节点需要满足处于PFAIL状态、不是握手状态、不是无地址状态。消息应用

节点接收到PING或PONG消息后,将按照消息头及消息体中的内容对本地维护的节点状态进行更新。顺着源码说明的话,其中涉及的字段和逻辑还是比较复杂的,我将从应用场景角度来说明消息的处理过程(结合源码函数clusterProcessPacket)。

集群纪元和配置纪元

心跳消息头包含了源节点的配置纪元(configEpoch)及在他看来的集群当前纪元(currentEpoch),目标节点接收后将检查自身维护的源节点的配置纪元和集群当前纪元。具体方式为:

若目标节点缓存的源节点配置纪元(缓存配置纪元)小于源节点心跳消息中声明的配置纪元(声明配置纪元),则修改缓存配置纪元为声明配置纪元;若目标节点缓存的集群当前纪元(缓存当前纪元)小于源节点心跳消息中声明的集群当前纪元(声明当前纪元),则修改缓存当前纪元为声明当前纪元;哈希槽变更检测

消息头包含了源节点当前负责的哈希槽列表,目标节点会检查本地缓存的哈希槽与节点的映射关系,看是否存在与映射关系不一致的哈希槽。当发现不一致的映射关系时,将按照以下情况进行处理:

如果源节点为主节点,并且其声明的配置纪元大于目标节点缓存的配置纪元,则按照声明的哈希槽修改本地哈希槽与节点的映射关系;如果本地缓存的节点配置纪元大于源节点声明的配置纪元,则通过UPDATE消息告知源节点更新本地的哈希槽配置;如果本地缓存的节点配置纪元与源节点声明的配置纪元相同,并且源节点和目标节点都是主节点,则处理配置纪元冲突:目标节点升级本地集群当前纪元和自身的配置纪元(与集群当前纪元保持一致)。这样在后续心跳中,将会再次调整哈希槽冲突,最终达到一致。新节点发现

在节点握手过程中,我们知道,新节点加入集群仅需与集群中任意一个节点通过握手加入集群,但是其他节点并不知道有新节点加入,新节点也不知道其他节点的存在;对于两者而言,都是新节点的发现过程。

在心跳过程中,源节点会把对方未知的新节点信息加入消息体,通知目标节点。目标节点将执行以下流程:

目标节点发现消息体中存在本地缓存不存在的节点,将会为其创建clusterNode对象,并加入集群节点列表nodes。目标节点在clusterCron函数中创建与其的网络链接,两者通过两次心跳交互完成新节点的发现。节点故障发现

节点故障发现是心跳的核心功能,该部分在下一节单独介绍。

故障发现与转移PFAIL与FAIL概念

Redis Cluster使用两个概念PFAIL、FAIL来描述节点的运行状态,这与哨兵模式中的SDOWN、ODOWN类似。

PFAIL:可能宕机当一个节点在超过 NODE_TIMEOUT 时间后仍无法访问某个节点,那么它会用 PFAIL 来标识这个不可达的节点。无论节点类型是什么,主节点和从节点都能标识其他的节点为 PFAIL。

Redis集群节点的不可达性是指:源节点向目标节点发送PING命令后,超过 NODE_TIMEOUT 时间仍未得到它的PONG回复,那么就认为目标节点具有不可达性。

这是由心跳引出的一个概念。为了让PFAIL尽可能,NODE_TIMEOUT 必须比两节点间的网络往返时间大;为了确保可靠性,当在经过一半 NODE_TIMEOUT 时间还没收到目标节点对于 PONG 命令的回复时,源节点就会马上尝试重连接该目标节点。

所以,PFAIL是从源节点对目标节点心跳检测的结果,具有一定的主观性。

FAIL:宕机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状态。从源码可知,将执行以下流程:

为B节点添加故障报告节点,即把C节点添加到B节点的fail_reports内。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;复制代码检查是否达到FAIL的条件:Redis Cluster规定,若超过半数的主节点认为某个节点为PFAIL状态,则设置节点状态为FAIL状态。接着上面的例子,具体过程为:计算达到FAIL的法定节点数:此时集群中包含3个主节点,则至少需要2个节点认可;在A节点集群状态中,C节点已经被加入B节点的fail_reports列表,并且A节点已经标记B节点故障。即2两个节点确认B节点发生故障,所以可以设置B节点为FAIL状态。A节点取消B节点的PFAIL状态,设置其FAIL状态,然后向所有可达节点发送关于B节点的FAIL消息。详细的代码过程如下函数所示:

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)):

从节点的主节点处于FAIL状态;从节点主节点负责的哈希槽不为空;为了保证从节点数据的时效性,从节点与主节点之间断联的时间必须小于指定时间。关于这个指定的时间,我从代码中提取了出来,如下所示:/* 数据有效时间 */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()复制代码解释一下这个公式:

固定的延迟值500,是为了保证主节点的FAIL状态在集群内传播完成,防止发起选举时主节点拒绝;随机延迟值random(0,500):为了确保从节点不再同一时间启动选举流程;SLAVE_RANK:该值取决于从节点数据的完整程度。当主节点变为FAIL状态后,从节点之间会通过PONG命令交换状态,以便建立最优的rank排序;从节点rank排序规则为:从节点repl_offset最大的从节点,rank = 0;其次,rank = 1,以此类推。所以,数据完整程度最高的节点将最先启动选举流程,如果后续一切顺利,它将被提升为新的主节点。

设置failover_auth_time后,当clusterCron()再次运行时,如果系统时间达到这个预设值(并且failover_auth_sent=0)就会进入选举流程。

从节点选举流程

从节点启动选举流程,先把自身维护的集群当前纪元(currentEpoch)加1,并设置failover_auth_sent=1以表示已经启动选举流程;然后通过FAILOVER_AUTH_REQUEST类型的消息向集群内的所有主节点发起选举请求,并在2倍NODE_TIMEOUT(至少2秒)时间内等待主节点的投票回复。

集群内的其他主节点是从节点选举的决策者,投票前需要做出严格的检查。为了避免多个从节点在选举中同时胜出,并且保证选举过程合法性,主节点接收到FAILOVER_AUTH_REQUEST命令消息后,将会做以下条件校验:

发起选举流程的从节点所属主节点必须处于FAIL状态(前面DELAY中的固定值500毫秒,就是为了保证FAIL消息在集群内传播充分);针对一个给定的currentEpoch,主节点只会投票一次,并且保存在lastVoteEpoch中,其他较老的纪元选取申请都会拒绝。另外,如果从节点投票请求中的currentEpoch小于当前主节点的currentEpoch,投票请求会被拒绝。主节点一旦通过FAILOVER_AUTH_ACK类型的消息投赞成票给指定的从节点,该主节点在2倍NODE_TIMEOUT时间内将不再投票给同主节点下的其他从节点。主节点投票完成将记录信息,并安全持久化保存到配置文件:

保存上次投票的集群当前纪元: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,半数以上的主节点。

从节点启动提升流程,将会对自身的状态信息进行一系列的修改,最终把自己提升为主节点,具体内容如下:

从节点把节点配置纪元configEpoch加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。

​ 通过以下过程,阐述节点故障时,从节点迁移的作用:

集群在运行过程中,由于B节点发生故障,B1通过选举被提升为新的主节点,导致B1成为无从节点的孤立状态,此时如果B1再发生故障,集群将不可用。但是此时A有两个从节点,Redis Cluster将启动从节点迁移机制,把A1转移至B1的从节点,使得B1不再被孤立。此时即使B1再发生故障,那么A1可以提升为新的主节点,集群可以继续工作。集群脑裂

作为分布式系统,必须解决网络分区带来的各种复杂问题。在Redis Cluster中,由于网络分区问题,导致集群节点分布在两个分区,使得集群发生“脑裂”。此时从节点的选举与提升在两个网络分区是如何工作的呢?

​ 如上图所示,节点A及其从节点A1,由于网络分区与其他节点失联。我们来看下两个分区内的节点是如何工作的?

多数节点网络分区该分区内的节点将检测到节点A的PFAIL状态,然后经过传播确认节点A达到FAIL状态;A2节点将触发选举流程并胜出,提升为新的主节点,继续工作。经过故障转移,含有大部分节点的网络分区可以继续工作。

少数节点分区位于少数节点分区的节点A、A1,会检测到其他节点B、C的PFAIL状态,但是由于无法得到大多数主节点的确认,B、C无法达到FAIL状态,进而导致不能发生后续的故障转移工作。

Redis Cluster总结本文从三个主要部分介绍了Redis Cluster的工作原理:集群结构、数据分片、容错机制,差不多覆盖了Redis Cluster的所有内容,希望能够给大家学习Redis Cluster带来帮助。

在研究官方文档、系统源码的过程中,确实遇到了好多不解的内容,通过反复梳理代码流程,逐个揭开各个谜底,最终建立起了整个知识体系。

终于写到这里了,这篇文章从起笔到完成历时一个月,每天写一点,还好完成了。

作者:码路印记链接:://juejin.cn/post/6949832776224866340来源:掘金

本文来自网络,不代表汉扬编程立场,转载请注明出处:http://www.hyzlch.com/mianfei/6610.html

C\\C++语言9|复合(构造)数据类型:结构体和联合体

代码:C语言中的匿名结构体和匿名联合体

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注

返回顶部