Skip to content

对 FlowMQ 的技术深度分析与思考

声明:本文基于 FlowMQ 公开文档推测的架构进行技术推理,仅用于个人技术学习和思考,不代表 FlowMQ 的实际实现细节。 所有分析均为逻辑推演,不保证与 FlowMQ 内部架构一致,不构成对 EMQ 或 FlowMQ 的任何贬低。欢迎技术交流讨论。

一、AMQP 消息能存储在 FoundationDB 吗?

FlowMQ 使用 FoundationDB 做元数据存储,那 Queue(AMQP 语义)的消息体是否也存在 FoundationDB 中?

技术上能存,但有硬限制。FoundationDB 单个 value 上限 100KB,单个事务上限 10MB、5 秒超时。AMQP 消息体如果是普通业务消息(几百字节到几 KB),完全能存。大部分企业级 AMQP 场景的消息体都在这个范围内。

但 Queue 语义对 FoundationDB 有压力。Queue 的核心操作是:写入 → 竞争消费 → ACK → 删除。每条消息至少两次写事务(写入一次、删除一次),高吞吐场景下事务量翻倍。FoundationDB 针对 OLTP 小事务优化过,单条消息的操作没问题,但百万级 QPS 的消息队列场景下,事务开销会成为瓶颈。

还有一个隐含问题:FoundationDB 的 key 是有序的,Queue 如果用递增 key 做消息排序,所有写入都会集中到同一个 range 的尾部,形成写热点。分布式 KV 最怕顺序写集中到一个 shard 的情况。要解决就得做 key 散列,但散列了又失去了队列的有序性。

结论是技术上能存,但不是最优解。小规模低吞吐没问题,大规模高吞吐场景下事务开销和写热点会成为瓶颈。

二、Queue 的消息应该怎么处理?

在 FlowMQ 的架构下,更合理的做法大概率是:Queue 的消息体存 S3,元数据存 FoundationDB。

消息体按 segment 批量追加写入 S3(复用 Stream 的 KaS3 模块),FoundationDB 只存 Queue 的控制信息——消息索引(哪条消息在 S3 的哪个 segment 的哪个 offset)、消费状态(未消费/已投递/已 ACK)、消费者分配关系、重试计数。

写入链路上,消息体批量追加写 S3,跟 Stream 共用存储路径和写入逻辑。FoundationDB 只处理轻量的索引写入,避免 100KB value 限制和事务压力。

消费链路上,消费者从 FoundationDB 拿到下一条待消费消息的索引,再从 S3 或 page cache 读消息体。ACK 后在 FoundationDB 标记消费状态,定期批量清理已 ACK 的索引和 S3 segment。

竞争消费利用 FoundationDB 的事务能力——用事务原子地把消息状态从"未消费"改为"已投递给 consumer X",天然避免重复消费。

但代价是消费一条消息需要两次 I/O:先读 FoundationDB 拿索引,再读 S3 拿消息体。底层存储不统一,就得不停在多个存储之间做协调。

三、Stream(Kafka 兼容)的消息怎么处理?

写入链路

Producer 发消息过来,Broker 不会每条消息都写一次 S3——S3 的写入粒度太粗、延迟太高。大概率是 Broker 在内存中攒一个 batch,达到一定大小或时间阈值后,把整个 batch 作为一个 segment 文件写入 S3。从监控指标的 kas3_segment_size 可以印证——segment 是 S3 上的基本存储单元。

索引管理

每个 segment 写入 S3 后,在 FoundationDB 中记录索引信息:segment 在 S3 上的路径、包含的 offset 范围、所属 partition、创建时间。

FoundationDB 的有序 KV 特性在这里很好用。key 设计大概是 {topic}/{partition}/{start_offset} → value 是 segment 的 S3 路径和元信息。消费者要读某个 offset 的消息,先在 FoundationDB 做范围查询定位到对应 segment,再去 S3 读那个 segment 文件。

Offset 管理

Consumer group 的 committed offset 存 FoundationDB,key 大概是 {group_id}/{topic}/{partition} → value 是当前 committed offset。每次 commit 是一次 FoundationDB 事务写入,频率不高(通常批量 commit),事务能力完全够。

读取链路

Consumer 拉取消息的流程:

  1. 从 FoundationDB 读当前 consumer group 的 committed offset
  2. 根据 offset 在 FoundationDB 索引中定位到对应 segment
  3. 先查本地 page cache,命中直接返回
  4. 未命中则从 S3 拉取 segment,放入 page cache,返回消息

kas3_page_cache_hit_countkas3_page_cache_miss_count 两个监控指标追踪的就是第 3、4 步的命中率。

Retention 和 Compaction

过期 segment 的清理:Broker 定期扫描 FoundationDB 中的索引,找出超过 retention 时间的 segment,先删 S3 文件,再删 FoundationDB 索引。先删 S3 再删索引,中间崩溃最多留下一条指向不存在文件的索引,下次清理兜底删掉。反过来先删索引会导致 S3 上的孤儿文件。

Compaction(kas3_online_running_tasks)大概率是合并小 segment 为大 segment,减少 S3 文件数和索引条目,提升读取效率。

这个方案的问题

每次消费至少两次 I/O——一次 FoundationDB 查索引,一次 S3/page cache 读数据。写入延迟受限于 batch 策略——batch 没满之前消息还在内存,Broker 崩溃会丢数据,要解决就得在 batch 期间先写 FoundationDB 做 WAL,又多一次写入。索引膨胀——支持按时间戳消费或按 key 查找等高级功能时,索引量会快速增长。

四、作为 FlowMQ 架构师,短期和长期怎么推进?

短期(6-12 个月):先活下来

