walgit 的源码里有一组故障注入的仿真测试,其中一个叫 healthy_request_round_trip_budgets,它数的是一次操作对对象存储发了几个请求(crates/walgit-server/tests/sim.rs,断言整理成一行)1:

Rust
ensure!(push_ops <= 5, "healthy push used {push_ops} requests, budget 5");
ensure!(warm_ops <= 1, "warm refs read used {warm_ops} requests, budget 1");

一次 push 最多 5 个请求,一次读最多 1 个。超过了,测试就失败。

walgit 是 Tobias Lütke 用 Rust 写的 Git 服务器,README 的第一句是“一个 git 服务器,就是对象存储前面的一个二进制文件”2。它没有数据库,没有主节点,可以有任意多台机器同时指向同一个桶(bucket,对象存储里装对象的容器),README 还说:把这些机器全关掉,丢掉的只是缓存的热度。

这两个数字让人有点不安。5 个请求里,没有一个是在和别的机器商量,那多台机器同时接受 push 时,谁说了算?读只问 1 次,凭什么敢说读到的就是最新的?对象存储一次请求要几十毫秒,git 读 packfile 却是成千上万次随机访问,仓库真的放在桶里吗?

《从 git clone 说起》的第五节用几段话介绍过 walgit 的思路。这一篇读它的源码,把这几个问题一层一层追下去,答案都落在桶里一个很小的文件上。


一、为什么要换一种存法

现象:副本越多,push 越慢

托管 Git 的老办法是把仓库放在本地 NVMe 盘上,再复制几份。GitHub 的 Spokes 每次 push 都要对引用更新做一次三阶段提交(3PC,让所有节点对“提交还是回滚”达成一致的协议),多数副本同意才算数。Cursor 的 Vicent Martí 指出,这样每一步都要等最慢的副本,副本越多 push 越慢;而每份副本都是真相的一部分,坏一份就得赶紧修,只能当“宠物”养3。

第一次尝试:让 git 直接读对象存储

对象存储(S3、GCS 这类)容量几乎无限、持久性极高,最直接的想法是把仓库放上去,让 git 直接读。图 1 说明了为什么不行:packfile 的排布只为“压得小”,读一个文件要沿着提交图跳好几次,大多数对象还是依赖别的对象的 delta,而每一跳的位置都要读完上一跳才知道。

读一个文件要在 pack 里串行跳几次 要读 src/main.rs 的内容,git 依次读提交 385c、根树 9f2a、子树 41c0,得到 blob 7d1e;它以 delta 形式存储,基对象 2b77 又是 delta,基于完整对象 e0a4。六个对象分散在 pack 的不同位置,每一跳的位置都要读完上一跳才知道。放在本地 NVMe 上是页缓存里的内存读;放在对象存储上,每跳一次 60 到 80 毫秒的 GET,六跳串行至少 360 毫秒。 读 src/main.rs:每一跳都要先读完上一跳 每一跳的代价 pack-b4f0….pack 里的 6 跳 示例 commit 385c… tree → 9f2a… @ 1.2 GB tree 9f2a… src/ → 41c0… @ 0.3 GB tree 41c0… main.rs → 7d1e… @ 2.7 GB blob 7d1e… delta,基于 2b77… @ 1.9 GB blob 2b77… delta,基于 e0a4… @ 0.8 GB blob e0a4… 完整对象 @ 2.5 GB 每一跳的位置,都写在上一跳读出来的内容里,没法并行。 本地 NVMe 页缓存里的内存读 git 的设计前提 对象存储 每跳一次 GET 60–80 ms,串行 6 跳 ≥ 360 ms 按 walgit 在 GCS 上的测量
图 1 · 为什么不能让 git 直接读桶:读一个文件就要串行跳好几次,每跳在对象存储上都是一次几十毫秒的 GET(哈希、偏移为示例)

按 walgit 在 GCS 上的测量,一次小对象 GET 要 60 到 80 毫秒4,而一次 clone 或 git log 要走成千上万跳。NFS 失败也是同一个原因;Cursor 还提到,Google 曾把 JGit 的对象放进分布式哈希表,最后因为 clone 太慢放弃了3。

解决:桶里放日志,磁盘只当缓存

Cursor 的系统 Continuity 换了一个角度:git 照旧在本地磁盘上跑,但本地仓库只是缓存,唯一的真相是对象存储里的一份预写日志(write-ahead log,WAL:先把“要做什么”持久地记下来,再去改实际状态)。walgit 是这个架构的开源实现,又补上了“机器比仓库还小”时需要的东西2。

walgit 的整体形状 客户端的 push 和 fetch 落到任意一台 walgit 实例上;实例之间没有连线,各自只和对象存储桶里的 manifest.pb 打交道:写用 If-Match 做 CAS,读用 If-None-Match 做条件读取,维护实例持有压缩租约。manifest 之外,日志段、pack 和检查点都是不可变对象。 客户端 walgit 实例 · 可随时丢弃 对象存储桶 · repos/acme/app/ git push git fetch 实例 A serve applied_seq 42 写 PUT · If-Match 实例 B serve applied_seq 41 → 42 读 GET · If-None-Match 实例 M maintain 持有 leases/compact.pb 写 COMPACT · 检查点 三台实例之间没有任何连线, 也没有数据库或路由表; 本地磁盘上的一切都只是缓存。 manifest.pb ETag v42 format_version 1 repo acme/app head_seq 42 min_seq 33 checkpoint seq 32 log_segments 2 段 packs 3 个 PackRef revision 57 writer 实例 A settings 内联 TOML log/000000000000002a.pb wal/9c1e….pack .idx .rev checkpoints/… leases/…
图 2 · walgit 的整体形状:实例之间不通信,所有协调都落在桶里 manifest.pb 这一个对象上(图中的序号、版本为示例)

