pack 越堆越高,压缩机还没开工 左边两行数字:Cursor 的 Continuity 每秒 120 次 push,walgit 持续推送 90 秒后每秒 6 次。右边是一排越堆越高的小方块,每个方块是一次 push 留下的 pack,最新的一个是橙色;最右边一台压缩机悬在半空,还没落下来。 120/s Continuity · S3 Standard 6/s walgit · 推送 90 秒后

Cursor 在《Git at any scale》里给过两个数字:他们的 Git 存储系统 Continuity 把仓库的真相放在 S3 上的一份预写日志里,在 S3 Standard 上“每秒能持续接受 120 次 push”,换成延迟更低的 S3 Express One Zone,“超过 300 次”1。walgit 的 README 说,它就是这套架构的开源实现2。

我在一台 4 核机器上测 walgit 时,单个仓库、16 个客户端同时推送,开头每秒 25 次左右,推上一分半钟,就只剩每秒 6 次。同一台机器上,git 自带的 git http-backend3 每秒稳定在 40 到 50 次。

差了一个数量级以上。是我的机器太小,是桶太慢,还是 walgit 本身少做了什么?同一个架构,为什么会差这么多?


一、两边是不是同一个架构

先把两边放在一起看。Cursor 的文章只讲了设计,没有开源代码;walgit 有代码,也有一份写给开发者的 GOAL.md 和 AGENTS.md,其中 GOAL.md 明说“Cursor 的《Git at any scale》/ Continuity 是我们遵循的设计”4。

Continuity 与 walgit 的设计对照
方面Continuity(据 Cursor 的文章)walgit(据源码和文档)
骨架:两边一样
唯一的真相S3 上的预写日志,每个 push 一条日志桶里的日志段和 pack,由 manifest.pb 列出
提交点对 WAL 索引对象做 CAS对 manifest.pb 做 CAS
合并写入批量提交,“仔细调过”group commit,窗口 5 毫秒,一批最多 64 个
读的一致性每次读先发条件 GET,304 平均不到 10 毫秒每次读先发条件 GET
谁来写rendezvous 哈希排第一的节点当主,CAS 兜底维护该仓库的主机写,其他主机转发;CAS 兜底
细节:开始分岔
副本怎么知道有新 pushUDP 广播,丢了也无妨不广播,实例之间没有任何通信
本地磁盘NVMe 上的普通裸仓库默认按 tmpfs 小机器设计,放不下时按 1 MiB 范围读桶
压缩主节点做,副本直接下载结果持租约的一台做,作为 COMPACT 日志发布;由周期性的维护循环触发
公布的吞吐S3 Standard 每秒 120 次,S3 Express 超过 300 次没有公布

Continuity 一列只来自 Cursor 的文章,文章没提机器配置、测试方法和代码。

表 1 · 骨架完全一致,差别在副本通知、本地磁盘的假设和压缩怎么触发

骨架是一样的:不可变的日志加一次 CAS 当提交点,读之前先问一句“有没有新东西”,push 合批写。差别在三处。第一,walgit 连 UDP 广播都不要,GOAL.md 第一条写的是“没有数据库,没有 Redis,没有广播,没有主节点”4。第二,walgit 的目标机器比仓库还小,默认配置写的是 disk = "tmpfs"5。第三,压缩怎么触发:Cursor 只说“只有主节点做”,walgit 是一个每隔一段时间跑一轮的维护循环。

还有一处不在表里,但更重要:两边追求的东西不一样。 Cursor 那篇文章的主角就是 push 吞吐;walgit 的 GOAL.md 把“通过预先算好的静态 pack 实现快速 clone 和追平”定为北极星,验收表里关于 push 的那一行只写了“桶确认后才回 ok;一批一次 CAS;维护仓库的主机来写”,没有吞吐指标4。

骨架既然相同,数字差这么多,就得一项一项排查。最先想到的是桶:我测的是本机的对象存储,Cursor 测的是 S3。


二、先排除桶延迟和机器

桶延迟只决定单次耗时

我在 walgit 和桶之间加了一个代理,给每个请求固定加一段延迟。桶是同机的 S3 兼容存储 rustfs,测试仓库是 redis6。同一台机器上连推 20 次、连 fetch 20 次,取中位数:

