37 云原生:MQ的分层存储架构都有哪些实现方案?
你好,我是文强。
这节课我们来看看消息队列中分层存储的功能。很多人对分层存储的概念比较模糊,经常会将它和存算分离混淆在一起。从功能上来看,两者是完全不一样的。存算分离架构主要解决的是集群架构的弹性问题,而分层存储架构解决的是低成本存储冷数据的问题。
下图是两种形态的架构对比,存算分离是将计算层和存储层独立开来,分别负责计算相关逻辑和存储数据。而分层存储在本地完成计算和存储逻辑,然后将 Broker 本地的冷数据上传到远程进行存储,需要时再拉下来处理。
从技术上看,左边的存算分离架构也可以支持分层存储的特性,即把存储层中的冷数据再导入到另外一个存储系统存储。
那分层存储是怎么实现的呢?业界主流消息队列是如何支持分层存储的呢? 带着这两个问题,我们开始今天的课程。
什么是分层存储
首先,我们来了解一下什么是分层存储。
消息队列中的分层存储,就是我们经常见到的 冷热数据分开存储。在存算一体的消息队列架构中,消息数据是以分段的形式存储在本地硬盘中的。一般情况下,消息数据保留的时间比较短,大部分在一天左右。而如果要保留长时间的数据,就需要占用大量的本地硬盘空间,这会导致存储成本较高。
为了降低存储成本,消息队列参考了冷热数据分离的思路,提出了分层存储的概念。如下图所示,写入数据的时候还是将数据写入到本地,然后通过在 Broker 中设置一定的策略将 Broker 上的老数据上传到远程的分布式文件系统中。在消费的时候,再从远程拉取数据到本地,给消费端消费。
从技术上看,远程存储系统与存算分离架构的存储引擎的选择思路不太一样。因为分层存储的数据一般是以数据段的形式上传到远端文件系统的。
所以总结来看,分层存储就是指 在不改动本地存算一体架构的前提下,通过一定的策略将本地的数据存储到远程,从而降低本地硬盘的负载压力。在消费的时候,再从远端文件系统下载对应的数据,提供给消费者消费。
清楚了分层存储的定义,接下来我们看看分层存储的应用场景和局限。
分层存储的应用和局限
从用户的角度来看,分层存储的核心作用是:通过将数据存储到更廉价的存储中,来降低存储成本。
因为消息队列本质上是一个存储引擎,所以从理论上看分层存储是能带来成本价值的。如下图所示,单台 Broker 日常需要存储 5TB 数据,开启分层存储后,就可以将 4TB 数据存储到远程,本地只需要保留1TB的数据。
从成本计算的角度,一般远程存储的存储成本是本地的三分之一,所以成本计算如下:
可以看到,总成本节省了近乎一半,听起来很不错。
那分层存储是成本优化的银弹吗?它有什么缺点吗?
从技术上来看,成本的降低是以牺牲性能和稳定性为代价的。我们可以明确两个信息:
- 从计算机理论基础可以知道,远程存储的性能肯定是比本地硬盘低的。
- 引入了第三方存储系统,第三方存储系统的稳定性肯定会影响消息队列集群的稳定性。
因为消息队列是一个需要高性能、高可靠、高稳定的系统,所以说分层存储并不是成本优化的银弹。
分层存储的价值在于,我们在什么场景中使用它。这句话怎么理解呢?还是我们上节课说到的那句话, “用户是多元的,从而诉求也是多元的”。
从技术上来看,我们可以通过技术和运维层面的优化,来提高读取远程数据的性能和稳定性。但是在极端场景下,这两个问题还是无法根治。
所以在一些大流量、不要求高可靠的场景当中,分层存储是一个很重要的成本优化手段。但在一些存储成本不高,对于性能、可靠性要求却很高的场景中,就不需要分层存储。
总结来说,分层存储是一个可选项,而不是必选项,是一个用户可控开关的特性。
接下来,我们看看技术上如何实现分层存储,以及有哪些技术要点需要注意。
实现分层存储的技术思考
我们先从开启分层存储后,数据的流动路径来理解一下分层存储在功能方面的表现。
参考图示,可以知道:
- 客户端的生产消费还是原先的流程,从分区 Leader 所在的 Broker 进行生产消费。
- Broker 收到数据后,还是将数据写入到本地存储。当开启分层特性后,Broker 内部会有一个模块(可以理解为一批线程)根据设置的分层策略,将本地的分段文件数据上传到远端的分布式文件系统中。
- 消费时如果数据还留在本地,则直接读取本地数据然后返回;如果数据不在本地,就从远程读取返回给客户端。
结合上面的流程,从技术上分析,实现分层存储主要需要关注 远程文件系统的选择、 生产性能优化、 消费性能优化、 隔离性和回滚 等四个方面。接下来我们依次来分析一下,先来看一下如何选择合适的分布式文件系统。
选择远程文件系统
先来看下图,回顾一下我们在 第05讲 讲过的消息队列底层消息数据的分段存储结构。
基于这个存储模型,从业界落地的角度来看, 目前最适合分层存储的分布式文件系统一般是各个云厂商提供的对象存储服务。因为对象存储作为云上的基础组件,稳定性和成本都具有较高的优势。而如果希望自建服务,一般会选择HDFS。
在分层存储架构中,最核心的就是读写性能和优化,这关系到消息队列的性能表现。接下来我们就来看看分层存储在生产和消费时的性能优化方案。
生产性能优化
先来看一下生产性能的优化。
如上图所示,从生产的角度来看,因为数据是写入到本地文件,然后再通过异步线程上传到远端文件系统,所以从性能的角度看,写入性能基本不受影响。只有异步线程上传或下载文件时,对资源的占用(比如对CPU、内存、网卡、硬盘等),可能导致写入性能受到影响。这部分的优化,我们一会儿讲隔离性的时候再展开。
而如果是实时写入远程存储的方案,性能肯定会受到影响,此时从技术上来看,只能通过编码技巧来优化,从而提高一定的性能。这块我们在后面讲 RocketMQ 的实现时再展开。
下面再来看一下消费性能的优化。
消费性能优化
消费流程的细节就比较多了,核心点在于: 当用户消费的数据在远程不在本地时,如何高性能地消费数据。从技术实现来看,有以下两种方案:
- 远程的分层文件先下载到本地,消费请求只从本地硬盘读取数据。
- 当数据在本地就读取本地的数据,当数据在远程时,就流式的从远程存储系统读取数据。
这两种方案的主要区别在于:远程数据的读取方式不同,从而导致消费性能和开发复杂度的差异。
先来看第一种方案。
如图所示,消费请求只从本地的硬盘读取数据,同时有一个异步线程根据设置好的预读策略,提前调度,从远程下载接下来可能会消费的数据,再写入到本地,供消费请求读取。这里的核心就是 预读算法 的设计。
从技术上来看,消息队列的预读算法比较好实现,因为消息队列都是顺序消费的模型,所以消费时我们自然就知道接下来消费哪些数据,只要提前下载好下一份数据分段即可。
但是预读算法无法做到完美,还是会存在冷启动的情况。比如我们初始化消费分组消费数据或在消费过程中重置消费位点时,可能出现数据不在本地的情况,此时就需要先把数据下载到本地,然后才能消费,此时消费就会有卡顿。这个问题是无法避免的,但是它只会出现在初始化和重置消费位点等场景,并且也可以通过一定的技术手段来优化,影响较小。
该方案的优点是,可以通过预读、批量读等手段提前将数据下载到本地,从而保证原先的消费流程不变,理论上如果全部命中热读,性能可以和非分层架构保持一致。缺点是下载数据写入到硬盘,可能会占用硬盘空间,影响 IOPS 性能,并且会占用 Broker 节点的带宽,此时可能会影响读写的性能。因为理论上会有冷启动的情况,所以此时消费性能就会低于非分层。
再来看第二种方案。
如图所示,消费数据的时候先判断数据是否在本地,在的话就读取本地数据;不在的话,则直接通过远程存储提供的SDK去流式地读取数据,然后在内存中将流数据转成FetchRecord,返回给客户端。
这种方案的好处是,当数据在本地时,性能理论上和非分层可以对齐。读冷数据时无需将数据写入到本地硬盘,因此不会对本地硬盘的写入IO和空间造成挤占。缺点是远程存储性能较低,直接远程读取数据的性能,肯定会低于非分层的性能,另外也会占用网卡带宽。
理论上来说,如果第二种方案中的性能问题能够通过技术手段解决,那么就可以优先选择第二种方案。但是从具体代码落地来看,纯技术手段很难使性能和第一种方案对齐。
所以,在我看来,短期内第一种方案是比较常用的选择。在第一种方案成熟后,再探索第二种方案。
接下来我们看看分层实现过程中的资源隔离性、限流、集群回滚等操作。
隔离性和回滚
隔离性是指如何避免上传和下载的操作过度挤占资源,导致主流程的生产消费性能受到影响。上面讲到,上传和下载操作影响的主要是CPU、内存、网卡和硬盘资源。
那么从技术上来看,在单个进程内是无法做到资源的强隔离的。但是有几个思路,你可以了解一下。
- 从CPU的角度,我们可以通过线程绑核操作(参考 第18讲),在一定程度解决 CPU 隔离的问题。即把上面提到的上传、下载文件的线程绑定到某一批固定的 CPU 核心上,从而让CPU的消耗控制在一定的范围内。
- 对于内存的占用,这点就很细节,比如我们可以通过堆外内存、Direct IO等手段,精细化控制内存,从而避免消耗过多内存。
- 对于网卡的占用,从应用程序上看,没有办法控制程序对网卡的消耗,但是可以通过控制同一时间上传或下载的文件数和速度,来避免把网卡的带宽资源用光。
- 对于硬盘IO的占用,在空间层面的占用可以通过扩容存储空间来解决。对于IOPS的占用,从软件层面来看比较难解决,但是可以在物理层面通过分盘来实现IO隔离,比如正常的写入操作用A盘,下载操作用B盘这样子,只是分盘操作会增加系统运维的复杂度。
这里给一个我的结论,如果要做到精细的资源隔离,细节很多,开发工作量也特别大,周期会比较长。并且从理论上讲,很难做到完美的隔离。
所以从具体落地的角度来看,我们可以 通过对上传下载线程数的控制、上传下载速度的限制,以及优化预读缓存算法等手段来降低对资源的损耗。 在这几个操作的基础上,配合上 CPU 绑核、内存精细化管理,就可以做到较好的资源保护。
当我们启动了分层特性后,单一的消息队列集群就引入了一个远端存储。此时当远程的存储系统服务抖动或服务不可用后,就会影响消息队列的集群,并且远端集群的异常可能会有很多种,无法在消息队列集群本身 cover 住所有异常。
所以,消息队列稳定性的兜底方案是 回滚。即当远端存储服务出现无法解决的问题时,可以将集群恢复到非分层的状态。从技术上看,集群抖动时不会影响生产数据的操作,只是新的数据段不应该再上传到远程存储,但是会影响老数据的消费,即如果数据不在本地,当远程服务异常,这些数据就无法正常消费。
所以回滚的核心分为以下两点:
- 暂停上传 。 即新增的数据段不再上传到远程存储,都保留在本地,保证生产和消费都是正常的。
- 消费老数据时提示错误,只允许消费新数据。理论上看,回滚方案无法解决的就是老数据的消费,这点是需要重点关注的。
业界主流消息队列的架构分析
业界主流消息队列 Kafka、Pulsar、RocketMQ 都支持了分层存储。因为 Kakfa 和 Pulsar 的实现思路基本一致,而 RocketMQ 的实现思路不太一样,所以接下来我们重点分析一下 Kakfa 和 RocketMQ 分层存储的实现。
RocektMQ 多级存储的实现分析
RocketMQ 把分层存储的特性叫做多级存储,当前 RocketMQ 的多级存储还处于很早期的阶段。不过已经有一个基础的技术设计了,我们接下来就来简单分析一下。
我们前面讲到分层存储的主要思路就是,异步地将底层分区的数据段上传到远端,然后消费时再从远端读取数据。但是 RocketMQ 的实现方式不一样,它是 通过准实时的方式上传消息,而不是等一个分段写满后再异步上传。
看一下 RocketMQ 多级存储的架构图。
参考图示,RocketMQ 多级存储的消息上传是由内核中的 Dispatch 机制触发的。初始化多级存储时,会将 TieredDispatcher 注册为 CommitLog 的 Dispacher。这样当消息发送到 Broker,就会调用 TieredDispatcher 进行消息分发。TieredDispatcher 将该消息的引用写入到内存 Buffer 以后立即返回成功。然后在底层以 Queue 维度构建 CommitLog、ConsumeQueue,再将文件上传到远端存储中。这个数据分发上传的逻辑是准实时的,即处理完部分数据后就会上传到远端存储。
从数据读取的角度来看,TieredMessageStore 实现了 MessageStore 中的消息读取相关接口,通过请求中的逻辑位点判断是否需要从多级存储中读取消息。如果需要,读取消息时会预读一部分消息供下次使用,这些消息暂存在预读缓存中。预读缓存的设计参考了 TCP 拥塞控制算法,每次预读的消息量类似拥塞窗口采用加法增、乘法减的机制控制。
从底层的实现来看,RocketMQ 的多级存储还实现了故障恢复、上传进度控制、分层元数据管理、广播消费等逻辑。如果需要的了解更多细节,可以参考官方的 RIP RocketMQ Tiered Storage。接下来我们来看一下 Kafka 分层存储的实现。
Kakfa 分层存储的实现分析
从技术实现上来看,Kafka 分层存储的核心思路就是, 将底层分区维度的分段数据上传到远端存储,在消费时再从远端读取数据返回给客户端。
需要注意的是,社区版本的 Kafka 的分层存储目前还在开发阶段,还不能在业务中使用。社区目前的进度是设计出了一个整体的架构,并实现了一部分核心代码。
看一下 Kafka 分层存储的架构图。
如上图所示,RemoteLogManager(RLM)是一个新的内部组件,不是一个公共 API 接口。它的主要作用是:
- 接收处理 Leader 切换和 Topic / 分区的创建、删除等操作,然后将Topic / 分区复制、读取和删除操作交给 RemoteStorageManager 实现。
- 通过 RemoteLogMetadataManager 维护相应的远程数据段的元数据。
RemoteLogMetadataManager 是一个接口,用于提供具有强一致性语义的远程日志段的元数据的生命周期。有一个使用内部主题的默认实现。如果用户打算使用另一个系统来存储远程日志段的元数据,则可以插入自己的实现,RemoteStorageManager 提供远程日志段和索引生命周期接口。
从数据流来看,Kafka 分层存储的核心就是 上传文件段和从远程读取文件数据。从功能上来看,Kafka 上传数据的实现跟我们前面讲到的思路是一样的。因为读取数据的逻辑官方还没有明确实现,从技术上看也是上面两种思路,所以就不再赘述。
从实现来看,Kafka 和 RocketMQ 最大的区别在于:
- RocketMQ 需要将开启分层存储的 Topic 的数据从 Broker 维度的 CommitLog 中分离出来,重新构建 Topic 维度的 CommitLog,然后将新的 CommitLog 上传到远程。而 Kafka 是直接将分区数据段上传到远程。
- RocketMQ 是准实时地将数据上传到远程。Kafka 是异步地将文件段上传到远程。
- RocketMQ 是实时读数据,会通过预读算法缓存数据。Kafka 的消费方式官方没有明确实现,技术上的思路就如我们上面所讲的两种。
总结
说实话,分层存储的技术细节特别多,不是一节课就能讲完的,这节课我们只挑了几个主要的技术点来分析讲解。如果需要了解更细节,欢迎从课程介绍页面进群或者留言讨论。
在不同的消息队列中,分层存储的叫法不一样。从技术上来看,都是基于冷热数据分离的思路,将冷数据保存到远端存储引擎,在需要读取数据的时候再从远程读取数据。
分层存储的核心作用就是降低成本,反作用是性能必然会有所降低。因此在一些对性能不敏感的场景,分层存储能起到节省成本的作用。而在性能敏感的场景,不建议开启分层存储。
从技术上来看,分层存储的基础是选择合适的远程文件系统。从实际落地以及稳定性的角度来看,云厂商提供的对象存储服务是一个比较优的选择。如果是自建集群,HDFS 集群是一个可选方案。
从性能的角度来看,生产和消费的性能优化是分层存储的核心。生产主要关注的是实时写入远程还是异步上传文件到远程,消费需要关注的是从远程读取数据的方式,以及预读算法的设计。集群资源的隔离性以及回滚方案设计,能够极大地提高消息队列集群的稳定性。
业界主流消息队列的分层思路,主要有实时写入和异步上传两种方式。两种方式的选择主要和消息队列集群的特性相关。比如 RocketMQ 因为底层文件存储模型的原因,需要重新构建 Topic 维度的分段文件,就选择了准实时的方案。Kafka 因为已经是分区维度的分段存储,则选择的是异步上传分段数据的方案。
思考题
为什么 RocketMQ 使用准实时的方式将数据上传到远端存储引擎呢?
欢迎分享你的思考,如果觉得有收获,也欢迎你把这节课分享给身边的朋友。我们下节课再见!
上节课思考闭环
Pulsar 的计算层为什么要引入 Bundle?引入Bundle又有什么好处呢?
从技术上看,引入 Bundle 的主要原因是 Pulsar 有自动负载均衡机制,会把负载较高的 Broker 上的一些 Topic 迁移到负载较低的 Broker 中,从而实现Broker间负载的均衡。
而这个迁移如果以Namespace为单位,可能会一下子迁移很多Topic。而如果以Topic为单位,每次搬移数据又可能会很小,因为迁移过程中需要修改大量 Topic 和 Broker 之间的元数据。所以,以 Bundle 为单位进行迁移是最合适的,用它迁移 Topic 会容易很多。