存储模块的主流程是数据的写入、存储、读取、过期,因为消息队列本质是做一个缓冲,它的持久化在一定时间或者数据被消费后需要删除。

消息队列中的数据一般分为元数据和消息数据。元数据是指 Topic、Group、User、ACL、Config 等集群维度的资源数据信息,消息数据指客户端写入的用户的业务数据。

元数据信息的存储

元数据信息的特点是数据量比较小,不会经常读写,但是需要保证数据的强一致和高可靠,不允许出现数据的丢失。同时,元数据信息一般需要通知到所有的 Broker 节点,Broker 会根据元数据信息执行具体的逻辑。比如创建 Topic 并生成元数据后,就需要通知对应的 Broker 执行创建分区、创建目录等操作。

所以元数据信息的存储,一般有两个思路:

  • 基于第三方组件来实现元数据的存储。

  • 在集群内部实现元数据的存储。

基于第三方组件来实现元数据的存储是目前业界的主流选择。比如 Kakfa 和 Pulsar 的元数据存储在 ZooKeeper 中,RocketMQ 存储在 NameServer 中。

image-20230718110010031

优点是集成方便,而且第三方软件已经保证了一致性,高性能等需求,可以降低开发成本。

但也有缺点。引入第三方组件会增加系统部署和运维的复杂度,而且第三方组件自身的稳定性问题会增加系统风险,第三方组件和多台 Broker 之间可能会出现数据信息不一致的情况,导致读写异常。

消息数据的存储

消息队列的存储主要是指消息数据的存储,分为存储结构、数据分段、数据存储格式、数据清理四个部分。

数据存储结构设计

在消息队列中,跟存储有关的主要是 Topic 和分区两个维度。用户可以将数据写入 Topic 或直接写入到分区。

如果写入 Topic,数据也是分发到多个分区去存储的。所以从实际数据存储的角度来看,Topic 和 Group 不承担数据存储功能,承担的是逻辑组织的功能,实际的数据存储是在在分区维度完成的

image-20230718110640501

而数据的罗盘也有两种思路:

  1. 每个分区单独一个存储“文件”。
  2. 每个节点上所有分区的数据都存储在同一个“文件”。

这里的“文件”是一个虚指,即表示所有分区的数据是存储在一起,还是每个分区的数据分开存储的意思。

第一种思路

每个分区的数据对应一个文件去存储,在实现上每个分区的数据是顺序写入到同一个磁盘文件中,数据是连续的,所以读写性能上效率最高。

image-20230718112210221

但如果分区太多,会占用太多的系统 FD 资源,极端情况下有可能把节点的 FD 资源耗完,并且硬盘层面会出现大量的随机写情况,导致写入的性能下降很多,另外管理起来也相对复杂。(fd是是内核为了高效管理这些已经被打开的文件所创建的一种索引,如果太多分区,相当于很多个文件在同时读写,虽然每个分区内文件是顺序读写的,但是分区之间并不是顺序,而是随机的,所以会导致磁盘层面大量的随机读写)

具体的磁盘组织结构:

image-20230718111845083

第二种思路

每个节点上所有分区的数据都存储在同一个文件中,这种方案需要为每个分区维护一个对应的索引文件,索引文件里会记录每条消息在 File 里面的位置信息,以便快速定位到具体的消息内容。

image-20230718112137877

因为所有文件都在一份文件上,管理简单,也不会占用过多的系统 FD 资源,单机上的数据写入都是顺序的,写入的性能会很高

缺点是同一个分区的数据一般会在文件中的不同位置,或者不同的文件段中,无法利用到顺序读的优势,读取的性能会受到影响。

消息数据的分段实现

数据分段的规则一般是根据大小来进行的,比如默认 1G 一个文件,同时会支持配置项调整分段数据的大小。从技术上来看,当数据段到达了规定的大小后,就会新创建一个新文件来保存数据。

如果进行了分段,消息数据可能分布在不同的文件中。所以我们在读取数据的时候,需要先定位消息数据在哪个文件中。为了满足这个需求,技术上一般有根据偏移量定位或根据索引定位两种思路。

偏移量

根据偏移量(Offset)来定位消息在哪个分段文件中,是指通过记录每个数据段文件的起始偏移量、中止偏移量、消息的偏移量信息,来快速定位消息在哪个文件。

当消息数据存储时,通常会用一个自增的数值型数据(比如 Long)来表示这条数据在分区或 commitlog 中的位置,这个值就是消息的偏移量。

image-20230718112847872

在实际的编码过程中,记录文件的起始偏移量一般有两种思路:单独记录每个数据段的起始和结束偏移量,在文件名称中携带起始偏移量信息。因为数据是顺序存储的,每个文件记录了本文件的起始偏移量,那么下一个文件的起始偏移量就是上一个文件的结束偏移量。