walgit 单次操作耗时随桶延迟的变化(中位数)
每个桶请求加的延迟0 ms10 ms30 ms
推一个只改一行的提交73 ms128 ms228 ms
没有新东西的 fetch26 ms48 ms88 ms

每加 10 毫秒,push 多 50 多毫秒,fetch 多 20 多毫秒:一次 push 要和桶串行往返 5 轮,一次 fetch 要 2 轮。

表 2 · 桶延迟按“轮数 × 延迟”加到单次耗时上

Cursor 说他们的 304 平均不到 10 毫秒1,写请求会更慢一些;按这张表,walgit 放在这样的桶上,单次 push 在 130 毫秒以上。可吞吐不一样:batch 本来就是为了把桶延迟摊掉,一批 push 共用一轮往返,延迟高一点,一批里攒的 push 就多一点。而且我测吞吐时用的是 0 毫秒的桶,walgit 依旧只有每秒 6 到 25 次。所以桶延迟能解释单次耗时,解释不了吞吐。

机器没有被用满

我的机器只有 4 核,16 个客户端也挤在上面,会不会是 CPU 不够?推送进行中,我按 /proc 采样了 walgit 进程(连同它启动的 git 子进程)的 CPU:只用了 0.2 到 0.3 个核,整台机器的忙碌度在 40% 到 54% 之间,大部分是客户端。同一台机器上 git http-backend 能跑到每秒 40 到 50 次,说明机器本身撑得住这个量。

Cursor 的机器肯定比这台大,他们的文章没给配置,这一项我无法量化。但 walgit 的服务端连一个核都没用满,它不是在算,而是在等。

那它在等什么?要回答这个问题,得先看清吞吐是怎么掉下来的。


三、为什么越推越慢

现象:吞吐随时间往下滑

前面那句“每秒 6 到 25 次”不是测量误差,而是同一次测试的开头和结尾。我让 16 个客户端各推各的分支,不停地推 90 秒,每 5 秒统计一次。为了只看收包和发布本身,walgit 只开了 serve 角色,后台维护先关掉:

持续推送时的吞吐 16 个客户端持续推送 90 秒:git http-backend 稳定在每秒 40 到 50 次,walgit 单仓库从 25 次滑到 6 次,去掉收包锁的版本几乎重合,分到 4 个仓库时下滑变慢。 16 个客户端持续推送 90 秒,每 5 秒成功的 push 数(次/秒) 0 10 20 30 40 50 0 s 15 s 30 s 45 s 60 s 75 s 90 s git http-backend 一个仓库,末段 50.0 walgit · 4 个仓库 合计,末段 16.0 walgit · 去掉收包锁 一个仓库,末段 6.8 walgit 一个仓库,末段 6.4 普通服务器一直在 40 到 50 之间;walgit 从 25 往下滑,去掉收包锁几乎没有变化; 同样的推送分到 4 个仓库,每个仓库攒下的 pack 少,下滑也慢。
图 1 · 普通服务器的吞吐是平的,walgit 越推越慢;去掉收包锁不改变这条曲线

普通服务器的线是平的,walgit 的线一路往下。同一段时间里,walgit 本地仓库里的 pack 从开头的一百多个涨到了八百多个。

原因:每个 push 留下一个 pack

为什么普通服务器不攒 pack?git 的 receive-pack 有个参数 receive.unpackLimit,默认 100:一次推送的对象少于 100 个,就解开成松散对象,不留 pack7。改一行代码的 push 只有三四个对象,所以普通服务器推了 4039 次之后,仓库里还是只有 2 个 pack,外加 3520 个松散对象,后台的 git gc --auto 会定期把它们收起来。

walgit 不能这么做。在它的设计里,一次 push 的 pack 就是写进桶里的那条日志:pack 和它的索引先作为不可变对象写进桶,再由 manifest 的 CAS 把它们“亮出来”。本地仓库只是桶的缓存,所以每个 push 都在本地留下一个 pack,推 900 次就是 900 个。Cursor 那边也是同样的结构,每个 push 写成一条 WAL 记录,pack 同时写进本地磁盘和 S31。

