《深入拆解消息队列47讲》架构升级篇——小记随笔

云原生:业界MQ的计算存储分离是如何实现的?

什么是存算分离架构

存算分离中的“存”是指存储层,“算”是指“计算层”。简单理解“计算”就是功能相关的实现,“存储”是指数据落地持久化存储。

消息队列中的存储层是指包括存储结构设计、消息存储格式、数据分段等具体的数据存储功能。计算层是指包括协议解析、事务消息、延时消息等主要消耗计算资源(如 CPU)的功能模块。

跟存算分离相对的是存算一体,“分离”和“一体”是指计算层和存储层是否在一台机器上。

img

如图所示,这是存算一体的消息队列的架构,这种存算一体的架构就是典型的有状态服务。

存算一体架构的最大特点就是计算层和存储层没有明显的界限,从代码层面上看,计算层和存储层交互的操作就是文件的读写。它的主要优势是架构简单、开发实现成本较低。缺点是它是一个有状态服务,无法快速弹性地扩容。而随着数据量越来越大,具备弹性快速扩缩容能力的消息队列集群可以极大地降低资源和人力成本,而存算分离架构则是目前实现弹性消息队列集群的主要技术方案。

img

再来看这张图,图中分离了计算层集群和存储层集群。计算层集群主要负责消息功能类的操作,比如压缩解压、事务消息、死信消息等等。存储层负责数据的存储。
存算分离架构的优点是计算层为无状态,因此计算层的扩缩容就很方便。缺点是架构变复杂,代码实现难度也提升很多,日常的运维、研发的学习成本也会相应提高。另外计算层和存储层的交互从本地调用变为了网络协议的调用,性能上会有一些下降。

我们真的需要存算分离架构吗

存算分离是消息队列架构中的可选项,而不是必选项。或者说合理的架构是:既可以存算一体也可以存算分离的可插拔的存储结构。

存算分离架构最大的好处就是集群变得更加弹性。从终态来说,没有存算分离,消息队列架构就无法 Serverless 化,也就无法做到快速扩缩容。从成本结构的角度来看,没法快速扩缩容,那么就无法提高集群的利用率,也就无法很好地降低成本。

我认为核心原因是:用户诉求的多元化。在我多年负责消息队列云产品的经历中,我最大的感受就是用户是多元的,从而导致诉求也是多元的。

提存算分离概念最多的一般都是云服务提供商。为什么呢?原因就是他们有这个需求,而一些小公司,更多是聚焦在功能和使用层面,对存算分离没有那么刚需。从理论上来看呢,这也符合二八原则,即大部分的客户其实并不需要存算分离架构带来的好处,或者因为规模限制,根本用不到存算分离的优势。

实现存算分离架构的技术思考

存算分离架构的核心就是选择合适的存储层引擎。

如何选择合适的存储层引擎

从业界的主流组件来看,可选择的存储层引擎主要有对象存储、分布式存储服务、虚拟云盘三类。

img

  • 对象存储是指各个云厂商提供的商业化的对象存储服务,比如 AWS 的 S3、腾讯云的 COS 等。
    ** 对象存储最大的特点是,它具备分布式可靠存储的能力且存储成本较低。缺点是读写方式不够灵活,流式读写性能较低。

  • 分布式存储服务是指一些专门用来提供分布式的数据存储的组件,比如 HDFS、BookKeeper 等等。也有一些公司会自己开发分布式文件系统,比如阿里的盘古、腾讯的 CFS 等等。
    ** 分布式存储服务的优点是具备分布式存储能力,读写性能也较高。缺点是存储集群本身会有一些稳定性和可靠性问题。从技术上看,稳定性和可靠性问题可以通过技术和运维优化来解决。

  • 虚拟云盘是指云厂商提供的在线云盘服务。
    ** 云盘的特点是数据在远端是多副本可靠存储的,天然支持分布式可靠存储的能力。它的缺点是云盘需要先绑定节点,同一时间只允许一台 Broker 写入数据到云盘。当 Topic 的分区迁移、Leader 切换的时候,需要将云盘从老的 Broker 节点卸载,再挂载到新的 Broker 节点,在这个过程中服务是停止的。从技术上看来,这个缺点几乎是不可解决的。

因为分布式存储服务一般会提供多语言的流式写入的 API 进行数据读写,读写性能较高,比较适合消息队列的数据特点。所以从业界落地的img角度来看,分布式存储服务用得比较多。比如 Pulsar 的存储层使用的是 BookKeeper,RocketMQ 5.0 的存储层用的是原先的 Broker 集群。

存储层:分区存储模型的设计

在存算一体架构中,这两种模型各有优劣,都有成熟产品使用。但是在存算分离的架构中,基本都是每个分区一个“文件”的方案。主要是出于数据的读写性能考虑。在存算分离的架构中,我们是通过网络协议从存储层读取数据的。

如果数据存储在一份文件中,则存储层在读取数据时就需要维护二级索引,并启动随机读,在性能上会有一定的降低。所以合理的方案如下图所示,不同的分区在存储层有独立的“文件”存储,然后顺序读写不同的段文件。