图 2 里实例之间没有任何连线,只靠桶里的 manifest.pb 协调,用的是对象存储的两种条件请求(walgit 的 ObjectStore 抽象在 S3 上直接用这两个请求头,在 GCS 上换成对象的 generation 编号5):

  • 条件写:PUT 时带上 If-Match: <ETag>。ETag 是对象存储给每个对象版本的标识,内容一变它就变。只有对象的当前版本还是我读到的那个,这次覆盖才会成功,否则返回 412。这就是比较并交换(compare-and-swap,CAS)。
  • 条件读:GET 时带上 If-None-Match: <ETag>。对象没变就返回 304,不带正文。

哪台机器负责哪个仓库,由配置里的 [placement] 决定;只读引用的请求在任何一台上都能回答6。

既然任何一台空白的机器“读一遍日志就有了仓库”,桶里到底存了些什么?


二、桶内布局

只有一个对象会被改写

每个仓库在桶里占一个前缀 repos/<owner>/<repo>/。下面的对象分成两类:会被改写的只有 manifest.pb 和几个控制对象(比如租约),其余的写一次就再也不改7。

manifest、日志段、pack 和检查点之间的引用 manifest.pb 记录 head_seq 42、min_seq 33、指向 seq 32 的检查点、两个日志段和当前有效的 pack 集合;日志段 log/000000000000002a.pb 由 uvarint 长度加 LogEntry 的帧组成,seq 42 的 PUSH 条目带着 pack 9c1e 的 PackRef 和 refs/heads/main 的引用事务;pack 本体以校验和为键存在 wal/ 下;检查点由按尝试唯一的元数据和按内容寻址的引用快照组成。 repos/acme/app/ manifest.pb 可变 · CAS head_seq 42 min_seq 33 checkpoint seq 32 → attempts/… log_segments[0] …21.pb [33..41] log_segments[1] …2a.pb [42..42] packs[0] b4f0… tier 2 packs[1] 71d2… tier 0 packs[2] 9c1e… tier 0 revision 57 log/000000000000002a.pb 不可变 uvarint(len) ‖ LogEntry,一批 push 一个对象 seq 42 kind ENTRY_KIND_PUSH pack.checksum 9c1e… txn.updates main 5be1… → 0a77… meta walgit.publication_nonce wal/9c1e….pack 不可变 · 按内容寻址 同名旁文件 .idx .rev (.bitmap .commit-graph) checkpoints/0000000000000020/ 元数据 attempts/<uuid>/… 引用快照 refs/<sha256>.pb packs seq 32 时的集合 leases/compact.pb 可变 · CAS + TTL holder 实例 M expires_at now + 10m(packs.lease_ttl) checkpoint 指针 除了 manifest 和租约,桶里的对象写一次就不再改;读者从不 LIST,只顺着 manifest 里的键去取。
图 3 · 桶内布局:只有 manifest 是可变的,它列出读者需要的一切;日志段、pack 和检查点都按序号或内容寻址、写一次不再改(哈希和序号为示例)

manifest 是目录,其余对象按序号或内容命名。 图里没画出来的细节有三点:manifest 连仓库级配置也内联在里面(D24),读的时候不必为配置多发请求;日志段的键是第一个序号的 16 位十六进制,条目有 PUSH、REF_UPDATE、COMPACT、CHECKPOINT、SETTINGS 五种;pack 和引用快照都按内容寻址,两个写者写同一份内容,写的是同一个键、同样的字节7。

读者从不 LIST

对象存储支持按前缀列出对象(LIST),但 walgit 规定热路径上不准用:它慢、要翻页,ROUNDTRIPS.md 还说它的一致性“有点像最终一致”4。读者只认 manifest 里列出的键。这条规矩其实是整个设计的地基:写进桶里却没被 manifest 引用的对象,对任何人都不可见,所以一次 push 可以先把数据慢慢写进桶,最后改一下 manifest 再“亮出来”。

那么一次 push 具体是怎么把东西放进来的?


三、一次 push 的完整流程

现象:5 个请求,3 轮往返

walgit 没有用 git 自带的 receive-pack,而是自己实现了它,这样才能把“提交”换成对桶的操作。图 4 是一次正常 push 的全过程89。

一次 push 的三轮往返 git 客户端把命令和 PACK 发给 walgit;walgit 先在本地用 git index-pack 收包、检查连通性和 policy.json;第 1 轮对 manifest.pb 做条件 GET 确认新鲜;本地用 verify_txn 校验旧值;第 2 轮并行 PUT pack、idx 和日志段,都带 If-None-Match: *;第 3 轮用 If-Match: v41 改写 manifest.pb,成功得到 v42;之后才在本地应用引用更新并回复 ok。 git 客户端 walgit 实例 A 桶 · repos/acme/app/ POST git-receive-pack ① GET manifest.pb If-None-Match: v41 → 304 Serve 级同步:确认本地是最新 index-pack(临时目录) 连通性检查 · 推送规则 GET policy.json HTTP 层读取,不在 5 个之内 verify_txn:main 旧值仍是 5be1… ② PUT wal/9c1e….pack ② PUT wal/9c1e….idx ② PUT log/…2a.pb If-None-Match: *(三个并行) ③ PUT manifest.pb If-Match: v41 → 200 · v42 本地应用引用 · 采纳 v42 ok refs/heads/main ③ 之前写进桶的对象,manifest 不引用, 对任何读者都不可见。
图 4 · 一次 push:本地先把包收好、检查完,再对桶发 3 轮共 5 个请求;第 3 轮的 CAS 成功之前,客户端拿不到 ok(版本号、哈希为示例)

