跳转至

41 容灾:如何实现跨地域、跨可用区的容灾和同步?

你好,我是文强。

近几年,多个知名互联网平台都出现过服务长时间不可用的情况,原因有机房断电、网络电缆中断等。对于我们业务侧来说,我们需要保证业务在任何情况都能正常运行,即服务自身拥有容灾能力是基本要求。

而消息队列作为基础组件,容灾是它的基本能力。所以这节课我们将会详细讲一下消息队列集群在发生异常时如何做好容灾,以及异常时如何保证数据不丢失。

容灾能力的理论基础

我们先来看一些容灾相关的基础理论知识点。

当系统发生这些异常时,服务能够自动切换并正常运行就是我们说的容灾。下面盘点几个常见的故障场景。

为了完成容灾,从技术上来看,容灾行为可以在集群内或者集群间完成。所以容灾可以分为集群内容灾和集群间容灾两种类型。接下来我们详细了解一下这两种类型。

集群内和集群间容灾

先来看下图,这是一个同时具备跨可用区和跨地域容灾特性的集群架构。

如上图所示,有主备两套集群。这两套集群分别部署在上海和广州,且这两套集群都是跨可用区部署的。所以当某个节点、某个机架、某个可用区发生故障时,可以通过主从切换来恢复服务。当某个地域故障时,也可以通过主备切换来恢复服务。

集群内容灾主要靠主从切换来达到容灾效果,集群间容灾主要靠主备集群切换达到容灾效果。从部署形态来看,这两种容灾方式,都可以是跨机架、跨可用区、跨地域部署的形式。

而为了衡量容灾切换的质量,我们会通过 RTO 和 RPO 两个指标来评估集群的容灾能力。下面带你了解下什么是 RTO 和 RPO。

RTO 和 RPO

RTO(Recovery Time Objective)指故障发生时业务系统所能容忍的服务停止时间。RTO 越低,表示业务对服务的可用性要求越高。

RPO(Recovery Point Objective)指故障发生时可能有多少数据会丢失。RPO 越低,表示业务对数据的可用性要求越高。

正常来说,我们希望 RTO 和 RPO 越低越好,即服务故障时间越短越好,丢失的数据越少越好。理想情况或在一些金融场景下,会要求 RTO 和 RPO 都做到 0。

那如何做到 RTO 和 RPO 都为 0 呢?我们后面结合具体方案详细分析。

那么有了理论基础之后,接下来我们详细看一下集群内容灾和集群间容灾的实现方案和原理分析。

集群内容灾方案的原理分析

如上图所示,这是一张集群内容灾的架构图,可以看到 Broker 节点和副本都是分布在多个可用区的。所以,实现集群内容灾应该包含3个步骤:

  1. 将 Broker 部署到不同的可用区
  2. 控制分区的副本分布在不同的可用区
  3. 控制主从切换

将 Broker 部署在不同的可用区是运维的工作,比较简单。直接购买不同可用区的节点,安装服务即可。

控制副本分布在多个可用区是容灾的核心。我们在第15讲讲了创建 Topic 的流程,我们可以在这个创建流程中加上一步:感知 Broker 节点属于哪些可用区,然后控制副本落在不同的可用区

一般情况下,跨可用区集群的可用区数量没有限制,可以是2个、3个、甚至4个。此时建议集群中 Topic 的副本数是可用区数量的倍数,比如双可用区,则副本数建议是2、4、6这样子。因为副本数是可用区的倍数的话,可以尽量保证两个可用区之间的流量分布是均衡的。

当副本分布在多个可用区之后,则依赖内核自带的主从副本切换机制来完成容灾切换。当 Broker 节点、机架、机房故障时,就可以快速完成服务切换。

接下来我们看看在集群内容灾场景中,RTO 和 RPO 的表现。

RTO 和 RPO

集群内主从切换,理论上是无法做到 RTO 为 0 的。我们在第15讲讲过,主从切换需要经过 Broker 异常感知、Controller 控制 Leader 切换、客户端感知 Leader 切换、数据写入新Leader 这几个步骤。整套流程下来,最少都是秒级的。

RPO 的值取决于一致性协议的设置。我们知道,一致性协议有强一致、弱一致、最终一致三种。如果是强一致性协议,则主从切换的 RPO 一定是 0,不会丢数据。如果不是强一致性协议,就有可能丢数据,此时 RPO 大于0。弱一致性的 RPO 的值大于最终一致,因为弱一致丢数据的概率更大。

