在构建分布式系统时,消息队列通常是基础设施的核心组件。传统方案如 Kafka 或 RabbitMQ 依赖复杂的集群复制和专门的运维经验,而 turbopuffer 探索了一条截然不同的路径:利用对象存储的固有特性,以单一 JSON 文件实现持久化消息队列。这种设计并非简单的概念实验,而是已在生产环境中承载数百万级索引任务的实际方案。本文将从最简可行版本出发,逐层剖析每个工程挑战的形成原因与具体解法。
从最简可行版本开始:queue.json 与 CAS
理解这套系统的最佳方式是自底向上构建。最基础的版本仅需要一个 JSON 文件,例如 queue.json,其内部结构包含一个 jobs 数组,每条任务用符号表示状态:圆圈表示待处理(○),半圆表示处理中(◐)。生产者(pusher)读取文件内容,将新任务追加到数组末尾,然后使用对象存储的 compare-and-set(CAS)原语写回。消费者(worker)同样通过 CAS 将第一个待处理任务标记为处理中,然后开始实际工作。
CAS 的核心语义是:写入操作附带一个前提条件 —— 只有当对象自读取以来未被修改时,写入才会成功。如果返回 412 Precondition Failed,客户端只需重新读取最新内容并重试。这种机制天然提供了强一致性保证,无需复杂的分布式锁或共识协议。对于 GCS(Google Cloud Storage),每秒约 1 次请求的限制使得这个最简版本能够稳定运行在每秒 1 请求以下的场景,这在某些低流量系统中已足够生产级别使用。
然而,大多数实际用例的吞吐量远高于此。对象存储的写入延迟可达 200 毫秒左右,如果每个任务都单独进行一次读 - 改 - 写循环,系统吞吐量将被限制在每秒 5 次左右。这远不能满足需要处理数万每秒请求的系统需求。
第一层进阶:组提交化解延迟瓶颈
解决吞吐量问题的关键技术称为组提交(group commit)。其核心思想是在一次写入完成前,将后续到达的请求缓存在内存中;当写入完成后,将缓冲区中的所有变更合并为下一次 CAS 写入的内容。这种做法将瓶颈从写入延迟转移到网络带宽 —— 对于大多数系统而言,带宽远超每秒数百万次请求的处理能力。
组提交的工作流程如下:当一个 CAS 写入正在进行时,新的 push 请求被放入内存缓冲区而非立即触发对象存储操作;一旦当前写入完成,缓冲区中的所有任务变更被合并到新的 JSON 状态中,作为下一次 CAS 写入的内容。这样,即使每次写入耗时 200 毫秒,缓冲区也能在一次写入窗口内积累数百个请求,从而实现每秒数千次的有效吞吐。这种模式与传统数据库将多次 fsync 操作合并为一次磁盘写入的优化思路一脉相承。
但组提交引入了新的问题:当系统中存在数十甚至数百个客户端同时竞争同一个 JSON 文件时,CAS 冲突的频率会急剧上升。每次 CAS 失败都意味着客户端需要重新读取、重新计算并再次尝试,这进一步降低了有效吞吐量。问题的本质在于写入点过多,需要一种机制将所有写入请求汇聚到一个统一的处理节点上。
第二层进阶:无状态 Broker 消除竞争
解决方案是引入一个无状态的 Broker 组件,所有客户端不再直接操作对象存储,而是与 Broker 通信。Broker 负责维护请求缓冲区并执行组提交循环,成为唯一需要与对象存储交互的角色。由于所有写入都汇聚到同一个进程,CAS 冲突的问题从根本上得到解决。
这个设计的关键在于 Broker 的无状态特性。它仅需要在内存中维护一个请求缓冲区,并将每次组提交的结果写入对象存储。重启 Broker 或将其迁移到其他机器对客户端完全透明,因为连接信息存储在 queue.json 的 broker 字段中。当一个 Broker 出现故障时,客户端检测到请求超时,便从 queue.json 中读取新的 Broker 地址并重连。整个系统不需要额外的服务发现机制,对象存储本身就是服务发现的载体。
需要强调的是,这个 Broker 的资源消耗极低。它不需要复杂的状态管理,只需维持网络连接和内存缓冲区,真正繁重的工作由对象存储完成。这意味着一个单实例 Broker 可以轻松服务数千个客户端,完全满足中小型系统的吞吐量需求。
第三层进阶:高可用与故障恢复
分布式系统的可靠性要求远不止于正常流程的处理。当 Broker 所在的机器突然宕机时,系统必须能够自动恢复;当 Worker 声称了任务但随后崩溃时,任务不能永久丢失。这两个问题的解决方案在形式上相似 —— 检测失效并转移职责,但具体实现细节有所不同。
对于 Broker 故障,客户端通过请求超时来触发故障检测。一旦检测到当前 Broker 不可用,客户端从 queue.json 中读取新 Broker 的网络地址(该地址由上一个成功写入的 Broker 写入文件中)并开始连接。多个 Broker 同时运行的情况也不会破坏正确性,因为 CAS 机制保证同一时间只有一个人的写入能够成功;其他 Broker 最终会因 CAS 失败而发现自己已不再是活跃的 Broker。这种短暂的「脑裂」状态只会带来轻微的性能下降,不会导致数据错误。
对于 Worker 故障,系统采用心跳机制进行检测。每个正在处理的任务在 JSON 中维护一个心跳时间戳,Worker 定期向 Broker 发送心跳,Broker 将其写入 queue.json;如果某个任务的心跳超过预设的超时阈值,系统假定原始 Worker 已失效,允许其他 Worker 重新认领该任务并从断点继续执行。这种设计提供了「至少一次」(at-least-once)的语义 —— 任务可能被重复执行,但永远不会丢失,是许多后台 job 处理场景的可接受折衷。
适用场景与边界条件
这种基于对象存储的队列设计并非万能解药,其适用性取决于几个关键约束。首先,队列的完整状态必须能够加载到内存中 ——turbopuffer 的实际使用中,队列大小远小于 1 GiB,这对于大多数索引通知类任务足够,但不适合需要存储海量消息历史的应用。其次,每次写入都需要序列化整个 JSON 对象,这意味着消息体的平均大小直接影响序列化和网络传输的开销,对于消息体巨大的场景需要谨慎评估。最后,虽然 Broker 可以处理极高的请求速率,但对象存储本身的请求速率限制仍可能成为瓶颈,此时需要考虑分区(sharding)策略,将流量分散到多个独立的 queue.json 文件上。
从另一个角度看,这套设计的优势同样显著:无需部署和维护专用消息队列软件,所有状态持久化由对象存储的已有能力担保,架构简洁到可以在几小时内从零实现原型。对于已经深度依赖对象存储、期望最小化运维复杂度的团队,这种设计提供了一条务实的技术路径。
资料来源:本文核心设计参考 turbopuffer 技术博客《How to build a distributed queue in a single JSON file on object storage》,该方案已在 turbopuffer 生产环境部署并实现 10 倍尾延迟优化。