Hotdry.
systems

对象存储 JSON 队列的 ETag 乐观锁并发控制实践

深入解析 turbopuffer 如何利用 ETag 乐观锁与 CAS 机制实现单 JSON 文件分布式队列的并发写入一致性。

在分布式系统设计中,如何在缺乏传统数据库事务能力的情况下保证数据一致性,是一个常见的工程挑战。TurboPuffer 的解决方案 elegantly 利用对象存储原生的条件写(Conditional Write)能力,通过 ETag 乐观锁实现单 JSON 文件队列的并发控制。本文将深入解析这一设计的工程细节,为类似场景提供可落地的参数与实现参考。

核心挑战:单文件队列的并发写入

TurboPuffer 的索引任务队列本质上是通知系统,用于在数据写入预写日志(WAL)后调度异步索引构建任务。设计目标是实现 FIFO 执行、至少一次传递(at-least-once)保证,并降低尾延迟。最初的做法是将队列分片到多个索引节点,但这导致慢节点阻塞所有分配给它的任务,即使其他节点处于空闲状态。

新的设计采用单一队列文件(例如 queue.json)存放在对象存储上,所有客户端通过 Compare-And-Set(CAS)原语进行原子更新。核心问题在于:当数十甚至数百个客户端同时竞争写入同一个 JSON 文件时,如何保证写入不丢失、数据不冲突?

这正是 ETag 乐观锁发挥作用的地方。

ETag 作为全局版本号

对象存储(如 Amazon S3、Google Cloud Storage)每次对象变更都会生成一个 ETag(Entity Tag),本质上等同于文件的版本标识符。TurboPuffer 将这个 ETag 作为队列文件的全局版本号,每一次读取操作都捕获当前 ETag,每一次写入都携带「仅当 ETag 匹配时才写入」的条件。

JSON 队列的数据模型简洁而完整。顶层包含元信息与任务列表:元信息记录当前 broker 的地址与版本时间戳,任务列表中的每个任务具有唯一 ID、负载内容、执行状态(queued /in_progress)、认领者标识以及最后心跳时间戳。状态流转遵循严格的生命周期:初始为「待处理」(queued),被 worker 认领后变为「进行中」(in_progress),完成后移除或归档。

这种设计的巧妙之处在于不需要额外的版本号字段 —— 对象存储提供的 ETag 本身就是最可靠的版本标识。

入队操作的 CAS 流程

入队(enqueue)操作是典型的读 - 修改 - 写场景。具体流程如下:首先客户端读取 queue.json,同时捕获响应头中的 ETag;然后在内存中将新任务追加到 jobs 数组末尾;接着执行条件写入,使用 S3 的 If-Match: "<etag>" 或 GCS 的 ifGenerationMatch 参数;最后根据写入结果判断是否成功 —— 如果返回 412 Precondition Failed 或条件不满足,说明在读取与写入之间有其他客户端修改了队列,此时进入重试循环。

这个过程的本质是乐观锁:假设并发冲突是小概率事件,因此不主动加锁,而是先操作,发现冲突再回退重试。相比悲观锁,乐观锁在低冲突场景下性能更优,因为省去了获取锁的网络往返。

任务认领的原子性保障

Worker 认领任务采用完全相同的 CAS 模式。Worker 读取队列后,选择第一个状态为「queued」的任务,在本地修改其状态为「in_progress」、设置 claimed_by 为自身标识、记录当前时间戳作为 last_heartbeat,然后以条件写回。

由于每次更新都需要赢得全局 ETag 竞争,同一个任务在同一个版本中只能被认领一次。即使两个 worker 同时读取到相同的队列状态并尝试认领同一个任务,CAS 机制也保证只有一个写入成功,另一个会在重试时发现状态已变更,转而认领其他任务。

这种设计天然避免了分布式锁的复杂性。传统方案需要引入 Redis 分布式锁或 etcd 协调服务,而这里仅依赖对象存储的原子性保证,基础设施成本大幅降低。

心跳保活与超时检测

