﻿---
title: "walgit 架构原理：把 Git 仓库交给对象存储"
description: "walgit 的一次 push 只对桶发 5 个请求，一次读只发 1 个，却保证 push 被确认后任何一台机器都能读到。顺着这两个数字往下追：为什么不能让 git 直接读对象存储，manifest 的 CAS 为什么就是提交点，并发 push 和“每个对象每秒一次写”怎么处理，读路径为什么要分级，日志怎么折叠和压缩，进程崩溃之后怎么办，以及 GitLab 的对象存储后端为什么选了另一条路。"
pubDate: 2026-10-17
tags: ["Git", "源码分析", "分布式系统", "对象存储", "walgit"]
refs:
  - id: walgit
    title: tobi/walgit
    url: https://github.com/tobi/walgit
    author: Tobias Lütke
    note: "本文读的是 2026-09-12 的提交 `80e9a20`，README 与 `docs/announcing-walgit.md`"
  - id: walgit-agents
    title: "walgit AGENTS.md：架构与运维手册"
    url: https://github.com/tobi/walgit/blob/main/AGENTS.md
    note: "§1 约束、§2 WAL、§3 原则、§4 决策 D1–D49"
  - id: walgit-roundtrips
    title: "walgit docs/ROUNDTRIPS.md：往返次数就是成本模型"
    url: https://github.com/tobi/walgit/blob/main/docs/ROUNDTRIPS.md
    note: "§1 GCS 实测数字，§2 各操作的请求预算"
  - id: walgit-store
    title: "walgit-store：ObjectStore 抽象与 S3 映射"
    url: https://github.com/tobi/walgit/blob/main/crates/walgit-store/src/lib.rs
    note: "`lib.rs` 的 `GetOptions`、`PutMode`；`s3.rs` 开头注释说明 `Create`→`If-None-Match: *`、`Update`→`If-Match`"
  - id: walgit-proto
    title: "walgit wal.proto：桶内对象的格式"
    url: https://github.com/tobi/walgit/blob/main/crates/walgit-proto/proto/walgit/v1/wal.proto
    note: "`Manifest`、`LogEntry`、`PackRef`、`Checkpoint`、`Lease`；键名在 `walgit-proto/src/lib.rs` 的 `keys` 模块"
  - id: walgit-publish
    title: "walgit publish.rs：发布路径"
    url: https://github.com/tobi/walgit/blob/main/crates/walgit-wal/src/publish.rs
    note: "开头注释 1–36 行；`claim_log_slot`、`cas_landed`、`verify_txn`、`publisher_task`、`process_batch`、`publish_compact_classified`"
  - id: walgit-smart
    title: "walgit smart.rs：receive-pack 的 HTTP 处理"
    url: https://github.com/tobi/walgit/blob/main/crates/walgit-server/src/smart.rs
    note: "`receive_pack_process`：收包、连通性检查、读 `policy.json`、发布"
  - id: walgit-sync
    title: "walgit sync.rs 与 handle.rs：读路径"
    url: https://github.com/tobi/walgit/blob/main/crates/walgit-wal/src/sync.rs
    note: "`freshness_check`、`apply_delta`、`replay_log`、`SyncLevel`；分级入口 `sync_refs`/`sync`/`sync_full`/`sync_objects` 在 `handle.rs`"
  - id: walgit-remote
    title: "walgit remote.rs：remote reader"
    url: https://github.com/tobi/walgit/blob/main/crates/walgit-wal/src/remote.rs
    note: "`BLOCK_SIZE = 1 MiB`，pack 索引本地、数据范围读"
  - id: walgit-checkpoint
    title: "walgit checkpoint.rs：检查点"
    url: https://github.com/tobi/walgit/blob/main/crates/walgit-wal/src/checkpoint.rs
    note: "`checkpoint_due` 的三个触发条件，`write_checkpoint_inner` 的两轮写"
  - id: walgit-coord
    title: "walgit coord.rs：CAS 循环与租约"
    url: https://github.com/tobi/walgit/blob/main/crates/walgit-store/src/coord.rs
    note: "`try_acquire`、`LeaseGuard::heartbeat`、`LEASE_SKEW_TOLERANCE = 2s`"
  - id: walgit-config
    title: "walgit-config：默认配置"
    url: https://github.com/tobi/walgit/blob/main/crates/walgit-config/src/lib.rs
    note: "`WalConfig`、`CacheConfig` 的 `Default`；租约 TTL 在 `packs.rs`"
  - id: walgit-sim
    title: "walgit 仿真测试 sim.rs"
    url: https://github.com/tobi/walgit/blob/main/crates/walgit-server/tests/sim.rs
    note: "`healthy_request_round_trip_budgets` 等故障注入场景"
  - id: walgit-spec
    title: "walgit docs/spec：有界模型检查"
    url: https://github.com/tobi/walgit/blob/main/docs/spec/README.md
    note: "TLA+ 模型 `WALContract.tla` 与契约编号 C1–C16"
  - id: cursor
    title: Git at any scale
    url: https://cursor.com/blog/git-at-any-scale
    author: Vicent Martí
    site: Cursor
    date: 2026-08-18
  - id: gcs-quotas
    title: "Cloud Storage quotas & limits"
    url: https://docs.cloud.google.com/storage/quotas
    site: Google Cloud 文档
    note: "同一对象名的写入上限为每秒一次"
  - id: gitaly-objectstorage
    title: "Gitaly ee/mvcc/objectstorage/objectstorage.go"
    url: https://gitlab.com/gitlab-org/gitaly/-/blob/master/ee/mvcc/objectstorage/objectstorage.go
    site: GitLab 源码
    note: "包注释 “Manifest generations”，`head`、`Commit`、`createGeneration`；读的是 2026-10-14 的提交 `77bee05`"
  - id: gitaly-mvcc-flow
    title: "Gitaly doc/mvcc_rpc_flow.md"
    url: https://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/mvcc_rpc_flow.md
    site: GitLab 源码
    note: "文档自称描述的是 POC 分支上的流程"
  - id: hn
    title: "Walgit: a Git server that runs as one binary in front of an object store"
    url: https://news.ycombinator.com/item?id=49420598
    site: Hacker News
  - id: s3-conditional
    title: "Amazon S3 conditional writes"
    url: https://docs.aws.amazon.com/AmazonS3/latest/userguide/conditional-writes.html
    site: AWS 文档
    further: true
---

walgit 的源码里有一组故障注入的仿真测试，其中一个叫 `healthy_request_round_trip_budgets`，它数的是一次操作对对象存储发了几个请求（`crates/walgit-server/tests/sim.rs`，断言整理成一行）[^walgit-sim]：

