发布时间:2023-11-09 13:00
Redis集群自身实现了高可用。高可用首先需要解决集群部分失败的场
景:当集群内少量节点出现故障时通过自动故障转移保证集群可以正常对外
提供服务。本节介绍故障转移的细节,分析故障发现和替换故障节点的过
程。
10.6.1 故障发现
当集群内某个节点出现问题时,需要通过一种健壮的方式保证识别出节
点是否发生了故障。Redis集群内节点通过ping/pong消息实现节点通信,消
息不但可以传播节点槽信息,还可以传播其他状态如:主从状态、节点故障
等。因此故障发现也是通过消息传播机制实现的,主要环节包括:主观下线
(pfail)和客观下线(fail)。
·主观下线:指某个节点认为另一个节点不可用,即下线状态,这个状
态并不是最终的故障判定,只能代表一个节点的意见,可能存在误判情况。
·客观下线:指标记一个节点真正的下线,集群内多个节点都认为该节
点不可用,从而达成共识的结果。如果是持有槽的主节点故障,需要为该节
点进行故障转移。
1.主观下线
集群中每个节点都会定期向其他节点发送ping消息,接收节点回复pong
消息作为响应。如果在cluster-node-timeout时间内通信一直失败,则发送节
点会认为接收节点存在故障,把接收节点标记为主观下线(pfail)状态。流
程如图10-34所示。
流程说明:
1)节点a发送ping消息给节点b,如果通信正常将接收到pong消息,节
点a更新最近一次与节点b的通信时间。
2)如果节点a与节点b通信出现问题则断开连接,下次会进行重连。如
果一直通信失败,则节点a记录的与节点b最后通信时间将无法更新。
3)节点a内的定时任务检测到与节点b最后通信时间超高cluster-node-
timeout时,更新本地对节点b的状态为主观下线(pfail)。
主观下线简单来讲就是,当cluster-note-timeout时间内某节点无法与另一
个节点顺利完成ping消息通信时,则将该节点标记为主观下线状态。每个节
点内的cluster State结构都需要保存其他节点信息,用于从自身视角判断其他
节点的状态。结构关键属性如下:
typedef struct clusterState {
clusterNode *myself; /* 自身节点 /
dict *nodes;/* 当前集群内所有节点的字典集合, key 为节点 ID , value 为对应节点 ClusterNode 结构 */
...
} clusterState; 字典 nodes 属性中的 clusterNode 结构保存了节点的状态 , 关键属性如下 :
typedef struct clusterNode {
int flags; /* 当前节点状态 , 如 : 主从角色,是否下线等 */
mstime_t ping_sent; /* 最后一次与该节点发送 ping 消息的时间 */
mstime_t pong_received; /* 最后一次接收到该节点 pong 消息的时间 */
...
} clusterNode;
其中最重要的属性是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 /* 该节点被选中为新的主节点状态 */
使用以上结构,主观下线判断伪代码如下:
// 定时任务 , 默认每秒执行 10 次
def clusterCron():
// ... 忽略其他代码
for(node in server.cluster.nodes):
// 忽略自身节点比较
if(node.flags == CLUSTER_NODE_MYSELF):
continue;
// 系统当前时间
long now = mstime();
// 自身节点最后一次与该节点 PING 通信的时间差
long delay = now - node.ping_sent;
// 如果通信时间差超过 cluster_node_timeout ,将该节点标记为 PFAIL (主观下线)
if (delay > server.cluster_node_timeout) :
node.flags = CLUSTER_NODE_PFAIL;
Redis集群对于节点最终是否故障判断非常严谨,只有一个节点认为主
观下线并不能准确判断是否故障。例如图10-35的场景。
节点6379与6385通信中断,导致6379判断6385为主观下线状态,但是
6380与6385节点之间通信正常,这种情况不能判定节点6385发生故障。因此
对于一个健壮的故障发现机制,需要集群内大多数节点都判断6385故障时,
才能认为6385确实发生故障,然后为6385节点进行故障转移。而这种多个节
点协作完成故障发现的过程叫做客观下线。
2.客观下线
当某个节点判断另一个节点主观下线后,相应的节点状态会跟随消息在
集群内传播。ping/pong消息的消息体会携带集群1/10的其他节点状态数据,
当接受节点发现消息体中含有主观下线的节点状态时,会在本地找到故障节
点的ClusterNode结构,保存到下线报告链表中。结构如下:
struct clusterNode { /* 认为是主观下线的 clusterNode 结构 */
list *fail_reports; /* 记录了所有其他节点对该节点的下线报告 */
...
};
通过Gossip消息传播,集群内节点不断收集到故障节点的下线报告。当
半数以上持有槽的主节点都标记某个节点是主观下线时。触发客观下线流
程。这里有两个问题:
1)为什么必须是负责槽的主节点参与故障发现决策?因为集群模式下
只有处理槽的主节点才负责读写请求和集群槽等关键信息维护,而从节点只
进行主节点数据和状态信息的复制。
2)为什么半数以上处理槽的主节点?必须半数以上是为了应对网络分
区等原因造成的集群分割情况,被分割的小集群因为无法完成从主观下线到
客观下线这一关键过程,从而防止小集群完成故障转移之后继续对外提供服
务。
假设节点a标记节点b为主观下线,一段时间后节点a通过消息把节点b的
状态发送到其他节点,当节点c接受到消息并解析出消息体含有节点b的pfail
状态时,会触发客观下线流程,如图10-36所示。
流程说明:
1)当消息体内含有其他节点的pfail状态会判断发送节点的状态,如果
发送节点是主节点则对报告的pfail状态处理,从节点则忽略。
2)找到pfail对应的节点结构,更新clusterNode内部下线报告链表。
3)根据更新后的下线报告链表告尝试进行客观下线。
这里针对维护下线报告和尝试客观下线逻辑进行详细说明。
(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状态,都会尝试触发客观下
线,流程如图10-37所示。
流程说明:
1)首先统计有效的下线报告数量,如果小于集群内持有槽的主节点总
数的一半则退出。
2)当下线报告大于槽主节点数量一半时,标记对应故障节点为客观下
线状态。
3)向集群广播一条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消息,如图10-38所示。
但是当网络恢复后,只要故障节点变为客观下线,最终总会通过Gossip
消息传播至集群的所有节点。
运维提示
网络分区会导致分割后的小集群无法收到大集群的fail消息,因此如果
故障节点所有的从节点都在小集群内将导致无法完成后续故障转移,因此部
署主从结构时需要根据自身机房/机架拓扑结构,降低主从被分区的可能
性。
10.6.2 故障恢复
故障节点变为客观下线后,如果下线节点是持有槽的主节点则需要在它
的从节点中选出一个替换它,从而保证集群的高可用。下线主节点的所有从
节点承担故障恢复的义务,当从节点通过内部定时任务发现自身复制的主节
点进入客观下线时,将会触发故障恢复流程,如图10-39所示。
1.资格检查
每个从节点都要检查最后与主节点断线时间,判断是否有资格替换故障
的主节点。如果从节点与主节点断线时间超过cluster-node-time*cluster-slave-
660
validity-factor,则当前从节点不具备故障转移资格。参数cluster-slave-
validity-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;
所有的从节点中复制偏移量最大的将提前触发故障选举流程,如图10-
40所示。
主节点b进入客观下线后,它的三个从节点根据自身复制偏移量设置延
迟选举时间,如复制偏移量最大的节点slave b-1延迟1秒执行,保证复制延
迟低的从节点优先发起选举。
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) || !nodeIsMaster
(myself)) :
return;
// 发送节点的 nodeId 小于自身节点 nodeId 时忽略
if (sender.nodeId <= myself.nodeId):
return
// 更新全局和自身配置纪元
server.cluster.currentEpoch++;
myself.configEpoch = server.cluster.currentEpoch;
配置纪元的主要作用:
·标示集群内每个主节点的不同版本和当前集群最大的版本。
·每次集群发生重要事件时,这里的重要事件指出现新的主节点(新加
入的或者由从节点转换而来),从节点竞争选举。都会递增集群全局的配置
纪元并赋值给相关主节点,用于记录这一关键事件。
·主节点具有更大的配置纪元代表了更新的集群状态,因此当节点间进
663
行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。
4.选举投票
只有持有槽的主节点才会处理故障选举消息
(FAILOVER_AUTH_REQUEST),因为每个持有槽的节点在一个配置纪元
内都有唯一的一张选票,当接到第一个请求投票的从节点消息时回复
FAILOVER_AUTH_ACK消息作为投票,之后相同配置纪元内其他从节点的
选举消息将忽略。
投票过程其实是一个领导者选举的过程,如集群内有N个持有槽的主节
点代表有N张选票。由于在每个配置纪元内持有槽的主节点只能投票给一个
从节点,因此只能有一个从节点获得N/2+1的选票,保证能够找出唯一的从
节点。
Redis集群没有直接使用从节点进行领导者选举,主要因为从节点数必
须大于等于3个才能保证凑够N/2+1个节点,将导致从节点资源浪费。使用
集群内所有持有槽的主节点进行领导者选举,即使只有一个从节点也可以完
成选举过程。
当从节点收集到N/2+1个持有槽的主节点投票时,从节点可以执行替换
主节点操作,例如集群内有5个持有槽的主节点,主节点b故障后还有4个,
当其中一个从节点收集到3张投票时代表获得了足够的选票可以进行替换主
节点操作,如图10-41所示。
运维提示
故障主节点也算在投票数内,假设集群内节点规模是3主3从,其中有2
个主节点部署在一台机器上,当这台机器宕机时,由于从节点无法收集到
3/2+1个主节点选票将导致故障转移失败。这个问题也适用于故障发现环
节。因此部署集群时所有主节点最少需要部署在3台物理机上才能避免单点
问题。
投票作废:每个配置纪元代表了一次选举周期,如果在开始投票之后的
cluster-node-timeout*2时间内从节点没有获取足够数量的投票,则本次选举
作废。从节点对配置纪元自增并发起下一轮投票,直到选举成功为止。
5.替换主节点
当从节点收集到足够的选票之后,触发替换主节点操作:
1)当前从节点取消复制变为主节点。
2)执行clusterDelSlot操作撤销故障主节点负责的槽,并执行
clusterAddSlot把这些槽委派给自己。
3)向集群广播自己的pong消息,通知集群内所有的节点当前从节点变
为主节点并接管了故障主节点的槽信息。
10.6.3 故障转移时间
在介绍完故障发现和恢复的流程后,这时我们可以估算出故障转移时
间:
1)主观下线(pfail)识别时间=cluster-node-timeout。
2)主观下线状态消息传播时间<=cluster-node-timeout/2。消息通信机制
对超过cluster-node-timeout/2未通信节点会发起ping消息,消息体在选择包含
哪些节点时会优先选取下线状态节点,所以通常这段时间内能够收集到半数
以上主节点的pfail报告从而完成故障发现。
3)从节点转移时间<=1000毫秒。由于存在延迟发起选举机制,偏移量
最大的从节点会最多延迟1秒发起选举。通常第一次选举就会成功,所以从
节点执行转移时间在1秒以内。
根据以上分析可以预估出故障转移时间,如下:
failover-time( 毫秒 ) ≤ cluster-node-timeout + cluster-node-timeout/2 + 1000
因此,故障转移时间跟cluster-node-timeout参数息息相关,默认15秒。
配置时可以根据业务容忍度做出适当调整,但不是越小越好,下一节的带宽
消耗部分会进一步说明。
10.6.4 故障转移演练
到目前为止介绍了故障转移的主要细节,下面通过之前搭建的集群模拟
主节点故障场景,对故障转移行为进行分析。使用kill-9强制关闭主节点
6385进程,如图10-42所示。
确认集群状态:
127.0.0.1:6379> cluster nodes
1a205dd8b2819a00dd1e8b6be40a8e2abe77b756 127.0.0.1:6385 master - 0 1471877563600 16
connected 0-1365 5462-6826 10923-12287 15018-16383
40622f9e7adc8ebd77fca0de9edfe691cb8a74fb 127.0.0.1:6382 slave cfb28ef1deee4e0fa78da
86abe5d24566744411e 0 1471877564608 13 connected
8e41673d59c9568aa9d29fb174ce733345b3e8f1 127.0.0.1:6380 master - 0 1471877567129 11
connected 6827-10922 13653-15017
475528b1bcf8e74d227104a6cf1bf70f00c24aae 127.0.0.1:6386 slave 1a205dd8b2819a00dd1e8
b6be40a8e2abe77b756 0 1471877569145 16 connected
cfb28ef1deee4e0fa78da86abe5d24566744411e 127.0.0.1:6379 myself,master - 0 0 13
connected 1366-5461 12288-13652
be9485a6a729fc98c5151374bc30277e89a461d8 127.0.0.1:6383 slave 8e41673d59c9568aa9
d29fb174ce733345b3e8f1 0 1471877568136 11 connected
强制关闭6385进程:
# ps -ef | grep redis-server | grep 6385
501 1362 1 0 10:50 0: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 MASTER 127.0.0.1:6385
* MASTER <-> SLAVE sync started
# Error condition on socket for SYNC: Connection refused
·6379和6380两个主节点都标记6385为主观下线,超过半数因此标记为
客观下线状态,打印如下日志:
==> redis-6380.log <==
* Marking node 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756 as failing (quorum reached).
==> redis-6379.log <==
* Marking node 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756 as failing (quorum reached).
·从节点识别正在复制的主节点进入客观下线后准备选举时间,日志打
印了选举延迟964毫秒之后执行,并打印当前从节点复制偏移量。
==> redis-6386.log <==
# Start of election delayed for 964 milliseconds (rank #0, offset 1822).
·延迟选举时间到达后,从节点更新配置纪元并发起故障选举。
==> redis-6386.log <==
1364:S 22 Aug 23:12:25.064 # Starting a failover election for epoch 17.
·6379和6380主节点为从节点6386投票,日志如下:
670
==> redis-6380.log <==
# Failover auth granted to 475528b1bcf8e74d227104a6cf1bf70f00c24aae for epoch 17
==> redis-6379.log <==
# Failover auth granted to 475528b1bcf8e74d227104a6cf1bf70f00c24aae for epoch 17
·从节点获取2个主节点投票之后,超过半数执行替换主节点操作,从而
完成故障转移:
==> redis-6386.log <==
# Failover election won: I'm the new master.
# configEpoch set to 17 after successful failover
成功完成故障转移之后,我们对已经出现故障节点6385进行恢复,观察
节点状态是否正确:
1)重新启动故障节点6385。
#redis-server conf/redis-6385.conf
2)6385节点启动后发现自己负责的槽指派给另一个节点,则以现有集
群配置为准,变为新主节点6386的从节点,关键日志如下:
# I have keys for slot 4096, but the slot is assigned to another node. Setting it to
importing state.
# Configuration change detected. Reconfiguring myself as a replica of 475528b1bcf
8e74d227104a6cf1bf70f00c24aae
3)集群内其他节点接收到6385发来的ping消息,清空客观下线状态:
==> redis-6379.log <==
* Clear FAIL state for node 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756: master without
slots is reachable again.
==> redis-6380.log <==
* Clear FAIL state for node 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756: master without
slots is reachable again.
==> redis-6382.log <==
* Clear FAIL state for node 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756: master without
slots is reachable again.
==> redis-6383.log <==
* Clear FAIL state for node 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756: master without
slots is reachable again.
==> redis-6386.log <==
* Clear FAIL state for node 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756: master without
slots is reachable again.
4)6385节点变为从节点,对主节点6386发起复制流程:
==> redis-6385.log <==
* MASTER <-> SLAVE sync: Flushing old data
* MASTER <-> SLAVE sync: Loading DB in memory
* MASTER <-> SLAVE sync: Finished with success
5)最终集群状态如图10-43所示。