pack 多了,为什么会慢?git 找一个对象要挨个翻 pack 的索引;walgit 每批发布之后,还要把新 pack 装进本地缓存、更新 commit-graph、刷新引用。我从日志里量到三个随 pack 数变长的东西:

  • 发布任务一批接一批,两批之间的间隔从开头的 78 毫秒涨到 270 毫秒,每批平均只有 1.6 到 1.9 个 push。吞吐约等于“每批个数 ÷ 间隔”,正好从每秒 24 次掉到 6 次左右。
  • 每批之后更新 commit-graph,从 45 毫秒涨到 200 多毫秒。
  • 收包时 index-pack 的耗时从 14 毫秒涨到 26 毫秒,因为瘦包里引用的基础对象要在更多 pack 里找。

两次没有奏效的尝试

第一次尝试:关掉 commit-graph。 既然它涨得最多,关掉([git] commit_graph = false)会不会就平了?不会。关掉之后开头反而更慢,每秒 13 次(我猜是推送后的连通性检查少了 commit-graph 加速),之后照样往下滑,90 秒时每秒 5 次。commit-graph 只是 pack 变多以后变慢的环节之一。

第二次尝试:去掉收包锁。 walgit 给每个仓库配了一把 ingest_lock,同一仓库的收包一个接一个做8。我在测 walgit 与普通服务器的对比时,起初以为这把锁就是瓶颈9。这次我编译了一个把这把锁删掉的版本,用同样的方式测:开头每秒 26 次,结尾每秒 7 次,和带锁的版本几乎重合(图 1 的虚线)。锁不是原因。

把同样的压力分给 4 个仓库,结果就不同了:合计吞吐的起点一样是每秒 25 次左右,可下滑慢得多,60 秒时还有每秒 14 到 16 次,每个仓库只攒了 290 个左右的 pack。决定快慢的,是单个仓库里攒了多少 pack。

既然 pack 会越攒越多,就得有人把它们合起来。walgit 有压缩,为什么没有起作用?


四、压缩为什么跟不上

现象:推送期间 pack 只增不减

把后台维护打开(默认每 60 秒一轮),再推 3 分钟。吞吐的走势和图 1 一样,开头每秒 27 次,后面掉到每秒 1 到 4 次,本地的 pack 涨到了 1092 个,期间维护跑了两轮,pack 一个也没少。

我把维护间隔调到 5 秒,结果也一样。图 2 是这次的 pack 个数和每一轮维护在做什么:

推送期间和之后的 pack 个数 walgit 本地 pack 个数在 180 秒推送期间涨到 925 个,维护循环每一轮都在做分类;推送停止约 80 秒后才开始合并,87 秒后 pack 个数降到 2。 walgit 本地的 pack 个数:推送 180 秒,之后停手(维护间隔调到 5 秒) 16 个客户端持续推送 0 250 500 750 1000 0 s 60 s 120 s 180 s 240 s 300 s 360 s 维护 分类 分类 分类 分类 分类 分类 合并 925 → 2 925 推送期间每一轮维护都在给新 pack 分类,分完又有新的,合并一直排不上; 停手 80 秒后才轮到合并,花 87 秒把 925 个 pack 合成 2 个。
图 2 · 推送期间压缩只顾着给新 pack 分类,合并要等推送停下来才开始

原因:分类排在合并前面

维护循环每一轮只做一件事,选哪件由 pack_lifecycle.rs 里的 plan 决定。它的第一步是:只要 manifest 里还有没分类的 pack,就返回“分类”(Work::Classify);全部分完类,才去检查是否该合并(Work::Fold)10。合并的门槛并不高,同一类里攒够 16 个新 pack 就该合并了(fold_when_fresh_packs_reach = 16)5。

分类给每个 pack 标上它属于哪一组引用,这是 walgit 为 clone 准备静态 pack(它的北极星)所需的元数据。在这台机器上,一轮分类要 25 到 50 秒;这段时间里,客户端又推上来几百个新 pack。下一轮开始时,manifest 里又有了没分类的 pack,于是又是分类。只要推送不停,合并就永远排不上。

推送停下来以后,最后一轮分类把剩下的全部分完,接着才是合并:一次把 925 个 pack 合成 2 个,花了 87 秒。合并完之后再推,pack 从 2 个重新涨起,吞吐只有每秒 7 到 12 次,不到维护间隔 60 秒时开头的一半:每 5 秒一轮的维护本身也在占用这台机器。