img

计算层:弹性无状态的写入

计算层中 Topic 的分区就不需要有副本的概念,数据的可靠存储可以交给存储层去解决。即计算层的 Topic 永远是单分区。

img

客户端数据写入到分区有 Metadata(元数据)寻址机制和服务端内部转发机制两种形式。在存算一体的架构中,推荐使用的是元数据寻址方案。但是在存算分离的架构中,数据都需要转发写入到存储层,因为都需要再转发一次,所以服务端内部转发也变为了一种常用方案。

从技术上看,计算层和存储层之间的调用是比较重要的一个模块。计算层 Broker 需要使用存储层引擎提供的 SDK 或者写入方式将数据写入到存储层。比如 Pulsar 是调用 BookKeeper 提供的 SDK 将数据写入 BookKeeper 的。此时考验的就是编码的技巧和功力了,比如线程管理、线程安全、批量写入等等

业界主流存算分离架构分析

RocketMQ 5.0 架构分析

当前 RocketMQ 5.0 不是存算分离的架构,只是代理(Proxy)模式。

img

RocketMQ 5.0 的客户端可以直连负载均衡或 gRPC Server,进行读写、管控等操作。gRPC Server 收到请求后,再把请求转发到实际的 Broker 集群。
所以从技术上看,当前的 Proxy 组件只是转发层,不处理任何计算和存储的逻辑。集群实际意义上的计算和存储逻辑,都是在 Broker 集群上完成的。这就是我们前面所说的,当前 RocketMQ 5.0 的架构不是真正意义上的存算分离架构的原因。更准确的说法是,RocketMQ 5.0 只是从当前存算一体的架构往存算分离架构演化走出了第一步。

RocketMQ 的存算分离架构是演化来的,而不是一开始就往这个方向设计。因此 RocketMQ 往存算分离架构演化的挑战非常大,因为它需要兼顾到当前架构中的功能和设计模型。简单说就是,有很多的历史包袱。

Pulsar 存算架构分析

img

Pulsar 在设计的时候,就选择好了使用 Apache BookKeeper 来当作它的存储层。那为什么选择 BookKeeper,不选择其他引擎呢?在我看来,有以下三方面原因:

  1. Bookeeper 设计的初衷就是用来高性能地存储分布式流日志的,而日志最大的特点就是顺序的 Append 模型,消息队列的消息数据特点也是顺序 Append 的,所以 BookKeeper 就很适合当作消息队列的存储层。
  2. BookKeeper 具备流式读写的能力,写入和读取的性能较高,并且具备分布式可靠存储的特性。
  3. 因为 Pulsar 社区的主要开发者是之前是维护 BookKeeper 的成员,比较熟悉 BookKeeper,这可能也是其中一个原因。

img

Pulsar 计算层的分区都是单副本的,即没有副本的概念。每个 Pulsar 分区底层由多个 Ledger 组成,每个 Ledger 只包含一个分区的数据。每个 Ledger 有多个副本,这些 Ledger 副本分布在 BookKeeper 集群中的多个节点上。

消息队列相关的特性都是在计算层 Broker 中实现的。Pulsar 在设计分区存储模型的时候,是根据 BookKeeper 已有的特性和概念来设计的。
比如说,BookKeeper 的最小存储单位是 Ledger,Ledger 里面由多个 Entry 组成,每个 Entry 可以理解就是一条数据。
基于 BookKeeper 的这些特性,Pulsar 分区模型的底层单位就是 Ledger,每条消息就是一个 Entry,每个 Ledger 都是一个数据分段。这就是我们说的,分区存储模型的设计依赖于底层分布式存储引擎的选择的原因。当你选择了其他的分布式存储引擎,分区存储模型可能就是另外的实现。

在计算层中 Pulsar 的分区是和某台 Broker 进行绑定的,可以简单理解这台 Broker 就是分区的 Leader,分区数据的读写都是在这条 Broker 上完成的。客户端的生产消费请求发送到这台 Broker 后,Broker 会先经过计算逻辑的处理,再去 BookKeeper 节点读写数据。当某台 Broker 负载高时,就需要快速迁移分区降低 Broker 负载。因为计算层就都是无状态的,迁移起来就很快,直接修改元数据即可。

在内核具备了快速迁移的能力后,为了能够进行快速的负载调度,内核就需要具备自动化调度迁移的能力。所以 Pulsar 在内核中提供了自动化负载均衡的机制,即有一个主节点(可以理解就是我们讲过的 Controller 节点)不断地检测每台 Broker 的负载,然后根据一定的负载均衡策略执行 Topic 自动迁移,将负载高的节点上的分区迁移到负载低的节点。

同时,为了提高负载均衡和迁移的效率,Pulsar 引入了 Bundle 的概念。参考图示,Bundle 是处于 Namespace 和 Topic 之间的一个概念,它是用来组织多个 Topic 的一个逻辑概念。即一个 Namespace 有多个 Bundle,一个 Bundle 里面有多个 Topic。

img

