﻿---
title: "walgit 离 Cursor 的 Continuity 还差在哪"
description: "Cursor 说 Continuity 在 S3 上每秒能接 120 次 push，号称照着它实现的 walgit 在我的机器上只有每秒 6 到 25 次。先对齐两边的设计，再排除桶延迟和机器，最后顺着“越推越慢”追到每个 push 留下的 pack 和跟不上的压缩。"
pubDate: 2026-10-27
tags: ["Git", "分布式系统", "对象存储", "walgit", "性能"]
clc: "TP311.5"
hero: "./hero.svg"
refs:
  - id: cursor
    title: Git at any scale
    url: https://cursor.com/blog/git-at-any-scale
    collect: "cursor-git-at-any-scale"
    author: Vicent Martí
    site: Cursor
    date: 2026-08-18
    note: "Continuity 的设计与吞吐数字：S3 Standard 每秒 120 次 push，S3 Express One Zone 超过 300 次，瓶颈是 Git 压缩磁盘数据的速度"
  - id: walgit
    title: tobi/walgit
    url: https://github.com/tobi/walgit
    collect: "github-tobi-walgit"
    author: Tobias Lütke
    note: "本文用的是 2026-09-12 的提交 `80e9a20`"
  - id: walgit-goal
    title: "walgit GOAL.md：walgit 为什么而做"
    url: https://github.com/tobi/walgit/blob/main/GOAL.md
    site: "GitHub · tobi/walgit"
    note: "“the one sentence”、验收表、“刻意不优化”一节"
  - id: walgit-agents
    title: "walgit AGENTS.md：架构与运维手册"
    url: https://github.com/tobi/walgit/blob/main/AGENTS.md
    collect: "github-tobi-walgit-agents-md"
    note: "D1、D2、D28 等决策"
  - id: walgit-lifecycle
    title: "walgit pack_lifecycle.rs：维护任务怎么选"
    url: https://github.com/tobi/walgit/blob/main/crates/walgit-server/src/pack_lifecycle.rs
    site: "GitHub · tobi/walgit"
    note: "`plan`：有未分类的 pack 就先返回 `Work::Classify`，否则才看 `fold_reason`"
  - id: walgit-example
    title: "walgit walgit.example.toml：配置默认值"
    url: https://github.com/tobi/walgit/blob/main/walgit.example.toml
    site: "GitHub · tobi/walgit"
    note: "`[maintenance] interval = \"60s\"`、`disk = \"tmpfs\"`；`[packs] fold_when_fresh_packs_reach = 16`"
  - id: walgit-midx
    title: "walgit midx.rs：写 MIDX 位图后的校验"
    url: https://github.com/tobi/walgit/blob/main/crates/walgit-git/src/midx.rs
    site: "GitHub · tobi/walgit"
    note: "`write_verified_midx` 最后用 `git rev-list --test-bitmap <commit>` 检查"
  - id: walgit-ingest
    title: "walgit walgit-git lib.rs：按仓库串行的收包"
    url: https://github.com/tobi/walgit/blob/main/crates/walgit-git/src/lib.rs
    site: "GitHub · tobi/walgit"
    note: "`ingest_pack` 开头的 `ingest_lock`"
  - id: git-unpacklimit
    title: "git-config：receive.unpackLimit"
    url: https://git-scm.com/docs/git-config#Documentation/git-config.txt-receiveunpackLimit
    site: Git 文档
    note: "推送的对象数少于这个值（默认 100）时解成松散对象，否则保留为 pack"
  - id: git-http-backend
    title: "git-http-backend：Server side implementation of Git over HTTP"
    url: https://git-scm.com/docs/git-http-backend
    collect: "git-scm-git-http-backend"
    site: Git 文档
  - id: redis
    title: redis/redis
    url: https://github.com/redis/redis
    site: GitHub
    note: "测试仓库：只推 `unstable` 分支和全部标签时，218 MiB pack、约 10.8 万个对象"
  - id: walgit-arch
    title: "walgit 架构原理：把 Git 仓库交给对象存储"
    url: https://twwy.net/blog/walgit-architecture/
    site: twwy.net
    note: "walgit 的提交点、group commit、读路径、压缩的源码分析，以及与 git http-backend 的单项实测"
