深入剖析 Redis 高可用解决方案:哨兵、集群
前言
哨兵和集群的两种高可用解决方案,但是两者在保证高可用上的实现基本是一致的,因为集群模式的高可用解决方案基本就是“照搬”哨兵模式的。
集群可以认为就是用来代替哨兵的,解决哨兵存在的一些问题,同时提供更优秀的特性。
因为现在基本不会使用到哨兵模式,哨兵模式可以说基本只存在于面试中,同时由于哨兵的内容在集群中都有类似的,所以本文对哨兵的介绍会比较简单。
正文
哨兵是什么
哨兵(Sentinel) 是 Redis 的高可用性解决方案:由一个或多个 Sentinel 实例组成的 Sentinel 系统可以监视任意多个主服务器,以及这些主服务器属下的所有从服务器。
Sentinel 可以在被监视的主服务器进入下线状态时,自动将下线主服务器的某个从服务器升级为新的主服务器,然后由新的主服务器代替已下线的主服务器继续处理命令请求。
哨兵故障检测
检查主观下线状态
在默认情况下,Sentinel 会以每秒一次的频率向所有与它创建了命令连接的实例(包括主服务器、从服务器、其他 Sentinel 在内)发送 PING 命令,并通过实例返回的 PING 命令回复来判断实例是否在线。
如果一个实例在 down-after-miliseconds 毫秒内,连续向 Sentinel 返回无效回复,那么 Sentinel 会修改这个实例所对应的实例结构,在结构的 flags 属性中设置 SRI_S_DOWN 标识,以此来表示这个实例已经进入主观下线状态。
检查客观下线状态
当 Sentinel 将一个主服务器判断为主观下线之后,为了确定这个主服务器是否真的下线了,它会向同样监视这一服务器的其他 Sentinel 进行询问,看它们是否也认为主服务器已经进入了下线状态(可以是主观下线或者客观下线)。
当 Sentinel 从其他 Sentinel 那里接收到足够数量(quorum,可配置)的已下线判断之后,Sentinel 就会将服务器置为客观下线,在 flags 上打上 SRI_O_DOWN 标识,并对主服务器执行故障转移操作。
哨兵故障转移流程
当哨兵监测到某个主节点客观下线之后,就会开始故障转移流程。核心流程如下:
1、发起一次选举,选举出领头 Sentinel
2、领头 Sentinel 在已下线主服务器的所有从服务器里面,挑选出一个从服务器,并将其升级为新的主服务器。
3、领头 Sentinel 将剩余的所有从服务器改为复制新的主服务器。
4、领头 Sentinel 更新相关配置信息,当这个旧的主服务器重新上线时,将其设置为新的主服务器的从服务器。
选举领头哨兵
当一个主服务器被判断为客观下线时,监视这个下线主服务器的各个 Sentinel 会进行协商,选举出一个领头 Sentinel,并由领头 Sentinel 对下线主服务器执行故障转移操作。Redis 选举领头 Sentinei 的流程如下:
1、当 Sentinel 发现自己监视的主服务器进入客观下线时,会发起一次选举:将 current_epoch(集群纪元)加1,向其他监视该 master 的 Sentinel 发送拉票命令:SENTINEL is-master-down-by-addr,要求目标 Sentinel 将选票投给自己。
2、目标 Sentinel 在接收到 SENTINEL is-master-down-by-addr 命令之后,会判断自己是否已经在本届选举投过票,如果没有则会将选票投给源 Sentinel,最后回复 leader 和 leader_epoch,代表自己所投的局部领头 Sentinel 的运行ID和配置纪元。
3、源 Sentinel 收到回复后,会将目标 Sentinel 的投票信息记录下来,用于后续统计。
4、Sentinel 中同样有自己的时间事件会被定期触发,当 Sentinel 状态为:SENTINEL_FAILOVER_STATE_WAIT_START,会触发选举的投票结果统计。如果某个 Sentinel 获得超过半数以上的选票(>=voters/2+1),而且票数要大于等于 quorum,那么这个 Sentinel 将成为领头 Sentinel 。
因为领头 Sentinel 的产生需要半数以上 Sentinel 的支持,并且每个 Sentinel 在每个配置纪元里面只能投一次票 ,所以在一个配置纪元里面,只会出现一个领头 Sentinel 。
5、如果在一个配置纪元里没有一个 Sentinel 被选举为领头 Sentinel ,那么各个 Sentinel 将在一段时间之后再次进行选举,直到选出领头 Sentinel 为止。
哨兵选举主服务器
1、领头 Sentinel 会遍历已下线主节点的所有从节点,留下状态好的节点,过滤掉状态不好的节点,过滤规则主要有以下几个:
从节点状态为主观下线或客观下线
从节点链接断开
上次收到该从节点的正常 PING 回复超过5秒(5 * SENTINEL_PING_PERIOD)
从节点的优先级为0
上一次收到该从节点对于 INFO 的回复时间超过允许的时间,master 为主观下线则为5秒,否则为30秒。这是因为在 master 为主观下线后,Sentinel 会更频繁的向从节点发送 INFO 命令,因为需要更实时的获取从节点的状态。
从节点与已下线的主节点链接断开时间超过 down-after-miliseconds 配置的 10 倍
2、对剩下的状态好的节点进行排序,状态越好的排在越前面,排序规则如下:
比较优先级,优先级值(slave-priority)较小的排在前面,跟 Spring 里的 Order 有点类似,也是小的排前面。
如果优先级相同,比较复制偏移量,复制偏移量较大的排前面。
如果优先级和复制偏移量相同,比较运行ID,运行ID小的排前面。
3、最终,排序后的第一个从节点会当选为新的主节点
集群模式
哨兵模式最大的缺点就是所有的数据都放在一台服务器上,无法较好的进行水平扩展。
为了解决哨兵模式存在的问题,集群模式应运而生。在高可用上,集群基本是直接复用的哨兵模式的逻辑,并且针对水平扩展进行了优化。
集群模式具备的特点如下:
1、采取去中心化的集群模式,将数据按槽存储分布在多个 Redis 节点上。集群共有 16384 个槽,每个节点负责处理部分槽。
2、使用 CRC16 算法来计算 key 所属的槽:crc16(key,keylen) & 16383。
3、所有的 Redis 节点彼此互联,通过 PING-PONG 机制来进行节点间的心跳检测。
4、分片内采用一主多从保证高可用,并提供复制和故障恢复功能。在实际使用中,通常会将主从分布在不同机房,避免机房出现故障导致整个分片出问题,下面的架构图就是这样设计的。
5、客户端与 Redis 节点直连,不需要中间代理层(proxy)。客户端不需要连接集群所有节点,连接集群中任何一个可用节点即可。
集群的架构图如下所示:
Redis 集群目标
1、高性能:没有代理层(proxy),采用异步复制,不对值进行合并操作。
2、水平扩展:集群共有 16384 个槽,每个节点负责处理部分槽,可以支持线性扩展至 1000 个节点。
3、写安全性:系统尝试(尽最大努力)保留来自与大多数主节点连接的客户机的所有写操作。但是由于使用异步复制,所以可能会丢失一些写命令。
4、可用性:集群模式下,Redis 能够自动进行故障检测、master 选举、故障转移。
MOVED错误
通过上面的介绍和架构图,我们知道客户端只会连接到某个节点上,但是该节点只负责部分槽,万一客户端请求的是其他槽怎么办?
Redis 引入 MOVED 错误来解决这个问题。
当客户端向节点发送与数据库键有关的命令时,接收命令的节点会计算出要处理的数据库键属于哪个槽,并检查这个槽是否是自己负责的。
如果键所在的槽正好是自己负责,那么节点直接执行这个命令。
否则,节点会向客户端返回一个 MOVED 错误,该命令可以指引客户端转向(redirect)正确的节点,并再次发送之前想要执行的命令,得到正确的结果。
集群的主从复制
集群的每个分片中使用了主从复制来保证高可用,这边的主从复制逻辑也是直接复用的之前的主从复制模式的逻辑。
纪元(epoch)
Redis 集群中使用了类似于 Raft 算法 term(任期)的概念称为 epoch(纪元),用来给事件增加版本号。
Redis 集群中的纪元主要是两种:currentEpoch 和 configEpoch。
currentEpoch
集群当前的配置纪元,这是一个集群状态相关的概念,可以当做记录集群状态变更的递增版本号。
currentEpoch 作用在于,当集群的状态发生改变,某个节点为了执行一些动作需要寻求其他节点的同意时,就会增加 currentEpoch 的值,例如故障转移流程。
当从节点 A 发现其所属的主节点下线时,就会试图发起故障转移流程。首先就是增加 currentEpoch 的值,这个增加后的 currentEpoch 是所有集群节点中最大的。然后从节点A向所有节点发包用于拉票,请求其他主节点投票给自己,使自己能成为新的主节点。
其他节点收到包后,发现发送者的 currentEpoch 比自己的 currentEpoch 大,就会更新自己的 currentEpoch,并在尚未投票的情况下,投票给从节点 A,表示同意使其成为新的主节点。
configEpoch
节点当前的配置纪元,这是一个集群节点配置相关的概念,每个集群节点都有自己独一无二的 configepoch。所谓的节点配置,实际上是指节点所负责的槽位信息。
每一个 master 在向其他节点发送消息时,都会附带其 configEpoch 信息,以及一份表示它所负责的 slots 信息。
节点收到消息之后,就会根据消息中的 configEpoch 和负责的 slots 信息,记录到相应节点属性中。这边有两种情况:
1)如果该消息中的 slots 在当前节点中被记录为还未有节点负责,那可以直接指定为发送消息的节点。
2)如果消息中的 slots 在当前节点已经被记录为有节点负责,这种情况相当于有多个节点都宣称他负责了某个 slot,那怎么处理了?
这时候就要用到 configEpoch,configEpoch 更大的说明是更新的配置,当前节点会使用 configEpoch 更大的配置。
多个节点宣称负责同一个 slot 最常见的场景就是故障转移之后。当故障的主节点重新连接时,他会向集群其他节点发送消息,会带上自己故障前负责的 slots 信息,当其他节点收到后判断该节点的 configEpoch 更小,知道是旧的配置信息,则不会进行更新。
节点的 configEpoch 会在自己当选为新的主节点的时候,更新为集群当前选举的纪元,其实也就是 currentEpoch 的值。
因为每一次选举只会有一个从节点当选为新的主节点,所以该从节点的 configEpoch 会是当前所有集群节点 configEpoch 中的最大值。这样,该从节点成为主节点后,就会向所有节点发送广播包,强制其他节点更新相关槽位的负责节点为自己。
集群故障检测
本节与哨兵的故障核心思想是相同的。
集群中的每个节点都会定期地向集群中的其他节点发送 PING 消息,以此来检测对方是否在线。
集群节点间互相发送 PING 检测的时机,目前看主要有以下两个:
1、每秒执行1次:随机检查5个节点,选出最早收到 PONG 回复的节点,也就是最久没有通信过的节点,发送 PING 消息。
2、每100毫秒执行1次:轮询集群的节点,对于那些链接正常的节点,如果上一次收到该节点的 PONG 回复时间距离现在已经超过集群超时时间的一半(server.cluster_node_timeout/2),则直接向该节点发送 PING。
如果接收 PING 消息的节点在规定的时间内(cluster_node_timeout,默认15秒),没有向发送 PING 消息的节点返回 PONG 回复或者发送其他任何消息,那么发送 PING 消息的节点就会将接收 PING 消息的节点标记为疑似下线(probable failure,PFAIL)。
这边 Redis 没有将 PONG 回复作为目标节点存活的唯一证明,而是将目标节点的任何消息都作为存活的证明。这是因为在集群负载较高的时候,收到 PONG 回复可能会出现延迟。
集群中的各个节点在向其他节点发送 PING(PONG、MEET)消息的时候,会附加上 Gossip(八卦)消息,Gossip 消息记录了集群中其他节点的状态信息,例如某个节点是处于在线状态、疑似下线状态(PFAIL),还是已下线状态(FAIL)。
Gossip 消息包含两部分:
1)正常节点的状态信息:随机选择集群节点数的 1/10,但是不能小于3,除非目标集群节点中正常的数量已经小于3。
2)被标记为 PFAIL 的节点信息会被全部添加到 Gossip 消息中。
当主节点 A 通过 Gossip 消息得知主节点 B 认为主节点 C 进入了 PFAIL 或 FAIL 状态时,主节点 A 会在自己的 clusterState.nodes 字典中找到主节点 C 对应的 clusterNode 结构,并将主节点 C 的故障报告(failure report)添加到 clusterNode 结构的 fail_reports 链表中。
这样,主节点 A 就可以通过主节点 C 的 clusterNode->fail_reports 链表快速计算出有多少个节点将主节点 C 标记为 PFAIL 状态。
当主节点 A 为主节点 C 新增故障报告的时候,会顺带检查是否需要将主节点 C 标记为 FAIL,如果通过 fail_reports 链表发现主节点 C 被半数以上负责处理槽的主节点标记为疑似下线(PFAIL),则会进一步将主节点 C 标记为已下线(FAIL),同时向集群广播 “主节点 C 已经 FAIL 的消息”,所有收到消息的节点都会立即将主节点 C 标记为已下线。
集群故障转移
当 slave 发现自己正在复制的 master 进入了已下线(FAIL)状态,slave 会对下线的 master 进行故障转移,以下是故障转移的执行步骤:
1、发起一次选举,该下线的 master 的所有 slave 里面,会有一个 slave 被选中。
2、被选中的 slave 会升级为新的 master,清除 slave 相关的信息:slave 标记位等。
3、新的 master 会撤销所有对已下线 master 的槽指派,并将这些槽全部指派给自己。
4、新的 master 向集群广播一条 PONG 消息,这条 PONG 消息可以让集群中的其他节点立即知道这个节点已经由 slave 变成了 master ,并且这个新的 master 已经接管了原本由已下线节点负责处理的槽。集群中的其他节点收到消息后会更新自己保存的相关配置信息。
5、新的 master 开始接收和自己负责处理的槽有关的命令请求,故障转移完成。
集群选举
故障转移的第一步就是选举出新的主节点,以下是集群选举新的主节点的方法:
1、当从节点发现自己正在复制的主节点进入已下线状态时,会发起一次选举:将 currentEpoch 加1,然后向集群广播一条 CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST 消息,要求所有收到这条消息、并且具有投票权的主节点向这个从节点投票。
2、其他节点收到消息后,会判断是否要给发送消息的节点投票,判断流程如下:
当前节点是 slave,或者当前节点是 master,但是不负责处理槽,则当前节点没有投票权,直接返回。
请求节点的 currentEpoch 小于当前节点的 currentEpoch,校验失败返回。因为发送者的状态与当前集群状态不一致,可能是长时间下线的节点刚刚上线,这种情况下,直接返回即可。
当前节点在该 currentEpoch 已经投过票,校验失败返回。
请求节点是 master,校验失败返回。
请求节点的 master 为空,校验失败返回。
请求节点的 master 没有故障,并且不是手动故障转移,校验失败返回。因为手动故障转移是可以在 master 正常的情况下直接发起的。
上一次为该master的投票时间,在cluster_node_timeout的2倍范围内,校验失败返回。这个用于使获胜从节点有时间将其成为新主节点的消息通知给其他从节点,从而避免另一个从节点发起新一轮选举又进行一次没必要的故障转移
请求节点宣称要负责的槽位,是否比之前负责这些槽位的节点,具有相等或更大的 configEpoch,如果不是,校验失败返回。
如果通过以上所有校验,那么主节点将向要求投票的从节点返回一条 CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK 消息,表示这个主节点支持从节点成为新的主节点。
3、每个参与选举的从节点都会接收 CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK 消息,并根据自己收到了多少条这种消息来统计自己获得了多少个主节点的支持。
4、如果集群里有N个具有投票权的主节点,那么当一个从节点收集到大于等于N/2+1 张支持票时,这个从节点就会当选为新的主节点。因为在每一个配置纪元里面,每个具有投票权的主节点只能投一次票,所以如果有 N个主节点进行投票,那么具有大于等于 N/2+1 张支持票的从节点只会有一个,这确保了新的主节点只会有一个。
5、如果在一个配置纪元里面没有从节点能收集到足够多的支持票,那么集群进入一个新的配置纪元,并再次进行选举,直到选出新的主节点为止。
这个选举新主节点的方法和选举领头 Sentinel 的方法非常相似,因为两者都是基于 Raft 算法的领头选举(leader election)方法来实现的。
如何保证集群在线扩容的安全性?
例如:集群已经对外提供服务,原来有3分片,准备新增2个分片,怎么在不下线的情况下,无损的从原有的3个分片指派若干个槽给这2个分片?
Redis 使用了 ASK 错误来保证在线扩容的安全性。
在槽的迁移过程中若有客户端访问,依旧先访问源节点,源节点会先在自己的数据库里面査找指定的键,如果找到的话,就直接执行客户端发送的命令。
如果没找到,说明该键可能已经被迁移到目标节点了,源节点将向客户端返回一个 ASK 错误,该错误会指引客户端转向正在导入槽的目标节点,并再次发送之前想要执行的命令,从而获取到结果。
ASK错误
在进行重新分片期间,源节点向目标节点迁移一个槽的过程中,可能会出现这样一种情况:属于被迁移槽的一部分键值对保存在源节点里面,而另一部分键值对则保存在目标节点里面。
当客户端向源节点发送一个与数据库键有关的命令,并且命令要处理的数据库键恰好就属于正在被迁移的槽时。源节点会先在自己的数据库里面査找指定的键,如果找到的话,就直接执行客户端发送的命令。
否则,这个键有可能已经被迁移到了目标节点,源节点将向客户端返回一个 ASK 错误,指引客户端转向正在导入槽的目标节点,并再次发送之前想要执行的命令,从而获取到结果。
MOVED和ASK的区别
从上面的介绍来看 MOVED 错误和 ASK 错误非常类似,都起到重定向客户端的效果,他们有什么区别?能否合并成一个?
MOVED 错误代表槽位的负责权已经从一个节点转移到了另一个节点:在客户端收到关于槽位 k 的MOVED 错误之后,会更新槽位 k 及其负责节点的对应关系,这样下次遇到关于槽位 k 的命令请求时,就可以直接将命令请求发送新的负责节点。
ASK 错误只是两个节点在迁移槽的过程中使用的一种临时措施:客户端收到关于槽位 k 的 ASK 错误之后,客户端只会在接下来的一次命令请求中将关于槽位 k 的命令请求发送至 ASK 错误所指示的节点,但这种重定向不会对客户端今后发送关于槽位 k 的命令请求产生任何影响,客户端之后仍然会将关于槽位 k 的命令请求发送至目前负责处理 k 槽位的节点,除非 ASK 错误再次出现。
总结就是:
1)ASK 是一种迁移槽临时措施,只是会产生一次重定向
2)MOVED 代表该槽已经完全由另一个节点负责了,会触发客户端刷新本地路由表,之后对于该槽的请求都会请求新的节点。
这边提到的本地路由表是该集群的插槽和负责处理该槽的节点地址的映射,通过该路由表客户端可以在大部分情况下都直接请求到正确的节点,而无需重定向,从而提升性能。
数据丢失场景:脑裂(split-brain)
脑裂是导致 Redis 产生数据丢失比较常见的场景。如下例子:
集群有3个节点,每个节点采用1主2从,如下图所示,红色圈代表出现了网络分区故障。
当主节点 A 与集群中其他节点出现网络分区故障时,此时集群会分为2个分区,“少数派”:节点 A;多数派:节点 A1、A2、B、B1、B2、C、C1、C2。
由于集群节点之间的故障检测需要一定时间,通常是 cluster_node_timeout,因此在 cluster_node_timeout 期间内 Client1 仍然可以向节点 A 发出写命令,但此时由于网络分区节点 A 已经无法通过异步复制将命令传播到节点 A1 和 A2。
如果节点 A 在 cluster_node_timeout 内仍然无法恢复,则集群 “多数派” 这边会发起故障转移,选择 A1 和 A2 中的一个升级为新的 master,对外提供服务。
与此同时,节点 A 所在的 “少数派” 由于在 cluster_node_timeout 内无法检测到与其他节点的心跳,此时也会开始拒绝对外提供服务。
当网络分区故障恢复后,由于新 master 拥有更高的配置纪元,此时节点 A 会被降级为 slave,清空自身数据,然后复制新的 master。此时,节点 A 在网络分区故障期间处理的写命令就全部丢失了。
最后
当你的才华还撑不起你的野心的时候,你就应该静下心来学习,愿你在我这里能有所收获。
原创不易,如果你觉得本文写的还不错,对你有帮助,请通过【点赞】让我知道,支持我写出更好的文章。
推荐阅读