从技术上看,引入 Bundle 的主要原因是 Pulsar 有自动负载均衡机制,会把负载较高的 Broker 上的一些 Topic 迁移到负载较低的 Broker 中,从而实现 Broker 间负载的均衡。而这个迁移如果以 Namespace 为单位,可能会一下子迁移很多 Topic。而如果以 Topic 为单位,每次搬移数据又可能会很小,因为迁移过程中需要修改大量 Topic 和 Broker 之间的元数据。所以,以 Bundle 为单位进行迁移是最合适的,用它迁移 Topic 会容易很多。

云原生:MQ的分层存储架构都有哪些实现方案?

存算分离架构主要解决的是集群架构的弹性问题,而分层存储架构解决的是低成本存储冷数据的问题。

img

存算分离是将计算层和存储层独立开来,分别负责计算相关逻辑和存储数据。而分层存储在本地完成计算和存储逻辑,然后将 Broker 本地的冷数据上传到远程进行存储,需要时再拉下来处理。

什么是分层存储

分层存储就是指在不改动本地存算一体架构的前提下,通过一定的策略将本地的数据存储到远程,从而降低本地硬盘的负载压力。在消费的时候,再从远端文件系统下载对应的数据,提供给消费者消费。

img

分层存储的应用和局限

img

因为消息队列本质上是一个存储引擎,所以从理论上看分层存储是能带来成本价值的。如下图所示,单台 Broker 日常需要存储 5TB 数据,开启分层存储后,就可以将 4TB 数据存储到远程,本地只需要保留 1TB 的数据。从成本计算的角度,一般远程存储的存储成本是本地的三分之一,所以成本计算如下:

原先的成本 = 5TB * 1 = 5
开启分层后的成本 = 1TB * 1 + 4 * 1/3 = 2.3 

那分层存储是成本优化的银弹吗?它有什么缺点吗?
从技术上来看,成本的降低是以牺牲性能和稳定性为代价的。我们可以明确两个信息:

  1. 从计算机理论基础可以知道,远程存储的性能肯定是比本地硬盘低的。
  2. 引入了第三方存储系统,第三方存储系统的稳定性肯定会影响消息队列集群的稳定性。

实现分层存储的技术思考

我们先从开启分层存储后,数据的流动路径来理解一下分层存储在功能方面的表现。

img

  1. 客户端的生产消费还是原先的流程,从分区 Leader 所在的 Broker 进行生产消费。
  2. Broker 收到数据后,还是将数据写入到本地存储。当开启分层特性后,Broker 内部会有一个模块(可以理解为一批线程)根据设置的分层策略,将本地的分段文件数据上传到远端的分布式文件系统中。
  3. 消费时如果数据还留在本地,则直接读取本地数据然后返回;如果数据不在本地,就从远程读取返回给客户端。

结合上面的流程,从技术上分析,实现分层存储主要需要关注远程文件系统的选择、生产性能优化、消费性能优化、隔离性和回滚等四个方面

选择远程文件系统

目前最适合分层存储的分布式文件系统一般是各个云厂商提供的对象存储服务

生产性能优化

因为数据是写入到本地文件,然后再通过异步线程上传到远端文件系统,所以从性能的角度看,写入性能基本不受影响。只有异步线程上传或下载文件时,对资源的占用(比如对 CPU、内存、网卡、硬盘等),可能导致写入性能受到影响。

消费性能优化

消费流程的细节就比较多了,核心点在于:当用户消费的数据在远程不在本地时,如何高性能地消费数据。从技术实现来看,有以下两种方案:

  1. 远程的分层文件先下载到本地,消费请求只从本地硬盘读取数据。
  2. 当数据在本地就读取本地的数据,当数据在远程时,就流式的从远程存储系统读取数据。

方案一

img

消费请求只从本地的硬盘读取数据,同时有一个异步线程根据设置好的预读策略,提前调度,从远程下载接下来可能会消费的数据,再写入到本地,供消费请求读取。这里的核心就是预读算法的设计。

从技术上来看,消息队列的预读算法比较好实现,因为消息队列都是顺序消费的模型。

但是预读算法无法做到完美,还是会存在冷启动的情况。比如我们初始化消费分组消费数据或在消费过程中重置消费位点时,可能出现数据不在本地的情况,此时就需要先把数据下载到本地,然后才能消费,此时消费就会有卡顿。这个问题是无法避免的,但是它只会出现在初始化和重置消费位点等场景,并且也可以通过一定的技术手段来优化,影响较小。

该方案的优点是,可以通过预读、批量读等手段提前将数据下载到本地,从而保证原先的消费流程不变,理论上如果全部命中热读,性能可以和非分层架构保持一致。缺点是下载数据写入到硬盘,可能会占用硬盘空间,影响 IOPS 性能,并且会占用 Broker 节点的带宽,此时可能会影响读写的性能。因为理论上会有冷启动的情况,所以此时消费性能就会低于非分层。

方案二

img

消费数据的时候先判断数据是否在本地,在的话就读取本地数据;不在的话,则直接通过远程存储提供的 SDK 去流式地读取数据,然后在内存中将流数据转成 FetchRecord,返回给客户端。