能在本地做的,都先在本地做完。 收包在一个临时目录里进行(默认还带 --fsck-objects),连通性和 policy.json 里的推送规则(保护分支、只许快进之类)也在这时检查,任何一步失败,临时目录一删,桶里什么都没留下。verify_txn 拿每条命令带的旧值和本地引用对比,这和 git 自己更新引用时“比对旧值、再写新值”是同一个检查。

对桶只发 3 轮。 ROUNDTRIPS.md 把图里的 ①②③ 算作 5 个请求、3 轮串行往返,这就是开头那个 5;读 policy.json 那一次属于 HTTP 层,不在这份预算里48。第 ② 轮里 pack 若返回 412,说明别人传过同样的字节,直接当成功;日志段用 Create 抢的是 head_seq + 1 这个序号。

为什么 CAS 就是提交点

CAS 成功之前,新的 pack 和日志段已经在桶里,但 manifest 不引用它们,对谁都不可见;CAS 成功的那一刻,它们一起出现。两台实例同时做 CAS,对象存储只让一个成功,所以所有 push 能排成一个全序,每个都好像发生在自己 CAS 成功的那一瞬间。这叫线性化(linearizable),walgit 的文档直接把 manifest 叫作 “the linearization point”6,Cursor 的原话是“这次 CAS 就是共识”3。所以 walgit 规定:桶确认之前,绝不给客户端回 ok;反过来,CAS 一旦成功,就一定回 ok(原因在第八节)。

一台实例这样做没有问题。可如果两台实例同时收到 push 呢?


四、并发 push 的冲突处理

现象:两个写者抢同一个序号

两台实例都同步到了 head_seq = 41,又同时收到 push。它们都想用 seq 42,都会去创建 log/000000000000002a.pb。图 5 是接下来发生的事9。

两台实例抢同一个序号 实例 A 和 B 都同步到 head_seq 41。A 先用 If-None-Match: * 写成 log/000000000000002a.pb,B 写同一个键得到 412。B 重读 manifest,发现 head_seq 还是 41,于是每 100 毫秒探测一次;A 的 CAS 成功后 head_seq 变成 42,B 判定该序号已被提交,重新同步并重新校验:它批次里推 main 的请求旧值已过期,回 ng;推 feat 的请求仍然有效,写 log/000000000000002b.pb 并以 If-Match: v42 提交成 v43。 实例 A 桶 实例 B ① PUT log/…2a.pb If-None-Match: * → 200 ② PUT log/…2a.pb If-None-Match: * → 412 ③ GET manifest → head 41 HEAD log/…2a.pb 还在,等 100 ms ④ PUT manifest.pb If-Match: v41 → 200 v42 回 ok:main 5be1… → 0a77… ⑤ GET manifest → head 42 序号已被提交:重新同步 重新校验 verify_txn main ng feat 通过 ⑥ PUT log/…2b.pb → 200 ⑦ PUT manifest.pb If-Match: v42 → 200 v43 B 推 main 的那个请求 带的旧值是 5be1…, 现在已是 0a77…: 只拒绝它,不拖累 feat。
图 5 · 并发 push:两个写者先在日志序号上相撞,输的一方重新同步、逐个请求重新校验旧值,旧值过期的回 ng,其余的换下一个序号再提交(序号、哈希为示例)

冲突先出现在日志段上。 B 创建 …2a.pb 得到 412 时,还分不清这个段是已经提交,还是对方还没来得及 CAS,所以它重读 manifest:head_seq 到了 42,就重新同步、对批次里每个请求重跑 verify_txn;还是 41,就隔 100 毫秒再看。图里推 main 的请求被拒,ng 的原因写着 conflict: expected 5be1…, got 0a77…;推 feat 的请求换 seq 43 提交9。

manifest 也可能被别人改了。 检查点、压缩、改配置也会 CAS manifest,它们不占新序号。这时 B 的 If-Match 返回 412,它带着版本号条件删除自己刚写的段(不会误删别人后来写在同一个键上的东西),再重新同步、校验、重试,默认最多 16 次(wal.cas_max_retries)10。

冲突是正确的,但不便宜。 Cursor 用 rendezvous hashing(把仓库 ID 稳定映射到一组节点上的哈希)让一个仓库总落在同一台主节点上3;walgit 让维护某个仓库的主机当它的写者(D28),其他主机可以把 receive-pack 转发给一个 push broker。文档强调这只是优化,不是正确性的依赖,因为发布本身是 CAS 安全的6。

可即使只有一个写者,问题也没有消失:manifest 是一个对象,而对象存储对同一个对象的写入速度有上限。


五、每秒一次写与 group commit

现象:一个对象每秒只能改一次

GCS 的配额文档写得很直白:同一个对象名,写入上限是每秒一次,超过了可能被限流11。walgit 在 GCS 上测到的也是“同一对象的覆盖写是串行的,约每秒一次”,并在文档里写明:一个被 CAS 改写的对象,就是一个吞吐上限4。

manifest 恰好是每次 push 都要 CAS 的那个对象。如果一个 push 一次 CAS,一个热门仓库每秒就只能接受大约一个 push。