另一个坑:多分支仓库的合并会失败

上面的测试只推了 redis 的 unstable 一个分支。我最早推的是 redis 全部 550 个分支和标签,那时维护确实挑中了合并,可合并每次都失败,日志里是:

subprocess `git rev-list --test-bitmap 8ff874d2…` exited 128:
fatal: commit '8ff874d2…' doesn't have an indexed bitmap

walgit 合并之后会写一个带位图的多包索引(MIDX),再用 git rev-list --test-bitmap 检查某个提交的位图能否正常遍历11。它挑的检查对象,是按名字排序后的第一个分支,在 redis 里是 refs/heads/2.2,一个很老的分支。git 生成位图时只给一部分提交建位图,这个提交恰好不在其中,校验就失败,整次合并作废。于是这个仓库的 pack 永远合不起来。这是我对着日志和源码推出来的原因,没有改代码验证。

到这里,差距的来源基本清楚了。可 Cursor 不也是每个 push 一个 pack 吗?他们为什么没被拖垮?


五、差距的账

Cursor 文章里有一句话很关键:换成 S3 Express 以后,“我们实际上被 Git 压缩磁盘数据的速度卡住了”1。也就是说,他们的压缩是和推送同时进行、一直在追赶的,吞吐的上限就是压缩追得上的速度。walgit 在持续推送下根本没有开始合并,上限就由“pack 攒到多少时每批要等多久”决定,而且只会越来越低。

把前面的排查放在一起:

walgit 实测与 Cursor 公布的吞吐之间,差距来自哪里
因素实测或依据对吞吐的影响
桶延迟0、10、30 毫秒下,单次 push 是 73、128、228 毫秒只影响单次耗时,批量提交能摊掉
机器walgit 进程只用了 0.2 到 0.3 个核;同机的普通服务器每秒 40 到 50 次不是这台机器上的瓶颈;Cursor 的配置未知
收包锁去掉后开头每秒 26 次、结尾 7 次,和带锁时几乎一样可以忽略
pack 越攒越多90 秒内从一百多个涨到八百多个,批间隔从 78 毫秒涨到 270 毫秒吞吐从每秒 25 次掉到 6 次,而且会继续掉
压缩不合并推送期间每轮维护都在分类;停手后才把 925 个合成 2 个;多分支仓库的合并校验失败让上一行的下滑没有尽头
项目目标北极星是从小机器上快速 clone;验收表没有 push 吞吐指标push 吞吐不是 walgit 优化的方向

“开头每秒 25 次”是 pack 很少时这台机器上的上限,仍比同机的普通服务器低一半左右,其中有多少能靠更大的机器补回来,这次的实验分不出来。

表 3 · 拉开数量级差距的,是持续推送下没有及时合并的 pack,不是桶,也不是锁

所以,回到开头那几个问题:机器小是事实,但 walgit 连一个核都没用满;桶延迟只决定一次 push 要多等一两百毫秒;真正拉开差距的,是每个 push 都留下一个 pack,而 walgit 的压缩在持续推送时让位给了分类。Cursor 把压缩放在主节点上和推送同时进行,并且已经在改磁盘上的布局,想让压缩的影响更小1;walgit 的维护循环是为另一个目标设计的,它优先保证的是给 clone 用的静态 pack 都有正确的分类。

如果要让 walgit 在持续推送下追上来,从这次的观察看,至少要做两件事:维护在 pack 数超过合并门槛时让合并优先于分类(或者两者并行);合并的位图校验换一个一定有位图的提交,比如某个被选中的引用的顶端。这是我的推断,没有改代码验证。

最后说明几点边界。所有 walgit 的数字都出自同一台 4 核机器,客户端和服务器在一起,桶在本机;Cursor 的数字出自他们自己的生产环境,测试方法没有公开,两者不能直接比大小,只能比走势和原因。walgit 是一个不到 1.0 的年轻项目,本文读的版本是 2026 年 9 月的提交,维护策略还在演进12。