这种方案的好处是,当数据在本地时,性能理论上和非分层可以对齐。读冷数据时无需将数据写入到本地硬盘,因此不会对本地硬盘的写入 IO 和空间造成挤占。缺点是远程存储性能较低,直接远程读取数据的性能,肯定会低于非分层的性能,另外也会占用网卡带宽。

隔离性

隔离性是指如何避免上传和下载的操作过度挤占资源,导致主流程的生产消费性能受到影响。上面讲到,上传和下载操作影响的主要是 CPU、内存、网卡和硬盘资源。

那么从技术上来看,在单个进程内是无法做到资源的强隔离的。但是有几个思路,你可以了解一下。

  1. 从 CPU 的角度,我们可以通过线程绑核操作,在一定程度解决 CPU 隔离的问题。即把上面提到的上传、下载文件的线程绑定到某一批固定的 CPU 核心上,从而让 CPU 的消耗控制在一定的范围内。
  2. 对于内存的占用,这点就很细节,比如我们可以通过堆外内存、Direct IO 等手段,精细化控制内存,从而避免消耗过多内存。
  3. 对于网卡的占用,从应用程序上看,没有办法控制程序对网卡的消耗,但是可以通过控制同一时间上传或下载的文件数和速度,来避免把网卡的带宽资源用光。
  4. 对于硬盘 IO 的占用,在空间层面的占用可以通过扩容存储空间来解决。对于 IOPS 的占用,从软件层面来看比较难解决,但是可以在物理层面通过分盘来实现 IO 隔离,比如正常的写入操作用 A 盘,下载操作用 B 盘这样子,只是分盘操作会增加系统运维的复杂度。

所以从具体落地的角度来看,我们可以通过对上传下载线程数的控制、上传下载速度的限制,以及优化预读缓存算法等手段来降低对资源的损耗。在这几个操作的基础上,配合上 CPU 绑核、内存精细化管理,就可以做到较好的资源保护。

回滚

当我们启动了分层特性后,单一的消息队列集群就引入了一个远端存储。此时当远程的存储系统服务抖动或服务不可用后,就会影响消息队列的集群,并且远端集群的异常可能会有很多种,无法在消息队列集群本身 cover 住所有异常。

所以,消息队列稳定性的兜底方案是回滚。即当远端存储服务出现无法解决的问题时,可以将集群恢复到非分层的状态。从技术上看,集群抖动时不会影响生产数据的操作,只是新的数据段不应该再上传到远程存储,但是会影响老数据的消费,即如果数据不在本地,当远程服务异常,这些数据就无法正常消费。

所以回滚的核心分为以下两点:

  1. 暂停上传。即新增的数据段不再上传到远程存储,都保留在本地,保证生产和消费都是正常的。
  2. 消费老数据时提示错误,只允许消费新数据。理论上看,回滚方案无法解决的就是老数据的消费,这点是需要重点关注的。

Serverless:如何基于Serverless架构实现流式数据处理?

img

这是一张消息队列上下游生态的架构图,分为数据源、总线管道、数据目标三部分。可以看到消息队列在架构中处于缓存层,起到的是削峰填谷的缓冲作用。

然而在上面的架构中,存在一个问题:每种技术方案所适用的场景不一样,业务一般需要同时使用多种方案,而使用和运维多种方案的成本很高。

为了解决使用和运维成本问题,接下来我们来学习一种非常实用的方案,那就是基于 Serverless Funciton 实现流式的数据处理。

典型的数据流场景

从业务形态上看,数据流场景主要可以分为计算、集成、清洗、容灾 4 个方向。

  1. 计算:主要解决流式数据处理计算、分析、清洗、聚合、转储等需求。Spark/Flink 是计算方向中的主流解决方案,其优点是功能和性能都很强大,几乎可以满足所有流式计算的需求。缺点是学习和运维成本较高,在很大一部分简单的数据处理场景(如 ETL)下的投入产出比不高。
  2. 集成:是指将数据从数据源同步到数据目标的过程。链路构成通常为:数据源、数据集成套件、数据目标。其代表组件为 Flink CDC、Apache SeaTunnel、DataX 等等。这些组件的优势是具备开箱即用的能力。缺点是无法满足复杂的计算场景,遇到一些复杂的计算场景,需要引入 Spark/Flink。另外,一般集成组件底层引擎是 Spark 或 Flink,在引擎上层做了应用封装,所以在运维成本上也相对较高。
  3. 清洗:严格来说清洗场景是计算或集成场景下的一个子集,具备计算、集成的套件都具备 ETL 能力。这里的处理指简单的数据清洗,即将数据简单清洗格式化(不需要计算聚合)后分发到下游。主要代表组件是 Logstash、MQ Connector。它们通过简单的语法完成数据的格式化、清洗、分发。优点是使用简单、运维简单。缺点是功能场景相对局限、单一。
  4. 容灾:指消息队列集群之间的容灾,即集群间的数据同步,包括元数据、业务数据。主要解决方案是采用各个消息队列自带的容灾组件,比如 Kafka/RocketMQ Connector、Pulsar IO、RabbitMQ Federation/Shovel 等等。