解决:把并发的 push 合成一批

walgit 给每个仓库在每台实例上只开一个发布任务 publisher_task,所有 push 都排进它的队列。它一次取出一批,整批只写一个日志段、只做一次 CAS。这就是数据库里常说的 group commit。

group commit:三个 push 合成一次 CAS 同一实例上三个 receive-pack 几乎同时到达,单飞的 publisher_task 在 5 毫秒的窗口里把它们收成一批,依次在工作引用表上校验:请求 1 推 main 通过,请求 2 也推 main 但旧值已被请求 1 改掉而被拒,请求 3 推 feat 通过。两个有效请求得到连续序号 42、43,写进同一个日志段 log/000000000000002a.pb,然后只做一次 manifest CAS,head_seq 从 41 变成 43。 3 个并发的 receive-pack publisher_task · 每仓库一个 桶 请求 1 main 5be1→0a77 请求 2 main 5be1→c3d9 请求 3 feat 0000→7e42 收批 batch_window 5ms max_batch 64 verify_txn 工作引用表 请求 1 通过 → seq 42 请求 2 ng:旧值已变 请求 3 通过 → seq 43 log/000000000000002a.pb seq 42 PUSH main seq 43 PUSH feat manifest.pb 一次 CAS head_seq 41 → 43 log_segments + […2a.pb] packs + 2 个 tier 0 单独一个 push 到来时不等待, 立即进入 CAS;只有排队里 已经有人时,才等满这 5 ms。 按 GCS 文档,同名对象 每秒约只能写一次; N 个 push 合成一次写。
图 6 · group commit:一个批次只写一个日志段、只做一次 CAS;被拒的请求不占序号,也不影响同批其他请求(序号、哈希为示例)

批里的每个请求各自校验。 校验在一张“工作引用表”上依次进行:请求 1 通过后,它的新值立刻写进这张表,所以请求 2 再拿旧值 5be1… 来比,就会发现已经变了。被拒的请求不分配序号;一个请求里有一条引用被拒,同一请求的其他引用也一起被拒(错误信息是 “another ref in the submission was rejected”),但不会影响别的请求9。

单独一个 push 不会被拖慢。 默认窗口 batch_window = 5ms,一批最多 max_batch = 64 个10。代码注释特意写了:只有队列里已经有别的请求在等时,才会等满这个窗口;一个孤零零的 push 直接进入 CAS。

walgit 的文档没有给出它自己的 push 吞吐数字。Cursor 的文章给了 Continuity 的数据:按他们自己的测量,在 S3 Standard 上每秒能接受 120 个 push,在延迟更低的 S3 Express One Zone 上超过 300 个,瓶颈变成了 git 压缩磁盘数据的速度3。另外,“每秒一次”是 GCS 文档写明的限制,S3 的文档里我没有找到同样的条款;walgit 的设计是按 GCS 的数字定的。

写的一侧到这里就收拾好了。读的一侧,那个“只问一次”又是怎么做到的?


六、读路径:条件 GET 与分级同步

每个请求先问一句“有新东西吗”

实例在本地记着自己上次看到的 manifest ETag。每个读请求开始前,freshness_check 用 If-None-Match 带上它去问桶:返回 304,本地就是最新的;返回 200,就带回了新 manifest,先追平再服务12。按 walgit 在 GCS 上的测量,304 只要 15 到 18 毫秒,大约是普通 GET 的四分之一4。这就是开头那个 1。

这一步默认从不省略。 wal.freshness_ttl 默认是 010,对应 walgit 的原则“每次读都重新校验,不存在最终一致”6:每个请求多一个 15 毫秒级的往返,换来的是 push 被确认之后,下一个请求不管落在哪台机器上都能看到它。Continuity 另用 UDP 广播提醒副本有新 push,丢了也无妨3;walgit 连广播也不做,实例之间没有任何网络通信。

追平的成本被压到最低。 返回 200 时,apply_delta 按需加载检查点的引用快照,把日志尾巴的各段以每批 16 个并行取回,最后合并所有引用事务、一次性改写 packed-refs。代码注释记了一笔账:一个 50 万引用的仓库,如果每段都改写一次 packed-refs,每段要 500 毫秒12。

解决:按请求需要分级

“追平”到什么程度,取决于请求要什么。walgit 把同步分成几级,图 7 从左到右拿得越来越多612。

四级同步:一个请求只取它需要的那部分 每个请求先对 manifest.pb 做条件 GET:304 说明本地是最新的,200 则先应用新条目。然后按请求需要分级:Refs 只从检查点和日志尾巴算出 packed-refs,不下载 pack,用于 info/refs 和 ls-refs;Serve 把能放下的 pack 放在本地,太大的 base 通过只读挂载或远程读取,用于 upload-pack 和 receive-pack;Full 要求所有 pack 都在本地,放不下就报 TooLarge,用于重建 base;Objects 在放不下时只放 pack 索引,数据按 1 MiB 范围读,用于 Web API。 每个请求的第一步 sync.rs::freshness_check GET manifest.pb If-None-Match: v42 → 304:本地就是最新,直接进入下面某一级 → 200:带回新 manifest,加载检查点、并行取日志尾巴,一次性改写 packed-refs Refs sync_refs packed-refs ← 快照 + 日志 不下载 pack info/refs · ls-refs 冷启动 2 个请求 Serve sync tier 0/1 本地 history 本地 base 挂载/远程 upload-pack receive-pack Full sync_full 全部 pack 本地 放不下 → 503 (TooLarge) compact --base 几何压缩 Objects sync_objects 放得下 = Serve 否则只放 .idx 数据 1 MiB 范围读 Web API 读对象 块缓存 1 GiB 按项目在 GCS 上的测量,普通 GET 60–80 ms,条件 GET 返回 304 只要 15–18 ms。
图 7 · 读路径:先用一次条件 GET 确认新鲜,再按请求需要的级别补齐本地缓存;越往右拿得越多,也越可能放不下(数值为 walgit 配置默认值)