```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 服务器，就是对象存储前面的一个二进制文件”[^walgit]。它没有数据库，没有主节点，可以有任意多台机器同时指向同一个桶（bucket，对象存储里装对象的容器），README 还说：把这些机器全关掉，丢掉的只是缓存的热度。

这两个数字让人有点不安。5 个请求里，没有一个是在和别的机器商量，那多台机器同时接受 push 时，谁说了算？读只问 1 次，凭什么敢说读到的就是最新的？对象存储一次请求要几十毫秒，git 读 packfile 却是成千上万次随机访问，仓库真的放在桶里吗？

《[从 git clone 说起](/blog/git-server-from-clone/)》的第五节用几段话介绍过 walgit 的思路。这一篇读它的源码，把这几个问题一层一层追下去，答案都落在桶里一个很小的文件上。

---

## 一、为什么要换一种存法

### 现象：副本越多，push 越慢

托管 Git 的老办法是把仓库放在本地 NVMe 盘上，再复制几份。GitHub 的 Spokes 每次 push 都要对引用更新做一次三阶段提交（3PC，让所有节点对“提交还是回滚”达成一致的协议），多数副本同意才算数。Cursor 的 Vicent Martí 指出，这样每一步都要等最慢的副本，副本越多 push 越慢；而每份副本都是真相的一部分，坏一份就得赶紧修，只能当“宠物”养[^cursor]。

### 第一次尝试：让 git 直接读对象存储

对象存储（S3、GCS 这类）容量几乎无限、持久性极高，最直接的想法是把仓库放上去，让 git 直接读。图 1 说明了为什么不行：packfile 的排布只为“压得小”，读一个文件要沿着提交图跳好几次，大多数对象还是依赖别的对象的 delta，而每一跳的位置都要读完上一跳才知道。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 320" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="wg-hops-title wg-hops-desc">
<title id="wg-hops-title">读一个文件要在 pack 里串行跳几次</title>
<desc id="wg-hops-desc">要读 src/main.rs 的内容，git 依次读提交 385c、根树 9f2a、子树 41c0，得到 blob 7d1e；它以 delta 形式存储，基对象 2b77 又是 delta，基于完整对象 e0a4。六个对象分散在 pack 的不同位置，每一跳的位置都要读完上一跳才知道。放在本地 NVMe 上是页缓存里的内存读；放在对象存储上，每跳一次 60 到 80 毫秒的 GET，六跳串行至少 360 毫秒。</desc>
<defs>
<marker id="wg-hops-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="wg-hops-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="320" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="504" height="288" rx="8"/>
<text class="t-zone" x="36" y="44">读 src/main.rs：每一跳都要先读完上一跳</text>
<rect class="dd-zone" x="536" y="16" width="248" height="288" rx="8"/>
<text class="t-zone" x="556" y="44">每一跳的代价</text>
<path class="dd-edge" d="M504 102 H550" marker-end="url(#wg-hops-arrow)"/>
<path class="dd-edge-acc" d="M504 215 H550" marker-end="url(#wg-hops-arrow-acc)"/>
<rect class="dd-card" x="32" y="60" width="472" height="188" rx="6"/>
<path class="dd-card-head" d="M32.6 92 V66 Q32.6 60.6 38 60.6 H498 Q503.4 60.6 503.4 66 V92 Z"/>
<line class="dd-edge" x1="32" y1="92" x2="504" y2="92"/>
<text class="t-name" x="48" y="81">pack-b4f0….pack 里的 6 跳</text>
<text class="t-val" x="488" y="81" text-anchor="end">示例</text>
<text class="t-mono" x="48" y="109">commit 385c…</text>
<text class="t-val" x="168" y="109">tree → 9f2a…</text>
<text class="t-note" x="488" y="109" text-anchor="end">@ 1.2 GB</text>
<line class="dd-hair" x1="48" y1="118" x2="488" y2="118"/>
<text class="t-mono" x="48" y="135">tree 9f2a…</text>
<text class="t-val" x="168" y="135">src/ → 41c0…</text>
<text class="t-note" x="488" y="135" text-anchor="end">@ 0.3 GB</text>
<line class="dd-hair" x1="48" y1="144" x2="488" y2="144"/>
<text class="t-mono" x="48" y="161">tree 41c0…</text>
<text class="t-val" x="168" y="161">main.rs → 7d1e…</text>
<text class="t-note" x="488" y="161" text-anchor="end">@ 2.7 GB</text>
<line class="dd-hair" x1="48" y1="170" x2="488" y2="170"/>
<text class="t-mono" x="48" y="187">blob 7d1e…</text>
<text class="t-val" x="168" y="187">delta，基于 2b77…</text>
<text class="t-note" x="488" y="187" text-anchor="end">@ 1.9 GB</text>
<line class="dd-hair" x1="48" y1="196" x2="488" y2="196"/>
<text class="t-mono" x="48" y="213">blob 2b77…</text>
<text class="t-val" x="168" y="213">delta，基于 e0a4…</text>
<text class="t-note" x="488" y="213" text-anchor="end">@ 0.8 GB</text>
<line class="dd-hair" x1="48" y1="222" x2="488" y2="222"/>
<text class="t-mono" x="48" y="239">blob e0a4…</text>
<text class="t-val" x="168" y="239">完整对象</text>
<text class="t-note" x="488" y="239" text-anchor="end">@ 2.5 GB</text>
<text class="t-say" x="32" y="276">每一跳的位置，都写在上一跳读出来的内容里，没法并行。</text>
<rect class="dd-card" x="552" y="60" width="216" height="84" rx="6"/>
<path class="dd-card-head" d="M552.6 92 V66 Q552.6 60.6 558 60.6 H762 Q767.4 60.6 767.4 66 V92 Z"/>
<line class="dd-edge" x1="552" y1="92" x2="768" y2="92"/>
<text class="t-name" x="568" y="81">本地 NVMe</text>
<text class="t-val" x="568" y="109">页缓存里的内存读</text>
<line class="dd-hair" x1="568" y1="118" x2="752" y2="118"/>
<text class="t-val" x="568" y="135">git 的设计前提</text>
<rect class="dd-card" x="552" y="160" width="216" height="110" rx="6"/>
<path class="dd-card-head" d="M552.6 192 V166 Q552.6 160.6 558 160.6 H762 Q767.4 160.6 767.4 166 V192 Z"/>
<line class="dd-edge" x1="552" y1="192" x2="768" y2="192"/>
<text class="t-name" x="568" y="181"><tspan class="t-acc">对象存储</tspan></text>
<text class="t-val" x="568" y="209">每跳一次 GET</text>
<line class="dd-hair" x1="568" y1="218" x2="752" y2="218"/>
<text class="t-val" x="568" y="235">60–80 ms，串行</text>
<line class="dd-hair" x1="568" y1="244" x2="752" y2="244"/>
<text class="t-val" x="568" y="261">6 跳 ≥ 360 ms</text>
<text class="t-note" x="552" y="292">按 walgit 在 GCS 上的测量</text>
</svg>
</div>
<figcaption>图 1 · 为什么不能让 git 直接读桶：读一个文件就要串行跳好几次，每跳在对象存储上都是一次几十毫秒的 GET（哈希、偏移为示例）</figcaption>
</figure>

按 walgit 在 GCS 上的测量，一次小对象 GET 要 60 到 80 毫秒[^walgit-roundtrips]，而一次 clone 或 `git log` 要走成千上万跳。NFS 失败也是同一个原因；Cursor 还提到，Google 曾把 JGit 的对象放进分布式哈希表，最后因为 clone 太慢放弃了[^cursor]。

### 解决：桶里放日志，磁盘只当缓存

Cursor 的系统 Continuity 换了一个角度：**git 照旧在本地磁盘上跑，但本地仓库只是缓存，唯一的真相是对象存储里的一份预写日志（write-ahead log，WAL：先把“要做什么”持久地记下来，再去改实际状态）**。walgit 是这个架构的开源实现，又补上了“机器比仓库还小”时需要的东西[^walgit]。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 528" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="wg-overview-title wg-overview-desc">
<title id="wg-overview-title">walgit 的整体形状</title>
<desc id="wg-overview-desc">客户端的 push 和 fetch 落到任意一台 walgit 实例上；实例之间没有连线，各自只和对象存储桶里的 manifest.pb 打交道：写用 If-Match 做 CAS，读用 If-None-Match 做条件读取，维护实例持有压缩租约。manifest 之外，日志段、pack 和检查点都是不可变对象。</desc>
<defs>
<marker id="wg-overview-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="wg-overview-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="528" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="160" height="496" rx="8"/>
<text class="t-zone" x="36" y="44">客户端</text>
<rect class="dd-zone" x="192" y="16" width="288" height="496" rx="8"/>
<text class="t-zone" x="212" y="44">walgit 实例 · 可随时丢弃</text>
<rect class="dd-zone" x="496" y="16" width="288" height="496" rx="8"/>
<text class="t-zone" x="516" y="44">对象存储桶 · repos/acme/app/</text>
<path class="dd-edge" d="M160 106 H206" marker-end="url(#wg-overview-arrow)"/>
<path class="dd-edge" d="M160 206 H206" marker-end="url(#wg-overview-arrow)"/>
<path class="dd-edge-acc" d="M464 106 H510" marker-end="url(#wg-overview-arrow-acc)"/>
<path class="dd-edge-acc" d="M464 206 H510" marker-end="url(#wg-overview-arrow-acc)"/>
<path class="dd-edge-acc" d="M464 306 H510" marker-end="url(#wg-overview-arrow-acc)"/>
<rect class="dd-card" x="32" y="88" width="128" height="36" rx="6"/>
<text class="t-mono" x="48" y="111">git push</text>
<rect class="dd-card" x="32" y="188" width="128" height="36" rx="6"/>
<text class="t-mono" x="48" y="211">git fetch</text>
<rect class="dd-card" x="208" y="64" width="256" height="84" rx="6"/>
<path class="dd-card-head" d="M208.6 96 V70 Q208.6 64.6 214 64.6 H458 Q463.4 64.6 463.4 70 V96 Z"/>
<line class="dd-edge" x1="208" y1="96" x2="464" y2="96"/>
<text class="t-name" x="224" y="85">实例 A</text>
<text class="t-val" x="448" y="85" text-anchor="end">serve</text>
<text class="t-mono" x="224" y="113">applied_seq</text>
<text class="t-val" x="320" y="113">42</text>
<line class="dd-hair" x1="224" y1="122" x2="448" y2="122"/>
<text class="t-mono" x="224" y="139">写</text>
<text class="t-val" x="320" y="139">PUT · If-Match</text>
<rect class="dd-card" x="208" y="164" width="256" height="84" rx="6"/>
<path class="dd-card-head" d="M208.6 196 V170 Q208.6 164.6 214 164.6 H458 Q463.4 164.6 463.4 170 V196 Z"/>
<line class="dd-edge" x1="208" y1="196" x2="464" y2="196"/>
<text class="t-name" x="224" y="185">实例 B</text>
<text class="t-val" x="448" y="185" text-anchor="end">serve</text>
<text class="t-mono" x="224" y="213">applied_seq</text>
<text class="t-val" x="320" y="213">41 → 42</text>
<line class="dd-hair" x1="224" y1="222" x2="448" y2="222"/>
<text class="t-mono" x="224" y="239">读</text>
<text class="t-val" x="320" y="239">GET · If-None-Match</text>
<rect class="dd-card" x="208" y="264" width="256" height="84" rx="6"/>
<path class="dd-card-head" d="M208.6 296 V270 Q208.6 264.6 214 264.6 H458 Q463.4 264.6 463.4 270 V296 Z"/>
<line class="dd-edge" x1="208" y1="296" x2="464" y2="296"/>
<text class="t-name" x="224" y="285">实例 M</text>
<text class="t-val" x="448" y="285" text-anchor="end">maintain</text>
<text class="t-mono" x="224" y="313">持有</text>
<text class="t-val" x="320" y="313">leases/compact.pb</text>
<line class="dd-hair" x1="224" y1="322" x2="448" y2="322"/>
<text class="t-mono" x="224" y="339">写</text>
<text class="t-val" x="320" y="339">COMPACT · 检查点</text>
<text class="t-say" x="208" y="400">三台实例之间没有任何连线，</text>
<text class="t-say" x="208" y="422">也没有数据库或路由表；</text>
<text class="t-say" x="208" y="444">本地磁盘上的一切都只是缓存。</text>
<rect class="dd-card" x="512" y="64" width="256" height="292" rx="6"/>
<path class="dd-card-head" d="M512.6 96 V70 Q512.6 64.6 518 64.6 H762 Q767.4 64.6 767.4 70 V96 Z"/>
<line class="dd-edge" x1="512" y1="96" x2="768" y2="96"/>
<text class="t-name" x="528" y="85"><tspan class="t-acc">manifest.pb</tspan></text>
<text class="t-val" x="752" y="85" text-anchor="end">ETag v42</text>
<text class="t-mono" x="528" y="113">format_version</text>
<text class="t-val" x="648" y="113">1</text>
<line class="dd-hair" x1="528" y1="122" x2="752" y2="122"/>
<text class="t-mono" x="528" y="139">repo</text>
<text class="t-val" x="648" y="139">acme/app</text>
<line class="dd-hair" x1="528" y1="148" x2="752" y2="148"/>
<text class="t-mono" x="528" y="165">head_seq</text>
<text class="t-val" x="648" y="165">42</text>
<line class="dd-hair" x1="528" y1="174" x2="752" y2="174"/>
<text class="t-mono" x="528" y="191">min_seq</text>
<text class="t-val" x="648" y="191">33</text>
<line class="dd-hair" x1="528" y1="200" x2="752" y2="200"/>
<text class="t-mono" x="528" y="217">checkpoint</text>
<text class="t-val" x="648" y="217">seq 32</text>
<line class="dd-hair" x1="528" y1="226" x2="752" y2="226"/>
<text class="t-mono" x="528" y="243">log_segments</text>
<text class="t-val" x="648" y="243">2 段</text>
<line class="dd-hair" x1="528" y1="252" x2="752" y2="252"/>
<text class="t-mono" x="528" y="269">packs</text>
<text class="t-val" x="648" y="269">3 个 PackRef</text>
<line class="dd-hair" x1="528" y1="278" x2="752" y2="278"/>
<text class="t-mono" x="528" y="295">revision</text>
<text class="t-val" x="648" y="295">57</text>
<line class="dd-hair" x1="528" y1="304" x2="752" y2="304"/>
<text class="t-mono" x="528" y="321">writer</text>
<text class="t-val" x="648" y="321">实例 A</text>
<line class="dd-hair" x1="528" y1="330" x2="752" y2="330"/>
<text class="t-mono" x="528" y="347">settings</text>
<text class="t-val" x="648" y="347">内联 TOML</text>
<rect class="dd-card" x="512" y="372" width="256" height="36" rx="6"/>
<text class="t-mono" x="528" y="395">log/000000000000002a.pb</text>
<rect class="dd-card" x="512" y="416" width="256" height="36" rx="6"/>
<text class="t-mono" x="528" y="439">wal/9c1e….pack .idx .rev</text>
<rect class="dd-card" x="512" y="460" width="256" height="36" rx="6"/>
<text class="t-mono" x="528" y="483">checkpoints/…  leases/…</text>
</svg>
</div>
<figcaption>图 2 · walgit 的整体形状：实例之间不通信，所有协调都落在桶里 manifest.pb 这一个对象上（图中的序号、版本为示例）</figcaption>
</figure>

图 2 里实例之间没有任何连线，只靠桶里的 `manifest.pb` 协调，用的是对象存储的两种条件请求（walgit 的 `ObjectStore` 抽象在 S3 上直接用这两个请求头，在 GCS 上换成对象的 generation 编号[^walgit-store]）：

- **条件写**：`PUT` 时带上 `If-Match: <ETag>`。ETag 是对象存储给每个对象版本的标识，内容一变它就变。只有对象的当前版本还是我读到的那个，这次覆盖才会成功，否则返回 412。这就是**比较并交换**（compare-and-swap，CAS）。
- **条件读**：`GET` 时带上 `If-None-Match: <ETag>`。对象没变就返回 304，不带正文。

哪台机器负责哪个仓库，由配置里的 `[placement]` 决定；只读引用的请求在任何一台上都能回答[^walgit-agents]。

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

---

## 二、桶内布局

### 只有一个对象会被改写

每个仓库在桶里占一个前缀 `repos/<owner>/<repo>/`。下面的对象分成两类：会被改写的只有 `manifest.pb` 和几个控制对象（比如租约），其余的写一次就再也不改[^walgit-proto]。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 556" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="wg-layout-title wg-layout-desc">
<title id="wg-layout-title">manifest、日志段、pack 和检查点之间的引用</title>
<desc id="wg-layout-desc">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/ 下；检查点由按尝试唯一的元数据和按内容寻址的引用快照组成。</desc>
<defs>
<marker id="wg-layout-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="wg-layout-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="556" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="524" rx="8"/>
<text class="t-zone" x="36" y="44">repos/acme/app/</text>
<path class="dd-edge" d="M332 209 H366" marker-end="url(#wg-layout-arrow)"/>
<path class="dd-edge-acc" d="M332 287 H366" marker-end="url(#wg-layout-arrow-acc)"/>
<path class="dd-edge-acc" d="M574 248 V269" marker-end="url(#wg-layout-arrow-acc)"/>
<path class="dd-edge" d="M182 326 V370" marker-end="url(#wg-layout-arrow)"/>
<rect class="dd-card" x="32" y="60" width="300" height="266" rx="6"/>
<path class="dd-card-head" d="M32.6 92 V66 Q32.6 60.6 38 60.6 H326 Q331.4 60.6 331.4 66 V92 Z"/>
<line class="dd-edge" x1="32" y1="92" x2="332" y2="92"/>
<text class="t-name" x="48" y="81">manifest.pb</text>
<text class="t-val" x="316" y="81" text-anchor="end">可变 · CAS</text>
<text class="t-mono" x="48" y="109">head_seq</text>
<text class="t-val" x="176" y="109">42</text>
<line class="dd-hair" x1="48" y1="118" x2="316" y2="118"/>
<text class="t-mono" x="48" y="135">min_seq</text>
<text class="t-val" x="176" y="135">33</text>
<line class="dd-hair" x1="48" y1="144" x2="316" y2="144"/>
<text class="t-mono" x="48" y="161">checkpoint</text>
<text class="t-val" x="176" y="161">seq 32 → attempts/…</text>
<line class="dd-hair" x1="48" y1="170" x2="316" y2="170"/>
<text class="t-mono" x="48" y="187">log_segments[0]</text>
<text class="t-val" x="176" y="187">…21.pb  [33..41]</text>
<line class="dd-hair" x1="48" y1="196" x2="316" y2="196"/>
<text class="t-mono" x="48" y="213">log_segments[1]</text>
<text class="t-val" x="176" y="213">…2a.pb  [42..42]</text>
<line class="dd-hair" x1="48" y1="222" x2="316" y2="222"/>
<text class="t-mono" x="48" y="239">packs[0]</text>
<text class="t-val" x="176" y="239">b4f0… tier 2</text>
<line class="dd-hair" x1="48" y1="248" x2="316" y2="248"/>
<text class="t-mono" x="48" y="265">packs[1]</text>
<text class="t-val" x="176" y="265">71d2… tier 0</text>
<line class="dd-hair" x1="48" y1="274" x2="316" y2="274"/>
<text class="t-mono" x="48" y="291">packs[2]</text>
<text class="t-val" x="176" y="291"><tspan class="t-acc">9c1e…</tspan> tier 0</text>
<line class="dd-hair" x1="48" y1="300" x2="316" y2="300"/>
<text class="t-mono" x="48" y="317">revision</text>
<text class="t-val" x="176" y="317">57</text>
<rect class="dd-card" x="380" y="60" width="388" height="188" rx="6"/>
<path class="dd-card-head" d="M380.6 92 V66 Q380.6 60.6 386 60.6 H762 Q767.4 60.6 767.4 66 V92 Z"/>
<line class="dd-edge" x1="380" y1="92" x2="768" y2="92"/>
<text class="t-name" x="396" y="81">log/000000000000002a.pb</text>
<text class="t-val" x="752" y="81" text-anchor="end">不可变</text>
<text class="t-note" x="396" y="109">uvarint(len) ‖ LogEntry，一批 push 一个对象</text>
<line class="dd-hair" x1="396" y1="118" x2="752" y2="118"/>
<text class="t-mono" x="396" y="135">seq</text>
<text class="t-val" x="516" y="135">42</text>
<line class="dd-hair" x1="396" y1="144" x2="752" y2="144"/>
<text class="t-mono" x="396" y="161">kind</text>
<text class="t-val" x="516" y="161">ENTRY_KIND_PUSH</text>
<line class="dd-hair" x1="396" y1="170" x2="752" y2="170"/>
<text class="t-mono" x="396" y="187">pack.checksum</text>
<text class="t-val" x="516" y="187"><tspan class="t-acc">9c1e…</tspan></text>
<line class="dd-hair" x1="396" y1="196" x2="752" y2="196"/>
<text class="t-mono" x="396" y="213">txn.updates</text>
<text class="t-val" x="516" y="213">main  5be1… → 0a77…</text>
<line class="dd-hair" x1="396" y1="222" x2="752" y2="222"/>
<text class="t-mono" x="396" y="239">meta</text>
<text class="t-val" x="516" y="239">walgit.publication_nonce</text>
<rect class="dd-card" x="380" y="271" width="388" height="58" rx="6"/>
<path class="dd-card-head" d="M380.6 303 V277 Q380.6 271.6 386 271.6 H762 Q767.4 271.6 767.4 277 V303 Z"/>
<line class="dd-edge" x1="380" y1="303" x2="768" y2="303"/>
<text class="t-name" x="396" y="292">wal/<tspan class="t-acc">9c1e…</tspan>.pack</text>
<text class="t-val" x="752" y="292" text-anchor="end">不可变 · 按内容寻址</text>
<text class="t-mono" x="396" y="320">同名旁文件</text>
<text class="t-val" x="516" y="320">.idx  .rev  (.bitmap .commit-graph)</text>
<rect class="dd-card" x="32" y="384" width="300" height="110" rx="6"/>
<path class="dd-card-head" d="M32.6 416 V390 Q32.6 384.6 38 384.6 H326 Q331.4 384.6 331.4 390 V416 Z"/>
<line class="dd-edge" x1="32" y1="416" x2="332" y2="416"/>
<text class="t-name" x="48" y="405">checkpoints/0000000000000020/</text>
<text class="t-mono" x="48" y="433">元数据</text>
<text class="t-val" x="136" y="433">attempts/&lt;uuid&gt;/…</text>
<line class="dd-hair" x1="48" y1="442" x2="316" y2="442"/>
<text class="t-mono" x="48" y="459">引用快照</text>
<text class="t-val" x="136" y="459">refs/&lt;sha256&gt;.pb</text>
<line class="dd-hair" x1="48" y1="468" x2="316" y2="468"/>
<text class="t-mono" x="48" y="485">packs</text>
<text class="t-val" x="136" y="485">seq 32 时的集合</text>
<rect class="dd-card" x="380" y="384" width="388" height="84" rx="6"/>
<path class="dd-card-head" d="M380.6 416 V390 Q380.6 384.6 386 384.6 H762 Q767.4 384.6 767.4 390 V416 Z"/>
<line class="dd-edge" x1="380" y1="416" x2="768" y2="416"/>
<text class="t-name" x="396" y="405">leases/compact.pb</text>
<text class="t-val" x="752" y="405" text-anchor="end">可变 · CAS + TTL</text>
<text class="t-mono" x="396" y="433">holder</text>
<text class="t-val" x="516" y="433">实例 M</text>
<line class="dd-hair" x1="396" y1="442" x2="752" y2="442"/>
<text class="t-mono" x="396" y="459">expires_at</text>
<text class="t-val" x="516" y="459">now + 10m（packs.lease_ttl）</text>
<text class="t-note" x="196" y="356">checkpoint 指针</text>
<text class="t-say" x="32" y="520">除了 manifest 和租约，桶里的对象写一次就不再改；读者从不 LIST，只顺着 manifest 里的键去取。</text>
</svg>
</div>
<figcaption>图 3 · 桶内布局：只有 manifest 是可变的，它列出读者需要的一切；日志段、pack 和检查点都按序号或内容寻址、写一次不再改（哈希和序号为示例）</figcaption>
</figure>

**manifest 是目录，其余对象按序号或内容命名。** 图里没画出来的细节有三点：manifest 连仓库级配置也内联在里面（D24），读的时候不必为配置多发请求；日志段的键是第一个序号的 16 位十六进制，条目有 PUSH、REF_UPDATE、COMPACT、CHECKPOINT、SETTINGS 五种；pack 和引用快照都按内容寻址，两个写者写同一份内容，写的是同一个键、同样的字节[^walgit-proto]。

### 读者从不 LIST

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

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

---

## 三、一次 push 的完整流程

### 现象：5 个请求，3 轮往返

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

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 560" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="wg-push-title wg-push-desc">
<title id="wg-push-title">一次 push 的三轮往返</title>
<desc id="wg-push-desc">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。</desc>
<defs>
<marker id="wg-push-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="wg-push-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="560" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="160" height="528" rx="8"/>
<text class="t-zone" x="36" y="44">git 客户端</text>
<rect class="dd-zone" x="192" y="16" width="296" height="528" rx="8"/>
<text class="t-zone" x="212" y="44">walgit 实例 A</text>
<rect class="dd-zone" x="504" y="16" width="280" height="528" rx="8"/>
<text class="t-zone" x="524" y="44">桶 · repos/acme/app/</text>
<path class="dd-edge dd-dash" d="M96 60 V524"/>
<path class="dd-edge dd-dash" d="M760 60 V524"/>
<path class="dd-edge" d="M96 76 H206" marker-end="url(#wg-push-arrow)"/>
<path class="dd-edge" d="M472 112 H746" marker-end="url(#wg-push-arrow)"/>
<path class="dd-edge" d="M472 202 H746" marker-end="url(#wg-push-arrow)"/>
<path class="dd-edge" d="M472 290 H746" marker-end="url(#wg-push-arrow)"/>
<path class="dd-edge" d="M472 314 H746" marker-end="url(#wg-push-arrow)"/>
<path class="dd-edge" d="M472 338 H746" marker-end="url(#wg-push-arrow)"/>
<path class="dd-edge-acc" d="M472 400 H746" marker-end="url(#wg-push-arrow-acc)"/>
<path class="dd-edge" d="M206 480 H98" marker-end="url(#wg-push-arrow)"/>
<text class="t-note" x="104" y="68">POST git-receive-pack</text>
<text class="t-mono" x="488" y="104">① GET manifest.pb</text>
<text class="t-val" x="488" y="130">If-None-Match: v41 → 304</text>
<rect class="dd-card" x="208" y="94" width="264" height="36" rx="6"/>
<text class="t-name" x="224" y="117">Serve 级同步：确认本地是最新</text>
<rect class="dd-card" x="208" y="140" width="264" height="36" rx="6"/>
<text class="t-mono" x="224" y="163">index-pack（临时目录）</text>
<rect class="dd-card" x="208" y="184" width="264" height="36" rx="6"/>
<text class="t-name" x="224" y="207">连通性检查 · 推送规则</text>
<text class="t-mono" x="488" y="194">GET policy.json</text>
<text class="t-note" x="488" y="220">HTTP 层读取，不在 5 个之内</text>
<rect class="dd-card" x="208" y="228" width="264" height="36" rx="6"/>
<text class="t-name" x="224" y="251">verify_txn：main 旧值仍是 5be1…</text>
<text class="t-mono" x="488" y="284">② PUT wal/9c1e….pack</text>
<text class="t-mono" x="488" y="308">② PUT wal/9c1e….idx</text>
<text class="t-mono" x="488" y="332">② PUT log/…2a.pb</text>
<text class="t-val" x="488" y="356">If-None-Match: *（三个并行）</text>
<text class="t-mono" x="488" y="392">③ PUT <tspan class="t-acc">manifest.pb</tspan></text>
<text class="t-val" x="488" y="418">If-Match: v41 → 200 · <tspan class="t-acc">v42</tspan></text>
<rect class="dd-card" x="208" y="420" width="264" height="36" rx="6"/>
<text class="t-name" x="224" y="443">本地应用引用 · 采纳 v42</text>
<text class="t-mono" x="104" y="472">ok refs/heads/main</text>
<text class="t-say" x="208" y="512">③ 之前写进桶的对象，manifest 不引用，</text>
<text class="t-say" x="208" y="532">对任何读者都不可见。</text>
</svg>
</div>
<figcaption>图 4 · 一次 push：本地先把包收好、检查完，再对桶发 3 轮共 5 个请求；第 3 轮的 CAS 成功之前，客户端拿不到 ok（版本号、哈希为示例）</figcaption>
</figure>

**能在本地做的，都先在本地做完。** 收包在一个临时目录里进行（默认还带 `--fsck-objects`），连通性和 `policy.json` 里的推送规则（保护分支、只许快进之类）也在这时检查，任何一步失败，临时目录一删，桶里什么都没留下。`verify_txn` 拿每条命令带的旧值和本地引用对比，这和 git 自己更新引用时“比对旧值、再写新值”是同一个检查。

**对桶只发 3 轮。** ROUNDTRIPS.md 把图里的 ①②③ 算作 5 个请求、3 轮串行往返，这就是开头那个 5；读 `policy.json` 那一次属于 HTTP 层，不在这份预算里[^walgit-roundtrips][^walgit-smart]。第 ② 轮里 pack 若返回 412，说明别人传过同样的字节，直接当成功；日志段用 Create 抢的是 `head_seq + 1` 这个序号。

### 为什么 CAS 就是提交点

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

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

---

## 四、并发 push 的冲突处理

### 现象：两个写者抢同一个序号

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

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 556" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="wg-race-title wg-race-desc">
<title id="wg-race-title">两台实例抢同一个序号</title>
<desc id="wg-race-desc">实例 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。</desc>
<defs>
<marker id="wg-race-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="wg-race-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="556" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="232" height="524" rx="8"/>
<text class="t-zone" x="36" y="44">实例 A</text>
<rect class="dd-zone" x="264" y="16" width="272" height="524" rx="8"/>
<text class="t-zone" x="284" y="44">桶</text>
<rect class="dd-zone" x="552" y="16" width="232" height="524" rx="8"/>
<text class="t-zone" x="572" y="44">实例 B</text>
<path class="dd-edge dd-dash" d="M400 60 V512"/>
<path class="dd-edge" d="M232 92 H386" marker-end="url(#wg-race-arrow)"/>
<path class="dd-edge" d="M568 140 H414" marker-end="url(#wg-race-arrow)"/>
<path class="dd-edge" d="M568 196 H414" marker-end="url(#wg-race-arrow)"/>
<path class="dd-edge-acc" d="M232 252 H386" marker-end="url(#wg-race-arrow-acc)"/>
<path class="dd-edge" d="M568 316 H414" marker-end="url(#wg-race-arrow)"/>
<path class="dd-edge" d="M568 472 H414" marker-end="url(#wg-race-arrow)"/>
<path class="dd-edge-acc" d="M568 508 H414" marker-end="url(#wg-race-arrow-acc)"/>
<text class="t-mono" x="32" y="84">① PUT log/…2a.pb</text>
<text class="t-val" x="32" y="108">If-None-Match: * → 200</text>
<text class="t-mono" x="768" y="132" text-anchor="end">② PUT log/…2a.pb</text>
<text class="t-val" x="768" y="156" text-anchor="end">If-None-Match: * → 412</text>
<text class="t-mono" x="768" y="188" text-anchor="end">③ GET manifest → head 41</text>
<text class="t-note" x="768" y="212" text-anchor="end">HEAD log/…2a.pb 还在，等 100 ms</text>
<text class="t-mono" x="32" y="244">④ PUT <tspan class="t-acc">manifest.pb</tspan></text>
<text class="t-val" x="32" y="268">If-Match: v41 → 200 <tspan class="t-acc">v42</tspan></text>
<text class="t-note" x="32" y="292">回 ok：main 5be1… → 0a77…</text>
<text class="t-mono" x="768" y="308" text-anchor="end">⑤ GET manifest → head 42</text>
<text class="t-note" x="768" y="332" text-anchor="end">序号已被提交：重新同步</text>
<rect class="dd-card" x="568" y="352" width="200" height="84" rx="6"/>
<path class="dd-card-head" d="M568.6 384 V358 Q568.6 352.6 574 352.6 H762 Q767.4 352.6 767.4 358 V384 Z"/>
<line class="dd-edge" x1="568" y1="384" x2="768" y2="384"/>
<text class="t-name" x="584" y="373">重新校验</text>
<text class="t-val" x="752" y="373" text-anchor="end">verify_txn</text>
<text class="t-mono" x="584" y="401">main</text>
<text class="t-val" x="640" y="401">ng</text>
<line class="dd-hair" x1="584" y1="410" x2="752" y2="410"/>
<text class="t-mono" x="584" y="427">feat</text>
<text class="t-val" x="640" y="427">通过</text>
<text class="t-mono" x="768" y="464" text-anchor="end">⑥ PUT log/…2b.pb → 200</text>
<text class="t-mono" x="768" y="500" text-anchor="end">⑦ PUT manifest.pb</text>
<text class="t-val" x="768" y="524" text-anchor="end">If-Match: <tspan class="t-acc">v42</tspan> → 200 v43</text>
<text class="t-say" x="32" y="360">B 推 main 的那个请求</text>
<text class="t-say" x="32" y="382">带的旧值是 5be1…，</text>
<text class="t-say" x="32" y="404">现在已是 0a77…：</text>
<text class="t-say" x="32" y="426">只拒绝它，不拖累 feat。</text>
</svg>
</div>
<figcaption>图 5 · 并发 push：两个写者先在日志序号上相撞，输的一方重新同步、逐个请求重新校验旧值，旧值过期的回 ng，其余的换下一个序号再提交（序号、哈希为示例）</figcaption>
</figure>

**冲突先出现在日志段上。** B 创建 `…2a.pb` 得到 412 时，还分不清这个段是已经提交，还是对方还没来得及 CAS，所以它重读 manifest：`head_seq` 到了 42，就重新同步、对批次里每个请求重跑 `verify_txn`；还是 41，就隔 100 毫秒再看。图里推 main 的请求被拒，`ng` 的原因写着 `conflict: expected 5be1…, got 0a77…`；推 feat 的请求换 seq 43 提交[^walgit-publish]。

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

**冲突是正确的，但不便宜。** Cursor 用 rendezvous hashing（把仓库 ID 稳定映射到一组节点上的哈希）让一个仓库总落在同一台主节点上[^cursor]；walgit 让维护某个仓库的主机当它的写者（D28），其他主机可以把 receive-pack 转发给一个 push broker。文档强调这只是优化，不是正确性的依赖，因为发布本身是 CAS 安全的[^walgit-agents]。

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

---

## 五、每秒一次写与 group commit

### 现象：一个对象每秒只能改一次

GCS 的配额文档写得很直白：同一个对象名，写入上限是每秒一次，超过了可能被限流[^gcs-quotas]。walgit 在 GCS 上测到的也是“同一对象的覆盖写是串行的，约每秒一次”，并在文档里写明：一个被 CAS 改写的对象，就是一个吞吐上限[^walgit-roundtrips]。

manifest 恰好是每次 push 都要 CAS 的那个对象。如果一个 push 一次 CAS，一个热门仓库每秒就只能接受大约一个 push。

### 解决：把并发的 push 合成一批

walgit 给每个仓库在每台实例上只开一个发布任务 `publisher_task`，所有 push 都排进它的队列。它一次取出一批，整批只写一个日志段、只做一次 CAS。这就是数据库里常说的 **group commit**。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 420" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="wg-batch-title wg-batch-desc">
<title id="wg-batch-title">group commit：三个 push 合成一次 CAS</title>
<desc id="wg-batch-desc">同一实例上三个 receive-pack 几乎同时到达，单飞的 publisher_task 在 5 毫秒的窗口里把它们收成一批，依次在工作引用表上校验：请求 1 推 main 通过，请求 2 也推 main 但旧值已被请求 1 改掉而被拒，请求 3 推 feat 通过。两个有效请求得到连续序号 42、43，写进同一个日志段 log/000000000000002a.pb，然后只做一次 manifest CAS，head_seq 从 41 变成 43。</desc>
<defs>
<marker id="wg-batch-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="wg-batch-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="420" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="232" height="388" rx="8"/>
<text class="t-zone" x="36" y="44">3 个并发的 receive-pack</text>
<rect class="dd-zone" x="264" y="16" width="240" height="388" rx="8"/>
<text class="t-zone" x="284" y="44">publisher_task · 每仓库一个</text>
<rect class="dd-zone" x="520" y="16" width="264" height="388" rx="8"/>
<text class="t-zone" x="540" y="44">桶</text>
<path class="dd-edge" d="M232 89 H278" marker-end="url(#wg-batch-arrow)"/>
<path class="dd-edge" d="M232 177 H240 Q248 177 248 169 V125 Q248 117 256 117 H278" marker-end="url(#wg-batch-arrow)"/>
<path class="dd-edge" d="M232 265 H252 Q260 265 260 257 V145 Q260 137 268 137 H278" marker-end="url(#wg-batch-arrow)"/>
<path class="dd-edge" d="M384 144 V158" marker-end="url(#wg-batch-arrow)"/>
<path class="dd-edge" d="M488 190 H512 Q520 190 520 182 V110 Q520 102 528 102 H534" marker-end="url(#wg-batch-arrow)"/>
<path class="dd-edge-acc" d="M652 144 V166" marker-end="url(#wg-batch-arrow-acc)"/>
<rect class="dd-card" x="32" y="60" width="200" height="58" rx="6"/>
<path class="dd-card-head" d="M32.6 92 V66 Q32.6 60.6 38 60.6 H226 Q231.4 60.6 231.4 66 V92 Z"/>
<line class="dd-edge" x1="32" y1="92" x2="232" y2="92"/>
<text class="t-name" x="48" y="81">请求 1</text>
<text class="t-mono" x="48" y="109">main</text>
<text class="t-val" x="96" y="109">5be1→0a77</text>
<rect class="dd-card" x="32" y="148" width="200" height="58" rx="6"/>
<path class="dd-card-head" d="M32.6 180 V154 Q32.6 148.6 38 148.6 H226 Q231.4 148.6 231.4 154 V180 Z"/>
<line class="dd-edge" x1="32" y1="180" x2="232" y2="180"/>
<text class="t-name" x="48" y="169">请求 2</text>
<text class="t-mono" x="48" y="197">main</text>
<text class="t-val" x="96" y="197">5be1→c3d9</text>
<rect class="dd-card" x="32" y="236" width="200" height="58" rx="6"/>
<path class="dd-card-head" d="M32.6 268 V242 Q32.6 236.6 38 236.6 H226 Q231.4 236.6 231.4 242 V268 Z"/>
<line class="dd-edge" x1="32" y1="268" x2="232" y2="268"/>
<text class="t-name" x="48" y="257">请求 3</text>
<text class="t-mono" x="48" y="285">feat</text>
<text class="t-val" x="96" y="285">0000→7e42</text>
<rect class="dd-card" x="280" y="60" width="208" height="84" rx="6"/>
<path class="dd-card-head" d="M280.6 92 V66 Q280.6 60.6 286 60.6 H482 Q487.4 60.6 487.4 66 V92 Z"/>
<line class="dd-edge" x1="280" y1="92" x2="488" y2="92"/>
<text class="t-name" x="296" y="81">收批</text>
<text class="t-mono" x="296" y="109">batch_window</text>
<text class="t-val" x="392" y="109">5ms</text>
<line class="dd-hair" x1="296" y1="118" x2="472" y2="118"/>
<text class="t-mono" x="296" y="135">max_batch</text>
<text class="t-val" x="392" y="135">64</text>
<rect class="dd-card" x="280" y="160" width="208" height="110" rx="6"/>
<path class="dd-card-head" d="M280.6 192 V166 Q280.6 160.6 286 160.6 H482 Q487.4 160.6 487.4 166 V192 Z"/>
<line class="dd-edge" x1="280" y1="192" x2="488" y2="192"/>
<text class="t-name" x="296" y="181">verify_txn</text>
<text class="t-val" x="472" y="181" text-anchor="end">工作引用表</text>
<text class="t-mono" x="296" y="209">请求 1</text>
<text class="t-val" x="352" y="209">通过 → seq 42</text>
<line class="dd-hair" x1="296" y1="218" x2="472" y2="218"/>
<text class="t-mono" x="296" y="235">请求 2</text>
<text class="t-val" x="352" y="235">ng：旧值已变</text>
<line class="dd-hair" x1="296" y1="244" x2="472" y2="244"/>
<text class="t-mono" x="296" y="261">请求 3</text>
<text class="t-val" x="352" y="261">通过 → seq 43</text>
<rect class="dd-card" x="536" y="60" width="232" height="84" rx="6"/>
<path class="dd-card-head" d="M536.6 92 V66 Q536.6 60.6 542 60.6 H762 Q767.4 60.6 767.4 66 V92 Z"/>
<line class="dd-edge" x1="536" y1="92" x2="768" y2="92"/>
<text class="t-name" x="552" y="81">log/000000000000002a.pb</text>
<text class="t-mono" x="552" y="109">seq 42</text>
<text class="t-val" x="608" y="109">PUSH main</text>
<line class="dd-hair" x1="552" y1="118" x2="752" y2="118"/>
<text class="t-mono" x="552" y="135">seq 43</text>
<text class="t-val" x="608" y="135">PUSH feat</text>
<rect class="dd-card" x="536" y="168" width="232" height="110" rx="6"/>
<path class="dd-card-head" d="M536.6 200 V174 Q536.6 168.6 542 168.6 H762 Q767.4 168.6 767.4 174 V200 Z"/>
<line class="dd-edge" x1="536" y1="200" x2="768" y2="200"/>
<text class="t-name" x="552" y="189"><tspan class="t-acc">manifest.pb</tspan></text>
<text class="t-val" x="752" y="189" text-anchor="end">一次 CAS</text>
<text class="t-mono" x="552" y="217">head_seq</text>
<text class="t-val" x="648" y="217">41 → <tspan class="t-acc">43</tspan></text>
<line class="dd-hair" x1="552" y1="226" x2="752" y2="226"/>
<text class="t-mono" x="552" y="243">log_segments</text>
<text class="t-val" x="648" y="243">+ […2a.pb]</text>
<line class="dd-hair" x1="552" y1="252" x2="752" y2="252"/>
<text class="t-mono" x="552" y="269">packs</text>
<text class="t-val" x="648" y="269">+ 2 个 tier 0</text>
<text class="t-say" x="280" y="312">单独一个 push 到来时不等待，</text>
<text class="t-say" x="280" y="334">立即进入 CAS；只有排队里</text>
<text class="t-say" x="280" y="356">已经有人时，才等满这 5 ms。</text>
<text class="t-note" x="536" y="316">按 GCS 文档，同名对象</text>
<text class="t-note" x="536" y="338">每秒约只能写一次；</text>
<text class="t-note" x="536" y="360">N 个 push 合成一次写。</text>
</svg>
</div>
<figcaption>图 6 · group commit：一个批次只写一个日志段、只做一次 CAS；被拒的请求不占序号，也不影响同批其他请求（序号、哈希为示例）</figcaption>
</figure>

**批里的每个请求各自校验。** 校验在一张“工作引用表”上依次进行：请求 1 通过后，它的新值立刻写进这张表，所以请求 2 再拿旧值 `5be1…` 来比，就会发现已经变了。被拒的请求不分配序号；一个请求里有一条引用被拒，同一请求的其他引用也一起被拒（错误信息是 “another ref in the submission was rejected”），但不会影响别的请求[^walgit-publish]。

**单独一个 push 不会被拖慢。** 默认窗口 `batch_window = 5ms`，一批最多 `max_batch = 64` 个[^walgit-config]。代码注释特意写了：只有队列里已经有别的请求在等时，才会等满这个窗口；一个孤零零的 push 直接进入 CAS。

walgit 的文档没有给出它自己的 push 吞吐数字。Cursor 的文章给了 Continuity 的数据：按他们自己的测量，在 S3 Standard 上每秒能接受 120 个 push，在延迟更低的 S3 Express One Zone 上超过 300 个，瓶颈变成了 git 压缩磁盘数据的速度[^cursor]。另外，“每秒一次”是 GCS 文档写明的限制，S3 的文档里我没有找到同样的条款；walgit 的设计是按 GCS 的数字定的。

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

---

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

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

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

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

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

### 解决：按请求需要分级

“追平”到什么程度，取决于请求要什么。walgit 把同步分成几级，图 7 从左到右拿得越来越多[^walgit-agents][^walgit-sync]。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 380" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="wg-levels-title wg-levels-desc">
<title id="wg-levels-title">四级同步：一个请求只取它需要的那部分</title>
<desc id="wg-levels-desc">每个请求先对 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。</desc>
<defs>
<marker id="wg-levels-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="wg-levels-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="380" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="348" rx="8"/>
<path class="dd-edge-acc" d="M400 120 V146" marker-end="url(#wg-levels-arrow-acc)"/>
<rect class="dd-card" x="32" y="32" width="736" height="84" rx="6"/>
<path class="dd-card-head" d="M32.6 64 V38 Q32.6 32.6 38 32.6 H762 Q767.4 32.6 767.4 38 V64 Z"/>
<line class="dd-edge" x1="32" y1="64" x2="768" y2="64"/>
<text class="t-name" x="48" y="53">每个请求的第一步</text>
<text class="t-val" x="752" y="53" text-anchor="end">sync.rs::freshness_check</text>
<text class="t-mono" x="48" y="81">GET manifest.pb</text>
<text class="t-val" x="176" y="81">If-None-Match: <tspan class="t-acc">v42</tspan> → 304：本地就是最新，直接进入下面某一级</text>
<line class="dd-hair" x1="48" y1="90" x2="752" y2="90"/>
<text class="t-mono" x="48" y="107"></text>
<text class="t-val" x="176" y="107">→ 200：带回新 manifest，加载检查点、并行取日志尾巴，一次性改写 packed-refs</text>
<rect class="dd-card" x="32" y="152" width="176" height="162" rx="6"/>
<path class="dd-card-head" d="M32.6 184 V158 Q32.6 152.6 38 152.6 H202 Q207.4 152.6 207.4 158 V184 Z"/>
<line class="dd-edge" x1="32" y1="184" x2="208" y2="184"/>
<text class="t-name" x="48" y="173">Refs</text>
<text class="t-val" x="192" y="173" text-anchor="end">sync_refs</text>
<text class="t-val" x="48" y="201">packed-refs</text>
<line class="dd-hair" x1="48" y1="210" x2="192" y2="210"/>
<text class="t-val" x="48" y="227">← 快照 + 日志</text>
<line class="dd-hair" x1="48" y1="236" x2="192" y2="236"/>
<text class="t-val" x="48" y="253">不下载 pack</text>
<line class="dd-hair" x1="48" y1="262" x2="192" y2="262"/>
<text class="t-note" x="48" y="279">info/refs · ls-refs</text>
<line class="dd-hair" x1="48" y1="288" x2="192" y2="288"/>
<text class="t-note" x="48" y="305">冷启动 2 个请求</text>
<rect class="dd-card" x="218" y="152" width="176" height="162" rx="6"/>
<path class="dd-card-head" d="M218.6 184 V158 Q218.6 152.6 224 152.6 H388 Q393.4 152.6 393.4 158 V184 Z"/>
<line class="dd-edge" x1="218" y1="184" x2="394" y2="184"/>
<text class="t-name" x="234" y="173">Serve</text>
<text class="t-val" x="378" y="173" text-anchor="end">sync</text>
<text class="t-val" x="234" y="201">tier 0/1 本地</text>
<line class="dd-hair" x1="234" y1="210" x2="378" y2="210"/>
<text class="t-val" x="234" y="227">history 本地</text>
<line class="dd-hair" x1="234" y1="236" x2="378" y2="236"/>
<text class="t-val" x="234" y="253">base 挂载/远程</text>
<line class="dd-hair" x1="234" y1="262" x2="378" y2="262"/>
<text class="t-note" x="234" y="279">upload-pack</text>
<line class="dd-hair" x1="234" y1="288" x2="378" y2="288"/>
<text class="t-note" x="234" y="305">receive-pack</text>
<rect class="dd-card" x="404" y="152" width="176" height="162" rx="6"/>
<path class="dd-card-head" d="M404.6 184 V158 Q404.6 152.6 410 152.6 H574 Q579.4 152.6 579.4 158 V184 Z"/>
<line class="dd-edge" x1="404" y1="184" x2="580" y2="184"/>
<text class="t-name" x="420" y="173">Full</text>
<text class="t-val" x="564" y="173" text-anchor="end">sync_full</text>
<text class="t-val" x="420" y="201">全部 pack 本地</text>
<line class="dd-hair" x1="420" y1="210" x2="564" y2="210"/>
<text class="t-val" x="420" y="227">放不下 → 503</text>
<line class="dd-hair" x1="420" y1="236" x2="564" y2="236"/>
<text class="t-val" x="420" y="253">（TooLarge）</text>
<line class="dd-hair" x1="420" y1="262" x2="564" y2="262"/>
<text class="t-note" x="420" y="279">compact --base</text>
<line class="dd-hair" x1="420" y1="288" x2="564" y2="288"/>
<text class="t-note" x="420" y="305">几何压缩</text>
<rect class="dd-card" x="590" y="152" width="176" height="162" rx="6"/>
<path class="dd-card-head" d="M590.6 184 V158 Q590.6 152.6 596 152.6 H760 Q765.4 152.6 765.4 158 V184 Z"/>
<line class="dd-edge" x1="590" y1="184" x2="766" y2="184"/>
<text class="t-name" x="606" y="173">Objects</text>
<text class="t-val" x="750" y="173" text-anchor="end">sync_objects</text>
<text class="t-val" x="606" y="201">放得下 = Serve</text>
<line class="dd-hair" x1="606" y1="210" x2="750" y2="210"/>
<text class="t-val" x="606" y="227">否则只放 .idx</text>
<line class="dd-hair" x1="606" y1="236" x2="750" y2="236"/>
<text class="t-val" x="606" y="253">数据 1 MiB 范围读</text>
<line class="dd-hair" x1="606" y1="262" x2="750" y2="262"/>
<text class="t-note" x="606" y="279">Web API 读对象</text>
<line class="dd-hair" x1="606" y1="288" x2="750" y2="288"/>
<text class="t-note" x="606" y="305">块缓存 1 GiB</text>
<text class="t-say" x="32" y="348">按项目在 GCS 上的测量，普通 GET 60–80 ms，条件 GET 返回 304 只要 15–18 ms。</text>
</svg>
</div>
<figcaption>图 7 · 读路径：先用一次条件 GET 确认新鲜，再按请求需要的级别补齐本地缓存；越往右拿得越多，也越可能放不下（数值为 walgit 配置默认值）</figcaption>
</figure>

**每一级只拿请求需要的那部分。** 回答 `ls-remote` 只要 Refs 级，冷启动 2 个请求，验收标准是最大的仓库也在 1 秒内回答；Full 级放不下时返回 `TooLarge`（HTTP 503），绝不硬拉一个装不下的 pack 把机器撑爆。下载 pack 跑在单独的运行时上，不拿 Refs 阶段的锁：AGENTS.md 记过一次事故，一个 24 分钟的 clone 加上一个排队的写锁，让整台实例的 `info/refs` 等了几分钟（D19）[^walgit-agents]。

### 仓库比机器还大：remote reader

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

<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="wg-remote-title wg-remote-desc">
<title id="wg-remote-title">remote reader：索引在本地，数据按块从桶里读</title>
<desc id="wg-remote-desc">实例只有 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 本体从不整个下载。</desc>
<defs>
<marker id="wg-remote-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="wg-remote-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="460" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="392" height="428" rx="8"/>
<text class="t-zone" x="36" y="44">实例 · 20 GiB tmpfs</text>
<rect class="dd-zone" x="424" y="16" width="360" height="428" rx="8"/>
<text class="t-zone" x="444" y="44">桶 · repos/acme/app/</text>
<path class="dd-edge-acc" d="M392 157 H438" marker-end="url(#wg-remote-arrow-acc)"/>
<rect class="dd-card" x="32" y="60" width="360" height="162" rx="6"/>
<path class="dd-card-head" d="M32.6 92 V66 Q32.6 60.6 38 60.6 H386 Q391.4 60.6 391.4 66 V92 Z"/>
<line class="dd-edge" x1="32" y1="92" x2="392" y2="92"/>
<text class="t-name" x="48" y="81">Web 界面读 src/main.rs</text>
<text class="t-val" x="376" y="81" text-anchor="end">remote.rs</text>
<text class="t-mono" x="48" y="109">①</text>
<text class="t-val" x="80" y="109">查 .idx：偏移 1931500000</text>
<line class="dd-hair" x1="48" y1="118" x2="376" y2="118"/>
<text class="t-mono" x="48" y="135">②</text>
<text class="t-val" x="80" y="135">块号 = 偏移 &gt;&gt; 20 = 1842</text>
<line class="dd-hair" x1="48" y1="144" x2="376" y2="144"/>
<text class="t-mono" x="48" y="161">③</text>
<text class="t-val" x="80" y="161">块缓存未命中 → <tspan class="t-acc">范围读</tspan></text>
<line class="dd-hair" x1="48" y1="170" x2="376" y2="170"/>
<text class="t-mono" x="48" y="187">④</text>
<text class="t-val" x="80" y="187">inflate，沿 delta 链取基对象</text>
<line class="dd-hair" x1="48" y1="196" x2="376" y2="196"/>
<text class="t-mono" x="48" y="213">⑤</text>
<text class="t-val" x="80" y="213">写入松散对象库，运行 git show</text>
<rect class="dd-card" x="32" y="240" width="360" height="136" rx="6"/>
<path class="dd-card-head" d="M32.6 272 V246 Q32.6 240.6 38 240.6 H386 Q391.4 240.6 391.4 246 V272 Z"/>
<line class="dd-edge" x1="32" y1="272" x2="392" y2="272"/>
<text class="t-name" x="48" y="261">常驻本地</text>
<text class="t-mono" x="48" y="289">remote-idx/</text>
<text class="t-val" x="168" y="289">.idx ≈ pack 的 6%</text>
<line class="dd-hair" x1="48" y1="298" x2="376" y2="298"/>
<text class="t-mono" x="48" y="315">history pack</text>
<text class="t-val" x="168" y="315">提交 + 树（D18）</text>
<line class="dd-hair" x1="48" y1="324" x2="376" y2="324"/>
<text class="t-mono" x="48" y="341">块缓存</text>
<text class="t-val" x="168" y="341">1 GiB · LRU</text>
<line class="dd-hair" x1="48" y1="350" x2="376" y2="350"/>
<text class="t-mono" x="48" y="367">对象缓存</text>
<text class="t-val" x="168" y="367">256 MiB</text>
<rect class="dd-derived" x="32" y="392" width="360" height="36" rx="6"/>
<text class="t-mono" x="48" y="415">pack-b4f0….pack（不在本地）</text>
<rect class="dd-card" x="440" y="60" width="328" height="162" rx="6"/>
<path class="dd-card-head" d="M440.6 92 V66 Q440.6 60.6 446 60.6 H762 Q767.4 60.6 767.4 66 V92 Z"/>
<line class="dd-edge" x1="440" y1="92" x2="768" y2="92"/>
<text class="t-name" x="456" y="81">wal/b4f0….pack</text>
<text class="t-val" x="752" y="81" text-anchor="end">tier 2 · 30+ GB</text>
<text class="t-mono" x="456" y="109">GET</text>
<text class="t-val" x="520" y="109">wal/b4f0….pack</text>
<line class="dd-hair" x1="456" y1="118" x2="752" y2="118"/>
<text class="t-mono" x="456" y="135">Range</text>
<text class="t-val" x="520" y="135">bytes=<tspan class="t-acc">1931476992-</tspan></text>
<line class="dd-hair" x1="456" y1="144" x2="752" y2="144"/>
<text class="t-mono" x="456" y="161"></text>
<text class="t-val" x="520" y="161"><tspan class="t-acc">1932525567</tspan></text>
<line class="dd-hair" x1="456" y1="170" x2="752" y2="170"/>
<text class="t-note" x="456" y="187">1 MiB 一块，同一块并发只取一次</text>
<line class="dd-hair" x1="456" y1="196" x2="752" y2="196"/>
<text class="t-note" x="456" y="213">约 100 MB/s 每连接（项目测量）</text>
<text class="t-say" x="440" y="384">虚线框：这台机器上不存在的文件。</text>
<text class="t-say" x="440" y="406">base pack 从不整个下载，</text>
<text class="t-say" x="440" y="428">只按块读需要的那几 MiB。</text>
</svg>
</div>
<figcaption>图 8 · remote reader：本地只放索引、history pack 和缓存，pack 数据按 1 MiB 一块从桶里范围读取（偏移、哈希为示例，缓存大小为默认值）</figcaption>
</figure>

读到对象之后，Web 界面把这条 git 命令会碰到的对象写进本地的松散对象库，再照常运行未经修改的 git。history pack（D18）只含提交和树，每台实例都放在本地，遍历历史就不用碰网络。按项目自己的测量，CI 常用的 `clone --filter=blob:none --depth=1 --sparse --single-branch` 从 2075 秒降到了 8 秒[^walgit-agents]。

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

---

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

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

日志不能无限长，否则冷启动要重放所有条目。`checkpoint_due` 定了三个触发条件，满足任一个就写检查点：距上次已有 256 条，或者上次已过 1 小时，或者日志尾巴超过 8 MiB[^walgit-checkpoint][^walgit-config]。

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

### 压缩：一台机器做，所有机器用

git 自己也要定期把很多小 pack 合并（repack），否则查一个对象要挨个翻几百个索引。在 Spokes 里 repack 必须在每个副本上各做一次，而且在两个副本上同时做同一个仓库的维护，很容易触发故障切换[^cursor]。

walgit 让一台机器做一次，结果作为一条 COMPACT 日志发布出去：新 pack 是什么，`supersedes` 列出它取代了哪几个旧 pack。其他实例下次同步时直接下载合并好的 pack，用带宽换 CPU。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 500" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="wg-compact-title wg-compact-desc">
<title id="wg-compact-title">压缩：租约决定谁来做，CAS 决定是否生效</title>
<desc id="wg-compact-desc">实例 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。</desc>
<defs>
<marker id="wg-compact-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="wg-compact-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="500" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="468" rx="8"/>
<path class="dd-edge-acc" d="M520 252 H542" marker-end="url(#wg-compact-arrow-acc)"/>
<rect class="dd-card" x="32" y="32" width="232" height="110" rx="6"/>
<path class="dd-card-head" d="M32.6 64 V38 Q32.6 32.6 38 32.6 H258 Q263.4 32.6 263.4 38 V64 Z"/>
<line class="dd-edge" x1="32" y1="64" x2="264" y2="64"/>
<text class="t-name" x="48" y="53">leases/compact.pb</text>
<text class="t-val" x="248" y="53" text-anchor="end">CAS + TTL</text>
<text class="t-mono" x="48" y="81">holder</text>
<text class="t-val" x="136" y="81">实例 M</text>
<line class="dd-hair" x1="48" y1="90" x2="248" y2="90"/>
<text class="t-mono" x="48" y="107">expires_at</text>
<text class="t-val" x="136" y="107">+10m</text>
<line class="dd-hair" x1="48" y1="116" x2="248" y2="116"/>
<text class="t-mono" x="48" y="133">epoch</text>
<text class="t-val" x="136" y="133">0 → 1 → 2</text>
<text class="t-note" x="32" y="168">抢：Create（If-None-Match: *）</text>
<text class="t-note" x="32" y="190">续：If-Match 改写，epoch + 1</text>
<text class="t-note" x="32" y="212">过期 2 s 后别人才能接手</text>
<rect class="dd-card" x="296" y="32" width="472" height="110" rx="6"/>
<path class="dd-card-head" d="M296.6 64 V38 Q296.6 32.6 302 32.6 H762 Q767.4 32.6 767.4 38 V64 Z"/>
<line class="dd-edge" x1="296" y1="64" x2="768" y2="64"/>
<text class="t-name" x="312" y="53">log/000000000000002d.pb</text>
<text class="t-val" x="752" y="53" text-anchor="end">COMPACT</text>
<text class="t-mono" x="312" y="81">seq</text>
<text class="t-val" x="408" y="81">45（44 被一个 push 抢先提交）</text>
<line class="dd-hair" x1="312" y1="90" x2="752" y2="90"/>
<text class="t-mono" x="312" y="107">pack</text>
<text class="t-val" x="408" y="107"><tspan class="t-acc">e71b…</tspan> tier 1</text>
<line class="dd-hair" x1="312" y1="116" x2="752" y2="116"/>
<text class="t-mono" x="312" y="133">supersedes</text>
<text class="t-val" x="408" y="133">71d2…  9c1e…  4a85…</text>
<rect class="dd-card" x="296" y="160" width="224" height="162" rx="6"/>
<path class="dd-card-head" d="M296.6 192 V166 Q296.6 160.6 302 160.6 H514 Q519.4 160.6 519.4 166 V192 Z"/>
<line class="dd-edge" x1="296" y1="192" x2="520" y2="192"/>
<text class="t-name" x="312" y="181">manifest.packs · 之前</text>
<text class="t-val" x="504" y="181" text-anchor="end">v44</text>
<text class="t-mono" x="312" y="209">b4f0…</text>
<text class="t-val" x="368" y="209">tier 2</text>
<line class="dd-hair" x1="312" y1="218" x2="504" y2="218"/>
<text class="t-mono" x="312" y="235">71d2…</text>
<text class="t-val" x="368" y="235">tier 0</text>
<line class="dd-hair" x1="312" y1="244" x2="504" y2="244"/>
<text class="t-mono" x="312" y="261">9c1e…</text>
<text class="t-val" x="368" y="261">tier 0</text>
<line class="dd-hair" x1="312" y1="270" x2="504" y2="270"/>
<text class="t-mono" x="312" y="287">4a85…</text>
<text class="t-val" x="368" y="287">tier 0</text>
<line class="dd-hair" x1="312" y1="296" x2="504" y2="296"/>
<text class="t-mono" x="312" y="313">5f30…</text>
<text class="t-val" x="368" y="313">tier 0 · seq 44</text>
<rect class="dd-card" x="544" y="160" width="224" height="110" rx="6"/>
<path class="dd-card-head" d="M544.6 192 V166 Q544.6 160.6 550 160.6 H762 Q767.4 160.6 767.4 166 V192 Z"/>
<line class="dd-edge" x1="544" y1="192" x2="768" y2="192"/>
<text class="t-name" x="560" y="181">manifest.packs · 之后</text>
<text class="t-val" x="752" y="181" text-anchor="end">v45</text>
<text class="t-mono" x="560" y="209">b4f0…</text>
<text class="t-val" x="616" y="209">tier 2</text>
<line class="dd-hair" x1="560" y1="218" x2="752" y2="218"/>
<text class="t-mono" x="560" y="235"><tspan class="t-acc">e71b…</tspan></text>
<text class="t-val" x="616" y="235">tier 1</text>
<line class="dd-hair" x1="560" y1="244" x2="752" y2="244"/>
<text class="t-mono" x="560" y="261">5f30…</text>
<text class="t-val" x="616" y="261">tier 0 · seq 44</text>
<text class="t-say" x="32" y="268">M 想占 seq 44，</text>
<text class="t-say" x="32" y="290">一个 push 抢先提交了 44，</text>
<text class="t-say" x="32" y="312">M 的 Create 得到 412。</text>
<text class="t-say" x="32" y="334">M 基于新 manifest 只删</text>
<text class="t-say" x="32" y="356">三个输入，5f30 原样保留。</text>
<rect class="dd-card" x="296" y="368" width="472" height="110" rx="6"/>
<path class="dd-card-head" d="M296.6 400 V374 Q296.6 368.6 302 368.6 H762 Q767.4 368.6 767.4 374 V400 Z"/>
<line class="dd-edge" x1="296" y1="400" x2="768" y2="400"/>
<text class="t-name" x="312" y="389">其他实例的下一次同步</text>
<text class="t-val" x="752" y="389" text-anchor="end">reconcile_packs</text>
<text class="t-mono" x="312" y="417">下载</text>
<text class="t-val" x="368" y="417">wal/e71b….pack .idx（不各自 repack）</text>
<line class="dd-hair" x1="312" y1="426" x2="752" y2="426"/>
<text class="t-mono" x="312" y="443">删除</text>
<text class="t-val" x="368" y="443">71d2 9c1e 4a85，等读请求的守卫释放后才删</text>
<line class="dd-hair" x1="312" y1="452" x2="752" y2="452"/>
<text class="t-mono" x="312" y="469">桶里</text>
<text class="t-val" x="368" y="469">旧 pack 字节保留，记在 retired_packs</text>
</svg>
</div>
<figcaption>图 9 · 压缩：租约只是让大家别重复干活，真正让结果生效的仍然是 manifest 的 CAS；压缩期间到达的 push 不会丢（哈希、序号为示例）</figcaption>
</figure>

**谁来做，由租约决定。** 租约是桶里的一个小对象 `leases/compact.pb`：不存在时用 Create 抢；已过期（再加 2 秒的时钟误差容忍）时用 `If-Match` 覆盖接手；持有者定期用 CAS 心跳，把 `epoch` 加一、把过期时间往后推，心跳返回 412 就说明租约被别人拿走了[^walgit-coord]。默认 TTL 是 10 分钟[^walgit-config]。仿真测试里专门有一个场景：持有租约的实例死掉，另一台在租约过期后接手完成压缩[^walgit-sim]。

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

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

**到这里，每一种写（push、检查点、压缩）都遵循“先写不可变对象，再 CAS manifest”。如果进程正好死在中间呢？**

---

## 八、崩溃恢复

一次 push 有好几个步骤，进程可能死在任何一步之间。图 10 把四个崩溃点和各自的结局放在一起。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 500" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="wg-crash-title wg-crash-desc">
<title id="wg-crash-title">进程在 push 的任意一步死掉会怎样</title>
<desc id="wg-crash-desc">push 的五步依次是：本地 index-pack、并行 PUT pack 和日志段、CAS manifest、本地应用引用、回复 ok。若死在第二步之前，桶里什么也没有；死在 PUT 之后、CAS 之前，留下孤儿日志段，下一个写者探测三次后跳过这个序号并在提交后删除它；CAS 请求发出但响应丢失，写者重读 manifest，只有列出了自己的段且字节完全一致才算成功，否则报告结果未知而不是 ng；CAS 成功之后才死，提交已经生效，本地副本由下次同步重放。</desc>
<defs>
<marker id="wg-crash-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="wg-crash-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="500" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="468" rx="8"/>
<path class="dd-edge" d="M160 58 H182" marker-end="url(#wg-crash-arrow)"/>
<path class="dd-edge-acc" d="M312 58 H334" marker-end="url(#wg-crash-arrow-acc)"/>
<path class="dd-edge" d="M464 58 H486" marker-end="url(#wg-crash-arrow)"/>
<path class="dd-edge" d="M616 58 H638" marker-end="url(#wg-crash-arrow)"/>
<path class="dd-edge dd-dash" d="M172 76 V112"/>
<path class="dd-edge dd-dash" d="M324 76 V112"/>
<path class="dd-edge dd-dash" d="M400 76 V112"/>
<path class="dd-edge dd-dash" d="M476 76 V112"/>
<rect class="dd-card" x="32" y="40" width="128" height="36" rx="6"/>
<text class="t-name" x="96" y="63" text-anchor="middle">index-pack</text>
<rect class="dd-card" x="184" y="40" width="128" height="36" rx="6"/>
<text class="t-name" x="248" y="63" text-anchor="middle">PUT pack/log</text>
<rect class="dd-card" x="336" y="40" width="128" height="36" rx="6"/>
<text class="t-name" x="400" y="63" text-anchor="middle"><tspan class="t-acc">CAS manifest</tspan></text>
<rect class="dd-card" x="488" y="40" width="128" height="36" rx="6"/>
<text class="t-name" x="552" y="63" text-anchor="middle">本地应用</text>
<rect class="dd-card" x="640" y="40" width="128" height="36" rx="6"/>
<text class="t-name" x="704" y="63" text-anchor="middle">回 ok</text>
<text class="t-name" x="172" y="128" text-anchor="middle">①</text>
<text class="t-name" x="324" y="128" text-anchor="middle">②</text>
<text class="t-name" x="400" y="128" text-anchor="middle">③</text>
<text class="t-name" x="476" y="128" text-anchor="middle">④</text>
<rect class="dd-card" x="32" y="152" width="360" height="84" rx="6"/>
<path class="dd-card-head" d="M32.6 184 V158 Q32.6 152.6 38 152.6 H386 Q391.4 152.6 391.4 158 V184 Z"/>
<line class="dd-edge" x1="32" y1="184" x2="392" y2="184"/>
<text class="t-name" x="48" y="173">① PUT 之前</text>
<text class="t-val" x="376" y="173" text-anchor="end">什么都没写</text>
<text class="t-val" x="48" y="201">客户端连接断开，看到失败</text>
<line class="dd-hair" x1="48" y1="210" x2="376" y2="210"/>
<text class="t-val" x="48" y="227">桶里没有任何新对象，重推即可</text>
<rect class="dd-card" x="408" y="152" width="360" height="110" rx="6"/>
<path class="dd-card-head" d="M408.6 184 V158 Q408.6 152.6 414 152.6 H762 Q767.4 152.6 767.4 158 V184 Z"/>
<line class="dd-edge" x1="408" y1="184" x2="768" y2="184"/>
<text class="t-name" x="424" y="173">② 写了日志段，没来得及 CAS</text>
<text class="t-val" x="752" y="173" text-anchor="end">孤儿段</text>
<text class="t-val" x="424" y="201">log/…2a.pb 存在，但 manifest 不引用它</text>
<line class="dd-hair" x1="424" y1="210" x2="752" y2="210"/>
<text class="t-val" x="424" y="227">下个写者 412 → 每 100 ms 探测，共 3 次</text>
<line class="dd-hair" x1="424" y1="236" x2="752" y2="236"/>
<text class="t-val" x="424" y="253">跳过序号（burn），写 …2b，提交后删孤儿</text>
<rect class="dd-card" x="32" y="300" width="360" height="110" rx="6"/>
<path class="dd-card-head" d="M32.6 332 V306 Q32.6 300.6 38 300.6 H386 Q391.4 300.6 391.4 306 V332 Z"/>
<line class="dd-edge" x1="32" y1="332" x2="392" y2="332"/>
<text class="t-name" x="48" y="321">③ CAS 发出，响应丢了</text>
<text class="t-val" x="376" y="321" text-anchor="end">cas_landed</text>
<text class="t-val" x="48" y="349">重读 manifest：列出了自己的段吗？</text>
<line class="dd-hair" x1="48" y1="358" x2="376" y2="358"/>
<text class="t-val" x="48" y="375">列出且字节一致（含 nonce）→ 成功</text>
<line class="dd-hair" x1="48" y1="384" x2="376" y2="384"/>
<text class="t-val" x="48" y="401">否则 CommitUnknown，绝不回 ng</text>
<rect class="dd-card" x="408" y="300" width="360" height="110" rx="6"/>
<path class="dd-card-head" d="M408.6 332 V306 Q408.6 300.6 414 300.6 H762 Q767.4 300.6 767.4 306 V332 Z"/>
<line class="dd-edge" x1="408" y1="332" x2="768" y2="332"/>
<text class="t-name" x="424" y="321">④ CAS 成功之后才死</text>
<text class="t-val" x="752" y="321" text-anchor="end">已提交</text>
<text class="t-val" x="424" y="349">push 已经持久，任何实例下次读都能看到</text>
<line class="dd-hair" x1="424" y1="358" x2="752" y2="358"/>
<text class="t-val" x="424" y="375">本地没采纳 v42 → 下次同步重放该条目</text>
<line class="dd-hair" x1="424" y1="384" x2="752" y2="384"/>
<text class="t-val" x="424" y="401">整台机器没了 → 检查点 + 日志尾巴重建</text>
<text class="t-say" x="32" y="440">这几种情况都不需要人工介入，也不需要任何“恢复程序”：</text>
<text class="t-say" x="32" y="462">恢复就是普通的同步，因为磁盘上的仓库从来都只是缓存。</text>
</svg>
</div>
<figcaption>图 10 · 崩溃点：提交点之前死掉，留下的只是没人引用的垃圾；之后死掉，提交已经生效，本地副本下次同步时自己修好</figcaption>
</figure>

**提交点之前死掉，留下的只是垃圾；之后死掉，提交已经生效。** ② 的孤儿段被下一个写者探测 3 次后跳过（代码里叫 burn），所以日志序号严格递增但不一定连续，读者顺着 `manifest.log_segments` 走，不在乎缺号[^walgit-publish]。④ 的情况下只要 CAS 成功就一律回 `ok`：代码注释记录过，曾经在本地应用失败时向客户端报错，结果出现了“push 实际已经持久、git 却报告失败”的情况。

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

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

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

---

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

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

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

| | 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 里“把状态放进你反正要取的那个对象”这条经验[^walgit-roundtrips]。代价则是写的一侧要面对每秒一次的上限，于是有了 group commit 和单写者的部署习惯。

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

---

## 十、回到开头的 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），相关部分还在演进[^walgit-agents]。文中所有性能数字都是项目自己的测量，我没有复现：本机缺少构建所需的 `protoc`，没有实际跑起来。Hacker News 的讨论里也有人质疑它的安全性[^hn]。

---

## 小结

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

## 参考资料
