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(据 Cursor 的文章) | walgit(据源码和文档) |
|---|---|---|
| 骨架:两边一样 | ||
| 唯一的真相 | S3 上的预写日志,每个 push 一条日志 | 桶里的日志段和 pack,由 manifest.pb 列出 |
| 提交点 | 对 WAL 索引对象做 CAS | 对 manifest.pb 做 CAS |
| 合并写入 | 批量提交,“仔细调过” | group commit,窗口 5 毫秒,一批最多 64 个 |
| 读的一致性 | 每次读先发条件 GET,304 平均不到 10 毫秒 | 每次读先发条件 GET |
| 谁来写 | rendezvous 哈希排第一的节点当主,CAS 兜底 | 维护该仓库的主机写,其他主机转发;CAS 兜底 |
| 细节:开始分岔 | ||
| 副本怎么知道有新 push | UDP 广播,丢了也无妨 | 不广播,实例之间没有任何通信 |
| 本地磁盘 | NVMe 上的普通裸仓库 | 默认按 tmpfs 小机器设计,放不下时按 1 MiB 范围读桶 |
| 压缩 | 主节点做,副本直接下载结果 | 持租约的一台做,作为 COMPACT 日志发布;由周期性的维护循环触发 |
| 公布的吞吐 | S3 Standard 每秒 120 次,S3 Express 超过 300 次 | 没有公布 |
Continuity 一列只来自 Cursor 的文章,文章没提机器配置、测试方法和代码。
骨架是一样的:不可变的日志加一次 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 次,取中位数:
| 每个桶请求加的延迟 | 0 ms | 10 ms | 30 ms |
|---|---|---|---|
| 推一个只改一行的提交 | 73 ms | 128 ms | 228 ms |
| 没有新东西的 fetch | 26 ms | 48 ms | 88 ms |
每加 10 毫秒,push 多 50 多毫秒,fetch 多 20 多毫秒:一次 push 要和桶串行往返 5 轮,一次 fetch 要 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 角色,后台维护先关掉:
普通服务器的线是平的,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_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 bitmapwalgit 合并之后会写一个带位图的多包索引(MIDX),再用 git rev-list --test-bitmap 检查某个提交的位图能否正常遍历11。它挑的检查对象,是按名字排序后的第一个分支,在 redis 里是 refs/heads/2.2,一个很老的分支。git 生成位图时只给一部分提交建位图,这个提交恰好不在其中,校验就失败,整次合并作废。于是这个仓库的 pack 永远合不起来。这是我对着日志和源码推出来的原因,没有改代码验证。
到这里,差距的来源基本清楚了。可 Cursor 不也是每个 push 一个 pack 吗?他们为什么没被拖垮?
五、差距的账
Cursor 文章里有一句话很关键:换成 S3 Express 以后,“我们实际上被 Git 压缩磁盘数据的速度卡住了”1。也就是说,他们的压缩是和推送同时进行、一直在追赶的,吞吐的上限就是压缩追得上的速度。walgit 在持续推送下根本没有开始合并,上限就由“pack 攒到多少时每批要等多久”决定,而且只会越来越低。
把前面的排查放在一起:
| 因素 | 实测或依据 | 对吞吐的影响 |
|---|---|---|
| 桶延迟 | 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 很少时这台机器上的上限,仍比同机的普通服务器低一半左右,其中有多少能靠更大的机器补回来,这次的实验分不出来。
所以,回到开头那几个问题:机器小是事实,但 walgit 连一个核都没用满;桶延迟只决定一次 push 要多等一两百毫秒;真正拉开差距的,是每个 push 都留下一个 pack,而 walgit 的压缩在持续推送时让位给了分类。Cursor 把压缩放在主节点上和推送同时进行,并且已经在改磁盘上的布局,想让压缩的影响更小1;walgit 的维护循环是为另一个目标设计的,它优先保证的是给 clone 用的静态 pack 都有正确的分类。
如果要让 walgit 在持续推送下追上来,从这次的观察看,至少要做两件事:维护在 pack 数超过合并门槛时让合并优先于分类(或者两者并行);合并的位图校验换一个一定有位图的提交,比如某个被选中的引用的顶端。这是我的推断,没有改代码验证。
最后说明几点边界。所有 walgit 的数字都出自同一台 4 核机器,客户端和服务器在一起,桶在本机;Cursor 的数字出自他们自己的生产环境,测试方法没有公开,两者不能直接比大小,只能比走势和原因。walgit 是一个不到 1.0 的年轻项目,本文读的版本是 2026 年 9 月的提交,维护策略还在演进12。
小结
- 两边是同一个架构吗? 骨架相同:不可变日志、一次 CAS 当提交点、读前条件 GET、合批写。walgit 不要广播,按小机器设计,压缩由周期性维护循环触发,目标是快速 clone 而不是 push 吞吐。
- 是桶太慢吗? 不是。桶延迟按“往返轮数 × 延迟”加到单次耗时上,批量提交能摊掉它,0 毫秒的桶上吞吐一样低。
- 是机器太小吗? 不全是。walgit 进程只用了 0.2 到 0.3 个核,同一台机器上的普通服务器每秒能接 40 到 50 次。
- 为什么越推越慢? 每个 push 都在本地留下一个 pack,pack 越多,每批发布后的收尾越慢。普通服务器把小 push 解成松散对象,不攒 pack。关掉 commit-graph、去掉收包锁都不改变这个走势。
- 压缩为什么没救回来? 维护循环有未分类的 pack 就先分类,推送不停,合并就排不上;多分支仓库的合并还会因为位图校验挑错提交而失败。
- Cursor 为什么没被拖垮? 他们的压缩和推送同时进行,吞吐上限就是压缩追得上的速度。
可以照做的实验:持续推送用的是一个 shell 脚本,16 个客户端各自循环 commit 加 push,每次记录完成时刻和耗时,再按 5 秒分桶;pack 个数每 2 秒数一次本地缓存目录里的 *.pack。桶前面的延迟代理是几十行 Go,给每个请求 sleep 固定时长后转发。
参考资料
Footnotes
-
Git at any scale收藏cursor.comContinuity 的设计与吞吐数字:S3 Standard 每秒 120 次 push,S3 Express One Zone 超过 300 次,瓶颈是 Git 压缩磁盘数据的速度https://cursor.com/blog/git-at-any-scale ↩ ↩2 ↩3 ↩4 ↩5
-
tobi/walgit收藏github.com本文用的是 2026-09-12 的提交
80e9a20https://github.com/tobi/walgit ↩ -
git-http-backend:Server side implementation of Git over HTTP收藏git-scm.comhttps://git-scm.com/docs/git-http-backend ↩
-
walgit GOAL.md:walgit 为什么而做github.com ↗“the one sentence”、验收表、“刻意不优化”一节https://github.com/tobi/walgit/blob/main/GOAL.md ↩ ↩2 ↩3
-
walgit walgit.example.toml:配置默认值github.com ↗
[maintenance] interval = "60s"、disk = "tmpfs";[packs] fold_when_fresh_packs_reach = 16https://github.com/tobi/walgit/blob/main/walgit.example.toml ↩ ↩2 -
redis/redisgithub.com ↗测试仓库:只推
unstable分支和全部标签时,218 MiB pack、约 10.8 万个对象https://github.com/redis/redis ↩ -
git-config:receive.unpackLimitgit-scm.com ↗推送的对象数少于这个值(默认 100)时解成松散对象,否则保留为 packhttps://git-scm.com/docs/git-config#Documentation/git-config.txt-receiveunpackLimit ↩
-
walgit walgit-git lib.rs:按仓库串行的收包github.com ↗
ingest_pack开头的ingest_lockhttps://github.com/tobi/walgit/blob/main/crates/walgit-git/src/lib.rs ↩ -
walgit 架构原理:把 Git 仓库交给对象存储twwy.net ↗walgit 的提交点、group commit、读路径、压缩的源码分析,以及与 git http-backend 的单项实测https://twwy.net/blog/walgit-architecture/ ↩
-
walgit pack_lifecycle.rs:维护任务怎么选github.com ↗
plan:有未分类的 pack 就先返回Work::Classify,否则才看fold_reasonhttps://github.com/tobi/walgit/blob/main/crates/walgit-server/src/pack_lifecycle.rs ↩ -
walgit midx.rs:写 MIDX 位图后的校验github.com ↗
write_verified_midx最后用git rev-list --test-bitmap <commit>检查https://github.com/tobi/walgit/blob/main/crates/walgit-git/src/midx.rs ↩ -
walgit AGENTS.md:架构与运维手册收藏github.comD1、D2、D28 等决策https://github.com/tobi/walgit/blob/main/AGENTS.md ↩