每一级只拿请求需要的那部分。 回答 ls-remote 只要 Refs 级,冷启动 2 个请求,验收标准是最大的仓库也在 1 秒内回答;Full 级放不下时返回 TooLarge(HTTP 503),绝不硬拉一个装不下的 pack 把机器撑爆。下载 pack 跑在单独的运行时上,不拿 Refs 阶段的锁:AGENTS.md 记过一次事故,一个 24 分钟的 clone 加上一个排队的写锁,让整台实例的 info/refs 等了几分钟(D19)6。

仓库比机器还大:remote reader

walgit 的参考负载是一个 57 GiB、7300 万个对象、140 万个提交、46.6 万个引用的 monorepo,跑它的机器所谓的“磁盘”是 20 GiB 的内存盘(tmpfs),而 base pack 本身就超过 30 GB6。Objects 级的 remote reader 就是为这种情况准备的,图 8 是它读一个文件的过程13。

remote reader:索引在本地,数据按块从桶里读 实例只有 20 GiB 内存盘,放不下 30 GB 以上的 base pack。读 src/main.rs 时,先在本地的 .idx 里查到 blob 的偏移 1931500000,右移 20 位得到块号 1842,块缓存未命中就对桶发一次范围读 bytes=1931476992-1932525567,解压后沿 delta 链再取基对象,最后写进松散对象库运行 git show。常驻本地的是 pack 索引(约为 pack 的 6%)、只含提交和树的 history pack、1 GiB 块缓存和 256 MiB 对象缓存;pack 本体从不整个下载。 实例 · 20 GiB tmpfs 桶 · repos/acme/app/ Web 界面读 src/main.rs remote.rs ① 查 .idx:偏移 1931500000 ② 块号 = 偏移 >> 20 = 1842 ③ 块缓存未命中 → 范围读 ④ inflate,沿 delta 链取基对象 ⑤ 写入松散对象库,运行 git show 常驻本地 remote-idx/ .idx ≈ pack 的 6% history pack 提交 + 树(D18) 块缓存 1 GiB · LRU 对象缓存 256 MiB pack-b4f0….pack(不在本地) wal/b4f0….pack tier 2 · 30+ GB GET wal/b4f0….pack Range bytes=1931476992- 1932525567 1 MiB 一块,同一块并发只取一次 约 100 MB/s 每连接(项目测量) 虚线框:这台机器上不存在的文件。 base pack 从不整个下载, 只按块读需要的那几 MiB。
图 8 · remote reader:本地只放索引、history pack 和缓存,pack 数据按 1 MiB 一块从桶里范围读取(偏移、哈希为示例,缓存大小为默认值)

读到对象之后,Web 界面把这条 git 命令会碰到的对象写进本地的松散对象库,再照常运行未经修改的 git。history pack(D18)只含提交和树,每台实例都放在本地,遍历历史就不用碰网络。按项目自己的测量,CI 常用的 clone --filter=blob:none --depth=1 --sparse --single-branch 从 2075 秒降到了 8 秒6。

日志每次 push 都在变长。新机器冷启动时要从头重放吗?pack 越来越多,谁来合并?


七、日志的折叠:检查点与压缩

检查点:冷启动 = 快照 + 尾巴

日志不能无限长,否则冷启动要重放所有条目。checkpoint_due 定了三个触发条件,满足任一个就写检查点:距上次已有 256 条,或者上次已过 1 小时,或者日志尾巴超过 8 MiB1410。

写检查点只需要 Refs 级的状态:把当前引用快照和 pack 集合并行写成两个不可变对象,然后 CAS manifest,让 checkpoint 指向它,把 min_seq 设成检查点序号加一,并从 log_segments 里去掉已经折叠进去的段。和 push 一样,CAS 是唯一的提交点;写了一半就死掉的检查点,只留下没人引用的对象。每次 push 后,写者若发现条件满足会顺手写一个,没人 push 的仓库则由 maintain 角色按时间补上9。

压缩:一台机器做,所有机器用

git 自己也要定期把很多小 pack 合并(repack),否则查一个对象要挨个翻几百个索引。在 Spokes 里 repack 必须在每个副本上各做一次,而且在两个副本上同时做同一个仓库的维护,很容易触发故障切换3。

walgit 让一台机器做一次,结果作为一条 COMPACT 日志发布出去:新 pack 是什么,supersedes 列出它取代了哪几个旧 pack。其他实例下次同步时直接下载合并好的 pack,用带宽换 CPU。

