Day 55 Hard Storage Object Storage WAL Consensus

对象存储当真相源 — 把共识外包给 S3 的「零盘」架构Object Storage as the Source of Truth: The Zero-Disk Architecture

问题场景 + 需求约束

给一个 agent 时代的 Git 托管服务做存储层。流量形状变了:几十万个 coding agent 在同一批仓库上并发 fetch、小步 push,读写比约 100:1,但写频率远高于人类。

传统答案是「有状态副本组 + 共识」:每仓三副本、Raft 选主、push 走 leader 多数派确认。能用,但副本组是有状态的:扩容要搬数据、节点死了要重建副本、读扩展受副本数限制、写吞吐被 leader 往返锁死,而仓库大小不均让 rebalance 成为常态。

本期的反转:如果对象存储已经给了你跨 AZ 的 11 个 9 持久性和单 key 原子 compare-and-swap,你为什么还要自己跑共识?把 WAL 直接写进对象存储、用条件写做串行化点,副本就全变成可丢的缓存,节点无状态、不选主、不搬数据。这不是理论:2026 年它已改写了 Git 托管和 Kafka 的默认答案。

高层架构

graph LR
    C["Git 客户端
push / fetch"] --> LB["L7 路由
rendezvous hashing"] LB --> N1["节点 A
NVMe 热缓存"] LB --> N2["节点 B
NVMe 热缓存"] LB --> N3["新扩容节点 C
缓存为空"] N1 -- "①数据 PUT ②index CAS" --> S3[("对象存储
WAL = 真相源
不可变数据 + 可变 index")] N2 -- "条件 GET
ETag 命中则 304" --> S3 N3 -- "从 WAL 重建整个仓库" --> S3 N1 -. "UDP gossip: WAL 已推进到 seq=N" .-> N2 N1 -. gossip .-> N3

三件事值得指出。真相源只有一份:对象存储里的 WAL——数据对象不可变、内容寻址,加一个可变 index 记录顺序。节点同质无状态:任何节点都能接任何仓库的 push,本地 NVMe 上那份真实 git 仓库只是热缓存。gossip 不承担正确性——它只让副本早一点知道该拉新数据;正确性全压在 index 的条件写上,落后的副本在服务读之前必然先做一次条件 GET 对账。

关键点 1:把串行化点从副本组搬到对象存储的条件写

一句话 trade-off:用一次 PUT 的延迟,换掉整套 leader 选举、成员变更和多数派协调。

原理

共识的本质是给并发写定一个全序。Raft 用「多数派承认的 leader + 单调 term」产生它,代价是维护一个有状态副本组。而 2024 年起 S3 支持条件写:PutObject 带 If-Match: <etag>,ETag 不匹配即返回 412——一个跨 AZ 持久化的单 key 原子 compare-and-swap。

架构于是反转:数据对象无条件并发写(不可变、重传安全),顺序由一次 index 的 CAS 决定。CAS 成功的那一刻既是「push 进入历史」的时刻,也是能 ack 客户端的最早时刻。谁赢 CAS 谁排前面,输的人重读、重算、重试——没有 leader,也不需要有。

sequenceDiagram
    participant A as 节点 A
    participant B as 节点 B
    participant S as 对象存储
    A->>S: PUT wal/0042.pack 不可变数据
    B->>S: PUT wal/0043.pack 不可变数据
    A->>S: PUT index If-Match etag_v7
    S-->>A: 200 OK 新 etag_v8
    A->>A: ack 客户端:push 成功
    B->>S: PUT index If-Match etag_v7
    S-->>B: 412 Precondition Failed
    B->>S: GET index 读到 v8 含 A 的记录
    B->>S: PUT index If-Match etag_v8
    S-->>B: 200 OK 新 etag_v9
    Note over A,B: 只有 index 的 CAS 是串行点
数据对象的 PUT 全程并发

选型 Trade-off

方案换来什么牺牲什么
Raft/Paxos 副本组写延迟可做到个位数毫秒有状态:选主、成员变更、rebalance;读扩展受副本数限制
外部仲裁(指针存 DynamoDB / etcd)数据在对象存储,元数据事务强、还能跨 key多一个有状态组件要运维付费;两套存储故障域叠加
对象存储原生 CAS零额外有状态组件;节点完全同质可随时扩缩写延迟下限 = 对象存储 PUT 的 p99;CAS 只覆盖单 key;语义绑定厂商

伪代码

# 追加 WAL 记录;index 的 CAS 是唯一串行化点(pseudo-code)
def append(repo, record):
    for _ in range(MAX_RETRY):
        idx, etag = s3.get_json(f"{repo}/index.json")     # 读当前 WAL 头
        key = f"{repo}/wal/{idx.next_seq:08d}.pack"
        s3.put(key, record)                              # ① 不可变,可并发、重传安全
        idx.entries.append({"seq": idx.next_seq, "key": key,
                            "sha": sha256(record)})
        idx.next_seq += 1
        try:
            s3.put_json(f"{repo}/index.json", idx, if_match=etag)   # ② CAS
            return idx.next_seq - 1                      # 到此才 ack 客户端
        except PreconditionFailed:                       # 412:有人抢先推进
            continue                                     # 重读 → 重算 → 重试
    raise Contention(repo)
# CAS 失败留下的 ① 是孤儿对象:无人引用,交给 GC,不影响正确性
现实案例:Cursor 的 Git 存储 Continuity(2026-08 公开)正是这套:S3 上的 WAL 是唯一真相源,index 的原子 CAS 就是 push 的串行化点,任何服务器都能接 push——定序从「一组副本的协调」变成「对象存储上的一次更新」(Git at any scale · Cursor)。同构模式在数据湖里已是标准:Apache Iceberg 的提交就是把表指针从旧 metadata 文件 CAS 到新的,失败就基于新状态重试(Iceberg spec)。

关键点 2:本地盘降级为可丢的热缓存

一句话 trade-off:放弃「本地盘就是数据」的假设,换来节点无状态、读副本线性扩展、spot 回收无痛。

原理

真相一旦在对象存储,本地 NVMe 上那份 git 仓库的身份就变了:它不是副本,是缓存。收益有三:扩容不再搬数据(空载上线、按需 materialize);节点故障不必重建副本;读扩展不受副本数限制——Cursor 报告合成测试中读吞吐可随副本数线性扩展到 100 节点。

读路径的代价是缓存可能落后,所以服务读之前先对账——拿本地 ETag 发条件 GET,If-None-Match 命中就返回 304、下载 0 字节(常态热路径);真落后了才按 seq 差值补拉。gossip 只是把「该对账了」提前通知到。

选型 Trade-off

代价是冷读放大:新节点的第一个 fetch 要从 WAL 重建仓库,延迟可能是热路径几十倍。三条缓解各有代价:rendezvous hashing 把同仓流量粘到少数偏好节点(命中率↑,牺牲负载均匀度、引入热点仓库);周期 snapshot 让重建从 replay 全史变成「snapshot + 尾部增量」(省冷读,花存储与后台 CPU)。

伪代码

def serve_fetch(repo, want):
    local = cache.get(repo)                                  # NVMe 上的真实 git 仓库
    head = s3.get_json(f"{repo}/index.json",
                       if_none_match=local.etag)             # 条件 GET
    if head is NOT_MODIFIED:
        return git_upload_pack(local, want)                  # 热路径:零 S3 流量
    for e in head.entries[local.seq + 1:]:                   # 落后多少补多少
        local.apply(s3.get(e.key))
    local.etag, local.seq = head.etag, head.last_seq
    return git_upload_pack(local, want)

def materialize(repo):                                       # 缓存丢失时
    repo_dir = restore(s3.get(f"{repo}/snapshot/latest"))
    for e in wal_entries_after(snap.seq):                     # 补尾部增量
        repo_dir.apply(s3.get(e.key))
    return repo_dir
现实案例:Cursor 把本地 NVMe 仓库明确定位成 warm cache,仓库可从 WAL 完整重建;副本间用 UDP gossip 传播 WAL 推进,再用带 ETag 的条件 GET 核对,未变则 S3 直接 304、packfile 下载 0 字节。WarpStream(Kafka 协议兼容,已被 Confluent 收购)更极端:Agent 完全没有本地盘、数据直写对象存储,因此是纯无状态进程、可随意扩缩重启(Kafka Is Dead, Long Live Kafka)。

关键点 3:延迟与成本曲线从「磁盘」换成「请求数」

一句话 trade-off:攒批窗口是一个旋钮——往右拧省钱提吞吐,往左拧降延迟涨成本。

原理

零盘架构的写延迟有硬下限:对象存储 PUT 的 p99,因为「未落对象存储不确认」是持久性承诺的全部依据。WarpStream 的公开数字很有代表性:默认 S3 Standard 下 Produce 的 p99 约 400 ms(4 MiB 对象写入 S3 的 p99 量级),换 S3 Express One Zone(低延迟、单 AZ)可降到 169 ms。Cursor 的对应数字是 S3 Standard 约 120 push/s、Express One Zone 300+ push/s,而后者瓶颈已不在 S3 而在 git 压缩本地数据的速度。

成本也换了维度:不再为 IOPS 和容量付费,而是为请求数付费。小对象风暴因此是头号成本杀手,解药只有攒批:把多个写(甚至多分区、多仓库的)合并进一个对象,N 次 PUT 变 1 次。

选型 Trade-off

配置写延迟 p99代价
本地盘 + Raft 多数派个位数毫秒有状态副本组的全部运维负担
对象存储 Standard数百毫秒量级延迟高一个数量级;交互式写(agent 小步 push)体感差
对象存储 Express One Zone百毫秒级单 AZ 持久性、单价更高;跨 AZ 耐久性要自己补

攒批窗口同理:窗口越长,摊薄的请求成本越低、吞吐越高,但 p99 直接加上窗口长度。旋钮必须由 workload 决定:日志摄取拧到 250 ms 以上毫无痛感;agent 等 push 返回才能走下一步时,每 100 ms 都在吃等待时间。

伪代码

// 多分区攒批成一个共享对象:按请求数计费,非 IOPS(pseudo-code)
for {
    batch := buf.Drain(maxWait: 250*time.Millisecond, maxBytes: 8<<20)
    key, err := objectStore.Put(encode(batch))     // 一对象混装多 partition
    if err != nil { batch.NackAll(); continue }    // 未落盘绝不 ack
    offsets := coordinator.Commit(key, batch.Index())  // 定序、分配 offset
    batch.AckAll(offsets)                          // 对象 + 元数据都提交后才 ack
}
现实案例:Kafka 的 KIP-1150 Diskless Topics 2026-03 被社区投票接受,正是这个形状:写是 leaderless(任何 broker 接任何分区),broker 把多分区批次合并上传成一个共享日志段对象,再由新增的 Batch Coordinator 在数据持久化之后统一分配 offset——注意它没有把定序交给对象存储 CAS。它同时是「范式已定、落地很难」的样本:KIP-1150 接受了,但定义核心实现的 KIP-1163 / 1164 仍在讨论,原生 Diskless 未 GA(KIP-1150 Accepted, and the Road Ahead)。

关键点 4:正确性押在厂商的条件写语义上

一句话 trade-off:省掉一整套共识实现,代价是把线性一致的根基交给一个外部 API 的语义细节。

原理

①「强一致」不等于「有 CAS」。S3 在 2020 年就给了读写强一致,但那只保证读到最新版本,不提供原子性原语;CAS 是 2024 年条件写上线后的事(AWS 文档)。这解释了为什么早期 Iceberg / Delta 在 S3 上要靠外部锁表补洞。

② CAS 只覆盖单 key。两个对象的两次条件写不构成一个事务,所以所有可变状态必须收口到一个 key,其余对象全部不可变、内容寻址。

③ 实现差异是真实风险。条件写语义在 S3、MinIO、R2、Ozone 之间并不天然一致(Apache Ozone 到 2026 年才专门实现 S3 条件请求以支持这类乐观并发)。声称多云,押的是最弱那家的语义。

选型 Trade-off

直押对象存储 CAS:组件最少,但正确性绑定单一厂商。外部共识兜底(指针放 etcd/DynamoDB):换回跨 key 事务与可移植性,代价是请回一个有状态组件。专用协调器(KIP-1150 路线):定序逻辑自己掌握、可做复杂 offset 语义,但协调器自己又是个需要高可用的有状态服务——共识被推走了,没有消失。

伪代码

# 反例:两个 key 的条件写不是一个事务
s3.put_json(f"{repo}/refs.json",  refs, if_match=e1)    # 成功
s3.put_json(f"{repo}/index.json", idx,  if_match=e2)    # 此处崩 → 两 key 永久不一致

# 正确形状:单 key 收口(Iceberg / Continuity 同一骨架)
mid = content_hash(full_state)
s3.put(f"{repo}/manifest/{mid}", full_state)            # 不可变,重传安全、无需条件
s3.put_json(f"{repo}/HEAD", {"manifest": mid},
            if_match=head_etag)                         # 唯一可变指针 = 唯一 CAS 点
# 旧节点醒来重试?手里的 head_etag 必然过期 → 412 → CAS 本身就是 fencing token
现实案例:Iceberg 的「写不可变 metadata 文件 + CAS 表指针」与 Continuity 的「写不可变 packfile + CAS index」是同一骨架的两次独立发现。Jack Vanlightly 对 Iceberg 一致性模型的拆解把这套乐观并发的失败模式讲得比规范更清楚(Understanding Apache Iceberg's Consistency Model)。

扩展与优化

Compaction 是第一等公民:WAL 无限增长会让冷读变成 replay 全史,必须周期把尾部压成 snapshot;它还与写路径争同一个 CAS 点——Cursor 的 120 push/s 正是「边压缩边复制」下的数字。CAS 热点要按仓库分片:全局一个 index 等于把全站写吞吐锁在一个 key 的争用上;按仓库(或表、分区组)各一个可变 key,争用才与业务粒度对齐。多区域是下一道坎:条件写是区域内语义,跨区域复制异步且非原子,要么回到单写区域 + 只读副本,要么重新引入跨区域共识。尾延迟靠 hedged request:p99 既由外部 PUT 决定,能做的就是对慢请求发第二份——而请求数就是账单。

常见陷阱 + 面试问题

深入资源

深入思考

Continuity 直押 S3 CAS,KIP-1150 却另建 Batch Coordinator。同样是零盘,为什么分叉?

差别在定序有多复杂。Git push 接近「把一条记录追加到某仓库的历史」——单仓库、单 key,一次 CAS 就表达完了。Kafka 要给成千上万个分区各自分配连续、无洞、单调的 offset,而一个上传对象混装多分区数据:要么每分区一个可变 key(一次写得 CAS 几百个 key,而 CAS 不跨 key),要么全局一个 key(全站写吞吐锁死在一个 CAS 上)——两条都不通。推论值得记住:对象存储 CAS 能否直接当共识用,取决于可变状态能否自然收口成一个 key。能,架构塌缩成极简;不能,共识只是被推到另一个组件里——它变便宜了(不在数据路径、不管持久性),但没消失。

p99 写延迟从个位数毫秒涨到几百毫秒,什么 workload 吃得下,什么会当场崩?

关键不是延迟绝对值,而是它有没有串进同步依赖链。吃得下的:日志摄取、CI 产物上传、数据湖提交——消费者容忍秒级新鲜度。会崩的:任何「写完才能读下一步」的回路。agent 工作流恰是危险的中间态:单次 400 ms 听着没事,但循环里一步 push 一步 fetch 跑几十轮,累积延迟就成了体感瓶颈——这正是 Cursor 要上 Express One Zone 的原因。更稳的做法是把延迟敏感回路与持久化解耦:ack 必须等对象存储(否则 RPO 破),但后续读可直接命中刚写入节点的本地缓存。

假设某次故障中 If-Match 语义被违背(两个写都返回 200),影响有多大,怎么设计使它可检测?

灾难级且静默:两个写都认为赢了 CAS,index 分叉,两节点各自 ack 了客户端,后写覆盖前写——已确认的写丢失。这是 Day 54「fail obviously」的反面:错误不崩,只是安静地错。三层防线:自校验结构——WAL 记录带 prev_seq 与 prev_hash 形成哈希链,分叉表现为链断裂而非悄悄覆盖;独立 oracle——后台扫描器验证链连续与 seq 无洞(注意 Day 51:底率极低,精确率必须极高才不会被忽略);可审计性——数据不可变、内容寻址,就算 index 分叉底层数据没丢,可人工重建顺序。这正是「只有一个 key 可变」在灾难时的价值。

这套架构真的减少了系统总复杂度,还是只把复杂度搬了位置?

搬了位置,这次搬得划算,但账要算全。消失的是你自己运维的那部分:选主、成员变更、副本重建、rebalance。新增四项:compaction/GC 成了关键路径上的后台系统(压不过来冷读爆炸)、攒批逻辑成了正确性相关代码(出 bug 丢批次)、CAS 争用成了新的扩展瓶颈、一条新的供应商语义依赖。划算在于:搬走的是分布式系统里最难写对的代码(共识 + 成员变更),换来的是普通工程问题(批处理、GC、分片)。这是个很一般的判断原则——优先把「难以证明正确」的复杂度换成「容易测试」的复杂度,即使总行数没少。