img

什么是 Serverless

基于 Serverless 架构是如何同时满足上面这四种场景的?首先我们得搞明白什么是 Serverless。

Serverless 的定义

Serverless 从语义上来讲是“无服务器”。从技术架构和底层技术运行的角度看,服务运行不可能没有服务器。实际上,无服务器是从客户的角度来理解的,指的是客户不需要关心服务器。某种意义上看,不关心相当于没有,因此是 Serverless 平台来负责服务器资源管理及运行。

目前 Serverless 主要的产品形态是 Serverless Funtion。

Serverless Function

Serverless Function 是指运行在 Serverless 平台中的函数段。这里的函数和任何一个开发语言中函数的概念是同一个。比如下面是一个 Python 函数,同时也是一个可以运行在 Serverless 平台的函数段。

import logging
logger = logging.getLogger()
logger.setLevel(logging.INFO)

def main_handler(event, context):
    logger.info('got event{}'.format(event))
    print("got event{}".format(event))
    return 'Hello World!'  

img

上面的函数段可以直接提交到 Serverless 平台上运行,平台会执行整个函数代码流程,并输出结果。

如何基于 Serverless 实现数据处理

数据处理流程

img

这张图和最开始那张,最大的区别在于:把中间这两个处理层替换为了 Serverless Function 平台,用它来替代流入和流出过程中的多款开源组件。

底层架构和技术原理

img

从左到右依次是输入源、Serverless 调度运行平台、数据目标三部分。这里面需要重点关注两个部分,一个是数据源事件的触发方式,另一个是 Serverless Function 平台的底层运行原理。

数据源事件的触发方式,是指当数据源有数据后,如何触发下游的函数执行。

  • 事件触发是指数据源接收到某个数据后,主动触发下游的函数段执行。
  • 定时触发是指设定时间后触发某个事件,比如设置为 0 点定时触发平台去运行某个函数代码段。
  • 流式拉取是指实时不间断地流式拉取数据源的数据,拉取到数据就触发函数逻辑进行处理。

Serverless 运行调度平台是指运行函数的平台,该平台底层的运行核心基本都是 Kubernetes 和容器。运行的原理是:先把函数代码段封装在镜像中;启动时,调度 Kubernetes 去运行带有代码段的镜像,并启动 Pod(Pod 为 Kubernetes 中的最小调度对象);镜像的核心逻辑都运行在这个调度平台里,因此系统会自动运行函数、扩容和缩容,以及上传运行结果等。

Serverless 平台的核心竞争力是通过灵活的调度能力来提高资源的利用率,从而降低成本。技术上的核心是中间的这层运行调度平台。调度平台能达到优化成本效果的理论依据是:下沉和规模效应。因为业务都有波峰波谷效应,多个业务使用同一个平台的话,就可以通过资源调度,达到资源复用的效果,从而提高利用率,节省成本。

两种方案的优劣势对比

我们接着来看一下基于开源方案和基于 Serverless Function 方案的优劣势对比。

img

Serverless:如何基于MQ和Serverless设计事件驱动架构?

如何基于消息队列和 Serverless 来设计实现事件驱动架构(EDA)。

什么是事件驱动架构

img

这是从事件驱动架构抽象出来的架构图,图中包含了事件源、事件处理平台(事件总线)、事件目标三个部分。

  1. 事件源就是数据源,一个事件可以理解为一个数据。比如业务定义了一个事件,然后投递到了事件处理中心,本质上就是投递了一个数据。
  2. 事件处理平台(在公有云产品化后一般叫做事件总线)负责接收和持久化存储不同事件源的事件,然后根据设置好的事件规则触发执行不同的业务逻辑。
  3. 事件目标包含事件目标和目标调用两部分。事件目标一般是实体,比如 HTTP/TCP Server、某个存储引擎等等。目标调用是一个动作,比如 HTTP API 调用、调用引擎客户端 SDK 写入数据等等。

事件驱动架构是指主动拉取或被动接收上游不同事件源的数据,然后根据配置好的事件规则,触发执行不同的业务逻辑。

什么是 CloudEvents

因此为了解决事件定义和描述的规范化,CNCF Serverless 工作组提出了 CloudEvents 的概念,用来制定统一的事件标准,比如请求方式、内容格式、内容组成等等。接下来我们来看看什么是 CloudEvents。

CloudEvents 是一个开源项目,它的目的是定义通用的、标准化的事件格式。

img

  1. 事件上下文:包含事件的元数据,如事件类型、事件源、事件 ID 等。
  2. 事件数据:包含事件的有效负载,即与事件相关的具体信息。
  3. 传输协议:定义了如何在不同的系统和服务之间传输事件,例如 HTTP、MQTT、AMQP 等。

业务中的典型应用场景

自动化运维

在业务架构中,系统异常时肯定会有一些指标异常。正常处理逻辑是:收集基础指标并上报后,根据一定的规则对源数据进行过滤或聚合,然后触发告警推送后续的自动化处理流程。