压缩:租约决定谁来做,CAS 决定是否生效 实例 M 用 Create 抢到 leases/compact.pb,每次心跳用 If-Match 把 epoch 加一、把 expires_at 往后推。它把 tier 0 的 71d2、9c1e、4a85 三个 pack 压成 tier 1 的 e71b,准备以 COMPACT 条目提交到 seq 44,但一个 push 抢先提交了 seq 44(带来 pack 5f30)。M 重新同步后基于新 manifest 重新构造:去掉三个输入、加入 e71b,保留 5f30,以 seq 45 提交成功。其他实例下次同步时下载 e71b,删除被取代的 pack。 leases/compact.pb CAS + TTL holder 实例 M expires_at +10m epoch 0 → 1 → 2 抢:Create(If-None-Match: *) 续:If-Match 改写,epoch + 1 过期 2 s 后别人才能接手 log/000000000000002d.pb COMPACT seq 45(44 被一个 push 抢先提交) pack e71b… tier 1 supersedes 71d2… 9c1e… 4a85… manifest.packs · 之前 v44 b4f0… tier 2 71d2… tier 0 9c1e… tier 0 4a85… tier 0 5f30… tier 0 · seq 44 manifest.packs · 之后 v45 b4f0… tier 2 e71b… tier 1 5f30… tier 0 · seq 44 M 想占 seq 44, 一个 push 抢先提交了 44, M 的 Create 得到 412。 M 基于新 manifest 只删 三个输入,5f30 原样保留。 其他实例的下一次同步 reconcile_packs 下载 wal/e71b….pack .idx(不各自 repack) 删除 71d2 9c1e 4a85,等读请求的守卫释放后才删 桶里 旧 pack 字节保留,记在 retired_packs
图 9 · 压缩:租约只是让大家别重复干活,真正让结果生效的仍然是 manifest 的 CAS;压缩期间到达的 push 不会丢(哈希、序号为示例)

谁来做,由租约决定。 租约是桶里的一个小对象 leases/compact.pb:不存在时用 Create 抢;已过期(再加 2 秒的时钟误差容忍)时用 If-Match 覆盖接手;持有者定期用 CAS 心跳,把 epoch 加一、把过期时间往后推,心跳返回 412 就说明租约被别人拿走了15。默认 TTL 是 10 分钟10。仿真测试里专门有一个场景:持有租约的实例死掉,另一台在租约过期后接手完成压缩1。

租约只决定谁来干活,决定结果是否生效的仍是 manifest 的 CAS。 压缩的发布和 push 走同一条路:抢日志序号、CAS manifest。图 9 里,压缩想占 seq 44,却被一个 push 抢先;M 重新同步后,基于新的 manifest 重新计算:只去掉自己的三个输入,把 push 带来的 5f30… 原样保留,以 seq 45 提交9。所以即使租约机制出了问题、两台机器都在压缩,从发布代码看,结果也只会是其中一个输了 CAS、重新计算,不会丢掉别人的 pack。这是我根据 publish_compact_classified 的实现得出的判断,文档里的说法是“发布是 CAS 安全的”。

旧 pack 不会马上消失。 本地的旧 pack 要等所有正在使用它的读请求结束才删;桶里的字节则一直保留,记在 manifest 的 retired_packs 里,没有按时间清理的开关。最近的几条设计决策(D45、D49)还要求,最终提交前必须重新核对:每个被取代的 pack 里的每个对象,都还能在新的 pack 里找到6。

到这里,每一种写(push、检查点、压缩)都遵循“先写不可变对象,再 CAS manifest”。如果进程正好死在中间呢?


八、崩溃恢复

一次 push 有好几个步骤,进程可能死在任何一步之间。图 10 把四个崩溃点和各自的结局放在一起。

进程在 push 的任意一步死掉会怎样 push 的五步依次是:本地 index-pack、并行 PUT pack 和日志段、CAS manifest、本地应用引用、回复 ok。若死在第二步之前,桶里什么也没有;死在 PUT 之后、CAS 之前,留下孤儿日志段,下一个写者探测三次后跳过这个序号并在提交后删除它;CAS 请求发出但响应丢失,写者重读 manifest,只有列出了自己的段且字节完全一致才算成功,否则报告结果未知而不是 ng;CAS 成功之后才死,提交已经生效,本地副本由下次同步重放。 index-pack PUT pack/log CAS manifest 本地应用 回 ok ① ② ③ ④ ① PUT 之前 什么都没写 客户端连接断开,看到失败 桶里没有任何新对象,重推即可 ② 写了日志段,没来得及 CAS 孤儿段 log/…2a.pb 存在,但 manifest 不引用它 下个写者 412 → 每 100 ms 探测,共 3 次 跳过序号(burn),写 …2b,提交后删孤儿 ③ CAS 发出,响应丢了 cas_landed 重读 manifest:列出了自己的段吗? 列出且字节一致(含 nonce)→ 成功 否则 CommitUnknown,绝不回 ng ④ CAS 成功之后才死 已提交 push 已经持久,任何实例下次读都能看到 本地没采纳 v42 → 下次同步重放该条目 整台机器没了 → 检查点 + 日志尾巴重建 这几种情况都不需要人工介入,也不需要任何“恢复程序”: 恢复就是普通的同步,因为磁盘上的仓库从来都只是缓存。
图 10 · 崩溃点:提交点之前死掉,留下的只是没人引用的垃圾;之后死掉,提交已经生效,本地副本下次同步时自己修好

提交点之前死掉,留下的只是垃圾;之后死掉,提交已经生效。 ② 的孤儿段被下一个写者探测 3 次后跳过(代码里叫 burn),所以日志序号严格递增但不一定连续,读者顺着 manifest.log_segments 走,不在乎缺号9。④ 的情况下只要 CAS 成功就一律回 ok:代码注释记录过,曾经在本地应用失败时向客户端报错,结果出现了“push 实际已经持久、git 却报告失败”的情况。

