用吃饭的场景生动地诠释了消息队列的几个关键概念:
通过日常生活的吃饭场景,形象地解释了消息队列的工作原理,包括消息主题、生产者、消费者、消息存储和消费等核心概念。这些概念抽象起来可能较难理解,但结合具象的例子就很容易理解了
总结为:分区实现了消息队列的并行化,是提升吞吐量和实现横向扩展的关键手段。
特性和性能是存储结构的外在表现,其实质是存储设计。我们需要了解每种消息传递协议的特性,以便更好地理解它们的架构设计。
我们将首先介绍 Kafka、RocketMQ 和 Pulsar 的架构特点,然后比较它们在架构上的不同之处,以及这些不同之处如何影响它们的功能特性。
Kafka 在底层设计上强依赖于文件系统(一个分区对应一个文件系统),本质上是基于磁盘存储的消息队列,在我们固有印象中磁盘的读写速度是非常慢的,慢的原因是因为在读写的过程中所有的进程都在抢占“磁头”这把锁,磁头在读写之前需要将其移动到合适的位置,这个“移动”极其耗费时间,这也就是磁盘慢的原因,但是如何不用移动磁头呢,顺序写盘就诞生了。
Kafka 消息存储在分区中,每个分区对应一组连续的物理空间。新消息追加到磁盘文件末尾。消费者按顺序拉取分区数据消费。Kafka 的读写是顺序的,可以高效地利用 PageCache,解决磁盘读写的性能问题。
这一特性非常重要,很多组件的底层存储设计都会用到这点,理解好这点对理解消息队列尤为重要。
The Pathologies of Big Data
kafka 的整体性能收到了 topic 数量的限制,这和底层的存储有密不可分的关系,我们上面讲过,当消息来的时候,底层数据使用追加写入的方式,顺序写盘,使得整体的写性能大大提高,但这并不能代表所有情况,当我们 topic 数量从几个变成上千个的时候,情况就有所不同了
就很好理解,为什么当 topic 数量很大时,kafka 的性能会急剧下降了。
当然没有其他办法了吗,当然有。我们可以把存储换成速度更快 ssd 或者针对每一个分区都搞一块磁盘,当然这都是钱! 这也是架构设计中的一种 trade off
对比 kafka,rocketmq 有两点很大的不同:
ookeeper 是 cp 强一致架构的一种,其内部使用 zab 算法,进行信息同步和容灾,在信息量较小的情况下,性能较好,当信息交互变多,因为同步带来的性能损耗加大,性能和吞吐量降低。如果 zookeeper 宕机,会导致整个集群的不可用,对于一些交易场景,这是不可接受的
RocketMQ 的存储结构设计是为了追求极致的消息写性能,它采用了混合存储的方式,将多个 Topic 的消息实体内容都存储于一个 CommitLog 中。在 RocketMQ 的存储架构中,有三个重要的存储文件,分别是 CommitLog、ConsumeQueue 和 IndexFile。
CommitLog
CommitLog 是存储消息的主体。Producer 发送的消息都会顺序写入 commitLog 文件,所以随着写入的消息增多,文件也会随之变大。单个文件大小默认 1G,文件名长度为 20 位,左边补零,剩余为起始偏移量。例如,00000000000000000000 代表了第一个文件,起始偏移量为 0,文件大小为 1G。当第一个文件写满了,第二个文件为 00000000001073741824,起始偏移量为 1073741824,以此类推。存储路径为 HOME/store/commitLog
。
ConsumeQueue
ConsumeQueue(逻辑消费队列) 可以看成基于 topic 的 commitLog 的索引文件。因为 CommitLog 是按照顺序写入的,不同的 topic 消息都会混淆在一起,而 Consumer 又是按照 topic 来消费消息的,这样的话势必会去遍历 commitLog 文件来过滤 topic,这样性能肯定会非常差,所以 rocketMq 采用 ConsumeQueue 来提高消费性能。即每个 Topic 下的每个 queueId 对应一个 Consumequeue,其中存储了单条消息对应在 commitLog 文件中的物理偏移量 offset,消息大小 size,消息 Tag 的 hash 值。存储路径为 HOME/store/consumequeue/topic/queueId/fileName
。
IndexFile
IndexFile 提供了一种可以通过 key(topicmsgId) 或时间区间来查询消息的方法。他的存在主要是针对在客户端 (生产者和消费者) 和控制台接口提供了根据 key 查询消息的实现。为了方便用户查询具体某条消息。IndexFile 的存储结构可以认为是一个 hashmap。存储路径为 HOME/store/index/
. HOME/store/index/fileName
文件名 fileName 是以创建时的时间戳命名的。
我们在想想 kafka 是怎么做的,对的,kafka 并没有类似的烦恼,因为所有信息都是连续的
总结起来,RocketMQ 的存储结构设计非常复杂,但它通过合理的设计实现了高效的消息写入和读取性能。同时,RocketMQ 也支持多种存储方式,如本地存储、分布式存储和云存储等,可以满足不同场景下的需求。
pulsar 相比与 kafka 与 rocketmq 最大的特点则是使用了分层和分片的架构,回想一下 kafka 与 rocketmq,一个服务节点即是计算节点也是服务节点,节点有状态使得平台化、容器化困难、数据迁移、数据扩缩容等运维工作都变的复杂且困难。
分层:Pulsar 分离出了 Broker(服务层)和 Bookie(存储层)架构,Broker 为无状态服务,用于发布和消费消息,而 BookKeeper 专注于存储。
分片 : 这种将存储从消息服务中抽离出来,使用更细粒度的分片(Segment)替代粗粒度的分区(Partition),为 Pulsar 提供了更高的可用性,更灵活的扩展能力
Broker 集群在 Pulsar 中形成无状态服务层。服务层是“无状态的”,所有的数据信息都存储在了 BookKeeper 上,所有的元信息都存储在了 zookeeper 上,这样使得一个 broker 节点没有任何的负担,这里的负担有几层含义:
pulsar 使用了类似于 raft 的存储方案,数据会并发的写入多个存储节点上,下图为四存储节点、三副本架构。
broker2 节点当前需要写入 segment1 到 segment4 数据,流程为: segment1 并发写入 b1、b2、b3 数据节点、segment2 并发写入 b2、b3、b4 数据节点、segment3 并发写入 b3、b4、b1 数据节点、segment4 并发写入 b1、b2、b4 数据节点。这种写入方式称为条带化的写入方式。
这种方式潜在的决定了数据的分布方式、通过路由算法,可以很快的找到对应数据的位置信息,在数据迁移与恢复中起到重要的作用。
当存储节点资源不足的时候,常规的运维操作就是动态扩容,相比 kafka 与 rocketmq、pulsar 不用考虑原数据的"人为"搬移工作,而是动态新增一个或者多个节点,broker 在写入数据时通过路有算法优先写入资源充足的节点,使得整体的资源利用力达到一个平衡的状态,如图所示。
以下是一张 kafka 分区和 pulsar 分片的一张对比图,左图是 kafka 的数据存储特点,因为数据和分区的强绑定,导致了第三艘小船没有任何的数据,而相比 pulsar,数据不和任何存储节点绑定,而是实时的动态写入,从数据分布和资源利用来说,要做的更好。
当 bookie4 存储节点宕机不可用时,如何恢复节点数据?这里只需要增加新的存储节点,并且拷贝 bookie2 与 bookie3 上的数据即可,这个过程对外是无感知的,实现了平滑切换,如图所示
每种设计都有其特定的优势和局限,适应不同场景和需求。因此,在选用产品时,需要根据实际业务场景和需求,权衡各种设计的优缺点,作出最合适的选择。这种选择过程正是体现了设计与需求之间的平衡。所以,针对不同场景选择合适的产品是非常关键的。