消息数据和元数据的存储
消息队列中的数据一般分为元数据和消息数据。元数据是指 Topic、Group、User、ACL、Config 等集群维度的资源数据信息,消息数据指客户端写入的用户的业务数据
元数据信息的存储
元数据信息的特点是数据量比较小,不会经常读写,但是需要保证数据的强一致和高可靠,不允许出现数据的丢失
同时,元数据信息一般需要通知到所有的 Broker 节点,Broker 会根据元数据信息执行具体的逻辑。比如创建 Topic 并生成元数据后,就需要通知对应的 Broker 执行创建分区、创建目录等操作。
另一种思路,集群内部实现元数据的存储是指在集群内部完成元数据的存储和分发。也就是在集群内部实现类似第三方组件一样的元数据服务,比如基于 Raft 协议实现内部的元数据存储模块或依赖一些内置的数据库。目前 Kafka 去 ZooKeeper 的版本、RabbitMQ 的 Mnesia、Kafka 的 C++ 版本 RedPanda 用的就是这个思路。
消息数据的存储
第一个思路,每个分区对应一个文件的形式去存储数据。具体实现时,每个分区上的数据顺序写到同一个磁盘文件中,数据的存储是连续的。因为消息队列在大部分情况下的读写是有序的,所以这种机制在读写性能上的表现是最高的。
但如果分区太多,会占用太多的系统 FD 资源,极端情况下有可能把节点的 FD 资源耗完,并且硬盘层面会出现大量的随机写情况,导致写入的性能下降很多,另外管理起来也相对复杂。Kafka 在存储数据的组织上用的就是这个思路。
第二种思路,每个节点上所有分区的数据都存储在同一个文件中,这种方案需要为每个分区维护一个对应的索引文件,索引文件里会记录每条消息在 File 里面的位置信息,以便快速定位到具体的消息内容。
因为所有文件都在一份文件上,管理简单,也不会占用过多的系统 FD 资源,单机上的数据写入都是顺序的,写入的性能会很高。缺点是同一个分区的数据一般会在文件中的不同位置,或者不同的文件段中,无法利用到顺序读的优势,读取的性能会受到影响,但是随着 SSD 技术的发展,随机读写的性能也越来越高。如果使用 SSD 或高性能 SSD,一定程度上可以缓解随机读写的性能损耗,但 SSD 的成本比机械硬盘高很多
如果进行了分段,消息数据可能分布在不同的文件中。所以我们在读取数据的时候,就需要先定位消息数据在哪个文件中。为了满足这个需求,技术上一般有根据偏移量定位或根据索引定位两种思路。
根据偏移量(Offset)来定位消息在哪个分段文件中,是指通过记录每个数据段文件的起始偏移量、中止偏移量、消息的偏移量信息,来快速定位消息在哪个文件。
当消息数据存储时,通常会用一个自增的数值型数据(比如 Long)来表示这条数据在分区或 commitlog 中的位置,这个值就是消息的偏移量。
这两种方案所面临的场景不一样。根据偏移量定位数据,通常用在每个分区各自存储一份文件的场景;根据索引定位数据,通常用在所有分区的数据存储在同一份文件的场景。因为在前一种场景,每一份数据都属于同一个分区,那么通过位点来二分查找数据的效率是最高的。第二种场景,这一份数据属于多个不同分区,则通过二分查找来查找数据效率很低,用哈希查找效率是最高的。
消息数据存储格式
如果消息格式设计得不够精简,功能和性能都会大打折扣。比如冗余字段会增加分区的磁盘占用空间,使存储和网络开销变大,性能也会下降。如果缺少字段,则可能无法满足一些功能上的需要,导致无法实现某些功能,又或者是实现某些功能的成本较高。
所以,在数据的存储格式设计方面,内容的格式需要尽量完整且不要有太多冗余。
Kafka 的消息内容包含了业务会感知到的消息的 Header、Key、Value,还包含了时间戳、偏移量、协议版本、数据长度和大小、校验码等基础信息,最后还包含了压缩、事务、幂等 Kafka 业务相关的信息。
RocketMQ 的存储格式中也包含基础的 Properties(相当于 Kafka 中的 Header)、Value、时间戳、偏移量、协议版本、数据长度和大小、校验码等信息,还包含了系统标记、事务等 RocketMQ 特有的信息,另外还包含了数据来源和数据目标的节点信息
消费完成执行 ACK 删除数据,技术上的实现思路一般是:当客户端成功消费数据后,回调服务端的 ACK 接口,告诉服务端数据已经消费成功,服务端就会标记删除该行数据,以确保消息不会被重复消费。ACK 的请求一般会有单条消息 ACK 和批量消息 ACK 两种形式
因为消息队列的 ACK 一般是顺序的,如果前一条消息无法被正确处理并 ACK,就无法消费下一条数据,导致消费卡住。此时就需要死信队列的功能,把这条数据先写入到死信队列,等待后续的处理。然后 ACK 这条消息,确保消费正确进行。
这个方案,优点是不会出现重复消费,一条消息只会被消费一次。缺点是 ACK 成功后消息被删除,无法满足需要消息重放的场景。
这个方案,一条消息可以重复消费多次。不管有没有被成功消费,消息都会根据配置的时间规则或大小规则进行删除。优点是消息可以多次重放,适用于需要多次进行重放的场景。缺点是在某些情况下(比如客户端使用不当)会出现大量的重复消费。
ACK 机制和过期机制相结合的方案。实现核心逻辑跟方案二很像,但保留了 ACK 的概念,不过 ACK 是相对于 Group 概念的。
当消息完成后,在 Group 维度 ACK 消息,此时消息不会被删除,只是这个 Group 也不会再重复消费到这个消息,而新的 Group 可以重新消费订阅这些数据。所以在 Group 维度避免了重复消费的情况,也可以允许重复订阅。
纵观业界主流消息队列,三种方案都有在使用,RabbitMQ 选择的是第一个方案,Kafka 和 RocketMQ 选择的是第二种方案,Pulsar 选择的是第三种方案
只有该段里面的数据都允许删除后,才会把数据删除。而删除该段数据中的某条数据时,会先对数据进行标记删除,比如在内存或 Backlog 文件中记录待删除数据,然后在消费的时候感知这个标记,这样就不会重复消费这些数据。