③ 最难:CAS 发出去了,响应丢了。 那次写可能已经生效,所以 cas_landed 要把段的字节取回来完整比对。S3 普通上传的 ETag 就是内容的 MD5,同样的内容删掉重写,版本号可能一模一样,因此每次抢序号都会在条目里塞一个随机的 walgit.publication_nonce95。找不到证据就报告 CommitUnknown,绝不能回 ng(D47)6。

这些结论都有测试守着:仿真测试给每个实例接一条可注入故障的链路,模拟崩溃、分区、过期读取、丢失的响应、孤儿段等场景1;docs/spec/ 下还有一组 TLA+ 有界模型,文档也坦白说明,它们只探索有限的交错,并不证明 Rust 代码本身正确16。

walgit 选择了“一个可变的 manifest 加 CAS”。同样想把 Git 放进对象存储,别人是怎么做的?


九、与 GitLab 对象存储后端的对照

GitLab 的 Gitaly 里也在做一个对象存储后端,属于实验性的 MVCC 后端,代码在 ee/mvcc/objectstorage/ 下,相关设计文档自称描述的是 POC 分支上的流程17。它遇到了同一个“每秒一次写”的限制,包注释里写得很明白:为了不把吞吐卡在“同一对象的更新上限”上,它不改写任何指针对象18。

它的做法是只追加:每次提交都新建一个不可变的 generation 对象 generations/<16 位十六进制>,用“不存在才创建”写下一个编号,创建成功就是提交成功。编号存成 MaxUint64 - generation,让最新的那个在字典序里排第一;读者要找当前版本,就对这个前缀做一次只取 2 条结果的 LIST,第二条是一个把 manifest 哈希写在键名里的兄弟对象,省掉一次读正文18。

walgitGitLab MVCC 对象存储后端
提交点对 manifest.pb 做 If-Match 覆盖用“不存在才创建”写下一个 generation
每秒一次写的限制存在,用 group commit 摊薄不存在,每次提交写的是新对象
读者怎么知道最新版本条件 GET,没变就 304LIST 一次(最多 2 条)
热路径上的 LIST禁止每次确认最新版本都要一次
冲突信号412创建冲突(S3 上还要特殊识别错误类型)

有意思的是,walgit 的日志序号用的正是 GitLab 的那种原语:log/<seq>.pb 也是“不存在才创建”,抢到序号的人才能继续。walgit 完全可以把“抢到序号”当成提交点,可它没有,而是在后面再加一次 manifest 的 CAS。我的推断是,这是为读路径付的代价:只有一个固定的、可变的对象,读者才能用条件 GET 拿到廉价的 304,而不用每次都 LIST;manifest 还能顺带告诉读者该去取哪些段、哪些 pack。这正是 ROUNDTRIPS.md 里“把状态放进你反正要取的那个对象”这条经验4。代价则是写的一侧要面对每秒一次的上限,于是有了 group commit 和单写者的部署习惯。

两者其实是同一个框架:不可变对象,加上一个原子的提交点。区别只在于提交点是“改写同一个对象”,还是“新建下一个对象”,一边把成本放在写上,一边放在读上。


十、回到开头的 5 和 1

一次 push 的 5 个请求,现在可以说清楚了: 1 次确认新鲜的条件 GET、3 个并行的“不存在才创建”(pack、idx、日志段)和 1 次 manifest 的 CAS。没有一个请求是在和别的机器商量,因为不需要商量:多台实例同时提交,对象存储只会让一个 CAS 成功,输的一方重新同步、重新校验旧值再试。

一次读的 1 个请求,是对 manifest 的条件 GET。manifest 是所有写入唯一的提交点,它的 ETag 没变,就说明自上次以来没有任何提交生效,本地缓存可以放心使用;变了,就顺着新 manifest 追平。

仓库也确实在桶里,但 git 从不直接读桶。它读的是本地磁盘上的缓存,缓存放不下的部分,才用 1 MiB 一块的范围读补上。

最后说明几点边界。walgit 是一个很新的项目,版本号还没到 1.0,AGENTS.md 开头就声明不保证向后兼容,只有桶里的数据格式保持可重放;本文读的 2026 年 9 月版本里,pack 分发方式刚刚从 bundle 换成可复用的 pack(D42),相关部分还在演进6。文中所有性能数字都是项目自己的测量,我没有复现:本机缺少构建所需的 protoc,没有实际跑起来。Hacker News 的讨论里也有人质疑它的安全性19。


小结

  1. 为什么不让 git 直接读对象存储? packfile 的读取是大量串行随机访问,对象存储一次请求就要几十毫秒。所以 git 仍然在本地磁盘上跑,本地仓库只是缓存,真相是桶里的预写日志。
  2. 桶里有什么? 一个会被 CAS 改写的 manifest.pb,加上按序号或内容寻址、写一次就不再改的日志段、pack 和检查点。读者只认 manifest,从不 LIST。
  3. push 什么时候算提交? manifest 的 CAS 成功的那一刻。之前写进桶的东西没人引用,对谁都不可见;之后才回 ok。
  4. 并发 push 怎么办? 先在日志序号上相撞,输的一方重新同步、逐个请求重新校验旧值,过期的回 ng,其余的换下一个序号再提交。
  5. 每个对象每秒只能写一次怎么办? 每个仓库一个发布任务,把并发的 push 合成一批,只写一个日志段、只做一次 CAS。
  6. 读怎么保证最新? 每次先对 manifest 做条件 GET,304 就直接服务;再按请求需要分成 Refs、Serve、Full、Objects 几级补齐,放不下的用范围读。
  7. 日志和 pack 越来越多怎么办? 检查点把日志折叠成快照;压缩由持有租约的一台机器做,结果作为 COMPACT 条目发布,但生效与否仍由 CAS 决定。
  8. 崩溃了怎么办? 提交点之前死掉,留下的是没人引用的垃圾;响应丢失时比对字节判定,找不到证据就报告“结果未知”;提交点之后死掉,下次同步自动重放。
  9. 还有别的做法吗? GitLab 的实验性后端用“只追加新的 generation 对象”避开了写入上限,代价是读者要靠 LIST 找最新版本。