---

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

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

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

---

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

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

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="wvc-design-title">Continuity 与 walgit 的设计对照</div>
<div class="dt-scroll">
<table class="dt" aria-labelledby="wvc-design-title">
<colgroup><col style="width:20%"><col><col class="hl"></colgroup>
<thead>
<tr><th scope="col">方面</th><th scope="col">Continuity（据 Cursor 的文章）</th><th scope="col" class="acc">walgit（据源码和文档）</th></tr>
</thead>
<tbody>
<tr class="dt-group"><th colspan="3" scope="rowgroup">骨架：两边一样</th></tr>
<tr><th scope="row">唯一的真相</th><td>S3 上的预写日志，每个 push 一条日志</td><td>桶里的日志段和 pack，由 <code>manifest.pb</code> 列出</td></tr>
<tr><th scope="row">提交点</th><td>对 WAL 索引对象做 CAS</td><td>对 <code>manifest.pb</code> 做 CAS</td></tr>
<tr><th scope="row">合并写入</th><td>批量提交，“仔细调过”</td><td>group commit，窗口 5 毫秒，一批最多 64 个</td></tr>
<tr><th scope="row">读的一致性</th><td>每次读先发条件 GET，304 平均不到 10 毫秒</td><td>每次读先发条件 GET</td></tr>
<tr><th scope="row">谁来写</th><td>rendezvous 哈希排第一的节点当主，CAS 兜底</td><td>维护该仓库的主机写，其他主机转发；CAS 兜底</td></tr>
<tr class="dt-group"><th colspan="3" scope="rowgroup">细节：开始分岔</th></tr>
<tr><th scope="row">副本怎么知道有新 push</th><td>UDP 广播，丢了也无妨</td><td>不广播，实例之间没有任何通信</td></tr>
<tr><th scope="row">本地磁盘</th><td>NVMe 上的普通裸仓库</td><td>默认按 tmpfs 小机器设计，放不下时按 1 MiB 范围读桶</td></tr>
<tr><th scope="row">压缩</th><td>主节点做，副本直接下载结果</td><td>持租约的一台做，作为 COMPACT 日志发布；由周期性的维护循环触发</td></tr>
<tr><th scope="row">公布的吞吐</th><td>S3 Standard 每秒 120 次，S3 Express 超过 300 次</td><td class="acc">没有公布</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">Continuity 一列只来自 Cursor 的文章，文章没提机器配置、测试方法和代码。</p>
</div>
<figcaption>表 1 · 骨架完全一致，差别在副本通知、本地磁盘的假设和压缩怎么触发</figcaption>
</figure>

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

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

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

---

## 二、先排除桶延迟和机器

### 桶延迟只决定单次耗时

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

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="wvc-lat-title">walgit 单次操作耗时随桶延迟的变化（中位数）</div>
<div class="dt-scroll">
<table class="dt" aria-labelledby="wvc-lat-title">
<colgroup><col style="width:34%"><col><col><col class="hl"></colgroup>
<thead>
<tr><th scope="col">每个桶请求加的延迟</th><th scope="col" class="num">0 ms</th><th scope="col" class="num">10 ms</th><th scope="col" class="num acc">30 ms</th></tr>
</thead>
<tbody>
<tr><th scope="row">推一个只改一行的提交</th><td class="num mono">73 ms</td><td class="num mono">128 ms</td><td class="num mono acc">228 ms</td></tr>
<tr><th scope="row">没有新东西的 fetch</th><td class="num mono">26 ms</td><td class="num mono">48 ms</td><td class="num mono acc">88 ms</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">每加 10 毫秒，push 多 50 多毫秒，fetch 多 20 多毫秒：一次 push 要和桶串行往返 5 轮，一次 fetch 要 2 轮。</p>
</div>
<figcaption>表 2 · 桶延迟按“轮数 × 延迟”加到单次耗时上</figcaption>
</figure>

