给一个 agent 时代的 Git 托管服务做存储层。流量形状变了:几十万个 coding agent 在同一批仓库上并发 fetch、小步 push,读写比约 100:1,但写频率远高于人类。
传统答案是「有状态副本组 + 共识」:每仓三副本、Raft 选主、push 走 leader 多数派确认。能用,但副本组是有状态的:扩容要搬数据、节点死了要重建副本、读扩展受副本数限制、写吞吐被 leader 往返锁死,而仓库大小不均让 rebalance 成为常态。
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 对账。
一句话 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 全程并发
| 方案 | 换来什么 | 牺牲什么 |
|---|---|---|
| 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,不影响正确性
一句话 trade-off:放弃「本地盘就是数据」的假设,换来节点无状态、读副本线性扩展、spot 回收无痛。
真相一旦在对象存储,本地 NVMe 上那份 git 仓库的身份就变了:它不是副本,是缓存。收益有三:扩容不再搬数据(空载上线、按需 materialize);节点故障不必重建副本;读扩展不受副本数限制——Cursor 报告合成测试中读吞吐可随副本数线性扩展到 100 节点。
读路径的代价是缓存可能落后,所以服务读之前先对账——拿本地 ETag 发条件 GET,If-None-Match 命中就返回 304、下载 0 字节(常态热路径);真落后了才按 seq 差值补拉。gossip 只是把「该对账了」提前通知到。
代价是冷读放大:新节点的第一个 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
一句话 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 次。
| 配置 | 写延迟 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
}
一句话 trade-off:省掉一整套共识实现,代价是把线性一致的根基交给一个外部 API 的语义细节。
①「强一致」不等于「有 CAS」。S3 在 2020 年就给了读写强一致,但那只保证读到最新版本,不提供原子性原语;CAS 是 2024 年条件写上线后的事(AWS 文档)。这解释了为什么早期 Iceberg / Delta 在 S3 上要靠外部锁表补洞。
② CAS 只覆盖单 key。两个对象的两次条件写不构成一个事务,所以所有可变状态必须收口到一个 key,其余对象全部不可变、内容寻址。
③ 实现差异是真实风险。条件写语义在 S3、MinIO、R2、Ozone 之间并不天然一致(Apache Ozone 到 2026 年才专门实现 S3 条件请求以支持这类乐观并发)。声称多云,押的是最弱那家的语义。
直押对象存储 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
Compaction 是第一等公民:WAL 无限增长会让冷读变成 replay 全史,必须周期把尾部压成 snapshot;它还与写路径争同一个 CAS 点——Cursor 的 120 push/s 正是「边压缩边复制」下的数字。CAS 热点要按仓库分片:全局一个 index 等于把全站写吞吐锁在一个 key 的争用上;按仓库(或表、分区组)各一个可变 key,争用才与业务粒度对齐。多区域是下一道坎:条件写是区域内语义,跨区域复制异步且非原子,要么回到单写区域 + 只读副本,要么重新引入跨区域共识。尾延迟靠 hedged request:p99 既由外部 PUT 决定,能做的就是对慢请求发第二份——而请求数就是账单。
If-Match / If-None-Match 的准确语义,设计前必读。差别在定序有多复杂。Git push 接近「把一条记录追加到某仓库的历史」——单仓库、单 key,一次 CAS 就表达完了。Kafka 要给成千上万个分区各自分配连续、无洞、单调的 offset,而一个上传对象混装多分区数据:要么每分区一个可变 key(一次写得 CAS 几百个 key,而 CAS 不跨 key),要么全局一个 key(全站写吞吐锁死在一个 CAS 上)——两条都不通。推论值得记住:对象存储 CAS 能否直接当共识用,取决于可变状态能否自然收口成一个 key。能,架构塌缩成极简;不能,共识只是被推到另一个组件里——它变便宜了(不在数据路径、不管持久性),但没消失。
关键不是延迟绝对值,而是它有没有串进同步依赖链。吃得下的:日志摄取、CI 产物上传、数据湖提交——消费者容忍秒级新鲜度。会崩的:任何「写完才能读下一步」的回路。agent 工作流恰是危险的中间态:单次 400 ms 听着没事,但循环里一步 push 一步 fetch 跑几十轮,累积延迟就成了体感瓶颈——这正是 Cursor 要上 Express One Zone 的原因。更稳的做法是把延迟敏感回路与持久化解耦:ack 必须等对象存储(否则 RPO 破),但后续读可直接命中刚写入节点的本地缓存。
灾难级且静默:两个写都认为赢了 CAS,index 分叉,两节点各自 ack 了客户端,后写覆盖前写——已确认的写丢失。这是 Day 54「fail obviously」的反面:错误不崩,只是安静地错。三层防线:自校验结构——WAL 记录带 prev_seq 与 prev_hash 形成哈希链,分叉表现为链断裂而非悄悄覆盖;独立 oracle——后台扫描器验证链连续与 seq 无洞(注意 Day 51:底率极低,精确率必须极高才不会被忽略);可审计性——数据不可变、内容寻址,就算 index 分叉底层数据没丢,可人工重建顺序。这正是「只有一个 key 可变」在灾难时的价值。
搬了位置,这次搬得划算,但账要算全。消失的是你自己运维的那部分:选主、成员变更、副本重建、rebalance。新增四项:compaction/GC 成了关键路径上的后台系统(压不过来冷读爆炸)、攒批逻辑成了正确性相关代码(出 bug 丢批次)、CAS 争用成了新的扩展瓶颈、一条新的供应商语义依赖。划算在于:搬走的是分布式系统里最难写对的代码(共识 + 成员变更),换来的是普通工程问题(批处理、GC、分片)。这是个很一般的判断原则——优先把「难以证明正确」的复杂度换成「容易测试」的复杂度,即使总行数没少。