参考资料

Footnotes

  1. walgit 仿真测试 sim.rsgithub.com ↗healthy_request_round_trip_budgets 等故障注入场景https://github.com/tobi/walgit/blob/main/crates/walgit-server/tests/sim.rs ↩ ↩2 ↩3

  2. tobi/walgitTobias Lütkegithub.com ↗本文读的是 2026-09-12 的提交 80e9a20,README 与 docs/announcing-walgit.mdhttps://github.com/tobi/walgit ↩ ↩2

  3. Git at any scaleVicent Martí · Cursor · 2026-08-18cursor.com ↗https://cursor.com/blog/git-at-any-scale ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7

  4. walgit docs/ROUNDTRIPS.md:往返次数就是成本模型github.com ↗§1 GCS 实测数字,§2 各操作的请求预算https://github.com/tobi/walgit/blob/main/docs/ROUNDTRIPS.md ↩ ↩2 ↩3 ↩4 ↩5 ↩6

  5. walgit-store:ObjectStore 抽象与 S3 映射github.com ↗lib.rs 的 GetOptions、PutMode;s3.rs 开头注释说明 Create→If-None-Match: *、Update→If-Matchhttps://github.com/tobi/walgit/blob/main/crates/walgit-store/src/lib.rs ↩ ↩2

  6. walgit AGENTS.md:架构与运维手册github.com ↗§1 约束、§2 WAL、§3 原则、§4 决策 D1–D49https://github.com/tobi/walgit/blob/main/AGENTS.md ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11

  7. walgit wal.proto:桶内对象的格式github.com ↗Manifest、LogEntry、PackRef、Checkpoint、Lease;键名在 walgit-proto/src/lib.rs 的 keys 模块https://github.com/tobi/walgit/blob/main/crates/walgit-proto/proto/walgit/v1/wal.proto ↩ ↩2

  8. walgit smart.rs:receive-pack 的 HTTP 处理github.com ↗receive_pack_process:收包、连通性检查、读 policy.json、发布https://github.com/tobi/walgit/blob/main/crates/walgit-server/src/smart.rs ↩ ↩2

  9. walgit publish.rs:发布路径github.com ↗开头注释 1–36 行;claim_log_slot、cas_landed、verify_txn、publisher_task、process_batch、publish_compact_classifiedhttps://github.com/tobi/walgit/blob/main/crates/walgit-wal/src/publish.rs ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8

  10. walgit-config:默认配置github.com ↗WalConfig、CacheConfig 的 Default;租约 TTL 在 packs.rshttps://github.com/tobi/walgit/blob/main/crates/walgit-config/src/lib.rs ↩ ↩2 ↩3 ↩4 ↩5

  11. Cloud Storage quotas & limitsGoogle Cloud 文档docs.cloud.google.com ↗同一对象名的写入上限为每秒一次https://docs.cloud.google.com/storage/quotas ↩

  12. walgit sync.rs 与 handle.rs:读路径github.com ↗freshness_check、apply_delta、replay_log、SyncLevel;分级入口 sync_refs/sync/sync_full/sync_objects 在 handle.rshttps://github.com/tobi/walgit/blob/main/crates/walgit-wal/src/sync.rs ↩ ↩2 ↩3

  13. walgit remote.rs:remote readergithub.com ↗BLOCK_SIZE = 1 MiB,pack 索引本地、数据范围读https://github.com/tobi/walgit/blob/main/crates/walgit-wal/src/remote.rs ↩

  14. walgit checkpoint.rs:检查点github.com ↗checkpoint_due 的三个触发条件,write_checkpoint_inner 的两轮写https://github.com/tobi/walgit/blob/main/crates/walgit-wal/src/checkpoint.rs ↩

  15. walgit coord.rs:CAS 循环与租约github.com ↗try_acquire、LeaseGuard::heartbeat、LEASE_SKEW_TOLERANCE = 2shttps://github.com/tobi/walgit/blob/main/crates/walgit-store/src/coord.rs ↩

  16. walgit docs/spec:有界模型检查github.com ↗TLA+ 模型 WALContract.tla 与契约编号 C1–C16https://github.com/tobi/walgit/blob/main/docs/spec/README.md ↩

  17. Gitaly doc/mvcc_rpc_flow.mdGitLab 源码gitlab.com ↗文档自称描述的是 POC 分支上的流程https://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/mvcc_rpc_flow.md ↩

  18. Gitaly ee/mvcc/objectstorage/objectstorage.goGitLab 源码gitlab.com ↗包注释 “Manifest generations”,head、Commit、createGeneration;读的是 2026-10-14 的提交 77bee05https://gitlab.com/gitlab-org/gitaly/-/blob/master/ee/mvcc/objectstorage/objectstorage.go ↩ ↩2

  19. Walgit: a Git server that runs as one binary in front of an object storeHacker Newsnews.ycombinator.com ↗https://news.ycombinator.com/item?id=49420598 ↩

延伸阅读