已认领任务的心跳更新是另一种 CAS 场景。Worker 定期读取队列、定位自己认领的任务、更新 last_heartbeat 为当前时间,然后条件写回。Broker 则负责扫描超时任务:如果某个「进行中」任务的当前时间与 last_heartbeat 之差超过阈值(例如 60 秒),Broker 将该任务状态翻转回「queued」,使其可被其他 worker 重新认领。

这种设计的容错能力来源于心跳的持续更新与超时检测的协同。一旦 worker 故障无法心跳,任务最终会被回收并重新分配,保证了至少一次的传递语义。

Broker 介入的组提交优化

纯粹的 CAS 模式在高频写入场景下面临瓶颈。单次对象存储写入延迟可达 200 毫秒,这意味着即使没有冲突,纯 CAS 的吞吐量也被限制在约 5 次每秒。GCS 更有每秒 1 次请求的限制。

TurboPuffer 引入无状态的 Broker 作为所有客户端与对象存储之间的中介。Broker 维护一个内存缓冲区,收集来自多个客户端的请求;每当一次写入完成时,Broker 将缓冲区中的所有累积操作合并为一次 CAS 写入。这正是数据库领域经典的组提交(Group Commit)模式 —— 通过合并多次提交为一次 I/O,大幅提升有效吞吐量。

Broker 的核心职责是确保所有请求在组提交完成后才向客户端确认成功。这样一来,写入速率与请求速率解耦,扩展瓶颈从写入延迟转向网络带宽 —— 对于绝大多数业务场景,这个瓶颈足够宽松。

Broker 本身也可以是冗余的。由于 CAS 保证即使两个 Broker 同时运行也不会破坏数据一致性,旧 Broker 会在 CAS 失败时发现自己已被取代,自动退出。这种自发现机制简化了高可用架构,无需复杂的领导者选举协议。

工程实践参数与监控建议

在生产环境中,以下参数需要根据实际负载进行调优。重试策略方面,建议指数退避(exponential backoff),初始退避 50 毫秒,最大退避 2 秒,最大重试次数 5 到 10 次。退避参数需要权衡两个因素:过短会增加无意义的 CAS 竞争,过长则影响正常路径的响应时间。

组提交缓冲区大小的设置取决于写入延迟与请求速率的比值。如果平均写入延迟 200 毫秒、目标吞吐量 1000 QPS,则缓冲区大小应设置为约 200(1000 × 0.2)。过大的缓冲区会增加单次写入失败时的重做成本。

心跳间隔与超时阈值的比例通常是 3:1 到 4:1。例如心跳每 15 秒发送一次、超时阈值 60 秒,允许最多 3 到 4 次心跳丢失。这种宽松度可以容忍短暂的网络抖动,同时确保故障检测的及时性。

监控指标方面,最关键的是 CAS 失败率。如果 CAS 失败率超过 5%,通常意味着并发度过高或 Broker 处理能力不足,需要考虑增加 Broker 数量或优化客户端请求模式。次要指标包括队列深度、任务平均等待时间、任务完成率等,用于评估队列的整体健康状况。

适用边界与局限性

这种设计并非万能。其适用场景包括:队列总大小可以全部加载到内存(通常数百 MB 到 1 GB 以内);写入吞吐量在组提交优化后可以满足需求;可以接受至少一次的传递语义而非精确一次。如果队列规模达到数 GB 级别或吞吐量需求远超组提交能够覆盖的范围,则需要考虑分区(sharding)与多队列的经典方案。

此外,由于每次更新都是整个文件替换,频繁的小更新会导致写入放大。在 TurboPuffer 的实际生产环境中,这个设计服务于索引任务的通知场景,写入频率与数据规模恰好落在合适的区间。

小结

TurboPuffer 证明了一个看似受限的对象存储单文件方案,通过 ETag 乐观锁与 CAS 原语,能够构建出可靠、可扩展的分布式任务队列。其核心洞见在于:充分利用对象存储提供的有限但强大的原语(条件写、ETag 版本管理),配合组提交与 Broker 中间层的设计,即可在不引入复杂协调服务的前提下达成强一致性。

这种「做减法」的架构思维值得借鉴 —— 与其不断叠加分布式系统的复杂性,不如深入理解底层基础设施的能力边界,在边界内找到优雅的解决方案。


参考资料

查看归档