Cursor 说他们的 304 平均不到 10 毫秒[^cursor]，写请求会更慢一些；按这张表，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 角色，后台维护先关掉：

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 460" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="wvc-tput-title wvc-tput-desc">
<title id="wvc-tput-title">持续推送时的吞吐</title>
<desc id="wvc-tput-desc">16 个客户端持续推送 90 秒：git http-backend 稳定在每秒 40 到 50 次，walgit 单仓库从 25 次滑到 6 次，去掉收包锁的版本几乎重合，分到 4 个仓库时下滑变慢。</desc>
<rect class="dd-frame" width="800" height="460" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="428" rx="8"/>
<text class="t-zone" x="32" y="44">16 个客户端持续推送 90 秒，每 5 秒成功的 push 数（次/秒）</text>
<line class="dd-hair" x1="72" y1="356.0" x2="600" y2="356.0"/>
<text class="t-note" x="62" y="360.0" text-anchor="end">0</text>
<line class="dd-hair" x1="72" y1="300.0" x2="600" y2="300.0"/>
<text class="t-note" x="62" y="304.0" text-anchor="end">10</text>
<line class="dd-hair" x1="72" y1="244.0" x2="600" y2="244.0"/>
<text class="t-note" x="62" y="248.0" text-anchor="end">20</text>
<line class="dd-hair" x1="72" y1="188.0" x2="600" y2="188.0"/>
<text class="t-note" x="62" y="192.0" text-anchor="end">30</text>
<line class="dd-hair" x1="72" y1="132.0" x2="600" y2="132.0"/>
<text class="t-note" x="62" y="136.0" text-anchor="end">40</text>
<line class="dd-hair" x1="72" y1="76.0" x2="600" y2="76.0"/>
<text class="t-note" x="62" y="80.0" text-anchor="end">50</text>
<text class="t-note" x="72.0" y="376" text-anchor="middle">0 s</text>
<text class="t-note" x="160.0" y="376" text-anchor="middle">15 s</text>
<text class="t-note" x="248.0" y="376" text-anchor="middle">30 s</text>
<text class="t-note" x="336.0" y="376" text-anchor="middle">45 s</text>
<text class="t-note" x="424.0" y="376" text-anchor="middle">60 s</text>
<text class="t-note" x="512.0" y="376" text-anchor="middle">75 s</text>
<text class="t-note" x="600.0" y="376" text-anchor="middle">90 s</text>
<polyline class="dd-edge" fill="none" points="86.7,137.6 116.0,119.7 145.3,124.2 174.7,107.4 204.0,104.0 233.3,126.4 262.7,124.2 292.0,113.0 321.3,107.4 350.7,106.2 380.0,107.4 409.3,108.5 438.7,86.1 468.0,88.3 497.3,79.4 526.7,73.8 556.0,76.0"/>
<polyline class="dd-edge dd-dash" fill="none" points="86.7,213.8 116.0,213.8 145.3,222.7 174.7,236.2 204.0,245.1 233.3,253.0 262.7,259.7 292.0,254.1 321.3,258.6 350.7,269.8 380.0,266.4"/>
<polyline class="dd-edge dd-dash" fill="none" points="86.7,209.3 116.0,230.6 145.3,246.2 174.7,267.5 204.0,264.2 233.3,289.9 262.7,300.0 292.0,297.8 321.3,292.2 350.7,295.5 380.0,303.4 409.3,300.0 438.7,310.1 468.0,306.7 497.3,317.9 526.7,303.4 556.0,311.2 585.3,317.9"/>
<polyline class="dd-edge-acc" fill="none" points="86.7,214.9 116.0,236.2 145.3,259.7 174.7,273.1 204.0,289.9 233.3,300.0 262.7,295.5 292.0,302.2 321.3,307.8 350.7,309.0 380.0,314.6 409.3,319.0 438.7,316.8 468.0,319.0 497.3,319.0 526.7,323.5 556.0,323.5 585.3,320.2"/>
<text class="t-name" x="616.0" y="80.0">git http-backend</text>
<text class="t-note" x="616.0" y="96.0">一个仓库，末段 50.0</text>
<text class="t-name" x="394.0" y="264.4">walgit · 4 个仓库</text>
<text class="t-note" x="394.0" y="280.4">合计，末段 16.0</text>
<text class="t-name" x="616.0" y="307.9">walgit · 去掉收包锁</text>
<text class="t-note" x="616.0" y="323.9">一个仓库，末段 6.8</text>
<text class="t-name t-acc" x="616.0" y="348.2">walgit</text>
<text class="t-note" x="616.0" y="364.2">一个仓库，末段 6.4</text>
<text class="t-say" x="32" y="408">普通服务器一直在 40 到 50 之间；walgit 从 25 往下滑，去掉收包锁几乎没有变化；</text>
<text class="t-say" x="32" y="430">同样的推送分到 4 个仓库，每个仓库攒下的 pack 少，下滑也慢。</text>
</svg>
</div>
<figcaption>图 1 · 普通服务器的吞吐是平的，walgit 越推越慢；去掉收包锁不改变这条曲线</figcaption>
</figure>

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

