walgit 的源码里有一组故障注入的仿真测试,其中一个叫 healthy_request_round_trip_budgets,它数的是一次操作对对象存储发了几个请求(crates/walgit-server/tests/sim.rs,断言整理成一行)1:
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,而每一跳的位置都要读完上一跳才知道。
按 walgit 在 GCS 上的测量,一次小对象 GET 要 60 到 80 毫秒4,而一次 clone 或 git log 要走成千上万跳。NFS 失败也是同一个原因;Cursor 还提到,Google 曾把 JGit 的对象放进分布式哈希表,最后因为 clone 太慢放弃了3。
解决:桶里放日志,磁盘只当缓存
Cursor 的系统 Continuity 换了一个角度:git 照旧在本地磁盘上跑,但本地仓库只是缓存,唯一的真相是对象存储里的一份预写日志(write-ahead log,WAL:先把“要做什么”持久地记下来,再去改实际状态)。walgit 是这个架构的开源实现,又补上了“机器比仓库还小”时需要的东西2。
图 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 是目录,其余对象按序号或内容命名。 图里没画出来的细节有三点: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。
能在本地做的,都先在本地做完。 收包在一个临时目录里进行(默认还带 --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。
冲突先出现在日志段上。 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。
批里的每个请求各自校验。 校验在一张“工作引用表”上依次进行:请求 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。
每一级只拿请求需要的那部分。 回答 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。
读到对象之后,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。
谁来做,由租约决定。 租约是桶里的一个小对象 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 把四个崩溃点和各自的结局放在一起。
提交点之前死掉,留下的只是垃圾;之后死掉,提交已经生效。 ② 的孤儿段被下一个写者探测 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。
| walgit | GitLab MVCC 对象存储后端 | |
|---|---|---|
| 提交点 | 对 manifest.pb 做 If-Match 覆盖 | 用“不存在才创建”写下一个 generation |
| 每秒一次写的限制 | 存在,用 group commit 摊薄 | 不存在,每次提交写的是新对象 |
| 读者怎么知道最新版本 | 条件 GET,没变就 304 | LIST 一次(最多 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,每组跑三轮取中位数。
单个操作:差距就是几轮桶往返
| 操作 | git http-backend | walgit,桶延迟 0 ms | walgit,桶延迟 30 ms |
|---|---|---|---|
| 大块数据:时间花在打包和传输上 | |||
| 首次推送全部分支和标签 | 13.4 s | 16.6 s | 17.1 s |
| 完整 clone | 12.0 s | 11.2 s | 11.2 s |
CI 式 clone(--depth=1 --filter=blob:none) | 1.51 s | 1.26 s | 1.42 s |
| 小操作:时间花在往返上 | |||
| 没有新东西的 fetch | 50 ms | 38 ms | 100 ms |
| 推一个只改一行的提交 | 88 ms | 117 ms | 278 ms |
| 拉回别人刚推的一个提交 | 79 ms | 77 ms | 212 ms |
clone 时 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 | git http-backend | walgit,桶延迟 0 ms | walgit,桶延迟 30 ms |
|---|---|---|---|
| 1 | 7.4 74 ms | 5.3 117 ms | 2.8 288 ms |
| 4 | 26.9 84 ms | 6.9 271 ms | 4.1 750 ms |
| 16 | 35.8 307 ms | 9.2 1.5 s | 6.9 1.9 s |
| 32 | 36.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 次。
普通服务器随并发涨上去,直到 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。
小结
- 为什么不让 git 直接读对象存储? packfile 的读取是大量串行随机访问,对象存储一次请求就要几十毫秒。所以 git 仍然在本地磁盘上跑,本地仓库只是缓存,真相是桶里的预写日志。
- 桶里有什么? 一个会被 CAS 改写的
manifest.pb,加上按序号或内容寻址、写一次就不再改的日志段、pack 和检查点。读者只认 manifest,从不 LIST。 - push 什么时候算提交? manifest 的 CAS 成功的那一刻。之前写进桶的东西没人引用,对谁都不可见;之后才回
ok。 - 并发 push 怎么办? 先在日志序号上相撞,输的一方重新同步、逐个请求重新校验旧值,过期的回
ng,其余的换下一个序号再提交。所以多台实例共用一个桶时按仓库分工:读落到哪台都行,写交给维护这个仓库的那一台,[placement]决定谁服务、谁维护哪些仓库。 - 每个对象每秒只能写一次怎么办? 每个仓库一个发布任务,把并发的 push 合成一批,只写一个日志段、只做一次 CAS。
- 读怎么保证最新? 每次先对 manifest 做条件 GET,304 就直接服务;再按请求需要分成 Refs、Serve、Full、Objects 几级补齐,放不下的用范围读。
- 日志和 pack 越来越多怎么办? 检查点把日志折叠成快照;压缩由持有租约的一台机器做,结果作为 COMPACT 条目发布,但生效与否仍由 CAS 决定。
- 崩溃了怎么办? 提交点之前死掉,留下的是没人引用的垃圾;响应丢失时比对字节判定,找不到证据就报告“结果未知”;提交点之后死掉,下次同步自动重放。
- 还有别的做法吗? GitLab 的实验性后端用“只追加新的 generation 对象”避开了写入上限,代价是读者要靠 LIST 找最新版本。
- 比普通 Git 服务器慢多少? 大块数据(clone、首次推送)差不多;小操作慢的部分约等于“桶延迟 × 串行往返轮数”;单个仓库的并发写入,walgit 单实例在每秒 10 次左右到顶,瓶颈是每个 push 在本地留下一个 pack、越攒越多,不在桶,也不在 Rust 这门语言。
参考资料
Footnotes
-
walgit 仿真测试 sim.rs收藏github.com
healthy_request_round_trip_budgets等故障注入场景https://github.com/tobi/walgit/blob/main/crates/walgit-server/tests/sim.rs ↩ ↩2 ↩3 -
tobi/walgit收藏github.com本文读的是 2026-09-12 的提交
80e9a20,README 与docs/announcing-walgit.mdhttps://github.com/tobi/walgit ↩ ↩2 ↩3 ↩4 -
Git at any scale收藏cursor.comhttps://cursor.com/blog/git-at-any-scale ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8
-
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
-
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 -
Amazon S3 conditional writes收藏docs.aws.amazon.comhttps://docs.aws.amazon.com/AmazonS3/latest/userguide/conditional-writes.html ↩
-
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
-
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 -
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 -
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 -
walgit-config:默认配置收藏github.com
WalConfig、CacheConfig、PlacementConfig的Default;租约 TTL 在packs.rshttps://github.com/tobi/walgit/blob/main/crates/walgit-config/src/lib.rs ↩ ↩2 ↩3 ↩4 ↩5 -
Cloud Storage quotas & limits收藏docs.cloud.google.com同一对象名的写入上限为每秒一次https://docs.cloud.google.com/storage/quotas ↩
-
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 -
walgit remote.rs:remote reader收藏github.com
BLOCK_SIZE = 1 MiB,pack 索引本地、数据范围读https://github.com/tobi/walgit/blob/main/crates/walgit-wal/src/remote.rs ↩ -
walgit checkpoint.rs:检查点收藏github.com
checkpoint_due的三个触发条件,write_checkpoint_inner的两轮写https://github.com/tobi/walgit/blob/main/crates/walgit-wal/src/checkpoint.rs ↩ -
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 ↩ -
walgit-config packs.rs:PacksConfig 默认值收藏github.com
PacksConfig的Default里lease_ttl: Duration::from_mins(10)https://github.com/tobi/walgit/blob/main/crates/walgit-config/src/packs.rs ↩ -
walgit docs/spec:有界模型检查收藏github.comTLA+ 模型
WALContract.tla与契约编号 C1–C16https://github.com/tobi/walgit/blob/main/docs/spec/README.md ↩ -
Gitaly doc/mvcc_rpc_flow.md收藏gitlab.com文档自称描述的是 POC 分支上的流程https://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/mvcc_rpc_flow.md ↩
-
Gitaly ee/mvcc/objectstorage/objectstorage.go收藏gitlab.com包注释 “Manifest generations”,
head、Commit、createGeneration;读的是 2026-10-14 的提交77bee05https://gitlab.com/gitlab-org/gitaly/-/blob/master/ee/mvcc/objectstorage/objectstorage.go ↩ ↩2 -
git-http-backend:Server side implementation of Git over HTTP收藏git-scm.comgit 自带的 CGI 程序,对照组的服务器https://git-scm.com/docs/git-http-backend ↩
-
rustfs/rustfsgithub.com ↗S3 兼容的对象存储,walgit 的
compose.yaml用它做本地桶https://github.com/rustfs/rustfs ↩ -
redis/redisgithub.com ↗测试用的仓库,2026-10-26 克隆:218 MiB pack、约 10.8 万个对象、2.1 万个提交、550 个分支和标签https://github.com/redis/redis ↩
-
walgit walgit-git lib.rs:按仓库串行的收包github.com ↗
ingest_pack开头的ingest_lock及其注释https://github.com/tobi/walgit/blob/main/crates/walgit-git/src/lib.rs ↩ -
Walgit: a Git server that runs as one binary in front of an object store收藏news.ycombinator.comhttps://news.ycombinator.com/item?id=49420598 ↩