索引定位

如果用索引定位,会直接存储消息对应的文件信息,而不是通过偏移量来定位到具体文件。

具体是通过维护一个单独的索引文件,记录消息在哪个文件和文件的哪个位置。读取消息的时候,先根据消息 ID 找到存储的信息,然后找到对应的文件和位置,读取数据。

区别

这两种方案所面临的场景不一样。

根据偏移量定位数据,通常用在每个分区各自存储一份文件的场景;根据索引定位数据,通常用在所有分区的数据存储在同一份文件的场景。

因为在前一种场景,每一份数据都属于同一个分区,那么通过位点来二分查找数据的效率是最高的。

第二种场景,这一份数据属于多个不同分区,则通过二分查找来查找数据效率很低,用哈希查找效率是最高的。

消息数据存储格式

消息数据存储格式一般包含消息写入文件的格式和消息内容的格式两个方面。

消息写入文件的格式指消息是以什么格式写入到文件中的,比如 JSON 字符串或二进制。从性能和空间冗余的角度来看,消息队列中的数据基本都是以二进制的格式写入到文件的。

消息内容的格式是指写入到文件中的数据都包含哪些信息。对于一个成熟的消息队列来说,消息内容格式不仅关系功能维度的扩展,还牵涉性能维度的优化。

消息数据清理机制

消息队列的数据过期机制一般有手动删除和自动删除两种形式,从实现上看主要有三种思路。

  • 消费完成执行 ACK 删除数据
  • 根据时间和保留大小删除
  • ACK 机制和过期机制相结合

方案1

消费完成执行 ACK 删除数据,技术上的实现思路一般是:当客户端成功消费数据后,回调服务端的 ACK 接口,告诉服务端数据已经消费成功,服务端就会标记删除该行数据,以确保消息不会被重复消费。ACK 的请求一般会有单条消息 ACK 和批量消息 ACK 两种形式。

image-20230718113808956

因为消息队列的 ACK 一般是顺序的,如果前一条消息无法被正确处理并 ACK,就无法消费下一条数据,导致消费卡住。此时就需要死信队列的功能,把这条数据先写入到死信队列,等待后续的处理。然后 ACK 这条消息,确保消费正确进行。

这个方案,优点是不会出现重复消费,一条消息只会被消费一次。缺点是 ACK 成功后消息被删除,无法满足需要消息重放的场景。

方案2

根据时间和保留大小删除指消息在被消费后不会被删除,只会通过提交消费位点的形式标记消费进度。

实现思路一般是服务端提供偏移量提交的接口,当客户端消费成功数据后,客户端会回调偏移量提交接口,告诉服务端这个偏移量的数据已经消费成功了,让服务端把偏移量记录起来。然后服务端会根据消息保留的策略,比如保留时间或保留大小来清理数据。一般通过一个常驻的异步线程来清理数据。

image-20230718114135696

这个方案,一条消息可以重复消费多次。不管有没有被成功消费,消息都会根据配置的时间规则或大小规则进行删除。优点是消息可以多次重放,适用于需要多次进行重放的场景。缺点是在某些情况下(比如客户端使用不当)会出现大量的重复消费。

方案3

我们结合前两个方案,就有了 ACK 机制和过期机制相结合的方案。实现核心逻辑跟方案二很像,但保留了 ACK 的概念,不过 ACK 是相对于 Group 概念的。

当消息完成后,在 Group 维度 ACK 消息,此时消息不会被删除,只是这个 Group 也不会再重复消费到这个消息,而新的 Group 可以重新消费订阅这些数据。所以在 Group 维度避免了重复消费的情况,也可以允许重复订阅。(说白了,一条消息在一个Group消费后会发送一个ACK确认,然后此Group就不会再消费该消息了,但是其他的Group还是可以重复的消费该消息,如果超过时间,则该消息被删除,其他Group也无法订阅)

image-20230718114619550

我们知道消息数据是顺序存储在文件中的,会有很多分段数据,一个文件可能会有很多行数据。

那么在 ACK 或者数据删除的时候,一个文件中可能既存在可删除数据,也存在不可删除数据。

如果我们每次都立即删除数据,需要不断执行“读取文件、找到记录、删除记录、写入文件”的过程,即使批量操作,降低频率,还是得不断地重复这个过程,会导致性能明显下降。

当前主流的思路都是延时删除,以段数据为单位清理,降低频繁修改文件内容和频繁随机读写文件的操作。

只有该段里面的数据都允许删除后,才会把数据删除。而删除该段数据中的某条数据时,会先对数据进行标记删除,比如在内存或 Backlog 文件中记录待删除数据,然后在消费的时候感知这个标记,这样就不会重复消费这些数据。

img

参考

《深入拆解消息队列 47 讲》