消息队列的高可用与容灾设计
你好,我是李玥。
上节课我们通过学习 Kafka 创建主题的过程,掌握了消息队列控制面配置下发的设计方法。
这节课我们继续学习消息队列控制面的另外一个重要功能:高可用。学习一下常见的消息队列都是如何实现高可用架构的,进一步的,在理解高可用架构的实现原理基础上,看看如何基于高可用能力来设计消息队列的容灾架构。
高可用和容灾的区别是什么?
广义的高可用(High Availability, HA)是指系统在长时间运行过程中,能够持续提供服务的能力。高可用系统的设计目标是尽量减少系统的停机时间,确保系统在遇到硬件故障、软件错误或其他意外情况时,仍能正常运行或快速恢复。
在分布式系统设计领域,可以将高可用理解为:“自动抵御小规模故障的能力”。通俗地说,系统应具备这样一种能力:当少量节点故障时,系统仍然持续可用或者能快速恢复。
这里的“少量”是多少呢?一般来说要看系统的规模,几个节点的系统可以容忍一两个节点故障,更大规模的系统可以容忍稍多一些的节点故障。
在设计一个系统的高可用和容灾架构时,有两个重要的衡量指标:RTO 和 RPO。
RTO 指的是系统可容忍的故障时长目标,RPO 就是系统可容忍的数据丢失数量目标。
具体来说,RTO (Recovery Time Objective,复原时间目标)指的是:故障发生后,从系统宕机导致服务不可用之时开始,到系统恢复至可以正常提供服务之时,这两个时间点之间的时间段。
比如,当系统中某个节点宕机时,设计的恢复时间不超过 10 秒,那这个 RTO 就是 10 秒;再比如说大规模故障发生后半天内便需要恢复,RTO 值就是十二小时。
RPO (Recovery Point Objective,复原点目标)是指对系统的数据而言,要实现能够恢复至可以正常提供服务时,可接受的数据恢复点。
相比于 RTO,RPO 不是太好理解。它是一个衡量系统故障时丢失多少数据的指标,但是它的单位却是时间,可以理解为“最多可以丢失多长时间的数据”。
比如,对于一个单机的数据库系统,每天凌晨零时进行备份一次。如果数据库宕机,当使用备份的数据恢复服务后,系统内储存的数据只会是宕机发生前那个凌晨零时的备份,那么 RPO 的值就是 24 小时。
需要注意的是,RPO 的时长是不包含系统不可用的这段时间的时长的,因为系统不可用的这段时间不产生新的数据,也就不存在丢失数据的问题。
对于 RPO 中定义的数据丢失,指的是在系统可用时本来已经完成正常更新,因为故障又丢失了的那部分数据。所以,你会看到有些系统它的 RTO 不等于 0,但 RPO 却等于 0,这是完全正常的。这表示,出现故障时,系统可能需要一段时间来恢复,但可以保证不会丢失数据。
大多数现代的消息队列的高可用能力,RTO 和 RPO 通常为“0~几秒钟”。也就是说,当系统故障时,最多几秒钟就会自动恢复,最多丢失几秒钟的数据。
容灾指的是系统面对大规模的故障时的恢复能力。
这里的大规模故障指的是系统同时失去大量节点,比如,因为网络、电力或空调系统故障而导致的数据中心内大规模宕机,或者由于地震等自然灾害或城市间的骨干网络故障而导致的城市级大面积故障。
容灾架构按照可抵御的故障规模,又分为同城容灾和异地容灾。同城容灾架构可以抵御 AZ 级故障,也就是说在失去一个机房时,系统仍然可以从故障中恢复。
异地容灾则可以抵御城市级故障。对于大规模系统来说,受限于成本以及性能等因素,容灾架构的 RTO 和 RPO 很难做到秒级,一般来说能做到几分钟至几十分钟已经是很优秀的水平了。
总结一下,高可用是指系统抵御小规模故障的能力,RTO 和 RPO 可以做到秒级。容灾是指系统抵御 AZ 或城市级大规模故障的能力,如果系统规模较大,RTO 和 RPO 通常只能分钟级。
消息队列的高可用设计原理
接下来,我们以 Kafka 为例来看一下,消息队列是如何实现其高可用架构的。
首先来看控制面,Kafka 的控制面包括:Controller、ZooKeeper 和 Coordinator 三部分。
其中 ZooKeeper 是一个外部系统,它有自己的高可用设计,这节课我们主要关注消息队列的高可用设计,因此就不再深入进 ZooKeeper 内部了。
我们需要了解的是,ZooKeeper是一个强一致的分布式协调系统,它的高可用能力能保证秒级的 RTO 和 0RPO。由于 Kafka 控制面使用的主题、分区 Broker 等全部的元数据都存储在 ZooKeeper中,得益于 ZooKeeper 的能力,Kafka 控制面的 RPO 也是 0。
Controller 是控制面最核心的模块,负责 Kafka 集群大部分的控制面的功能。Controller 的高可用主要是通过 ZooKeeper 的选举能力实现的,Kafka 集群中的每个 Broker 节点都会在 ZooKeeper 中注册自己,表示它们可以作为 Controller 候选者。
所有 Broker 节点在 ZooKeeper 的特定路径下创建一个临时顺序节点,ZooKeeper会根据节点的顺序号来选举出一个最小顺序号的节点作为 Controller。
如果当前的 Controller 节点发生故障,它在 ZooKeeper 中的临时节点会被自动删除,触发重新选举过程。剩余的 Broker 节点再次在 ZooKeeper 中进行选举,选出新的 Controller 节点,确保 Kafka 集群的控制面功能不中断。
Coordinator 的功能是管理 Consumer 和 Partition 的绑定关系,每个 Consumer Group 都有一个对应的 Coordinator。Coordinator 的高可用设计和 Controller 是一样的,都是通过 ZooKeeper 选举来实现的。
以上是 Kafka 控制面的高可用设计,再来看 Kafka 的数据面。Kafka 的数据面只有 Broker 一个组件。当某个 Broker 节点宕机时,这个 Broker 中的分区副本也都不可用了。
这些分区的副本分为两种情况。如果副本的角色是 Follower 角色,那并不影响这些分区的可用性,因为只有 Leader 副本真正提供收发消息的服务。
如果故障副本的是 Leader 角色,则会通过与 Controller 类似的选举机制,选举出其他可用 Broker 上的副本作为这个分区的新 Leader 来继续提供服务。
以上是 Kafka 保证数据面可用性的设计,关于如何保证数据面消息数据的可靠性这个问题,我们在之前的两节课程(《如何确保消息不丢失?》和《Kafka 和 RocketMQ 的消息复制实现的差异点在哪?》)中已经详细讲解过了,这里就不再重复了。
如何设计消息队列的容灾架构?
接下来我们看一下如何设计消息队列的容灾架构。
通常来说,同城容灾可以满足绝大多数系统的容灾要求。最简单也是最常用的同城容灾架构就是,利用消息队列的高可用能力,将消息队列集群的节点分布在同城的多个 AZ 中来实现。
对于像 Kafka 使用的 ZooKeeper 这样,使用多数派选举来实现高可用的系统来说,最少需要三个以上的 AZ。并且,ZooKeeper 节点要在这些 AZ 中均匀分布,这样才能保证失去任何一个 AZ 时,剩余的 AZ 中有超过半数的 ZooKeeper 节点,可以正常选举。
当系统发生 AZ 故障时,ZooKeeper 能选举出新的 Leader 继续提供服务,Kafka 的各模块也可以利用 ZooKeeper 选出新的 Leader 继续提供服务,从而实现同城容灾。
下图是典型的 3AZ 高可用容灾部署架构图:
以上是理论设计,工程实践中还需要考虑如下一些问题。
实践中最常见的选择就是同城 3AZ 容灾,每个 AZ 部署一个 ZooKeeper 节点。Broker 节点需要尽量均匀地分布在 3AZ 中。
为什么需要均匀部署呢?这里主要考虑的是系统容量问题,相比于高可用,在设计容灾架构时需要额外考虑容量问题。因为容灾架构应对的是大规模故障,所以在设计容灾架构的时候需要保证在失去任何一个 AZ 时,系统还有足够多的节点数量可以承担全部流量,不至于出现因为容量不足而引发过载。
当 Broker 节点分布不均匀的时候,系统的容量就需要按照 Broker 数量最多的那个 AZ 来计算,所以 Broker 节点分布得越均匀,实现同样容灾能力所需的节点数量就越少。要想保证当我们失去一个 AZ 时,剩余的服务有足够实例数能承载故障 AZ 的流量,需要的服务器数量为:
其中 N 为 AZ 数量,M 为不考虑容灾情况下所需的实例数量。换算成冗余比例就是:
通过简单计算可以得知,3AZ 的冗余度为 50%,4AZ 为 33.3%。可见,AZ 数量越多容灾所需的冗余成本越少。但是,考虑到一个城市内的 AZ 数量是有限的,所以 3AZ 是比较常见的选择。
在创建主题和为主题扩容分区时,需要定制化的分区副本分配策略,保证分区的每个副本都放在不同的 AZ 中。如果一个分区的副本都被分配到了同一个 AZ,当这个 AZ 故障时,这个分区就没法继续提供服务了。相应的生产和消费消息的服务节点也需要多 AZ 部署,以便在 AZ 故障时仍然可以收发消息。
实践中还有一个需要权衡的问题就是,如何在性能和数据可靠性上取舍?
我们知道在这种容灾架构下,数据可靠性主要依靠多副本来保证。Broker 在给生产者返回“消息发送成功”响应之前,写入的副本数量越多,数据可靠性就越高。
在 3AZ 容灾场景中,数据至少要写入 2 个 AZ 的副本中,才能实现 0RPO,也就是 AZ 故障时不丢消息。
代价则是更高的消息发送时延。通常同城不同 AZ 之间的时延大概在 1~5个 毫秒左右,AZ 内的时延则不超过 1 个毫秒。
也就是说同城容灾架构,会导致发送消息过程增加大约 1~5 个毫秒的时延。这对于大多数在线交易类业务系统来说都是可以接受的。
而对于一些像处理日志、监控和离线数据的系统,更在意系统的吞吐率,对数据的可靠性要求没那么高,这样的系统在设计高可用架构的时候,则可以倾向于高性能,牺牲一些数据可靠性。
将消息队列的主从复制方式配置为异步复制,副本数量也可以选择 2 副本以节省成本。代价是没法做到 0RPO,在故障时会丢失一些消息。
如果说,我们使用的是像 RabbitMQ 这样不具备原生的高可用能力的消息队列,或者说我们对消息队列本身的高可用能力信心不足,就可以考虑使用双主题这样的架构实现容灾。
双主题容灾架构的设计思路很简单,就是用两个主题互相备份来实现容灾能力。实现时,两个主题需要分布在两个不同的 AZ 中,这两个主题可以属于同一个集群,但最好是不同集群。
生产者发消息时,可以采用各种负载均衡算法选择一个主题发送消息。消费者同时接收两个主题的消息。当 AZ 故障时,其中一个主题不可用,还可以使用另外一个主题来继续收发消息,从而保证了系统持续可用。
下图是双主题容灾部署架构图:
这种双主题的容灾架构可以做到 0RTO,但是需要考虑如何处理故障 AZ 内的主题中那些来不及消费的消息。通常情况下这些消息并不会丢失,但直到故障 AZ 恢复之前都没法被消费。
在故障恢复后如何处理这部分消息,需要根据不同的业务场景有所区别。
对于消息时效性要求没那么高,且实现了幂等消费的场景,等故障恢复后就会自动继续把这部分消息消费掉,相当于没有数据丢失。
有些系统对消息的时效性或顺序有要求,如果故障恢复后自动消费了这些旧消息,反而会破坏系统的数据正确性。这种情况下,就需要根据具体的业务逻辑来针对性地处理这部分消息,或者直接丢弃,或者采用特殊的逻辑来做数据补偿。
对于异地容灾场景,考虑到不同城市之间的时延较高(通常在几十至几百毫秒这个范围内),上面的二种容灾架构就无法适用了。这种情况下可以考虑采用主备主题的方式来实现异地容灾。
主备主题容灾架构采用一主一备两个主题,两个主题分别部署在不同城市中。正常情况下,生产者只向主用主题发送消息。同时需要部署一个主从同步的服务,将消息从主用主题实时同步到备用主题。消费者可以根据其所在的城市就近选择主用或备用主题消费。当主用主题所在的城市发生故障时,生产者和消费者都切换到备用主题继续收发消息。
下图是主备容灾部署架构图:
实践中,因为异地容灾切换的代价非常高,通常异地容灾切换都不是自动触发的。所以,对于消息中间件的主备切换来说,也不需要自动触发主备切换的高可用服务。
但在主备切换时需要注意停掉主备的消息同步链路,防止故障恢复后,旧的主用主题中的数据被同步到新的主用主题中,引发数据错误。
以上就是比较常见的三种消息队列的容灾架构,实践中可以根据业务场景按需选择其中的一种。
小结
高可用主要解决局部小规模故障问题,通过多副本、自动故障转移等机制实现秒级恢复。而容灾则着眼于应对数据中心级别的大规模故障,需要在更高维度进行冗余设计。
在实际应用中,系统设计者需要根据 RTO 和 RPO 的要求,选择合适的架构方案。同城容灾通常采用多 AZ 部署或双主题方案,可以实现较好的可用性和数据一致性;而异地容灾则因受限于网络延迟,多采用主备架构,在可用性、性能和数据可靠性之间寻求一个合理平衡点。
思考题
请分别从数据面/控制面维度,和可用性(RTO)/数据可靠性(RPO)维度,来说明 RocketMQ 高可用架构是如何实现的?
欢迎你在评论区分享留言,我们下节课再见。
上节课思考题答案
关于我在上节课留的思考题,参考答案如下。
以下这些 Kubernetes 生态相关的系统都提供了声明式 API:
- Terraform:用于基础设施即代码(IaC)的工具,允许用户通过声明式配置文件来定义和提供数据中心基础设施。
- Ansible:一个开源的自动化工具,通过声明式的 YAML 文件来定义配置管理、应用程序部署和任务自动化。
- Helm:Kubernetes 的包管理工具,通过声明式的 YAML 文件来定义和管理 Kubernetes 应用程序。
- Prometheus:一个开源的监控系统和时间序列数据库,通过声明式的配置文件来定义监控目标和告警规则。
- Istio:一个开源的服务网格,通过声明式的配置文件来管理微服务之间的通信、安全和监控。
此外,我们日常使用最多的 SQL 语言,其实也符合声明式 API 的定义,它可能是最早提供声明式 API 的语言了。