架构不动。当前的 FoundationDB + S3 + 路由引擎能跑能卖,不做大手术。核心目标是商业验证:

  • MQTT 和 Kafka 兼容性做到极致,客户能直接替换现有客户端
  • 跑出 3-5 个标杆客户的生产案例,IoT + Kafka 数据管道是最容易讲的故事
  • 性能 benchmark 公开发布,跟 EMQX、Kafka、RabbitMQ 对标
  • 不碰存储层,先证明产品有市场

中期(1-2 年):减轻外部依赖

产品站稳后,开始解决架构技术债:

降低 FoundationDB 的角色权重。 自研轻量的内嵌元数据存储模块,先接管高频写入部分(consumer offset、消费状态),把 FoundationDB 逐步退化为只存低频变更的集群配置和路由表。类似 Kafka 去 ZooKeeper 的渐进式迁移。

统一写入路径。 当前 Subscription 走内存、Stream 走 S3、Queue 走 FoundationDB,三条路径。设计统一的本地写入层——所有消息先写本地 WAL(自研),再异步刷 S3 持久化。Subscription 和 Stream 的写入路径先统一,Queue 后续跟进。

减少写放大。 在统一写入层基础上,探索"一份写入,多视图消费"的可能性。不急着干掉路由引擎,但开始为它的退出铺路。

长期(2-3 年):走向统一存储

如果中期验证可行,长期目标是彻底替换 FoundationDB,实现完全自研的统一存储引擎。消息体、索引、元数据、消费状态全在同一个存储引擎里,Raft 做副本同步,本地嵌入式 KV 做单节点存储。S3 退化为可选的冷数据归档层。路由引擎逐步弱化,最终变成轻量的 Topic 别名映射。单二进制部署,不依赖任何外部分布式系统。

但这个长期目标大概率不会发生

原因很现实:EMQ 是商业公司,FlowMQ 当前架构能卖钱、客户能用,重写存储层的 ROI 很难证明。Kafka 从 ZooKeeper 到 KRaft 花了好几年,是因为有足够大的市场压力和工程资源。FlowMQ 作为新产品,短期内不会有这个压力。

更可能的结果是:中期做了一些优化,降低了 FoundationDB 的压力,产品能撑住更大负载,然后就稳定在这个状态。存储层的根本性重构会一直在 roadmap 上,但永远排在下一个版本。

这也是组合式架构一旦选了就很难回头的原因。 不是不想改,是商业节奏不允许。先有了客户和收入,再想改底层,代价就变得非常大。而从第一天就选择自研存储层的项目,虽然起步慢,但不会有这个历史包袱。

五、如果是独立架构师,会怎么从零设计?

如果跳出 FlowMQ 的约束,作为一个独立的基础架构师,拿到"统一消息平台"这个命题,思考路径是:

先定义问题本质

三种消费语义(Pub/Sub、Stream、Queue)表面差异很大,但往底层抽象一层,共性是:都是消息的顺序追加写入 + 不同的消费位点管理策略。

  • Stream 的 offset 是消费者自己维护的读取位置
  • Queue 的 ACK 本质也是一个消费位点,只不过带状态(未消费/已投递/已确认),支持竞争分配
  • Pub/Sub 的实时推送可以理解为消费位点永远追最新

统一的存储模型是:append-only log + 可插拔的消费位点策略。 消息只写一份,不同消费语义只是消费位点的管理方式不同。

存储引擎自己做

通用 KV 做不了顺序追加的极致优化。消息系统的写入模式极其特殊——几乎全是顺序追加,没有随机更新。可以用 io_uring 做异步批量刷盘,可以做 zero-copy 从磁盘到网络,可以针对 SSD 特性对齐 segment 大小。这些优化在通用分布式 KV 上做不了。

分布式协议也得自己做。用 Raft 做副本同步,针对消息场景优化。本地存储用嵌入式 KV(比如 RocksDB)做单节点引擎,元数据、索引、消息体在同一个进程内,一次写入原子完成。

协议层薄薄一层

存储模型统一后,协议层很薄。MQTT adapter 把 publish 翻译成 append,subscribe 翻译成注册消费位点。Kafka adapter 把 produce 翻译成 append,fetch 翻译成按 offset 读取。AMQP adapter 把 basic.publish 翻译成 append,basic.consume 翻译成竞争消费位点。

不需要路由引擎,不需要 Destination 抽象。协议层只做翻译,存储层只做 append + 读取,消费语义由消费位点策略决定。

S3 作为可选分层

S3 不是主存储,是冷数据归档层。热数据在本地磁盘,过期 segment 可选择归档到 S3 或直接删除。边缘场景、私有化部署、没有 S3 的环境也能跑。

代价与收益

代价是难。自己做存储引擎、分布式协议、副本管理,工作量大一个数量级。

但天花板高。存储层完全可控,极致优化都能做。单二进制部署,运维极简。没有外部依赖,故障域最小。数据模型统一,上层扩展自然。

六、总结

FlowMQ 的架构从工程交付角度合理,FoundationDB + S3 的组合能快速出产品。但组合式架构的长期代价是:跨系统协调复杂度高、写放大、优化天花板受限于外部组件、存储层不可控。

而从第一性原理出发,统一消息平台的核心应该是统一的存储模型——append-only log + 可插拔消费位点策略。数据只写一份,消费语义在读取层区分。存储引擎自研自控,协议层只做翻译。

两条路服务于不同的约束条件。有团队有客户有交付压力,选组合式架构是务实的。有耐心做长期的事,选自研存储层是更难但天花板更高的路。

🎉 既然都登录了 GitHub,不如顺手给我们点个 Star 吧!⭐ 你的支持是我们最大的动力 🚀