img

应用连接和集成

我们在工作中会使用邮件进行交流,那么在邮件安全审核场景,当收到某些特殊的邮件后,就需要触发下游的安全审核、深度木马分析等等动作。
正常流程是:开发一段代码对接安全审核系统,收到邮件后进行初步的审核。当发现可能有风险的邮件后,就根据安全系统定义的 API 将邮件数据投递过去。如果还需要对接其他业务系统,也需要重复整个过程,因此重复开发成本和维护成本就很高。

img

如上图所示,邮件系统对接事件中心,将有风险的邮件数据上报到事件处理平台。事件处理平台会根据事先设定的规则,自动化调用对应系统的接口进行投递。从邮件业务开发的角度,工作量只剩对接事件处理平台。而且当需要接入其他业务时,只需要在事件处理平台添加新的事件处理规则即可,没有重复的开发工作量。

商品订单中台

当前不少企业都会通过 ERP、CRM 等内部系统来实现企业数字化。此时就会出现多项系统彼此闭环,数据难以统一管理的问题。为了解决它,我们就需要拥有一套数据连接聚合系统,把数据汇总起来。如果全部自定义开发,成本就很高,此时我们就可以引入事件驱动架构。

img

事件处理平台完成了类似业务中台的基础能力。企业也可以基于事件处理平台提供的接口规范以及路由原则,将事件处理平台作为底层架构,完成更复杂的业务中台搭建,从而简化开发成本。

如何构建事件处理平台

img

事件处理平台分为接入层、缓存层、运行层、分发层四个部分。

接入层

接入层顾名思义就是用来接收事件数据的。从功能来看,有被动接收和主动拉取两种形态。

  • 被动接收是指开发部署维护支持 HTTP、CloudEvent 等协议的 Server,并设计上报协议。客户端会根据 Server 规定的协议组织数据并完成上报。
  • 主动拉取是指客户端没有上报的能力,需要事件处理平台通过一定的方式去事件源拉取事件数据。比如,数据库类的事件源(MySQL Binlog、Mongo ChangeStream 等),就需要事件处理平台去主动订阅。

缓存层

缓存层一般使用的是消息队列集群,比如 Kafka、Pulsar、RocketMQ。需要关注以下 3 个问题:

  • 消息队列集群的性能。即集群的容量,这个一般是业务根据数据源的数据量进行评估。数据的可靠性。
  • 即接收保存数据后,需要保证数据不会丢失。这个一般需要关注集群的副本数量和一致性协议的选择。
  • 事件数据存储方式。即事件源肯定是有归属的,比如事件是归属某一个客户或某一个业务,此时如果有多个事件源,底层如何保存数据。

关于第三点,你可能有点模糊。假设我们有 7 个事件源。此时如果所有的事件都存储在一个分区或 Topic 里面,当某个事件源数据量太大导致消费堆积时,同一个分区里面其他事件源的事件处理就会受到影响。此时就得考虑事件数据存储方式。所有的事件源存储在一起,还是每个事件源都进行独立的分区或 Topic 存储。使用哪种方案更合理呢?它们各自存在什么问题?又如何解决?

如果所有的事件数据都存储在同一个 Topic 或者分区里面,此时一旦某个事件源的数据很大导致消费堆积时,必然会影响其他事件源数据的处理。而这个问题是无法根治的,所以合理的方案是,每个事件源的底层都是单层存储。那么使用独立的存储模型,应该选择每个事件源一个 Topic,还是每个事件源一个分区呢?从技术上来看,我建议你选择每个事件源一个独立的 Topic 存储。当数据量小时,每个 Topic 只有一个分区。当数据量大时,可以扩容 Topic 的分区数。此时如果还出现消费堆积的话,可以通过扩容分区、调整消费者的消费模型来提高消费速度。

运行层

运行层主要用来执行事件处理、过滤、分发的逻辑,相当于事件总线的内核。它主要有三个功能。

  1. 提供接口给用户配置事件规则相关的信息,提供增删改的接口,并持久化存储这些数据。
  2. 从缓存中拉取数据,根据预先配置好的规则对数据进行处理、过滤、聚合。
  3. 对处理完成的数据,匹配对应的规则触发调用分发层的接口。

运行层需要关注它底层的运行时是什么?

  • 内置固定规则运行时,是指我们通过编码实现固定的业务处理、分发逻辑。然后用户通过一些配置项来配置事件处理规则或分发策略,从而触发固定的逻辑处理。从技术实现来看,这种运行时底层一般是基于自定义编码、Flink/Spark 等来构建的。
  • Serverless 运行时,是指底层的部署形态是 Serverless Funciton。

分发层

即集成各种下游系统的 SDK,比如 HTTP SDK、ES SDK、JDBC SDK 等,然后供运行层调用。每种下游系统,在运行层只需要对接一次即可。

保证数据不丢失

