Hotdry.
systems

对象存储单文件 JSON 分布式队列设计:从原子写入到高可用 broker

深入解析 Turbopuffer 如何利用 S3 兼容 API 的条件写入与组提交流量,构建具备精确一次语义的无状态分布式任务队列。

在分布式系统设计中,消息队列通常是基础设施的核心组件之一。传统方案依赖 Kafka、RabbitMQ 等专用中间件,这些组件功能强大但运维复杂度高。那么,是否有可能仅用对象存储和一个轻量级服务就构建出满足生产需求的分布式队列?Turbopuffer 最近公开的技术实践给出了肯定答案:他们利用对象存储上的单个 JSON 文件,结合无状态 broker 和组提交机制,成功构建了高可用的分布式任务队列。本文将详细拆解这一设计的技术要点与工程参数,为读者提供可落地的参考。

原子写入基础:compare-and-set 语义

对象存储的核心优势在于其极高的可用性与持久性设计,而现代 S3 兼容 API 还提供了条件写入能力,这为在对象存储上构建队列提供了关键原语。compare-and-set(CAS)操作允许客户端在写入文件时指定一个前提条件 —— 只有当对象的当前版本与客户端读取时的版本一致时,写入才会成功。如果在读取和写入之间有其他客户端修改了该文件,条件不满足,写入失败,客户端需要重新读取最新内容并重试。

这种乐观锁机制天然适配队列的 FIFO 语义。一个最简实现只需要一个名为 queue.json 的文件,其内部结构包含一个作业数组。生产者(pusher)读取文件、在数组末尾追加新作业、使用 CAS 写回;消费者(worker)同样使用 CAS 将队首的待处理作业标记为进行中状态(从 ○ 变为 ◐)。整个过程无需复杂的分布式锁,因为 CAS 保证了每一次更新的原子性 —— 要么完全成功,要么完全失败,不存在部分更新的中间状态。

这种基础设计在 Google Cloud Storage 环境下大约能够支持每秒一次请求的吞吐量,对于非核心路径的调度任务来说已经足够。然而,当请求速率超过这个阈值时,写入延迟就会成为瓶颈。

组提交:突破延迟瓶颈

对象存储的写入延迟通常在 200 毫秒左右,这个数字对于实时交易系统来说可能难以接受,但对于异步批处理任务来说却是可以接受的吞吐量上限。问题的关键在于:如果每个作业都触发一次独立的 CAS 写入,系统整体吞吐量将被严格限制在约 5 次写入每秒(1 / 200ms)。这对于 Turbopuffer 这样的索引构建任务来说是远远不够的。

组提交(group commit)的思路正是为了解决这一问题。其核心思想是:当一个写入请求正在飞行(in-flight)时,后续到达的新作业被缓存在客户端内存中,而不是立即触发新的对象存储写入。只有当当前写入完成后,缓冲区中积累的所有作业才被合并成一次新的 CAS 写入。这种模式将吞吐量瓶颈从「每次写入的延迟」转变为「网络带宽」,后者通常在每秒数 GB 的级别,远高于前者。

具体实现中,pusher 和 worker 各自维护一个内存缓冲区。当缓冲区中的作业达到一定数量或等待时间超过某个阈值(如 50 毫秒)时,触发一次组提交。Turbopuffer 在生产环境中将多个作业合并到单个 JSON 文件的更新中,显著提升了写入效率。这种模式与数据库系统中常见的日志组提交(log group commit)原理一致,都是通过批量合并来摊薄每次持久化的固定开销。

无状态 Broker:消除竞争热点

即使采用了组提交,当系统中存在大量并发客户端时,对同一个 JSON 文件的 CAS 竞争仍然会成为性能瓶颈。原因在于:CAS 机制要求每次写入必须等待前一次写入完成,任何并发的写入尝试都会导致其中一方失败并重试。当客户端数量达到数十甚至数百时,失败的 CAS 尝试会大量浪费计算资源。