从功能上来看,集群内跨可用区容灾可以解决单节点故障、机房故障、跨可用区故障等问题,但是解决不了整个地域故障的问题。所以接下来我们再看看集群间(跨集群)容灾方案的实现。

跨集群容灾方案的原理分析

跨集群容灾,顾名思义是指两套集群间的主备容灾。相对集群内容灾,这种方案除了解决地域级别的故障外,还能解决集群内部比如元数据丢失、Topic 负载异常导致整个服务不可用的问题。

如上图所示,这是一张主备集群的架构图。可以看到,主备集群的核心是主备集群之间消息数据和集群元数据的复制。从技术上看,主备集群之间数据的同步工具,可以是我们在前几节课讲到的连接器、事件驱动架构、Serverless Function 等方案。这些方案的技术思路是一致的,只是底层运行的 Runtime 不一样。

因为需要同时复制消息数据和元数据,所以主备复制应该有两条链路,分别是实时同步消息数据实时同步集群元数据的链路。从代码的角度,可以理解为有两个 Connector,一个是同步消息数据的 Connector,一个是同步元数据的 Connector。

那这两条链路数据的复制方式是怎样的呢?接下来我们看一下主备集群之间数据的复制方式。

两种复制方式

目前主要有独立运行复制组件主集群复制备集群复制三种思路。这三种方式的主要区别在于,复制组件运行在哪里。技术思路比较直观,就不展开细讲了。

我比较推荐你尝试“独立运行复制组件”的思路。因为这个方案从开发、稳定性、运维、升级的角度看,都会比较独立且不会影响主备集群原本的功能。

下面我们再来看看客户端是如何访问 Broker 集群的。

客户端连接集群

一般有“直连 Broker”和“通过网关或虚拟IP连接Broker”两种方案。

如上图所示,直连 Broker 是指客户端直接配置 Broker 地址来访问集群。通过网关或者虚拟 IP 连接则是在客户端和 Broker 之间加一个中间层,请求先到中间层,中间层再将请求转发到真实的 Broker。

主备集群切换时,这两种连接方式的切换策略是不一样的,同时 RTO 和 RPO 的表现也会不一样,来看一下细节。

主备切换

在切换时,直连 Broker 的方案一般需要客户端修改配置在代码中的 Broker 地址,然后重启集群。但是这种方案的人工操作成本太高了,而且 RTO 也会很高。

为了解决这个问题,在实际落地中就有通过域名来访问集群的方案。即配置域名解析,然后在代码中配置域名访问,此时代码会根据域名解析到真实的 Broker 的 IP 完成访问。基于 DNS 的方案,切换时只需要修改 DNS 解析的 IP 和端口即可,操作成本会降低很多。

不过,DNS 方案虽然避免了修改配置,但还是有两个风险需要关注。

  1. 节点会缓存 DNS 信息,默认情况下 DNS 的过期时间是10分钟,因此可能会出现最长10分钟内客户端无法感知主备切换,客户端还连接在老集群上,从而导致服务异常的情况。
  2. 消息队列客户端和 Broker 之间是长连接,即使本地 DNS 解析信息更新,如果长连接没有断开,客户端可能还是连接在老节点上,此时服务也可能异常。

所以如果是基于 DNS 的方案,切换的流程应该包含两步:首先是确认本地 DNS 信息已更新,然后通过重启服务保证客户端连接到新的节点。

通过网关或虚拟 IP 连接 Broker 方案的主备切换方式是,修改网关或者虚拟 IP 后面映射的Broker 地址,从而实现客户端不需要修改配置和重启服务就能连接到新的Broker。这种方案是比较优雅的,也是比较推荐的。

这里需要注意的是,从消费的视角来看,主备切换时可能会出现有些数据在老集群还没有被消费,此时这批数据短时间内不会被消费。这种场景需要消费端能够支持双向集群消费或者回溯消费,才能保证不漏消费数据。

讲到这里,你应该就知道了,这两种接入方式的主要区别就是容灾切换时的成本和影响不同。从技术上来看,第二种方案在容灾切换时的表现会更好。

但是这两种切换方式都会遇到一个问题,那就是当备集群提升为主集群后,数据如何同步回主集群?这就涉及到主备集群的双向同步问题了。所以接下来我们再来看看主备集群之间是如何实现数据双向同步的。

双向同步

双向同步的数据包括元数据和消息数据。

元数据双向同步的核心是确认元数据信息以哪个集群为准。因为如果短时间内发生多次主备切换,就可能出现主备集群中某个 Topic 的配置不一样的情况。