事件处理平台保证数据不丢失包含下面两层含义:

  1. 接入层接收成功的事件数据不能丢失。

  2. 对事件数据的处理必须有结果。

  3. 第一点比较好理解,即接入层收到数据后,需要写入缓存层成功后,才能返回成功。还得在缓存系统异常时,保证数据不能丢失。这块依赖的就是编码技巧,以及缓存引擎的选择和使用方式。

  4. 第二点需要重点关注,即一个事件必须有一个执行结果,结果可以成功也可以失败。成功,可以是写入或者触发事件目标成功。失败的话就需要保存相关的日志,或投递到死信队列。

数据一致性语义

先来看下图,事件数据在事件处理平台可能是链式的处理,此时一个事件就有可能被传递多次。那如何保证事件不会被重复投递呢?

img

  1. 最多一次指消息不会被重复发送,最多被传输一次,但也有可能一次也不传输。
  2. 最少一次指消息不会被漏发送,最少被传输一次,但也有可能被重复传输。
  3. 精确一次指不会漏传输也不会重复传输,每个消息都只会被传输一次。

我们一般实现的是最少一次的语义,即允许少量的重复,让业务侧来处理重复的情况。

设计实现事件总线架构

img

连接器:如何以MQ为核心搭建数据集成架构?

在消息队列中,连接器也称为 Connector,它的作用是把不同数据源中的数据导入到消息队列,或者把消息队列中的数据导出到下游的各种存储引擎。

连接器是什么

连接器中的“连接”指的是数据的连接,即把数据从某个地方搬到另外一个地方。所以连接器就是指将数据从源端搬到目标端的组件。或者说只要具备数据连接功能的组件,就可以称为连接器。

消息队列连接器的功能也是把数据从源端搬到目标端。但和普通连接器不同的是,消息队列连接器的其中一端一定是消息队列。

业界主流消息队列 Kafka、RocketMQ、Pulsar 都支持连接器的概念,组件名称分别是 Kafka Connector、RocketMQ Connector、Pulsar IO。
从功能上来看,消息队列连接器分为源连接器(Source)和 目标连接器(Sink),作用分别是将数据源的数据导入到消息队列和把消息队列中的数据导出到下游的存储。

img

如上图所示,连接器是运行在多个物理节点(Worker)上的。因为连接器需要并发运行多个任务且需要具备横向扩容的能力,所以连接器运行的平台本质上是一个分布式任务调度平台。

消息队列连接器是由分布式任务调度平台和源 / 目标连接器两部分组成。平台负责这些连接器的运行,连接器负责对接各种数据源和数据目标。

数据集成和连接器

数据集成是一个概念,不是具体的功能组件。它是将数据从数据源搬到数据目标这个功能的描述,数据从源到目标的过程就称为数据集成。

消息队列连接器是数据集成概念下的一种技术。即消息队列的 Source 和 Sink 连接器都是一种数据集成。

接下来我们通过一个典型的数据集成场景,让你对数据集成和连接器有一个更深的认识。场景描述:将 MySQL 中的数据实时同步到 Elasticsearch。实现这个功能,我们有使用典型数据集成组件和消息队列连接器两种方案。

img

  1. 典型数据集成组件:该方案使用开源组件,如 Flink CDC、DataX、SeaTunnel 等组件,订阅 MySQL 的数据,然后把订阅到的数据同步到下游。
  2. 消息队列连接器:该方案需要先使用源连接器 SourceConnector 从 MySQL 订阅数据,然后将数据写入到消息队列当中,再使用 SinkConnector 将消息队列中的数据写入到下游存储引擎。

第一种方案适合数据源和数据目标是一对一的场景,第二种方案适合数据源和数据目标是一对多的场景。

消息队列连接器底层原理分析

img

消息队列连接连接器由数据源、源数据连接器、分布式任务调度平台、目标数据连接器、数据目标五部分组成。

分布式任务调度平台

分布式任务调度平台,也称为连接器的 Runtime,它的作用是用来运行各种 Connector 任务。
Runtime 一般有 Connector 和 Task 两个概念。Connector 是一个逻辑概念,用来表示一个订阅任务。Task 是任务运行的实体,用来执行各个具体的订阅任务。就是说 Runtime 里面会运行多个源 / 目标 Connector,一个 Connector 会被拆分为多个 Task 运行,Task 是 Runtime 调度的最小单位。

从功能角度,Runtime 一般需要具备以下四个功能:

  1. HTTP API,提供增删改查 Connector 任务,启动、暂停、扩容、缩容等操作的 API 接口。
  2. 元数据存储,用来保存集群 / Connector 基本信息,Connector/Task 运行信息(比如运行在哪些节点上、当前运行状态等),执行节点的基本信息等等。
  3. 调度模块,用来完成 Connector 任务的拆分、调度、启停、扩容等等。
  4. 任务执行节点,用来执行各种 Connector 和 Task 任务。

因为 Runtime 是集群部署,集群由多个任务执行节点组成。所以同样也需要有节点发现、元数据存储、主节点、节点探活等流程

源 / 目标连接器

连接器的功能是从数据源消费数据写入到数据目标。所以,从技术上拆解连接器,它应该包含读取(Read)、本地处理(Local Transforms)、写入(Write)三部分。