小结

  1. 两边是同一个架构吗? 骨架相同:不可变日志、一次 CAS 当提交点、读前条件 GET、合批写。walgit 不要广播,按小机器设计,压缩由周期性维护循环触发,目标是快速 clone 而不是 push 吞吐。
  2. 是桶太慢吗? 不是。桶延迟按“往返轮数 × 延迟”加到单次耗时上,批量提交能摊掉它,0 毫秒的桶上吞吐一样低。
  3. 是机器太小吗? 不全是。walgit 进程只用了 0.2 到 0.3 个核,同一台机器上的普通服务器每秒能接 40 到 50 次。
  4. 为什么越推越慢? 每个 push 都在本地留下一个 pack,pack 越多,每批发布后的收尾越慢。普通服务器把小 push 解成松散对象,不攒 pack。关掉 commit-graph、去掉收包锁都不改变这个走势。
  5. 压缩为什么没救回来? 维护循环有未分类的 pack 就先分类,推送不停,合并就排不上;多分支仓库的合并还会因为位图校验挑错提交而失败。
  6. Cursor 为什么没被拖垮? 他们的压缩和推送同时进行,吞吐上限就是压缩追得上的速度。

可以照做的实验:持续推送用的是一个 shell 脚本,16 个客户端各自循环 commit 加 push,每次记录完成时刻和耗时,再按 5 秒分桶;pack 个数每 2 秒数一次本地缓存目录里的 *.pack。桶前面的延迟代理是几十行 Go,给每个请求 sleep 固定时长后转发。

参考资料

Footnotes

  1. Git at any scaleVicent Martí · Cursor · 2026-08-18收藏cursor.comContinuity 的设计与吞吐数字:S3 Standard 每秒 120 次 push,S3 Express One Zone 超过 300 次,瓶颈是 Git 压缩磁盘数据的速度https://cursor.com/blog/git-at-any-scale ↩ ↩2 ↩3 ↩4 ↩5

  2. tobi/walgitTobias Lütke收藏github.com本文用的是 2026-09-12 的提交 80e9a20https://github.com/tobi/walgit ↩

  3. git-http-backend:Server side implementation of Git over HTTPGit 文档收藏git-scm.comhttps://git-scm.com/docs/git-http-backend ↩

  4. walgit GOAL.md:walgit 为什么而做GitHub · tobi/walgitgithub.com ↗“the one sentence”、验收表、“刻意不优化”一节https://github.com/tobi/walgit/blob/main/GOAL.md ↩ ↩2 ↩3

  5. walgit walgit.example.toml:配置默认值GitHub · tobi/walgitgithub.com ↗[maintenance] interval = "60s"、disk = "tmpfs";[packs] fold_when_fresh_packs_reach = 16https://github.com/tobi/walgit/blob/main/walgit.example.toml ↩ ↩2

  6. redis/redisGitHubgithub.com ↗测试仓库:只推 unstable 分支和全部标签时,218 MiB pack、约 10.8 万个对象https://github.com/redis/redis ↩

  7. git-config:receive.unpackLimitGit 文档git-scm.com ↗推送的对象数少于这个值(默认 100)时解成松散对象,否则保留为 packhttps://git-scm.com/docs/git-config#Documentation/git-config.txt-receiveunpackLimit ↩

  8. walgit walgit-git lib.rs:按仓库串行的收包GitHub · tobi/walgitgithub.com ↗ingest_pack 开头的 ingest_lockhttps://github.com/tobi/walgit/blob/main/crates/walgit-git/src/lib.rs ↩

  9. walgit 架构原理:把 Git 仓库交给对象存储twwy.nettwwy.net ↗walgit 的提交点、group commit、读路径、压缩的源码分析,以及与 git http-backend 的单项实测https://twwy.net/blog/walgit-architecture/ ↩

  10. walgit pack_lifecycle.rs:维护任务怎么选GitHub · tobi/walgitgithub.com ↗plan:有未分类的 pack 就先返回 Work::Classify,否则才看 fold_reasonhttps://github.com/tobi/walgit/blob/main/crates/walgit-server/src/pack_lifecycle.rs ↩

  11. walgit midx.rs:写 MIDX 位图后的校验GitHub · tobi/walgitgithub.com ↗write_verified_midx 最后用 git rev-list --test-bitmap <commit> 检查https://github.com/tobi/walgit/blob/main/crates/walgit-git/src/midx.rs ↩

  12. walgit AGENTS.md:架构与运维手册收藏github.comD1、D2、D28 等决策https://github.com/tobi/walgit/blob/main/AGENTS.md ↩