此时 Topic 配置应该以哪个集群为准呢?是当前的主集群吗?

其实不应该直接以当前的主集群为准。因为当配置发生变更时,当前的主集群可能就不是主集群了。所以最合理的方案是:以Topic 配置变更时的主集群为主,即当时哪个集群是主集群,就以这个集群的配置为准。但是在实际业务场景中,频繁主备切换加上配置变更,可能会出现无法精准识别配置变更时哪个是主集群的情况,或者很难拿到当时的配置信息。

所以更常用的方案是:标记元数据信息的主集群,元数据信息只在主集群上进行变更,备集群永远是同步的角色。即不管主备如何切换,复制方向都不变。这种方案的好处是实现成本较低,也没有明显的缺点。

消息数据双向同步的核心是解决消息回环的问题。即启动双向同步后,可能会出现消息在主从之间来回同步,从而形成回环。而解决回环的思路就是标记消息的来源集群。

实现的思路就是通过在消息的 Header 中设置 source 字段来表示消息的来源集群,从而解决消息回环问题。

如上图所示,客户端将数据直接写入集群时,消息 Header 中 source 字段为空。比如集群 C1 的数据 A1~A6,集群 C2 的数据 A7~A8。主备集群 C1 和 C2 开启了双向同步后,同步数据时会执行以下四步判断:

  1. 判断消息 Header 是否有 source 信息,否的话就将其同步到目标集群。
  2. 如果是,则判断 source 字段包含的集群是否和目标集群一样。如果是,则不进行投递;如果否,则正常进行同步。
  3. 当消息写入目标集群时,设置消息 Header 中 source 字段的值为源集群。
  4. 当反方向同步时,执行 1~3 步的判断,就可以解决消息回环的问题。

最后我们来看一下在跨集群容灾的场景中,RTO 和 RPO 的表现。

RTO 和 RPO

在跨集群容灾的场景中,RTO 一定是大于 0 。如果是使用直连 Broker 方案,则通过修改配置、重启客户端的形式来进行切换,此时 RTO 能做到多少取决于客户端自动化运维的程度。但是服务重启本身需要花费时间,所以应该是分钟级的。如果使用网关和虚拟IP的方案,通过修改网关或者虚拟IP后面的RS的映射,触发客户端重连,理论上有可能做到秒级。

因为主备集群之间数据是双向同步的,及时数据没完成同步就发生切换,数据还是会留在老集群不会丢失,所以主备切换场景中的 RPO在大部分情况下可以做到0

从业界来看,Kafka 和 Pulsar 官方推出了自己的跨集群容灾方案,技术思路基本一致。所以接下来我们就挑 Kafka MirrorMaker 来分析一下它的实现,Pulsar Geo Replicated 则留作思考题,你可以举一反三。

Apache Kafka MirrorMaker

Apache Kafka 官方提供的主备集群复制方案,叫做 MirrorMaker,它的功能是实现主备集群之间消息数据和元数据的复制。

MirrorMaker 有 V1 和 V2 两个版本,两个版本最大的区别是 V2 支持消费进度信息的同步,V1不支持。所以接下来我们就以 MirrorMaker2 的实现为主展开讲解。

先来看一下它的系统架构图。

如上图所示,MirrorMaker 是一个可以独立部署的应用程序,它支持以集群模式运行。它的底层是基于 Kafka Connector 来实现的,简单理解就是,MirrorMaker 封装了多个 Connector。比如同步消息数据和元数据的 Connector、心跳检测的 Connector、Checkpoint 的 Connecor 等等。

在数据复制方面,它支持以下 3 种类型的数据复制:

  1. 从源集群消费数据,再将消息数据生产到目标集群。
  2. 同步源集群的Topic、分区、配置等元数据到目标集群。
  3. 同步消费分组的进度到目标集群。

同时 MirrorMaker 提供了故障转移和恢复功能。即如果 Worker 出现故障,其他 Worker 会自动承担其任务。从技术上来看,消息数据和元数据同步的底层原理,跟我们前面讲的一样,这里不再赘述。下面我们主要看一下消费位点的同步,这个比较重要。

消费位点同步

我们在第09讲中讲过,消费进度是由消费分组名称(订阅名称)+ Topic + 分区这个三元组标识的。那是不是直接把这部分数据复制到备集群就好了呢?

答案是否定的。因为同步 Offset 时需要先识别和记录分区在主备集群中 Offset 的映射关系。什么意思呢?先来看下面这张图。

