AutoMQ 是一款开源的,存算分离架构的 Kafka 发行版,目前在 Github 上有 4.2k star。AutoMQ 是基于 Kafka 代码改的,只改了底层存储的代码,所以也天然兼容 Kafka 协议。AutoMQ 的核心亮点就是存算分离,本文也主要介绍 AutoMQ 存算分离的实现。
为什么需要存算分离
要理解 AutoMQ 的设计,我们需要理解为什么要存算分离这个问题。虽然在这个云时代,存算分离似乎已经“烂大街”了,动不动就某某产品号称存算分离,成本节省 10x,无脑 buyin。但是冷静下来,我们还是要明白存算分离对 Kafka 来说有什么好处。
Kafka 是存算一体的架构,使用机器的本地磁盘保存数据。而存算分离指的是:不再使用本地磁盘保存数据,而是使用共享的对象存储来保存数据。基于这个解释,存算分离的 Kafka 则有如下好处:
- 成本节省
相比于本地磁盘,对象存储本身是非常廉价的。移除了本地磁盘,成本可以大幅削减
- 计算资源单独扩容
这里说的计算资源指的是 CPU 资源。
首先需要明确一点的是,对于云上本地盘 ECS来说,本地磁盘的存储和 CPU资源是绑定的,要升级本地磁盘的存储量,则 CPU 资源也需要相应升级。
Kafka 存算一体的架构下,数据是保存在 Kafka 集群 Broker 的本地磁盘上。如果本地磁盘满了的话,则必须对 Kafka 集群进行扩容–增加 Kafka 集群 Broker 的数量,或者升级本地磁盘的存储量,但这也就意味着增加了计算资源,然而此时可能计算资源并没有达到瓶颈,造成对计算资源的浪费。
使用对象存储来保存数据的话,可以认为对象存储是个无限大的存储,不需要扩容,只需要在计算资源达到瓶颈的时候对计算资源进行扩容即可。可以使用云上计算型 ECS(不配备本地盘),直接对 CPU + 内存进行升配。
- 秒级扩缩容
Kafka 存算一体的架构下,如果要进行集群的扩缩容,对分区进行 rebalance,则需要将数据从一台机器的本地磁盘中迁移(重新写入)到另外一台机器的本地磁盘,整个集群的 rebalance 通常需要耗费数小时的时间。
而在存算分离的架构下,数据是存储在共享的对象存储上的,机器本身是不存储数据的,则避免了数据迁移的操作,基本上在秒级时间内都能完成。
- 冷读不影响消息的写入
这个其实不是主要好处,算是存算分离顺便解决了 Kafka 的一个问题。
Kafka 存算一体的架构下,Kafka 写数据的时候,首先是写入 page cache 中,并异步地将数据写入磁盘。在冷读场景下,数据会从本地磁盘读出来,放到 page cache 中,对 page cache 造成污染,影响数据的实时写入。
而在存算分离的架构下,则没有本地磁盘,也就没有 page change,数据是直写对象存储的,所以也不存在这个问题了。
AutoMQ 如何做存算分离
其实存算分离说白了“不过就是”数据写入到对象存储,然后就结束了。
但当然了,实际上并不那么简单,实现上必须要适配对象存储的特性才行。对象存储个人理解有如下特性:
不支持 append
写入延迟数百毫秒
不喜欢 list
而 AutoMQ 则是希望在上述对象存储的特性上,实现毫秒(小于10毫秒)级延迟的 Kafka。接下来解释一下 AutoMQ 是如何适配上面提到的特性的,并实现毫秒(小于10毫秒)级延迟的。
基于对象存储实现 Kafka 的几个问题和 AutoMQ 的解决方案
不支持 Append
对象存储不支持 append,每次写入一批数据的时候都需要生成一个新的文件,这个时候这批数据才可见。要实现低延迟,生成新文件的频率也势必很高,而且考虑到一台 broker 上通常有数百上千个分区,如果对于每个分区,都生成一个新的文件,那么文件数就会很多,对象存储 API 的调用次数也会变多。
为了解决这个问题,AutoMQ 的做法是将某段时间内写入到这台 broker 上的所有分区的数据都聚合起来,写入到若干个对象存储文件上。大概如下图所示:

其中 Broker 中有 p1,p2,p3,p4 这四个分区,数据一开始都写到了内存,上传到对象存储的时候,将这些数据都聚合起来,根据设置的每个对象存储文件阈值,写到若干个对象存储文件中。如图所示:p1,p2和p3 的一部分数据写到了文件1,p3的另一部分数据和p4的数据写到了文件2。
值得注意的是:AutoMQ 的一个对象存储文件可能会包含多个分区的数据,为了快速定位到某个分区的数据在该文件中的位置,该文件的末尾还包含一个 index block 来进行 index。如下图所示:

文件末尾有个 footer 来指向 index 的起始位置,然后 index 分别指向 p1,p2,p3 数据所在的起始位置。这样 AutoMQ 如果要读这个文件的某个分区的数据的话,通过 footer 找到 index,然后再通过 index 找到对应的分区。
考虑到消息队列读分区数据的连续性,老是去多个对象存储文件中读一小部分数据也不是个事。所以 AutoMQ 后台会进行 compaction,尽可能地将相同分区的数据都 compact 到同一个对象存储文件中,提高读的效率。如下图所示:

在 compact 后,相同分区会倾向于在一个对象存储文件上,但是对于数据比较少的分区,不够多到可以组成单独的一个对象存储文件,依然还是会于其他分区的数据排列在一个对象存储文件上。
写入延迟数百毫秒
对象存储的写入延迟较高,通常数十到数百毫秒。WrapStream 的方案是数据直接写到对象存储(s3) 当中,延迟在 600 ms 以上。
而为了实现10毫秒内的延迟,直接写对象存储显然不现实。
所以在 AutoMQ 的实现中,虽然数据最终也是会写到对象存储中 当中,但为了实现数毫秒的延迟,数据一开始是写到 WAL(Write ahead log) 中(一般选择云存储 EBS,提供亚毫秒级别延迟),写入到 WAL 中则认为数据被持久化了,给 Client 返回 ack,这通常是在几个毫秒内就完成。WAL 中的数据再被近实时地上传至 S3 存储。
整体流程如下图所示:

上面这张图基本上涵盖了 AutoMQ的核心思路:
Producer 的数据一开始写入到 WAL 中,即云存储 EBS 中。云存储 EBS 内置 3 副本,所以写入到 EBS 中即认为数据写成功了。注意, WAL 并不会很大,它存的不是全量数据,存储的只是那部分还没有上传到对象存储 S3 中的数据
然后数据被 put 到内存作为 deltaWALCache,如果 Consumer 读数据的时候命中了 deltaWALCache,则直接从 deltaWALCache 中读数据
deltaWALCache 满了的话就异步上传到对象存储 S3当中
Consumer 在回追数据的场景下,读的那部分数据通常已经在对象存储 S3 当中,broker 会从 S3 中 fetch 数据放到自己的 BlockCache 中,Consumer 然后从 broker 的 BlockCache 中读。考虑到从对象存储 S3 中延迟比较高,AutoMQ 会采用 parallel read,prefetch read,batch read 等技术降低整体延迟。(注:这里图中的步骤4 有点问题,Consumer 并不是直接从 S3 读数据的,最终其实还是从 Broker 的 Message Cache 中读数据)
不喜欢 list
为了知道某个分区有哪些数据文件,Kafka 的方式是 list 这个分区目录(Kafka 将相同分区的数据文件都放到同一个目录下),这样就知道了这个分区有哪些数据文件。
但是基于对象存储的话,list 是非常废的,一定不能通过 list 目录的方式来知道分区有哪些数据文件。解决思路也比较简单,类似湖格式管理数据文件的方式,通过单独的对象存储文件来进行记录。
总结
AutoMQ 直接在 Kafka的代码上改,不费什么力气就实现了 Kafka 协议兼容确实挺取巧的。不过没有用 Rust 重写,差评(雾
AutoMQ 提出一开始直接写 WAL(通常是亚毫秒级别延迟的 EBS),然后再异步上传到对象存储的方式,在延迟和成本之间达到了一个 balacne,还是挺有吸引力的