img

  • 读取,负责从源端读取数据,读取方式一般是主动读取。主动读取需要源端支持远程读取。比如订阅 MySQL Binlog,可以理解为是通过 TCP 协议去 MySQL 拉取数据。再比如订阅某个钉钉应用的消息,就需要通过钉钉某个开放的 HTTP API 去读取数据。
  • 本地处理,指当数据拉取到本地后,需要对数据进行格式转换、类型转换等处理。
  • 写入,负责将处理完成的数据写入到下游的数据目标,这块主要是使用下游引擎提供的 SDK 写入数据。

从代码的角度,Connector 是如何实现的呢?核心就是先定义 Interface 接口,然后各个 Source、Sink 插件继承实现接口的具体逻辑,从而实现具备不同功能的 Connector。

Connector 是一个逻辑概念,Task 才是执行的主体。创建一个 Connector 就是创建一个订阅操作,Connector 的底层会拆分为多个 Task。

public abstract class Connector{
  public abstract void start(Map<String, String> var1);
  public abstract void stop();
}

public abstract class Task{
  public abstract void start(Map<String, String> var1);
  public abstract void execute(Collection<SinkRecord> var1);
  void stop();
}

简单的数据清洗能力

我们可以把数据转换的逻辑放到 Runtime 里面去实现。连接器中的 Transforms 模块就是做这个功能的。我们给一个基础的数据处理功能清单,来看看 Transforms 都能实现哪些功能。

img

Apache Kafka Connector

Kafka Connector 是 Apache Kafka 官方支持的一个开源框架,用于将数据从外部系统导入到 Kafka 集群,或者从 Kafka 集群导出到外部系统。

系统架构

img

  1. Cluster 表示 Connector 的集群,也就是我们前面提到的 Runtime 集群。
  2. Connector 是负责管理整个订阅任务的逻辑概念,它负责组织管理 Task 任务。
  3. Task 是执行实际数据传输工作的组件。一个 Connector 可以有一个或多个 Task,每个 Task 都是一个独立的数据处理单元,可以并行运行。
  4. Worker 是运行 Connector 和 Task 的节点。
  5. Transform 是用于数据转换的组件。
  6. Config & Status Storage 用来存储 Connector 和 Task 的配置信息以及运行状态,这些信息通常存储在 Kafka 的内部主题中。

从实现来看,Kakfa Connector 的 Runtime 就是 Connector 集群,集群的元数据是存储在 Kakfa 内置的 Topic 中的。集群启动后,它通过暴露 HTTP API 给用户管理 Connector 任务。

RESTful API

Kafka Connect 提供了一组 RESTful HTTP API,包含了增删改查 Connector、修改配置等等,用来管理和监控 Kafka Connector 集群。

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

容灾能力的理论基础

img

当系统发生这些异常时,服务能够自动切换并正常运行就是我们说的容灾。
为了完成容灾,从技术上来看,容灾行为可以在集群内或者集群间完成。所以容灾可以分为集群内容灾和集群间容灾两种类型。

集群内和集群间容灾

img

集群内容灾主要靠主从切换来达到容灾效果,集群间容灾主要靠主备集群切换达到容灾效果。

RTO 和 RPO

RTO(Recovery Time Objective)指故障发生时业务系统所能容忍的服务停止时间。
RTO 越低,表示业务对服务的可用性要求越高。RPO(Recovery Point Objective)指故障发生时可能有多少数据会丢失。RPO 越低,表示业务对数据的可用性要求越高。

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

img

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

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

RTO 和 RPO

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

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

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

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

img

主备集群的核心是主备集群之间消息数据和集群元数据的复制。因为需要同时复制消息数据和元数据,所以主备复制应该有两条链路,分别是实时同步消息数据和实时同步集群元数据的链路。

两种复制方式

独立运行复制组件、主集群复制、备集群复制三种思路

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

客户端连接集群

一般有“直连 Broker”和“通过网关或虚拟 IP 连接 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。这种方案是比较优雅的,也是比较推荐的。

双向同步

那就是当备集群提升为主集群后,数据如何同步回主集群?这就涉及到主备集群的双向同步问题了。所以接下来我们再来看看主备集群之间是如何实现数据双向同步的。

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

元数据双向同步的核心是确认元数据信息以哪个集群为准。所以更常用的方案是:标记元数据信息的主集群,元数据信息只在主集群上进行变更,备集群永远是同步的角色。即不管主备如何切换,复制方向都不变。

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

RTO 和 RPO

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

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

Apache Kafka MirrorMaker

img

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

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

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

消费位点同步

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

img

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

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

所以同步 ConsumeOffset 的时候,如果消费到 A4,则需要记录一下源集群 Offset=4 和目标集群 Offset=14 的映射关系,以保证消费的是同一条消息。从实现的角度,MirrorMaker 在同步消费进度时,会在一个内部 Topic 存储 Offset 的映射信息,然后通过这个映射关系在备集群找到准确的消费位点。

posted @ 2023-11-13 16:22  Blue Mountain  阅读(523)  评论(0)    收藏  举报