如上图所示,在复制过程中,消息数据是先从源集群消费再写入到目标集群的。因为消息队列消息数据有过期机制,可能就会导致一条数据在源分区和目标分区中的偏移量不一样。一般是源集群的 Offset 大于目标集群的 Offset。

所以如果我们直接将源集群的消费位点信息同步到目标集群,则会出现 Offset 错乱。比如上图中源集群消息 A4 的 offset=4,当某个消费分组消费到这条数据,ConsumeOffset 就为4。如果把 ConsumeOffset=4 复制到目标集群,因为目标集群中 A4 的 Offset 为 14,所以就对应不上了,那么就会出现消费关系错乱。

所以同步 ConsumeOffset 的时候,如果消费到 A4,则需要记录一下源集群 Offset=4 和目标集群 Offset=14 的映射关系,以保证消费的是同一条消息。

从实现的角度,MirrorMaker 在同步消费进度时,会在一个内部 Topic 存储 Offset 的映射信息,然后通过这个映射关系在备集群找到准确的消费位点。

这里就不过多展开 MirrorMaker 的细节和使用方式了,想了解更多的话,你可以参考官方文档 Geo-Replication

总结

容灾是指当系统发生这些异常时,服务能够自动切换,并正常运行。我们是通过 RTO 和 RPO 来衡量容灾的质量。

RTO 是指发生故障时业务系统所能容忍的最长停止服务时间,RPO 是指在故障期间能够容忍多少数据丢失。业务追求的是 RTO 和 RPO 都为 0,或者无限趋近于 0。

容灾分为集群内容灾和集群间容灾两种方案。可用性最高的方案是同时具备集群内容灾和集群间容灾两种能力的集群。

集群内容灾主要靠主从切换来达到容灾效果,集群间容灾主要靠主备集群切换达到容灾效果。主从切换是集群内自带的机制,没有额外的开发量。主备切换需要解决客户端切换、数据双向同步、消费位点同步等技术问题。所以从实现角度看,集群间容灾比集群内容灾的技术复杂度高很多。

集群内容灾的 RTO 一般是大于 0。RPO 取决于一致性协议的设计,强一致性协议时能做到 RPO 为 0,弱一致性协议的 RPO 大于最终一致性协议。跨集群容灾的 RTO 一般是大于0,RPO 一般可以做到 0。

业界主流消息队列都支持集群内容灾、主从副本切换的方案。在跨集群容灾方面,理论上所有的消息队列都是依赖第三方组件实现的跨集群容灾,只有 Kafka 和 Pulsar 官方推出了集群间复制的方案。

思考题

  1. 为什么消息队列集群内容灾不跨地域部署,从而实现地域间的主从切换呢?
  2. Pulsar 支持 Geo Replicated 的策略,请你学习一下它的实现,分析和 Kafka MirrorMaker 在技术上的异同。

期待你的分享,如果觉得有收获,也欢迎你把这节课分享给感兴趣的朋友。我们下节课再见!

上节课思考闭环

课程中我们提到,分布式任务调度平台的作用就是负责任务的运行、调度、启停。从功能上来看,像 Spark/Flink、Mesos 等分布式任务调度平台都具备这个能力,为什么主流的消息队列还要自己独立开发 Runtime 呢?

连接器实现的功能就是数据集成。而数据集成的功能有很多方案都可以满足,比如我们在第38讲讲的基于Serverless Function 的流式数据处理方案和在第39讲中讲的事件驱动架构都可以实现同样的功能。在开源社区,也有比如Flink、Spark这种流计算引擎可以实现。另外也有专门的数据集成组件,比如DataX、SeaTunnel、Flink CDC等。所以说,从数据集成的功能来看,竞争是非常激烈的。因此看起来,消息队列没必要自己开发连接器。

但是主流消息还是支持连接器,我认为核心原因就是生态的闭环。所以说不管是哪一款主流 MQ 的连接器,比如RocketMQ、Pulsar、Kafka,它们主打的核心竞争力都是和当前的 MQ 绑定,使用较为轻量。就是说,希望用户在使用当前消息队列的基础上,能够轻量地完成数据接入和流出。从这个角度来看,消息队列连接器是有一定意义的。

但我个人认为,随着业界主流的流计算引擎和数据集成组件的开源和丰富,这些组件会和消息队列连接器抢用户,因为它们的功能存在相对严重的同质化。本质上这些开源组件完成数据集成任务会比消息队列实现的连接器更加专业。所以消息队列连接器在客户侧的需求不会这么强烈,这也是在技术领域,你可能很少听到它们的原因之一。