集群故障转移1、故障发现1.1、主观下线1.2、客观下线2、故障恢复2.1、资格检查2.2、准备选举时间2.3、发起选举2.4、选举投票2.5、替换主节点3、故障转移时间4、故障转移演练1、故障发现当集群内某个节点出现问题时需要通过一种健壮的方式保证识别出节点是否发生了故障。Redis集群内节点通过ping/pong消息实现节点通信消息不但可以传播节点槽信息还可以传播其他状态如主从状态、节点故障等。因此故障发现也是通过消息传播机制实现的主要环节包括主观下线(pfail)和客观下线(fail)。主观下线指某个节点认为另一个节点不可用即下线状态这个状态并不是最终的故障判定只能代表一个节点的意见可能存在误判情况。客观下线指标记一个节点真正的下线集群内多个节点都认为该节点不可用从而达成共识的结果。如果是持有槽的主节点故障需要为该节点进行故障转移。1.1、主观下线集群中每个节点都会定期向其他节点发送ping消息接收节点回复pong消息作为响应。如果在cluster-node-timeout时间内通信一直失败则发送节点会认为接收节点存在故障把接收节点标记为主观下线(pfail)状态。流程如图所示。流程说明节点a发送ping消息给节点b如果通信正常将接收到pong消息节点a更新最近一次与节点b的通信时间。如果节点a与节点b通信出现问题则断开连接下次会进行重连。如果一直通信失败则节点a记录的与节点b最后通信时间将无法更新。节点a内的定时任务检测到与节点b最后通信时间超高cluster-node-timeout时更新本地对节点b的状态为主观下线(pfail)。主观下线简单来讲就是当cluster-note-timeout时间内某节点无法与另一个节点顺利完成ping消息通信时则将该节点标记为主观下线状态。每个节点内的cluster State结构都需要保存其他节点信息用于从自身视角判断其他节点的状态。结构关键属性如下typedefstructclusterState{clusterNode*myself;/* 自身节点 / dict *nodes;/* 当前集群内所有节点的字典集合key为节点IDvalue为对应节点ClusterNode结构 */…}clusterState;字典nodes属性中的clusterNode结构保存了节点的状态,关键属性如下:typedefstructclusterNode{intflags;/* 当前节点状态,如:主从角色是否下线等 */mstime_tping_sent;/* 最后一次与该节点发送ping消息的时间 */mstime_tpong_received;/* 最后一次接收到该节点pong消息的时间 */…}clusterNode;其中最重要的属性是flags用于标示该节点对应状态取值范围如下CLUSTER_NODE_MASTER1/* 当前为主节点 */CLUSTER_NODE_SLAVE2/* 当前为从节点 */CLUSTER_NODE_PFAIL4/* 主观下线状态 */CLUSTER_NODE_FAIL8/* 客观下线状态 */CLUSTER_NODE_MYSELF16/* 表示自身节点 */CLUSTER_NODE_HANDSHAKE32/* 握手状态未与其他节点进行消息通信 */CLUSTER_NODE_NOADDR64/* 无地址节点用于第一次meet通信未完成或者通信失败 */CLUSTER_NODE_MEET128/* 需要接受meet消息的节点状态 */CLUSTER_NODE_MIGRATE_TO256/* 该节点被选中为新的主节点状态 */使用以上结构主观下线判断伪代码如下// 定时任务,默认每秒执行10次 def clusterCron(): // … 忽略其他代码 for(nodeinserver.cluster.nodes): // 忽略自身节点比较 if(node.flagsCLUSTER_NODE_MYSELF):continue;// 系统当前时间 long nowmstime();// 自身节点最后一次与该节点PING通信的时间差 long delaynow - node.ping_sent;// 如果通信时间差超过cluster_node_timeout将该节点标记为PFAIL主观下线if(delayserver.cluster_node_timeout):node.flagsCLUSTER_NODE_PFAIL;Redis集群对于节点最终是否故障判断非常严谨只有一个节点认为主观下线并不能准确判断是否故障。例如图的场景。节点6379与6385通信中断导致6379判断6385为主观下线状态但是6380与6385节点之间通信正常这种情况不能判定节点6385发生故障。因此对于一个健壮的故障发现机制需要集群内大多数节点都判断6385故障时才能认为6385确实发生故障然后为6385节点进行故障转移。而这种多个节点协作完成故障发现的过程叫做客观下线。1.2、客观下线当某个节点判断另一个节点主观下线后相应的节点状态会跟随消息在集群内传播。ping/pong消息的消息体会携带集群1/10的其他节点状态数据当接受节点发现消息体中含有主观下线的节点状态时会在本地找到故障节点的ClusterNode结构保存到下线报告链表中。结构如下structclusterNode{/* 认为是主观下线的clusterNode结构 */list*fail_reports;/* 记录了所有其他节点对该节点的下线报告 */…};通过Gossip消息传播集群内节点不断收集到故障节点的下线报告。当半数以上持有槽的主节点都标记某个节点是主观下线时。触发客观下线流程。这里有两个问题为什么必须是负责槽的主节点参与故障发现决策因为集群模式下只有处理槽的主节点才负责读写请求和集群槽等关键信息维护而从节点只进行主节点数据和状态信息的复制。为什么半数以上处理槽的主节点必须半数以上是为了应对网络分区等原因造成的集群分割情况被分割的小集群因为无法完成从主观下线到客观下线这一关键过程从而防止小集群完成故障转移之后继续对外提供服务。假设节点a标记节点b为主观下线一段时间后节点a通过消息把节点b的状态发送到其他节点当节点c接受到消息并解析出消息体含有节点b的pfail状态时会触发客观下线流程如图所示。流程说明当消息体内含有其他节点的pfail状态会判断发送节点的状态如果发送节点是主节点则对报告的pfail状态处理从节点则忽略。找到pfail对应的节点结构更新clusterNode内部下线报告链表。根据更新后的下线报告链表告尝试进行客观下线。这里针对维护下线报告和尝试客观下线逻辑进行详细说明。(1)维护下线报告链表每个节点ClusterNode结构中都会存在一个下线链表结构保存了其他主节点针对当前节点的下线报告结构如下typedefstructclusterNodeFailReport{structclusterNode*node;/* 报告该节点为主观下线的节点 */mstime_ttime;/* 最近收到下线报告的时间 */}clusterNodeFailReport;下线报告中保存了报告故障的节点结构和最近收到下线报告的时间当接收到fail状态时会维护对应节点的下线上报链表伪代码如下defclusterNodeAddFailureReport(clusterNode failNode,clusterNode senderNode):// 获取故障节点的下线报告链表list report_listfailNode.fail_reports;// 查找发送节点的下线报告是否存在for(clusterNodeFailReport report:report_list):// 存在发送节点的下线报告上报if(senderNodereport.node):// 更新下线报告时间report.timenow();return0;// 如果下线报告不存在,插入新的下线报告report_list.add(newclusterNodeFailReport(senderNode,now()));return1;每个下线报告都存在有效期每次在尝试触发客观下线时都会检测下线报告是否过期对于过期的下线报告将被删除。如果在cluster-node-time*2的时间内该下线报告没有得到更新则过期并删除伪代码如下defclusterNodeCleanupFailureReports(clusterNode node):list report_listnode.fail_reports;longmaxtimeserver.cluster_node_timeout*2;longnownow();for(clusterNodeFailReport report:report_list):// 如果最后上报过期时间大于cluster_node_timeout * 2则删除if(now-report.timemaxtime):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 slotNodeSizegetSlotNodeSize();// 主观下线节点数必须超过槽节点数量的一半 int needed_quorum(slotNodeSize /2)1;// 统计failNode节点有效的下线报告数量不包括当前节点 int failuresclusterNodeFailureReportsCount(failNode);// 如果当前节点是主节点将当前节点计累加到failuresif(nodeIsMaster(myself)): failures;// 下线报告数量不足槽节点的一半退出if(failuresneeded_quorum):return;// 将改节点标记为客观下线状态(fail)failNode.flagsREDIS_NODE_FAIL;// 更新客观下线的时间 failNode.fail_timemstime();// 如果当前节点为主节点,向集群广播对应节点的fail消息if(nodeIsMaster(myself))clusterSendFail(failNode);广播fail消息是客观下线的最后一步它承担着非常重要的职责通知集群内所有的节点标记故障节点为客观下线状态并立刻生效。通知故障节点的从节点触发故障转移流程。需要理解的是尽管存在广播fail消息机制但是集群所有节点知道故障节点进入客观下线状态是不确定的。比如当出现网络分区时有可能集群被分割为一大一小两个独立集群中。大的集群持有半数槽节点可以完成客观下线并广播fail消息但是小集群无法接收到fail消息如图所示。但是当网络恢复后只要故障节点变为客观下线最终总会通过Gossip消息传播至集群的所有节点。网络分区会导致分割后的小集群无法收到大集群的fail消息因此如果故障节点所有的从节点都在小集群内将导致无法完成后续故障转移因此部署主从结构时需要根据自身机房/机架拓扑结构降低主从被分区的可能性。2、故障恢复故障节点变为客观下线后如果下线节点是持有槽的主节点则需要在它的从节点中选出一个替换它从而保证集群的高可用。下线主节点的所有从节点承担故障恢复的义务当从节点通过内部定时任务发现自身复制的主节点进入客观下线时将会触发故障恢复流程如图所示。2.1、资格检查每个从节点都要检查最后与主节点断线时间判断是否有资格替换故障的主节点。如果从节点与主节点断线时间超过cluster-node-time*cluster-slave-validity-factor则当前从节点不具备故障转移资格。参数cluster-slave-validity-factor用于从节点的有效因子默认为10。2.2、准备选举时间当从节点符合故障转移资格后更新触发故障选举的时间只有到达该时间后才能执行后续流程。故障选举时间相关字段如下structclusterState{…mstime_tfailover_auth_time;/* 记录之前或者下次将要执行故障选举时间 */intfailover_auth_rank;/* 记录当前从节点排名 */}这里之所以采用延迟触发机制主要是通过对多个从节点使用不同的延迟选举时间来支持优先级问题。复制偏移量越大说明从节点延迟越低那么它应该具有更高的优先级来替换故障主节点。优先级计算伪代码如下defclusterGetSlaveRank():intrank0;// 获取从节点的主节点ClusteRNode mastermyself.slaveof;// 获取当前从节点复制偏移量longmyoffsetreplicationGetSlaveOffset();// 跟其他从节点复制偏移量对比for(intj0;jmaster.slaves.length;j):// rank表示当前从节点在所有从节点的复制偏移量排名为0表示偏移量最大.if(master.slaves[j]!myselfmaster.slaves[j].repl_offsetmyoffset):rank;returnrank;}使用之上的优先级排名更新选举触发时间伪代码如下defupdateFailoverTime():// 默认触发选举时间发现客观下线后一秒内执行。server.cluster.failover_auth_timenow()500random()%500;// 获取当前从节点排名intrankclusterGetSlaveRank();longadded_delayrank*1000;// 使用added_delay时间累加到failover_auth_time中server.cluster.failover_auth_timeadded_delay;// 更新当前从节点排名server.cluster.failover_auth_rankrank;所有的从节点中复制偏移量最大的将提前触发故障选举流程如图所示。主节点b进入客观下线后它的三个从节点根据自身复制偏移量设置延迟选举时间如复制偏移量最大的节点slave b-1延迟1秒执行保证复制延迟低的从节点优先发起选举。2.3、发起选举当从节点定时任务检测到达故障选举时间(failover_auth_time)到达后发起选举流程如下(1)更新配置纪元配置纪元是一个只增不减的整数每个主节点自身维护一个配置纪元(clusterNode.configEpoch)标示当前主节点的版本所有主节点的配置纪元都不相等从节点会复制主节点的配置纪元。整个集群又维护一个全局的配置纪元(clusterState.current Epoch)用于记录集群内所有主节点配置纪元的最大版本。执行cluster info命令可以查看配置纪元信息127.0.0.1:6379cluster info … cluster_current_epoch:15 // 整个集群最大配置纪元 cluster_my_epoch:13 // 当前主节点配置纪元配置纪元会跟随ping/pong消息在集群内传播当发送方与接收方都是主节点且配置纪元相等时代表出现了冲突nodeId更大的一方会递增全局配置纪元并赋值给当前节点来区分冲突伪代码如下def clusterHandleConfigEpochCollision(clusterNode sender):if(sender.configEpoch!myself.configEpoch||!nodeIsMaster(sender)||!nodeIsMaster(myself)):return;// 发送节点的nodeId小于自身节点nodeId时忽略if(sender.nodeIdmyself.nodeId):return// 更新全局和自身配置纪元 server.cluster.currentEpoch;myself.configEpochserver.cluster.currentEpoch;配置纪元的主要作用标示集群内每个主节点的不同版本和当前集群最大的版本。每次集群发生重要事件时这里的重要事件指出现新的主节点新加入的或者由从节点转换而来​从节点竞争选举。都会递增集群全局的配置纪元并赋值给相关主节点用于记录这一关键事件。主节点具有更大的配置纪元代表了更新的集群状态因此当节点间进行ping/pong消息交换时如出现slots等关键信息不一致时以配置纪元更大的一方为准防止过时的消息状态污染集群。配置纪元的应用场景有新节点加入。槽节点映射冲突检测。从节点投票选举冲突检测。之前在通过cluster setslot命令修改槽节点映射时需要确保执行请求的主节点本地配置纪元(configEpoch)是最大值否则修改后的槽信息在消息传播中不会被拥有更高的配置纪元的节点采纳。由于Gossip通信机制无法准确知道当前最大的配置纪元在哪个节点因此在槽迁移任务最后的cluster setslot{slot}node{nodeId}命令需要在全部主节点中执行一遍。从节点每次发起投票时都会自增集群的全局配置纪元并单独保存在clusterState.failover_auth_epoch变量中用于标识本次从节点发起选举的版本。(2)广播选举消息在集群内广播选举消息(FAILOVER_AUTH_REQUEST)并记录已发送过消息的状态保证该从节点在一个配置纪元内只能发起一次选举。消息内容如同ping消息只是将type类型变为FAILOVER_AUTH_REQUEST。2.4、选举投票只有持有槽的主节点才会处理故障选举消息(FAILOVER_AUTH_REQUEST)因为每个持有槽的节点在一个配置纪元内都有唯一的一张选票当接到第一个请求投票的从节点消息时回复FAILOVER_AUTH_ACK消息作为投票之后相同配置纪元内其他从节点的选举消息将忽略。投票过程其实是一个领导者选举的过程如集群内有N个持有槽的主节点代表有N张选票。由于在每个配置纪元内持有槽的主节点只能投票给一个从节点因此只能有一个从节点获得N/21的选票保证能够找出唯一的从节点。Redis集群没有直接使用从节点进行领导者选举主要因为从节点数必须大于等于3个才能保证凑够N/21个节点将导致从节点资源浪费。使用集群内所有持有槽的主节点进行领导者选举即使只有一个从节点也可以完成选举过程。当从节点收集到N/21个持有槽的主节点投票时从节点可以执行替换主节点操作例如集群内有5个持有槽的主节点主节点b故障后还有4个当其中一个从节点收集到3张投票时代表获得了足够的选票可以进行替换主节点操作如图所示。故障主节点也算在投票数内假设集群内节点规模是3主3从其中有2个主节点部署在一台机器上当这台机器宕机时由于从节点无法收集到3/21个主节点选票将导致故障转移失败。这个问题也适用于故障发现环节。因此部署集群时所有主节点最少需要部署在3台物理机上才能避免单点问题。投票作废每个配置纪元代表了一次选举周期如果在开始投票之后的cluster-node-timeout*2时间内从节点没有获取足够数量的投票则本次选举作废。从节点对配置纪元自增并发起下一轮投票直到选举成功为止。2.5、替换主节点当从节点收集到足够的选票之后触发替换主节点操作当前从节点取消复制变为主节点。执行clusterDelSlot操作撤销故障主节点负责的槽并执行clusterAddSlot把这些槽委派给自己。向集群广播自己的pong消息通知集群内所有的节点当前从节点变为主节点并接管了故障主节点的槽信息。3、故障转移时间在介绍完故障发现和恢复的流程后这时我们可以估算出故障转移时间主观下线(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秒。配置时可以根据业务容忍度做出适当调整但不是越小越好下一节的带宽消耗部分会进一步说明。4、故障转移演练到目前为止介绍了故障转移的主要细节下面通过之前搭建的集群模拟主节点故障场景对故障转移行为进行分析。使用kill-9强制关闭主节点6385进程如图所示。确认集群状态127.0.0.1:6379cluster nodes 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756127.0.0.1:6385 master -0147187756360016connected0-13655462-682610923-1228715018-16383 40622f9e7adc8ebd77fca0de9edfe691cb8a74fb127.0.0.1:6382 slave cfb28ef1deee4e0fa78da 86abe5d24566744411e0147187756460813connected 8e41673d59c9568aa9d29fb174ce733345b3e8f1127.0.0.1:6380 master -0147187756712911connected6827-1092213653-15017 475528b1bcf8e74d227104a6cf1bf70f00c24aae127.0.0.1:6386 slave 1a205dd8b2819a00dd1e8 b6be40a8e2abe77b7560147187756914516connected cfb28ef1deee4e0fa78da86abe5d24566744411e127.0.0.1:6379 myself,master -0013connected1366-546112288-13652 be9485a6a729fc98c5151374bc30277e89a461d8127.0.0.1:6383 slave 8e41673d59c9568aa9 d29fb174ce733345b3e8f10147187756813611connected强制关闭6385进程# ps -ef | grep redis-server | grep 638550113621010:500:11.65 redis-server *:6385[cluster]# kill -9 1362日志分析如下从节点6386与主节点6385复制中断日志如下redis-6386.log# Connection with master lost.* Caching the disconnected master state. * Connecting to MASTER127.0.0.1:6385 * MASTER-SLAVEsyncstarted# Error condition on socket for SYNC: Connection refused6379和6380两个主节点都标记6385为主观下线超过半数因此标记为客观下线状态打印如下日志redis-6380.log* Markingnode1a205dd8b2819a00dd1e8b6be40a8e2abe77b756 as failing(quorum reached).redis-6379.log* Markingnode1a205dd8b2819a00dd1e8b6be40a8e2abe77b756 as failing(quorum reached).从节点识别正在复制的主节点进入客观下线后准备选举时间日志打印了选举延迟964毫秒之后执行并打印当前从节点复制偏移量。redis-6386.log# Start of election delayed for 964 milliseconds (rank #0, offset 1822).延迟选举时间到达后从节点更新配置纪元并发起故障选举。redis-6386.log1364:S22Aug23:12:25.064# Starting a failover election for epoch 17.6379和6380主节点为从节点6386投票日志如下redis-6380.log# Failover auth granted to 475528b1bcf8e74d227104a6cf1bf70f00c24aae for epoch 17redis-6379.log# Failover auth granted to 475528b1bcf8e74d227104a6cf1bf70f00c24aae for epoch 17从节点获取2个主节点投票之后超过半数执行替换主节点操作从而完成故障转移redis-6386.log# Failover election won: Im the new master.# configEpoch set to 17 after successful failover成功完成故障转移之后我们对已经出现故障节点6385进行恢复观察节点状态是否正确1)重新启动故障节点6385。#redis-server conf/redis-6385.conf2)6385节点启动后发现自己负责的槽指派给另一个节点则以现有集群配置为准变为新主节点6386的从节点关键日志如下# I have keys for slot 4096, but the slot is assigned to another node. Setting it toimporting state.# Configuration change detected. Reconfiguring myself as a replica of 475528b1bcf8e74d227104a6cf1bf70f00c24aae3)集群内其他节点接收到6385发来的ping消息清空客观下线状态redis-6379.log* Clear FAIL statefornode1a205dd8b2819a00dd1e8b6be40a8e2abe77b756: master without slots is reachable again.redis-6380.log* Clear FAIL statefornode1a205dd8b2819a00dd1e8b6be40a8e2abe77b756: master without slots is reachable again.redis-6382.log* Clear FAIL statefornode1a205dd8b2819a00dd1e8b6be40a8e2abe77b756: master without slots is reachable again.redis-6383.log* Clear FAIL statefornode1a205dd8b2819a00dd1e8b6be40a8e2abe77b756: master without slots is reachable again.redis-6386.log* Clear FAIL statefornode1a205dd8b2819a00dd1e8b6be40a8e2abe77b756: master without slots is reachable again.4)6385节点变为从节点对主节点6386发起复制流程redis-6385.log* MASTER-SLAVE sync: Flushing old data * MASTER-SLAVE sync: Loading DBinmemory * MASTER-SLAVE sync: Finished with success5)最终集群状态如图所示。