真相在桶里,磁盘只是缓存 左边一只对象存储的桶,桶身里叠着只写一次的日志段、pack 和检查点,最上面是新写入的 seq 42;桶口挂着唯一会被改写的 manifest.pb,用 CAS 从 v41 换到 v42。右边三台 walgit 实例用虚线连着桶,各自带一块本地磁盘,只当缓存。 seq 42 manifest.pb CAS v41 → v42

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 服务器放在同一台机器上量一量,看这些设计的代价落在哪里。


一、为什么要换一种存法

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

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

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


二、桶内布局

只有一个对象会被改写

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

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 和引用快照都按内容寻址,两个写者写同一份内容,写的是同一个键、同样的字节8。

读者从不 LIST

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

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


三、一次 push 的完整流程

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

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

一次 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 层,不在这份预算里49。第 ② 轮里 pack 若返回 412,说明别人传过同样的字节,直接当成功;日志段用 Create 抢的是 head_seq + 1 这个序号。

为什么 CAS 就是提交点

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

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


四、并发 push 的冲突处理

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

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

两台实例抢同一个序号 实例 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 提交10。

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

冲突是正确的,但不便宜。 输的一方要重新同步、逐个请求重新校验,再换一个序号;两台实例在同一个仓库上撞得越频繁,吞吐越低,第十节的实测里,第二台实例让吞吐不升反降。所以“多台 walgit 共用一个桶、前面挂负载均衡”是可以的,只是负载均衡不该随手轮询,要按仓库分工。

读可以落到任意一台。 每个读请求开始前都要对 manifest 发一次条件 GET(第六节),哪台实例接到请求,都先追平到最新再服务,实例之间不需要互相通知。

写交给一台。 Cursor 用 rendezvous hashing(把仓库 ID 稳定映射到一组节点上的哈希)让一个仓库总落在同一台主节点上3;walgit 的规则是维护某个仓库的主机就是它的写者(D28)。不维护任何仓库的主机可以用 wal.push_broker_url 把 receive-pack 转发给这台写者,让一台缓存热着的机器把并发的 push 攒成一批;写者联系不上时就退回本地发布。文档强调转发只是优化,不是正确性的依赖,因为发布本身是 CAS 安全的7。

谁管哪些仓库,写在配置里。 [placement] 用 owner/name 形式的通配列出每台主机服务(serve)和维护(maintain)哪些仓库,默认都是 *,单机时不用改11。README 给的多机例子是:大仓库放在带 SSD 的主机上,其余仓库放在小机器上,前面按 /<owner>/<repo> 路由2。请求落到不服务这个仓库的主机上,克隆、推送这类对象操作会在同步之前就返回 503 和 Retry-After: 15,只有引用级的读(info/refs、API、网页)处处可用;git 的写请求没有退路,不会被悄悄转到别的机器重放(D29、D30)7。压缩由桶里的租约决定谁做(第七节),也不需要额外协调。这样,加机器能扩展的是“仓库的数量”:不同仓库分给不同主机;单个仓库的写入不会因为加机器而变快。

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


五、每秒一次写与 group commit

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

GCS 的配额文档写得很直白:同一个对象名,写入上限是每秒一次,超过了可能被限流12。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”),但不会影响别的请求10。

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

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

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


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

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

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

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

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

解决:按请求需要分级

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

四级同步:一个请求只取它需要的那部分 每个请求先对 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)7。

仓库比机器还大:remote reader

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

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 秒7。

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


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

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

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

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

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

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 就说明租约被别人拿走了16。默认 TTL 是 10 分钟17。仿真测试里专门有一个场景:持有租约的实例死掉,另一台在租约过期后接手完成压缩1。

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

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

