在分布式系统领域,任务队列一直是核心基础设施之一。传统方案往往依赖 Redis、RabbitMQ 或 Kafka 等专用中间件,但 Turbopuffer 近期公开了一种更具颠覆性的设计思路 —— 仅使用对象存储上的单个 JSON 文件配合无状态 Broker,实现了功能完整的分布式队列系统。这一方案将对象存储的有限但强大的原语发挥到极致,为工程实践提供了全新的思考方向。
单 JSON 文件的核心设计理念
Turbopuffer 构建这一队列的初衷是替代其内部的索引任务通知系统。该系统负责在数据写入预写日志后,通知索引节点构建和更新搜索索引。原有的实现采用分片队列策略,分配给某个慢节点的作业会阻塞整个流程,即使其他节点处于空闲状态。新的设计方案则采用单一队列文件,所有节点共享同一个作业队列,实现了真正的 FIFO 执行语义。
设计的关键在于认识到 Turbopuffer 的作业队列数据量很小,远小于 1GB,可以完全加载到内存中。这意味着最简化的实现方案是可行的:创建一个名为 queue.json 的文件,每次操作都重新写入完整的队列内容。队列推送者读取现有队列内容,将新作业追加到末尾,然后使用 compare-and-set 操作写回。队列工作者同样使用 CAS 操作将第一个未认领的作业标记为进行中状态。
这种设计将对象存储的每一次文件替换视为原子操作。CAS 原语确保了强一致性保证:写操作只有在与读取时的 ETag 完全匹配时才会成功。如果文件在此期间被其他客户端修改,写入会失败,客户端需要重新读取并重试。整个过程不需要复杂的锁服务或协调机制,仅依靠对象存储提供的条件写入能力即可实现分布式互斥。
append-only 操作模式的工程实现
从语义上看,这个队列本质上是 append-only 的日志系统。新作业只会被添加到队列尾部或更新其状态,而不会进行原地删除或修改。作业状态的流转遵循简单的规则:未认领状态标记为○,进行中状态标记为◐,整个队列在逻辑上是一个不断增长的作业历史记录。
这种 append-only 模式带来了显著的优势。首先,它简化了并发控制逻辑 —— 所有操作都是添加或更新,不存在复杂的冲突合并场景。其次,它天然支持审计和回溯,因为完整的状态变更历史都被保留在队列文件中。第三,它使得故障恢复变得简单:任何时刻都可以通过读取队列文件恢复完整的系统状态,无需额外的持久化机制。
在具体的工程实现中,每次写入操作都需要经历读取、修改、写回的完整周期。虽然这看起来效率不高,但配合后文将介绍的 group commit 和 Broker 架构,实际吞吐量可以满足生产环境的需求。对象存储的写入延迟通常在 200 毫秒左右,这限制了单个文件的写入频率,但对于 Turbopuffer 的索引作业调度场景而言已经足够。
ETag 乐观锁的并发控制机制
对象存储系统普遍支持 ETag 机制,作为文件的版本标识符。Turbopuffer 巧妙地利用这一特性实现了分布式环境下的乐观锁。每个客户端在读取队列文件时同时获取其 ETag 值,在写入时通过条件写入指定期望的 ETag 值。只有当 ETag 匹配时,写入才会成功;否则返回失败错误。
这种 compare-and-set 语义构成了分布式队列的基石。多个客户端可能同时读取队列文件并尝试修改,但 CAS 保证只有一个写入操作能够成功。失败的客户端需要重新读取最新的队列内容和新的 ETag,然后再次尝试。这种自旋重试机制虽然看似简单粗暴,但在实际运行中表现良好,因为冲突窗口非常短暂。
值得注意的是,GCS 对单对象的写入操作有每秒 1 次的限制,而 S3 等对象存储虽然没有严格的频率限制,但单对象的并发写入仍然会受到限制。这意味着在多个客户端直接竞争单个文件的场景下,系统的吞吐量存在明显的天花板。Turbopuffer 通过引入 Broker 层来解决这一问题。
无状态 Broker 的组提交架构
为了消除多个客户端对单一队列文件的直接竞争,Turbopuffer 引入了无状态的 Broker 组件。Broker 是整个系统中唯一直接与对象存储交互的角色,所有客户端(推送者和工作者)都通过与 Broker 通信来执行操作,而不是直接访问 queue.json 文件。
Broker 内部维护一个内存缓冲区,收集来自多个客户端的请求。当一次写入操作正在进行时,新到达的请求会被缓冲在内存中。写入完成后,Broker 将缓冲区的所有变更合并为一次 CAS 操作写入对象存储。这种技术被称为组提交,是数据库系统中常用的优化手段,目的是将多次逻辑操作合并为一次物理写入。
组提交机制将请求速率与写入速率解耦。系统的瓶颈从写入延迟转移到网络带宽,而现代数据中心网络通常提供 10 GB/s 以上的带宽,远超单个队列文件的实际需求。单个 Broker 进程可以服务数百甚至数千个客户端,因为它的主要工作只是维护连接和缓冲请求,真正的重活由对象存储完成。
Broker 本身是无状态的,这意味着它可以随时被重启或迁移。当一个 Broker 进程失效时,客户端会检测到连接超时,进而寻找新的 Broker。新 Broker 的地址存储在 queue.json 中,所以任何节点都可以读取队列文件,发现当前的 Broker 地址并接管工作。这种设计实现了真正的高可用性,整个系统没有单点故障。
高可用性与至少一次投递语义
分布式队列的可靠性是核心需求之一。Turbopuffer 的设计需要处理两种失败场景:Broker 进程意外终止,以及工作者认领作业后未能完成。
对于 Broker 故障,系统通过队列文件本身存储当前 Broker 的地址信息。当客户端无法连接到当前 Broker 时,会读取队列文件获取新的 Broker 地址并重试。如果同时存在多个 Broker 尝试工作,CAS 机制会确保只有一个 Broker 的地址最终写入成功。旧的 Broker 最终会发现自己不再是 Broker,因为它会遇到 CAS 写入失败。这种自愈机制确保了系统的鲁棒性。
对于工作者故障,系统引入了心跳机制。工作者定期向 Broker 发送心跳,Broker 将时间戳写入队列文件中对应作业的记录。如果某个作业的最后一次心跳超过预设的超时时间,系统认为原始工作者已经失效,将该作业重新放回未认领状态,供其他工作者认领处理。这提供了至少一次的投递语义:每个作业最终会被处理,但可能被处理多次。
这种设计在简化系统复杂度的同时,提供了足够的可靠性保证。对于 Turbopuffer 的索引作业场景而言,作业可以安全地重放,恰好一次的语义并非必需,至少一次已经满足业务需求。
实践启示与参数选择
从 Turbopuffer 的经验中可以提取若干可落地的工程实践。首先,队列文件大小应控制在可以完全加载到内存的范围内,这意味着 JSON 序列化后的数据量不应超过可用内存的一半至三分之一。其次,Broker 的写入周期需要在延迟和吞吐量之间取得平衡,过短的周期会导致频繁的 CAS 冲突,过长的周期则会增加作业的排队时间。
心跳超时参数的设置需要根据实际业务场景调整。Turbopuffer 建议根据作业的典型处理时间设置合理的倍数,例如作业通常需要 30 秒完成,则心跳超时可以设置为 2 至 5 分钟。过于激进的心跳超时会导致频繁的作业重新认领,增加重复处理的风险;而过于宽松的超时则会延迟故障检测,影响系统的响应速度。
最后,监控系统需要关注几个关键指标:CAS 冲突频率反映了客户端竞争程度,过高的冲突率可能意味着需要调整 Broker 的缓冲策略;作业平均等待时间直接反映了队列的处理效率;心跳超时次数可以帮助调优超时参数。这些指标的持续监控是系统稳定运行的基础。
Turbopuffer 的方案证明了一个深刻的道理:当你深入理解底层原语的能力和限制时,完全可以在看似受限的接口上构建出功能强大的分布式系统。对象存储的 CAS 原语虽然简单,但配合适当的架构设计,就能支撑起可靠、高效的分布式队列系统。
资料来源:Turbopuffer 官方博客