简介
本文介绍Redis集群(Cluster)的故障转移的流程。
Redis集群自身实现了高可用。 高可用首先需要解决集群部分失败的场景: 当集群内少量节点出现故障时通过自动故障转移保证集群可以正常对外提供服务。
故障发现
当集群内某个节点出现问题时, 需要通过一种健壮的方式保证识别出节点是否发生了故障。 Redis集群内节点通过ping/pong消息实现节点通信, 消息不但可以传播节点槽信息, 还可以传播其他状态如: 主从状态、 节点故障等。 因此故障发现也是通过消息传播机制实现的, 主要环节包括: 主观下线(pfail) 和客观下线(fail) 。
- 主观下线:指某个节点认为另一个节点不可用, 即下线状态, 这个状态并不是最终的故障判定, 只能代表一个节点的意见, 可能存在误判情况。
- 客观下线:指标记一个节点真正的下线, 集群内多个节点都认为该节点不可用, 从而达成共识的结果。 如果是持有槽的主节点故障, 需要为该节点进行故障转移。
主观下线
集群中每个节点都会定期向其他节点发送ping消息, 接收节点回复pong消息作为响应。 如果在cluster-node-timeout时间内通信一直失败, 则发送节点会认为接收节点存在故障, 把接收节点标记为主观下线(pfail) 状态。 流程如下图所示:
流程说明:
1) 节点a发送ping消息给节点b, 如果通信正常将接收到pong消息, 节点a更新最近一次与节点b的通信时间。
2) 如果节点a与节点b通信出现问题则断开连接, 下次会进行重连。 如果一直通信失败, 则节点a记录的与节点b最后通信时间将无法更新。
3) 节点a内的定时任务检测到与节点b最后通信时间超高cluster-nodetimeout时, 更新本地对节点b的状态为主观下线(pfail) 。
主观下线简单来讲就是, 当cluster-note-timeout时间内某节点无法与另一个节点顺利完成ping消息通信时, 则将该节点标记为主观下线状态。 每个节点内的cluster State结构都需要保存其他节点信息, 用于从自身视角判断其他节点的状态。
Redis集群对于节点最终是否故障判断非常严谨, 只有一个节点认为主观下线并不能准确判断是否故障。 例如下图的场景:
节点6379与6385通信中断, 导致6379判断6385为主观下线状态, 但是6380与6385节点之间通信正常, 这种情况不能判定节点6385发生故障。 因此对于一个健壮的故障发现机制, 需要集群内大多数节点都判断6385故障时,才能认为6385确实发生故障, 然后为6385节点进行故障转移。 而这种多个节点协作完成故障发现的过程叫做客观下线。
客观下线
当某个节点判断另一个节点主观下线后, 相应的节点状态会跟随消息在集群内传播。 ping/pong消息的消息体会携带集群1/10的其他节点状态数据,当接受节点发现消息体中含有主观下线的节点状态时, 会在本地找到故障节点的ClusterNode结构, 保存到下线报告链表中。 结构如下:
struct clusterNode { /* 认为是主观下线的clusterNode结构 */ list *fail_reports; /* 记录了所有其他节点对该节点的下线报告 */ ... };
通过Gossip消息传播, 集群内节点不断收集到故障节点的下线报告。 当半数以上持有槽的主节点都标记某个节点是主观下线时。 触发客观下线流程。 这里有两个问题:
1) 为什么必须是负责槽的主节点参与故障发现决策? 因为集群模式下只有处理槽的主节点才负责读写请求和集群槽等关键信息维护, 而从节点只进行主节点数据和状态信息的复制。
2) 为什么半数以上处理槽的主节点? 必须半数以上是为了应对网络分区等原因造成的集群分割情况, 被分割的小集群因为无法完成从主观下线到客观下线这一关键过程, 从而防止小集群完成故障转移之后继续对外提供服务。
假设节点a标记节点b为主观下线, 一段时间后节点a通过消息把节点b的状态发送到其他节点, 当节点c接受到消息并解析出消息体含有节点b的pfail状态时, 会触发客观下线流程, 如下图所示:
流程说明:
- 当消息体内含有其他节点的pfail状态会判断发送节点的状态, 如果发送节点是主节点则对报告的pfail状态处理, 从节点则忽略。
- 找到pfail对应的节点结构, 更新clusterNode内部下线报告链表。
- 根据更新后的下线报告链表告尝试进行客观下线。
这里针对维护下线报告和尝试客观下线逻辑进行详细说明。
1. 维护下线报告链表
每个节点ClusterNode结构中都会存在一个下线链表结构, 保存了其他主节点针对当前节点的下线报告, 结构如下:
typedef struct clusterNodeFailReport { struct clusterNode *node; /* 报告该节点为主观下线的节点 */ mstime_t time; /* 最近收到下线报告的时间 */ } clusterNodeFailReport;
下线报告中保存了报告故障的节点结构和最近收到下线报告的时间, 当接收到fail状态时, 会维护对应节点的下线上报链表, 伪代码如下:
def clusterNodeAddFailureReport(clusterNode failNode, clusterNode senderNode) : // 获取故障节点的下线报告链表 list report_list = failNode.fail_reports; // 查找发送节点的下线报告是否存在 for(clusterNodeFailReport report : report_list): // 存在发送节点的下线报告上报 if(senderNode == report.node): // 更新下线报告时间 report.time = now(); return 0; // 如果下线报告不存在,插入新的下线报告 report_list.add(new clusterNodeFailReport(senderNode,now())); return 1;
每个下线报告都存在有效期, 每次在尝试触发客观下线时, 都会检测下线报告是否过期, 对于过期的下线报告将被删除。 如果在cluster-node-time*2的时间内该下线报告没有得到更新则过期并删除, 伪代码如下:
def clusterNodeCleanupFailureReports(clusterNode node) : list report_list = node.fail_reports; long maxtime = server.cluster_node_timeout * 2; long now = now(); for(clusterNodeFailReport report : report_list): // 如果最后上报过期时间大于cluster_node_timeout * 2则删除 if(now - report.time > maxtime): report_list.del(report);
下线报告的有效期限是server.cluster_node_timeout*2, 主要是针对故障误报的情况。 例如节点A在上一小时报告节点B主观下线, 但是之后又恢复正常。 现在又有其他节点上报节点B主观下线, 根据实际情况之前的属于误报不能被使用。
运维提示
如果在cluster-node-time*2时间内无法收集到一半以上槽节点的下线报告, 那么之前的下线报告将会过期, 也就是说主观下线上报的速度追赶不上下线报告过期的速度, 那么故障节点将永远无法被标记为客观下线从而导致故障转移失败。 因此不建议将cluster-node-time设置得过小。
2. 尝试客观下线
集群中的节点每次接收到其他节点的pfail状态, 都会尝试触发客观下线, 流程如下图所示:
流程说明:
- 首先统计有效的下线报告数量, 如果小于集群内持有槽的主节点总数的一半则退出。
- 当下线报告大于槽主节点数量一半时, 标记对应故障节点为客观下线状态。
- 向集群广播一条fail消息, 通知所有的节点将故障节点标记为客观下线, fail消息的消息体只包含故障节点的ID。
使用伪代码分析客观下线的流程, 如下所示:
def markNodeAsFailingIfNeeded(clusterNode failNode) { // 获取集群持有槽的节点数量 int slotNodeSize = getSlotNodeSize(); // 主观下线节点数必须超过槽节点数量的一半 int needed_quorum = (slotNodeSize / 2) + 1; // 统计failNode节点有效的下线报告数量(不包括当前节点) int failures = clusterNodeFailureReportsCount(failNode); // 如果当前节点是主节点, 将当前节点计累加到failures if (nodeIsMaster(myself)): failures++; // 下线报告数量不足槽节点的一半退出 if (failures < needed_quorum): return; // 将改节点标记为客观下线状态(fail) failNode.flags = REDIS_NODE_FAIL; // 更新客观下线的时间 failNode.fail_time = mstime(); // 如果当前节点为主节点,向集群广播对应节点的fail消息 if (nodeIsMaster(myself)) clusterSendFail(failNode);
广播fail消息是客观下线的最后一步, 它承担着非常重要的职责:
- 通知集群内所有的节点标记故障节点为客观下线状态并立刻生效。
- 通知故障节点的从节点触发故障转移流程。
需要理解的是, 尽管存在广播fail消息机制, 但是集群所有节点知道故障节点进入客观下线状态是不确定的。 比如当出现网络分区时有可能集群被分割为一大一小两个独立集群中。 大的集群持有半数槽节点可以完成客观下线并广播fail消息, 但是小集群无法接收到fail消息, 如下图所示:
但是当网络恢复后, 只要故障节点变为客观下线, 最终总会通过Gossip消息传播至集群的所有节点。
运维提示
网络分区会导致分割后的小集群无法收到大集群的fail消息, 因此如果故障节点所有的从节点都在小集群内将导致无法完成后续故障转移, 因此部署主从结构时需要根据自身机房/机架拓扑结构, 降低主从被分区的可能性。
故障恢复
故障节点变为客观下线后, 如果下线节点是持有槽的主节点则需要在它的从节点中选出一个替换它, 从而保证集群的高可用。 下线主节点的所有从节点承担故障恢复的义务, 当从节点通过内部定时任务发现自身复制的主节点进入客观下线时, 将会触发故障恢复流程,如下图所示:
1.资格检查
每个从节点都要检查最后与主节点断线时间, 判断是否有资格替换故障的主节点。 如果从节点与主节点断线时间超过cluster-node-time*cluster-slave-validity-factor,则当前从节点不具备故障转移资格。 参数cluster-slavevalidity-factor用于从节点的有效因子, 默认为10。
2.准备选举时间
当从节点符合故障转移资格后, 更新触发故障选举的时间, 只有到达该时间后才能执行后续流程。 故障选举时间相关字段如下:
struct clusterState { ... mstime_t failover_auth_time; /* 记录之前或者下次将要执行故障选举时间 */ int failover_auth_rank; /* 记录当前从节点排名 */ }
这里之所以采用延迟触发机制, 主要是通过对多个从节点使用不同的延迟选举时间来支持优先级问题。 复制偏移量越大说明从节点延迟越低, 那么它应该具有更高的优先级来替换故障主节点。 优先级计算伪代码如下:
def clusterGetSlaveRank(): int rank = 0; // 获取从节点的主节点 ClusteRNode master = myself.slaveof; // 获取当前从节点复制偏移量 long myoffset = replicationGetSlaveOffset(); // 跟其他从节点复制偏移量对比 for (int j = 0; j < master.slaves.length; j++): // rank表示当前从节点在所有从节点的复制偏移量排名, 为0表示偏移量最大. if (master.slaves[j] != myself && master.slaves[j].repl_offset > myoffset): rank++; return rank; }
使用之上的优先级排名, 更新选举触发时间, 伪代码如下:
def updateFailoverTime(): // 默认触发选举时间: 发现客观下线后一秒内执行。 server.cluster.failover_auth_time = now() + 500 + random() % 500; // 获取当前从节点排名 int rank = clusterGetSlaveRank(); long added_delay = rank * 1000; // 使用added_delay时间累加到failover_auth_time中 server.cluster.failover_auth_time += added_delay; // 更新当前从节点排名 server.cluster.failover_auth_rank = rank;
所有的从节点中复制偏移量最大的将提前触发故障选举流程, 如下图所示:
3.发起选举
当从节点定时任务检测到达故障选举时间(failover_auth_time) 到达后, 发起选举流程如下:
(1) 更新配置纪元
配置纪元是一个只增不减的整数, 每个主节点自身维护一个配置纪元clusterNode.configEpoch) 标示当前主节点的版本, 所有主节点的配置纪元都不相等, 从节点会复制主节点的配置纪元。 整个集群又维护一个全局的配置纪元(clusterState.current Epoch) , 用于记录集群内所有主节点配置纪元的最大版本。 执行cluster info命令可以查看配置纪元信息:
127.0.0.1:6379> cluster info ... cluster_current_epoch:15 // 整个集群最大配置纪元 cluster_my_epoch:13 // 当前主节点配置纪元
配置纪元会跟随ping/pong消息在集群内传播, 当发送方与接收方都是主节点且配置纪元相等时代表出现了冲突, nodeId更大的一方会递增全局配置纪元并赋值给当前节点来区分冲突, 伪代码如下:
def clusterHandleConfigEpochCollision(clusterNode sender) : if (sender.configEpoch != myself.configEpoch || !nodeIsMaster(sender) || !nodeIsMast (myself)) : return; // 发送节点的nodeId小于自身节点nodeId时忽略 if (sender.nodeId <= myself.nodeId): return // 更新全局和自身配置纪元 server.cluster.currentEpoch++; myself.configEpoch = server.cluster.currentEpoch;
配置纪元的主要作用:
- 标示集群内每个主节点的不同版本和当前集群最大的版本。
- 每次集群发生重要事件时, 这里的重要事件指出现新的主节点(新加入的或者由从节点转换而来) , 从节点竞争选举。 都会递增集群全局的配置纪元并赋值给相关主节点, 用于记录这一关键事件。
- 主节点具有更大的配置纪元代表了更新的集群状态, 因此当节点间进行ping/pong消息交换时, 如出现slots等关键信息不一致时, 以配置纪元更大的一方为准, 防止过时的消息状态污染集群。
配置纪元的应用场景有:
- 新节点加入。
- 槽节点映射冲突检测。
- 从节点投票选举冲突检测。
开发提示
之前在通过cluster setslot命令修改槽节点映射时, 需要确保执行请求的主节点本地配置纪元(configEpoch) 是最大值, 否则修改后的槽信息在消息传播中不会被拥有更高的配置纪元的节点采纳。 由于Gossip通信机制无法准确知道当前最大的配置纪元在哪个节点, 因此在槽迁移任务最后的clustersetslot{slot}node{nodeId}命令需要在全部主节点中执行一遍。
从节点每次发起投票时都会自增集群的全局配置纪元, 并单独保存在
clusterState.failover_auth_epoch变量中用于标识本次从节点发起选举的版本。
(2) 广播选举消息
在集群内广播选举消息(FAILOVER_AUTH_REQUEST) , 并记录已发送过消息的状态, 保证该从节点在一个配置纪元内只能发起一次选举。 消息内容如同ping消息只是将type类型变为FAILOVER_AUTH_REQUEST。
全局纪元与配置纪元
CurrentEpoch 1、全局纪元,全局版本号,从节点最终一致 2、集群节点初始化时,主从节点的CurrentEpoch都为0 3、节点间通信,会更新较小的CurrentEpoch值为较大的CurrentEpoch configEpoch 1、节点配置纪元。单节点版本号,各节点独立,可能会不一致 2、用于处理配置冲突,以较大的配置纪元为准。 例:节点B和节点C都声明为节点D的主节点。节点A此时会选择相信configEpoch值较大的 3、处理故障转移。 从节点在故障转移中升级为主节点后,会增加自身的configEpoch为当前集群节点的最大值。发生冲突时便于分辨归属,
4.选举投票
选举采用过半机制:有超过一半的主节点投票给这个从节点,这个从节点才被选举成功。
只有持有槽的主节点才会处理故障选举消息(FAILOVER_AUTH_REQUEST) , 因为每个持有槽的节点在一个配置纪元内都有唯一的一张选票, 当接到第一个请求投票的从节点消息时回复FAILOVER_AUTH_ACK消息作为投票, 之后相同配置纪元内其他从节点的选举消息将忽略。
投票过程其实是一个领导者选举的过程, 如集群内有N个持有槽的主节点代表有N张选票。 由于在每个配置纪元内持有槽的主节点只能投票给一个从节点, 因此只能有一个从节点获得N/2+1的选票, 保证能够找出唯一的从节点。
Redis集群没有直接使用从节点进行领导者选举, 主要因为从节点数必须大于等于3个才能保证凑够N/2+1个节点, 将导致从节点资源浪费。 使用集群内所有持有槽的主节点进行领导者选举, 即使只有一个从节点也可以完成选举过程。
当从节点收集到N/2+1个持有槽的主节点投票时, 从节点可以执行替换主节点操作, 例如集群内有5个持有槽的主节点, 主节点b故障后还有4个,当其中一个从节点收集到3张投票时代表获得了足够的选票可以进行替换主节点操作, 如下图所示:
运维提示
故障主节点也算在投票数内, 假设集群内节点规模是3主3从, 其中有2个主节点部署在一台机器上, 当这台机器宕机时, 由于从节点无法收集到3/2+1个主节点选票将导致故障转移失败。 这个问题也适用于故障发现环节。 因此部署集群时所有主节点最少需要部署在3台物理机上才能避免单点问题。
投票作废: 每个配置纪元代表了一次选举周期, 如果在开始投票之后的cluster-node-timeout*2时间内从节点没有获取足够数量的投票, 则本次选举作废。 从节点对配置纪元自增并发起下一轮投票, 直到选举成功为止。
5.替换主节点
当从节点收集到足够的选票之后, 触发替换主节点操作:
- 当前从节点取消复制变为主节点。
- 执行clusterDelSlot操作撤销故障主节点负责的槽, 并执行clusterAddSlot把这些槽委派给自己。
- 向集群广播自己的pong消息, 通知集群内所有的节点当前从节点变为主节点并接管了故障主节点的槽信息。
总结
- 资格检查
- 校验从节点与主节点断线时间是否超过cluster-node-time * cluster-slave-validity-factor
- cluster-slave-validity-factor 从节点有效因子,默认为10
- 从节点准备选举时间(failover_auth_time)
- 准备选举时间越短对应选举成功率越高,依次判断slave priority、replica offset、run id来决定哪个从节点的选举时间时间最短
- slave priority:选择优先级,值越大优先级越高
- replica offset:复制偏移量,偏移量越大说明最近一次复制占比越高
- run id:随机字符串,值越小说明越早创建
- 发起选举
- 从节点经过各自准备选举时间后,自增自身的CurrentEpoch,并向其余主节点发起拉票请求
- 选举投票
- 主节点收到拉票请求,发现拉票方的CurrentEpoch大于自身的CurrentEpoch,会更新自身的CurrentEpoch,并在自身未投票的情况下投票
- N/2+1,即超过半数的选票获胜
- 替换主节点
- 从节点取消复制变为主节点
- 执行clusterDelSlot撤销故障节点负责的槽,随后执行clusterAddSlot把对应槽委派给自己
- 向集群广播自己的pong消息,通知集群内所有节点当前从节点变为主节点并接管故障主节点的槽信息
- 后续故障节点恢复,会降级为从节点
故障转移时间
在介绍完故障发现和恢复的流程后, 这时我们可以估算出故障转移时间:
- 主观下线(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参数息息相关, 默认15秒。配置时可以根据业务容忍度做出适当调整, 但不是越小越好, 下一节的带宽消耗部分会进一步说明。
故障转移模拟
到目前为止介绍了故障转移的主要细节, 下面通过之前搭建的集群模拟主节点故障场景, 对故障转移行为进行分析。 使用kill-9强制关闭主节点6385进程, 如下图所示:
确认集群状态:
强制关闭6385进程:
日志分析
1. 从节点6386与主节点6385复制中断, 日志如下:
2. 6379和6380两个主节点都标记6385为主观下线, 超过半数因此标记为客观下线状态, 打印如下日志:
3. 从节点识别正在复制的主节点进入客观下线后准备选举时间, 日志打印了选举延迟964毫秒之后执行, 并打印当前从节点复制偏移量。
4. 延迟选举时间到达后, 从节点更新配置纪元并发起故障选举。
5. 6379和6380主节点为从节点6386投票, 日志如下:
6. 从节点获取2个主节点投票之后, 超过半数执行替换主节点操作, 从而完成故障转移:
手动恢复节点
成功完成故障转移之后, 我们对已经出现故障节点6385进行恢复, 观察节点状态是否正确:
1) 重新启动故障节点6385。
2) 6385节点启动后发现自己负责的槽指派给另一个节点, 则以现有集群配置为准, 变为新主节点6386的从节点, 关键日志如下:
3) 集群内其他节点接收到6385发来的ping消息, 清空客观下线状态:
4) 6385节点变为从节点, 对主节点6386发起复制流程:
5) 最终集群状态如下图所示。
6386成为主节点且6385变为它的从节点。
请先
!