到这里,每一种写(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 走,不在乎缺号10。④ 的情况下只要 CAS 成功就一律回 ok:代码注释记录过,曾经在本地应用失败时向客户端报错,结果出现了“push 实际已经持久、git 却报告失败”的情况。

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

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

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


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

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

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

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 和单写者的部署习惯。

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


十、与普通 Git 服务器的性能对照

前面数的都是“对桶发几个请求”。用的人更关心另一笔账:同样一次 clone、一次 push,walgit 比一台普通的 Git 服务器慢多少?多出来的时间花在哪里?项目自己没有和普通服务器比过,我把两边放在同一台机器上,用同一组操作量了一遍。

实验怎么搭

普通服务器是最朴素的那种。 本地磁盘上一个裸仓库,前面是 git 自带的 CGI 程序 git http-backend,用一个几十行的 Go HTTP 服务挂起来,就能提供 clone、fetch、push 所需的 smart HTTP 协议21。没有数据库,没有副本,坏了盘就丢仓库。

walgit 用的是本文读的那个提交(80e9a20),单实例。 桶是同一台机器上的 S3 兼容存储 rustfs,walgit 自己的本地开发环境用的就是它22。本机的桶几乎没有延迟,真实的对象存储却有,所以我在 walgit 和桶之间加了一个小代理,给每个请求固定加一段延迟,顺便按类型数请求:0 毫秒代表“桶就在隔壁”,30 毫秒近似同一地域里对象存储的一次往返(walgit 在 GCS 上测到小对象 GET 要 60 到 80 毫秒,304 要 15 到 18 毫秒4)。

其余条件两边一致:一台 4 核云主机,客户端和服务器在同一台机器上;git 都是 2.51(walgit 建议 2.47 以上);仓库是 redis,218 MiB 的 pack、10.8 万个对象、2.1 万个提交、550 个分支和标签23;推送时都不做 fsck(redis 早年的历史里有零填充的文件模式,walgit 默认的 --fsck-objects 会整包拒收,而 git http-backend 默认不检查)。每次只开一个服务,免得互相抢 CPU,每组跑三轮取中位数。

单个操作:差距就是几轮桶往返

同一台机器、同一个仓库(redis),三轮的中位数
操作git http-backendwalgit,桶延迟 0 mswalgit,桶延迟 30 ms
大块数据:时间花在打包和传输上
首次推送全部分支和标签13.4 s16.6 s17.1 s
完整 clone12.0 s11.2 s11.2 s
CI 式 clone(--depth=1 --filter=blob:none)1.51 s1.26 s1.42 s
小操作:时间花在往返上
没有新东西的 fetch50 ms38 ms100 ms
推一个只改一行的提交88 ms117 ms278 ms
拉回别人刚推的一个提交79 ms77 ms212 ms

clone 时 walgit 的本地缓存已经是热的(推送它的就是这台实例)。冷缓存的情况见下文。

表 1 · 大块数据两边差不多;小操作上,walgit 多出来的时间大致等于“桶延迟 × 串行往返的轮数”

搬大块数据时,两边几乎一样。 缓存是热的时候,walgit 的 clone 交给原生的 git upload-pack 去读本地 pack,和普通服务器做的是同一件事,桶只被问了几次 304;30 毫秒的桶延迟对一个十几秒的 clone 来说可以忽略。首次推送慢 3 到 4 秒,多出来的是把 218 MiB 的 pack 写进桶,再在本地建好索引。

小操作的差距,可以一轮一轮数出来。 代理的计数显示,一次 git push 是两个 HTTP 请求(先 info/refs,再 git-receive-pack),桶一共收到 7 个请求:每个 HTTP 请求开头各一次条件 GET,一次读 policy.json(不存在,返回 404),3 个并行的 Create,1 次 CAS。去掉 info/refs 那次和 policy.json,正好是第三节说的 5 个。它们排成 5 轮串行往返,每轮加 30 毫秒就是 150 毫秒,和表里 117 到 278 毫秒之间的差距(161 毫秒)对得上。桶延迟为 0 时,walgit 的 push 也比普通服务器慢 30 毫秒左右,这是收包、写临时 pack、发布这些本地步骤的开销。

fetch 也一样。“一次读只问 1 次”是按 HTTP 请求算的,而 git 在 v2 协议下做一次 fetch 至少要两个 HTTP 请求(先拿能力列表,再列引用),真要下载新提交还得再走一两轮协商,每个请求开头都问一次桶。所以没有新东西的 fetch 多了 2 轮(38 到 100 毫秒),拉一个新提交多了 4 到 5 轮。反过来,桶延迟为 0 时 walgit 的 fetch 比普通服务器还快:git http-backend 每个请求都要启动 CGI 进程和 git 进程,walgit 在自己的进程里就能回答引用列表。

冷缓存:换一台机器的代价

普通服务器没有“冷启动”这回事,仓库就在它的盘上。walgit 的实例却随时可能是新的。我在同一个桶上起了第二台实例,缓存目录清空,再让它服务请求:

  • git ls-remote 只需要引用:桶延迟 0 毫秒时 48 毫秒,30 毫秒时 110 毫秒。它读检查点的引用快照和日志尾巴,这些请求是并行发的,所以 30 毫秒的延迟只多出两三轮。
  • 第一次完整 clone 要 19.2 秒(30 毫秒时 19.5 秒),比热缓存时的 11 秒左右多了 8 秒。这 8 秒是把 pack 从桶里下载回来、在本地建好索引;代理数到这一步发了 450 到 620 个 GET,大多是并行的,所以桶延迟几乎不影响总时间。

换句话说,walgit 的“机器可以随便扔”不是免费的:扔掉一台机器,下一个落到新机器上的大请求要多付一次下载。这就是第六节分级同步要解决的问题:只回答引用的请求,不必等 pack 下载完。

并发 push:瓶颈在本地,不在桶

单个 push 慢一两百毫秒,对人来说感觉不到。更要紧的是并发:很多 CI 任务同时往一个仓库推。我让 K 个客户端同时推送,每个客户端往自己的分支连推 10 次。

K 个客户端同时往同一个仓库的不同分支推送,每格上一行是每秒成功的 push 数,下一行是单次 push 耗时的中位数
并发数 Kgit http-backendwalgit,桶延迟 0 mswalgit,桶延迟 30 ms
17.4
74 ms
5.3
117 ms
2.8
288 ms
426.9
84 ms
6.9
271 ms
4.1
750 ms
1635.8
307 ms
9.2
1.5 s
6.9
1.9 s
3236.9
622 ms
5.1
4.9 s
7.9
3.1 s
两台 walgit 实例共用一个桶,客户端轮流分到两台上
4—2.7
427 ms
1.7
1.1 s
16—3.3
2.3 s
3.0
2.8 s

客户端和服务器挤在同一台 4 核机器上,普通服务器在 K=16 以后就被 CPU 卡住了。失败的 push 不计入吞吐:单实例 K=32 时,桶延迟 0 毫秒的三轮共失败 3 次,30 毫秒的共 2 次;两台实例 K=16 时三轮共失败 3 次。

表 2 · 普通服务器随并发数涨到 CPU 的上限;walgit 单实例在每秒 10 次左右就到顶,加第二台实例反而更慢

普通服务器随并发涨上去,直到 CPU 用完。 每个 push 是一个独立的 git receive-pack 进程,各自在隔离目录里收包,只在最后更新引用时短暂加锁,所以 4 个客户端就有 4 倍的吞吐。

walgit 单实例在每秒 10 次左右就到顶了。 第一反应会怀疑第五节说的“manifest 每秒只能写一次”,可本机的 rustfs 没有这个限制,而且日志显示 group commit 在起作用:并发时常常有六七个 push 在同一毫秒发布,共用一次 CAS。我起初以为排队发生在 walgit-git 给每个仓库配的那把 ingest_lock 上:代码注释说,安装 pack、刷新仓库和 gix 读 pack 不能同时进行,所以同一仓库的收包一个接一个做24。后来我编译了一个去掉这把锁的版本做对照,吞吐几乎没变。真正的原因是 pack 越攒越多:walgit 每个 push 都在本地留下一个新 pack(它就是写进桶里的那条日志),表 2 的各组是在同一个仓库上依次跑的,越往后仓库里攒的 pack 越多(K=32 开始时已有两百多个),每批发布之后的本地收尾也越慢;持续推送时,后台的压缩又一直轮不到合并它们。这条线索我在《walgit 离 Cursor 的 Continuity 还差在哪》里单独追了一遍。桶延迟为 30 毫秒时,各轮往返和本地处理能叠在一起,K=32 的吞吐反而比 0 毫秒时略高,但单次耗时依然是秒级。

加第二台实例,吞吐不升反降。 两台实例各自收包、各自抢日志序号,16 路并发时,代理每轮数到 66 到 110 次日志段 Create 返回 412,就是第四节画的那种相撞;输的一方要重新同步、逐个请求重新校验,再换下一个序号。冲突多到一定程度,wal.cas_max_retries 的 16 次重试也会用完,服务器日志里是 retry exhausted after 16 attempts,客户端收到的是一次失败的 push,而不是一次被悄悄吞掉的 push。这正是第四节说的,多台实例要按仓库分工:一个仓库只落在一台写者上(D28),其他机器把 push 转给它7。

和 Cursor 报告的每秒 120 次以上相比3,这里的数字低一个数量级。条件差得很远:他们测的是自己的生产系统,这里是一台 4 核机器,客户端也挤在上面;redis 的 550 个引用也让每次刷新引用都更重。所以表 2 只说明一件事:在同一台机器上,单个仓库的写入吞吐,普通服务器高出 walgit 数倍,而 walgit 的瓶颈在本地越攒越多的 pack,不在桶。

瓶颈不在语言

既然瓶颈是对桶的往返和本地的 git,一个自然的推论是:walgit 用 Rust 写,并不是它快或慢的原因。按本文读的提交数,它的 Rust 源码约 4.9 万行,测试另有约 1.9 万行;代码里有 177 处直接启动原生 git 进程,跑的是 index-pack、pack-objects、commit-graph、multi-pack-index 这些。AGENTS.md 的第一条决策(D1)只写了“Rust、tokio + axum”,没有给理由7;GOAL.md 说的是“该用原生 git 的地方用 git,gix 更快且测过的地方用 gix”2。

所以,如果要用 Go 复刻一份,难度要分开看:

  • 前九节讲的 WAL 核心(条件读写、manifest 的 CAS、group commit、检查点、压缩租约、崩溃恢复)在 Go 里很顺手。S3 和 GCS 的 Go SDK 都支持条件请求,“每个仓库一个发布任务”就是一个 goroutine 加一个 channel;wal.proto 能直接生成 Go 代码,桶里的格式甚至可以和 walgit 互通。
  • smart HTTP 加原生 git 子进程也没有难点,GitLab 的 Gitaly 就是用 Go 这样做的。
  • 贵的是“仓库比机器还大”那一块。 第六节的 remote reader,加上为远端 pack 定制的 upload-pack,都是用 gix 在进程内直接读 pack 索引和 pack 数据;用到 gix 的源文件合计约 6700 行。Go 的 go-git 处理 pack 慢得多,也没有按 commit-graph 协商这些能力,只能自己写,或者退回去调用原生 git。如果仓库都放得进本地磁盘,这一块可以整个不要。

另外一笔不能省的成本是正确性保障:第八节提到的故障注入仿真测试和 TLA+ 模型要跟着移植,否则 CAS、响应丢失、孤儿段这些边界情况就没人守着。

到这里,几个问题都有了答案,也都有了代价。最后回到开头那两个数字。


十一、回到开头的 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),相关部分还在演进7。第十节的数字是我在一台 4 核机器上实测的,只代表那台机器和那个仓库;其余性能数字都是项目自己的测量,我没有复现。Hacker News 的讨论里也有人质疑它的安全性25。