### 原因：每个 push 留下一个 pack

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

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

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`，同一仓库的收包一个接一个做[^walgit-ingest]。我在测 walgit 与普通服务器的对比时，起初以为这把锁就是瓶颈[^walgit-arch]。这次我编译了一个把这把锁删掉的版本，用同样的方式测：开头每秒 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 个数和每一轮维护在做什么：

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 480" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="wvc-packs-title wvc-packs-desc">
<title id="wvc-packs-title">推送期间和之后的 pack 个数</title>
<desc id="wvc-packs-desc">walgit 本地 pack 个数在 180 秒推送期间涨到 925 个，维护循环每一轮都在做分类；推送停止约 80 秒后才开始合并，87 秒后 pack 个数降到 2。</desc>
<rect class="dd-frame" width="800" height="480" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="448" rx="8"/>
<text class="t-zone" x="32" y="44">walgit 本地的 pack 个数：推送 180 秒，之后停手（维护间隔调到 5 秒）</text>
<rect class="dd-derived" x="72.0" y="68" width="295.2" height="248" rx="4"/>
<text class="t-note" x="219.6" y="86" text-anchor="middle">16 个客户端持续推送</text>
<line class="dd-hair" x1="72" y1="316.0" x2="728" y2="316.0"/>
<text class="t-note" x="62" y="320.0" text-anchor="end">0</text>
<line class="dd-hair" x1="72" y1="256.0" x2="728" y2="256.0"/>
<text class="t-note" x="62" y="260.0" text-anchor="end">250</text>
<line class="dd-hair" x1="72" y1="196.0" x2="728" y2="196.0"/>
<text class="t-note" x="62" y="200.0" text-anchor="end">500</text>
<line class="dd-hair" x1="72" y1="136.0" x2="728" y2="136.0"/>
<text class="t-note" x="62" y="140.0" text-anchor="end">750</text>
<line class="dd-hair" x1="72" y1="76.0" x2="728" y2="76.0"/>
<text class="t-note" x="62" y="80.0" text-anchor="end">1000</text>
<text class="t-note" x="72.0" y="336" text-anchor="middle">0 s</text>
<text class="t-note" x="170.4" y="336" text-anchor="middle">60 s</text>
<text class="t-note" x="268.8" y="336" text-anchor="middle">120 s</text>
<text class="t-note" x="367.2" y="336" text-anchor="middle">180 s</text>
<text class="t-note" x="465.6" y="336" text-anchor="middle">240 s</text>
<text class="t-note" x="564.0" y="336" text-anchor="middle">300 s</text>
<text class="t-note" x="662.4" y="336" text-anchor="middle">360 s</text>
<polyline class="dd-edge-acc" fill="none" points="72.0,315.8 75.4,311.9 78.7,307.4 82.0,306.9 85.3,302.3 88.6,301.8 91.9,297.5 95.2,293.7 98.5,289.6 101.8,285.8 105.1,281.2 108.5,280.7 111.8,276.4 115.1,272.3 118.4,271.6 121.7,267.3 125.0,263.2 128.3,262.5 131.6,257.9 134.9,253.6 138.2,249.8 141.5,245.0 144.8,240.4 148.2,238.5 151.5,233.4 154.8,231.3 158.1,228.4 161.4,225.0 164.7,221.0 168.0,215.9 171.3,211.6 174.7,210.4 178.0,207.5 181.3,206.8 184.6,206.1 187.9,205.4 191.2,204.9 194.5,204.2 197.8,203.7 201.1,199.8 204.5,196.0 207.8,195.5 211.1,191.4 214.4,188.1 217.8,184.5 221.1,183.8 224.4,179.9 227.7,175.4 231.0,171.8 234.3,169.1 237.6,167.4 240.9,163.6 244.2,161.9 247.6,159.3 250.9,155.4 254.2,154.5 257.5,150.6 260.8,150.2 264.1,149.7 267.4,145.8 270.7,142.0 274.1,138.9 277.4,136.2 280.7,133.8 284.0,130.0 287.4,126.9 290.7,125.9 294.0,121.8 297.4,119.9 300.7,117.5 304.0,113.7 307.3,113.2 310.7,112.7 314.0,112.2 317.3,111.8 320.6,111.0 323.9,110.8 327.2,107.9 330.5,106.7 333.8,106.2 337.1,105.8 340.4,105.3 343.7,104.8 347.0,104.3 350.3,103.6 353.7,103.1 357.0,102.9 360.3,98.6 363.6,98.3 401.6,94.0 418.0,94.0 434.4,94.0 450.8,94.0 467.2,94.0 483.6,94.0 500.0,94.0 516.4,94.0 532.8,94.0 549.2,94.0 565.6,94.0 582.0,94.0 598.4,94.0 614.8,94.0 631.2,94.0 647.6,93.5 664.0,315.5 680.4,315.5 696.8,315.5 714.9,315.5"/>
<text class="t-name" x="32" y="374">维护</text>
<rect class="dd-card" x="75.3" y="360" width="42.6" height="22" rx="3"/>
<text class="t-note" x="96.6" y="375" text-anchor="middle">分类</text>
<rect class="dd-card" x="126.1" y="360" width="50.8" height="22" rx="3"/>
<text class="t-note" x="151.5" y="375" text-anchor="middle">分类</text>
<rect class="dd-card" x="185.2" y="360" width="72.2" height="22" rx="3"/>
<text class="t-note" x="221.2" y="375" text-anchor="middle">分类</text>
<rect class="dd-card" x="265.5" y="360" width="65.6" height="22" rx="3"/>
<text class="t-note" x="298.3" y="375" text-anchor="middle">分类</text>
<rect class="dd-card" x="339.3" y="360" width="82.0" height="22" rx="3"/>
<text class="t-note" x="380.3" y="375" text-anchor="middle">分类</text>
<rect class="dd-card" x="429.5" y="360" width="62.3" height="22" rx="3"/>
<text class="t-note" x="460.7" y="375" text-anchor="middle">分类</text>
<rect class="dd-card" x="500.0" y="360" width="142.7" height="22" rx="3"/>
<text class="t-note t-acc" x="571.4" y="375" text-anchor="middle">合并</text>
<text class="t-mono t-acc" x="650.7" y="305.5">925 → 2</text>
<text class="t-mono" x="375.2" y="86.0">925</text>
<text class="t-say" x="32" y="424">推送期间每一轮维护都在给新 pack 分类，分完又有新的，合并一直排不上；</text>
<text class="t-say" x="32" y="446">停手 80 秒后才轮到合并，花 87 秒把 925 个 pack 合成 2 个。</text>
</svg>
</div>
<figcaption>图 2 · 推送期间压缩只顾着给新 pack 分类，合并要等推送停下来才开始</figcaption>
</figure>

### 原因：分类排在合并前面

维护循环每一轮只做一件事，选哪件由 `pack_lifecycle.rs` 里的 `plan` 决定。它的第一步是：只要 manifest 里还有没分类的 pack，就返回“分类”（`Work::Classify`）；全部分完类，才去检查是否该合并（`Work::Fold`）[^walgit-lifecycle]。合并的门槛并不高，同一类里攒够 16 个新 pack 就该合并了（`fold_when_fresh_packs_reach = 16`）[^walgit-example]。

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

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

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

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

```text
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` 检查某个提交的位图能否正常遍历[^walgit-midx]。它挑的检查对象，是按名字排序后的第一个分支，在 redis 里是 `refs/heads/2.2`，一个很老的分支。git 生成位图时只给一部分提交建位图，这个提交恰好不在其中，校验就失败，整次合并作废。于是这个仓库的 pack 永远合不起来。这是我对着日志和源码推出来的原因，没有改代码验证。

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

---

## 五、差距的账

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

把前面的排查放在一起：

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="wvc-bill-title">walgit 实测与 Cursor 公布的吞吐之间，差距来自哪里</div>
<div class="dt-scroll">
<table class="dt" aria-labelledby="wvc-bill-title">
<colgroup><col style="width:24%"><col><col class="hl"></colgroup>
<thead>
<tr><th scope="col">因素</th><th scope="col">实测或依据</th><th scope="col" class="acc">对吞吐的影响</th></tr>
</thead>
<tbody>
<tr><th scope="row">桶延迟</th><td>0、10、30 毫秒下，单次 push 是 73、128、228 毫秒</td><td>只影响单次耗时，批量提交能摊掉</td></tr>
<tr><th scope="row">机器</th><td>walgit 进程只用了 0.2 到 0.3 个核；同机的普通服务器每秒 40 到 50 次</td><td>不是这台机器上的瓶颈；Cursor 的配置未知</td></tr>
<tr><th scope="row">收包锁</th><td>去掉后开头每秒 26 次、结尾 7 次，和带锁时几乎一样</td><td>可以忽略</td></tr>
<tr><th scope="row">pack 越攒越多</th><td>90 秒内从一百多个涨到八百多个，批间隔从 78 毫秒涨到 270 毫秒</td><td class="acc">吞吐从每秒 25 次掉到 6 次，而且会继续掉</td></tr>
<tr><th scope="row">压缩不合并</th><td>推送期间每轮维护都在分类；停手后才把 925 个合成 2 个；多分支仓库的合并校验失败</td><td class="acc">让上一行的下滑没有尽头</td></tr>
<tr><th scope="row">项目目标</th><td>北极星是从小机器上快速 clone；验收表没有 push 吞吐指标</td><td>push 吞吐不是 walgit 优化的方向</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">“开头每秒 25 次”是 pack 很少时这台机器上的上限，仍比同机的普通服务器低一半左右，其中有多少能靠更大的机器补回来，这次的实验分不出来。</p>
</div>
<figcaption>表 3 · 拉开数量级差距的，是持续推送下没有及时合并的 pack，不是桶，也不是锁</figcaption>
</figure>

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

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

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

---

## 小结

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` 固定时长后转发。

## 参考资料