解决思路是引入一个中心化的无状态 broker。所有的 pusher 和 worker 都不再直接与对象存储交互,而是连接到 broker,由 broker 统一维护与对象存储的连接并执行组提交。Broker 本身不存储持久化状态,只负责缓冲来自客户端的请求、定期批量写入对象存储、并在写入完成后通知客户端。这种架构将竞争点从「多个客户端竞争同一个 JSON 文件」转变为「单个 broker 的组提交循环」,而后者是完全可以并行处理的。

一个 broker 实例可以轻松处理数百甚至数千个并发客户端连接,因为每个连接的开销极小 —— 主要工作只是维护内存缓冲区和等待 I/O 完成。Broker 的水平扩展也非常简单:启动多个 broker 实例,通过 DNS 或负载均衡器分发请求即可。关键在于,每个 broker 都会尝试在 queue.json 中写入自己的地址,CAS 机制确保只有一个 broker 成功成为「主 broker」,其他 broker 自动退居备用或重新竞争。

高可用与故障恢复:心跳与自动切换

分布式系统的可靠性要求决定了任何单一组件的故障都必须被妥善处理。在这个设计中,需要考虑两种故障场景:broker 进程意外终止,以及 worker 进程在处理任务时卡死或崩溃。

对于 broker 故障,系统采用与 leader 选举类似的机制。客户端配置了与 broker 通信的超时时间(如 5 秒),一旦超时,客户端会尝试在 queue.json 中写入新的 broker 地址。由于所有客户端都在监听同一个 JSON 文件,当新的 broker 成功写入自己的地址后,其他客户端会自动重定向到新地址。旧的 broker 在恢复后尝试写入时会发现 CAS 失败,从而意识到自己已经失去领导地位,自动进入备用状态。这种自愈机制确保了系统不会因为单个 broker 的故障而长时间不可用。

对于 worker 故障,系统引入了心跳(heartbeat)机制。每个被 claimed 的作业在 JSON 中不仅记录状态(○ 或 ◐),还记录最后一次心跳的时间戳。Worker 在处理作业的过程中需要定期向 broker 发送心跳,broker 将时间戳更新到 queue.json 中。如果某个作业的最后一次心跳距离当前时间超过了预设的 timeout(如 30 秒),系统就认为原来的 worker 已经崩溃,将该作业重新放回待处理队列,由其他 worker 接手。这正是「至少一次」(at-least-once)语义的体现:即使 worker 真的完成了任务并只是心跳更新延迟,也只会导致任务被重新执行一次,而不会丢失任务。

适用场景与工程决策边界

并非所有队列场景都适合用这种基于对象存储的设计。Turbopuffer 明确指出了适用条件:队列的总数据量必须能够完全加载到内存中(通常小于 1 GiB),因为每次更新都需要读写完整的 JSON 文件。如果队列规模达到数十 GiB,每次序列化和反序列化的开销将变得不可接受。此外,这种设计更适合非核心路径的异步任务调度(如索引构建),而不适合对延迟极为敏感的在线请求处理。

在工程实践中,还需要关注以下参数配置:CAS 重试的最大次数(建议 3 到 5 次)、心跳间隔(建议 5 到 10 秒)、超时阈值(建议 30 秒到 1 分钟)、组提交的批量大小或时间窗口(建议 50 到 100 毫秒)。这些数值需要根据实际负载和对象存储的延迟特性进行调优。

总结

Turbopuffer 的实践表明,利用对象存储的条件写入能力、无状态 broker 的组提交机制、以及心跳与 CAS 的配合,完全可以在不引入复杂消息中间件的前提下,构建出满足生产需求的分布式任务队列。这种设计的核心优势在于极低的运维复杂度 —— 对象存储本身就是成熟且高可用的基础设施,无需额外部署和监控。这种「用简单组件组合出复杂功能」的思路,值得在更多分布式系统设计中借鉴。


参考资料

  • Turbopuffer 官方博客:How to build a distributed queue in a single JSON file on object storage(2026 年 2 月 12 日)
  • AWS S3 条件写入功能文档
查看归档