小结

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

参考资料

Footnotes

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

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

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

  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 ↩7

  5. walgit-store:ObjectStore 抽象与 S3 映射收藏github.comlib.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. Amazon S3 conditional writesAWS 文档收藏docs.aws.amazon.comhttps://docs.aws.amazon.com/AmazonS3/latest/userguide/conditional-writes.html ↩

  7. 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 ↩12 ↩13 ↩14

  8. walgit wal.proto:桶内对象的格式收藏github.comManifest、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

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

  10. 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

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

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

  13. walgit sync.rs 与 handle.rs:读路径收藏github.comfreshness_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

  14. walgit remote.rs:remote reader收藏github.comBLOCK_SIZE = 1 MiB,pack 索引本地、数据范围读https://github.com/tobi/walgit/blob/main/crates/walgit-wal/src/remote.rs ↩

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

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

  17. walgit-config packs.rs:PacksConfig 默认值GitHub · tobi/walgit收藏github.comPacksConfig 的 Default 里 lease_ttl: Duration::from_mins(10)https://github.com/tobi/walgit/blob/main/crates/walgit-config/src/packs.rs ↩

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

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

  20. 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

  21. git-http-backend:Server side implementation of Git over HTTPGit 文档收藏git-scm.comgit 自带的 CGI 程序,对照组的服务器https://git-scm.com/docs/git-http-backend ↩

  22. rustfs/rustfsGitHubgithub.com ↗S3 兼容的对象存储,walgit 的 compose.yaml 用它做本地桶https://github.com/rustfs/rustfs ↩

  23. redis/redisGitHubgithub.com ↗测试用的仓库,2026-10-26 克隆:218 MiB pack、约 10.8 万个对象、2.1 万个提交、550 个分支和标签https://github.com/redis/redis ↩

  24. walgit walgit-git lib.rs:按仓库串行的收包GitHub · tobi/walgitgithub.com ↗ingest_pack 开头的 ingest_lock 及其注释https://github.com/tobi/walgit/blob/main/crates/walgit-git/src/lib.rs ↩

  25. Walgit: a Git server that runs as one binary in front of an object storeHacker News收藏news.ycombinator.comhttps://news.ycombinator.com/item?id=49420598 ↩