﻿---
title: "从 git clone 说起：Git 服务器是怎么工作的"
description: "一次 git clone 在网络上到底交换了什么？从抓包看到的三次 HTTP 请求出发，先搭一个最小的 Git 服务器，再顺着它解决不了的问题，依次读 GitLab、Gerrit 和 walgit 的源码：存储怎么拆、引用怎么管、多副本怎么保持一致。"
pubDate: 2026-10-14
updatedDate: 2026-10-17
tags: ["Git", "源码分析", "分布式系统", "GitLab", "Gerrit"]
refs:
  - id: gitaly-design
    title: Gitaly design
    url: https://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/DESIGN.md
    site: GitLab
    note: "`doc/DESIGN.md`：“the first design reason for Gitaly - to avoid having the Git binary operate on remote storage”"
  - id: reftable
    title: reftable
    url: https://github.com/eclipse-jgit/jgit/blob/master/Documentation/technical/reftable.md
    site: Eclipse JGit
    note: "`Documentation/technical/reftable.md`，Android 86.6 万个引用的性能对比"
  - 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: walgit
    title: tobi/walgit
    url: https://github.com/tobi/walgit
    author: Tobias Lütke
    note: "README、`AGENTS.md`、`docs/ROUNDTRIPS.md`"
  - 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: relnotes-1.6.6
    title: Git 1.6.6 发布说明
    url: https://github.com/git/git/blob/master/Documentation/RelNotes/1.6.6.adoc
    note: smart HTTP
    further: true
  - id: relnotes-2.16
    title: Git 2.16 发布说明
    url: https://github.com/git/git/blob/master/Documentation/RelNotes/2.16.0.adoc
    note: 协议就地升级机制
    further: true
  - id: relnotes-2.26
    title: Git 2.26 发布说明
    url: https://github.com/git/git/blob/master/Documentation/RelNotes/2.26.0.adoc
    note: v2 设为默认
    further: true
  - id: relnotes-2.27
    title: Git 2.27 发布说明
    url: https://github.com/git/git/blob/master/Documentation/RelNotes/2.27.0.adoc
    note: v2 默认值退回
    further: true
  - id: relnotes-2.29
    title: Git 2.29 发布说明
    url: https://github.com/git/git/blob/master/Documentation/RelNotes/2.29.0.adoc
    note: v2 再次设为默认
    further: true
  - id: doc-protocol-v2
    title: Protocol v2
    url: https://git-scm.com/docs/protocol-v2
    site: Git 文档
    further: true
  - id: doc-pack-protocol
    title: pack-protocol
    url: https://git-scm.com/docs/pack-protocol
    site: Git 文档
    further: true
  - id: doc-http-protocol
    title: http-protocol
    url: https://git-scm.com/docs/http-protocol
    site: Git 文档
    further: true
  - id: doc-http-backend
    title: git-http-backend
    url: https://git-scm.com/docs/git-http-backend
    site: Git 文档
    further: true
  - id: doc-reference-transaction
    title: "githooks: reference-transaction"
    url: https://git-scm.com/docs/githooks#_reference_transaction
    site: Git 文档
    further: true
  - id: gitaly
    title: gitlab-org/gitaly
    url: https://gitlab.com/gitlab-org/gitaly
    site: GitLab 源码
    note: "`doc/sidechannel.md`、`doc/design_pack_objects_cache.md`、`doc/design_ha.md`、`doc/object_quarantine.md`"
    further: true
  - id: gitlab
    title: gitlab-org/gitlab
    url: https://gitlab.com/gitlab-org/gitlab
    site: GitLab 源码
    note: "`workhorse/`、`lib/gitlab/git_access.rb`"
    further: true
  - id: gitlab-shell
    title: gitlab-org/gitlab-shell
    url: https://gitlab.com/gitlab-org/gitlab-shell
    site: GitLab 源码
    further: true
  - id: gerrit
    title: GerritCodeReview/gerrit
    url: https://github.com/GerritCodeReview/gerrit
    site: Gerrit 源码
    note: "`Documentation/note-db.txt`、`server/git/receive/`"
    further: true
  - id: jgit
    title: eclipse-jgit/jgit
    url: https://github.com/eclipse-jgit/jgit
    site: Gerrit 源码
    further: true
---

`git clone` 大概是我们每天敲得最多的命令之一。终端里能看到的只有几行进度：

```
$ git clone http://127.0.0.1:8765/hello.git
Cloning into 'hello'...
remote: Enumerating objects: 8, done.
remote: Counting objects: 100% (8/8), done.
remote: Compressing objects: 100% (4/4), done.
Receiving objects: 100% (8/8), done.
```

这几行背后，客户端和服务器之间到底说了什么？服务器收到请求以后又做了什么？同样是“托管 Git 仓库”，GitLab、Gerrit 这类成熟系统和最近出现的 walgit 为什么长得完全不一样？

带着这几个问题，本文从一次 `git clone` 的抓包开始，先看清协议里传递的数据，再用 Git 自带的工具搭一个最小的服务器。这个最小服务器很快会暴露出几个问题：仓库放在哪、谁能看见什么、多台机器之间怎么保持一致。GitLab、Gerrit 和 walgit 分别给出了不同的回答，我们就顺着这几个问题，一层一层去读它们的源码。

---

## 一、git clone 背后的数据传递

### 现象：一次 clone，三次 HTTP 请求

为了看清楚网络上发生了什么，我们在本地准备一个只有两次提交的仓库 `hello.git`（`main` 和标签 `v1.0` 都指向提交 `385ce2c`），用 Git 自带的 `git http-backend` 把它通过 HTTP 提供出来，然后打开 Git 的报文跟踪：

```bash
$ GIT_TRACE_PACKET=1 GIT_TRACE_CURL=1 git clone http://127.0.0.1:8765/hello.git
```

整理之后，一次 clone 一共发了三次 HTTP 请求（Git 2.43）：

```
GET  /hello.git/info/refs?service=git-upload-pack     Git-Protocol: version=2
POST /hello.git/git-upload-pack                       command=ls-refs
POST /hello.git/git-upload-pack                       command=fetch
```

第一次问服务器“你支持什么”，第二次问“你有哪些分支和标签”，第三次说“我要这些，打包发给我”。

这里有两个细节不太好理解。第一次请求的地址 `info/refs?service=git-upload-pack` 看起来像是在取一个文件，后面却跟着查询参数；请求头里还带着一个 `Git-Protocol: version=2`。**为什么是 version=2？之前的版本又是什么样的？** 这两个细节都是 Git 网络协议一路演进留下的痕迹，我们先把这段历史讲清楚。

### 背景：Git 网络协议的几代

Git 的网络协议前后换过几代，每一代都得照顾已经装在各处的老服务器和老客户端，第一次请求里的这两个细节就是这样留下来的。图 1 按时间把它们排了一遍。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 412" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gs-history-title gs-history-desc">
<title id="gs-history-title">Git 网络协议的几代</title>
<desc id="gs-history-desc">上一行是服务器一连上就开口的三种传输：2005 年的 SSH 和 git://（后来叫 v0）、早期的哑 HTTP、Git 1.6.6 的 smart HTTP。下一行是客户端先表明版本的三步：Git 2.16 的 v1、2.18 的 v2、2.29 起 v2 成为默认。</desc>
<defs>
<marker id="gs-history-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="gs-history-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="412" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="168" rx="8"/>
<text class="t-zone" x="36" y="44">2005–2017 · 服务器一连上就先开口</text>
<rect class="dd-zone" x="16" y="200" width="768" height="168" rx="8"/>
<text class="t-zone" x="36" y="228">2018 起 · 客户端先说自己会哪个版本</text>
<path class="dd-edge" d="M264 115 H282" marker-end="url(#gs-history-arrow)"/>
<path class="dd-edge" d="M516 115 H534" marker-end="url(#gs-history-arrow)"/>
<path class="dd-edge" d="M264 299 H282" marker-end="url(#gs-history-arrow)"/>
<path class="dd-edge-acc" d="M516 299 H534" marker-end="url(#gs-history-arrow-acc)"/>
<rect class="dd-card" x="32" y="60" width="232" height="110" rx="6"/>
<path class="dd-card-head" d="M32.6 92 V66 Q32.6 60.6 38 60.6 H258 Q263.4 60.6 263.4 66 V92 Z"/>
<line class="dd-edge" x1="32" y1="92" x2="264" y2="92"/>
<text class="t-name" x="48" y="81">SSH / git://</text>
<text class="t-val" x="248" y="81" text-anchor="end">2005</text>
<line class="dd-hair" x1="48" y1="118" x2="248" y2="118"/>
<line class="dd-hair" x1="48" y1="144" x2="248" y2="144"/>
<text class="t-mono" x="48" y="109">&lt; 385ce2c HEAD\0能力…</text>
<text class="t-mono" x="48" y="135">&lt; 385ce2c refs/heads/main</text>
<text class="t-note" x="48" y="161">先列全部引用，后来叫 v0</text>
<rect class="dd-card" x="284" y="60" width="232" height="110" rx="6"/>
<path class="dd-card-head" d="M284.6 92 V66 Q284.6 60.6 290 60.6 H510 Q515.4 60.6 515.4 66 V92 Z"/>
<line class="dd-edge" x1="284" y1="92" x2="516" y2="92"/>
<text class="t-name" x="300" y="81">哑 HTTP</text>
<text class="t-val" x="500" y="81" text-anchor="end">早期</text>
<line class="dd-hair" x1="300" y1="118" x2="500" y2="118"/>
<line class="dd-hair" x1="300" y1="144" x2="500" y2="144"/>
<text class="t-mono" x="300" y="109">&gt; GET info/refs</text>
<text class="t-mono" x="300" y="135">&lt; 385ce2c  refs/heads/main</text>
<text class="t-note" x="300" y="161">静态文件，对象要自己逐个下载</text>
<rect class="dd-card" x="536" y="60" width="232" height="110" 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">smart HTTP</text>
<text class="t-val" x="752" y="81" text-anchor="end">1.6.6 · 2009</text>
<line class="dd-hair" x1="552" y1="118" x2="752" y2="118"/>
<line class="dd-hair" x1="552" y1="144" x2="752" y2="144"/>
<text class="t-mono" x="552" y="109">&gt; GET info/refs?service=…</text>
<text class="t-mono" x="552" y="135">&lt; # service=git-upload-pack</text>
<text class="t-note" x="552" y="161">接下来和 v0 一样列全部引用</text>
<rect class="dd-card" x="32" y="244" width="232" height="110" rx="6"/>
<path class="dd-card-head" d="M32.6 276 V250 Q32.6 244.6 38 244.6 H258 Q263.4 244.6 263.4 250 V276 Z"/>
<line class="dd-edge" x1="32" y1="276" x2="264" y2="276"/>
<text class="t-name" x="48" y="265">v1</text>
<text class="t-val" x="248" y="265" text-anchor="end">2.16 · 2018</text>
<line class="dd-hair" x1="48" y1="302" x2="248" y2="302"/>
<line class="dd-hair" x1="48" y1="328" x2="248" y2="328"/>
<text class="t-mono" x="48" y="293">&gt; Git-Protocol: version=1</text>
<text class="t-mono" x="48" y="319">&lt; version 1</text>
<text class="t-note" x="48" y="345">其余同 v0，只为验证升级通道</text>
<rect class="dd-card" x="284" y="244" width="232" height="110" rx="6"/>
<path class="dd-card-head" d="M284.6 276 V250 Q284.6 244.6 290 244.6 H510 Q515.4 244.6 515.4 250 V276 Z"/>
<line class="dd-edge" x1="284" y1="276" x2="516" y2="276"/>
<text class="t-name" x="300" y="265">v2</text>
<text class="t-val" x="500" y="265" text-anchor="end">2.18 · 2018</text>
<line class="dd-hair" x1="300" y1="302" x2="500" y2="302"/>
<line class="dd-hair" x1="300" y1="328" x2="500" y2="328"/>
<text class="t-mono" x="300" y="293">&gt; Git-Protocol: <tspan class="t-acc">version=2</tspan></text>
<text class="t-mono" x="300" y="319">&lt; <tspan class="t-acc">version 2</tspan></text>
<text class="t-note" x="300" y="345">只报能力，引用用 ls-refs 按需取</text>
<rect class="dd-card" x="536" y="244" width="232" height="110" rx="6"/>
<path class="dd-card-head" d="M536.6 276 V250 Q536.6 244.6 542 244.6 H762 Q767.4 244.6 767.4 250 V276 Z"/>
<line class="dd-edge" x1="536" y1="276" x2="768" y2="276"/>
<text class="t-name" x="552" y="265">v2 成为默认</text>
<text class="t-val" x="752" y="265" text-anchor="end">2.29 · 2020</text>
<line class="dd-hair" x1="552" y1="302" x2="752" y2="302"/>
<line class="dd-hair" x1="552" y1="328" x2="752" y2="328"/>
<text class="t-note" x="552" y="293">2.26 第一次设为默认</text>
<text class="t-note" x="552" y="319">2.27 发现问题，暂时退回 v0</text>
<text class="t-note" x="552" y="345">2.29 起一直是默认</text>
<text class="t-say" x="32" y="392">&gt; 是客户端发出的，&lt; 是服务器的回答。上一行的三种传输，服务器都是一连上就先开口。</text>
</svg>
</div>
<figcaption>图 1 · Git 网络协议的几代：前十几年都是服务器先开口；2018 年起，客户端先通过旁路说明想用的版本，v2 才有了落脚的地方</figcaption>
</figure>

**上面一行：服务器先开口。** 2005 年起，Git 就能通过 SSH 和自己的 `git://` 协议（由 `git daemon` 提供，默认端口 9418）传输仓库：连上以后，服务器端的 `git upload-pack` 先把全部引用和能力列一遍，再和客户端协商需要哪些对象。这套对话后来被称为 **v0**。早期的 HTTP 只能“哑”传输，服务器是一个普通的静态文件服务器，替客户端算不出“你缺哪些对象”。Git 1.6.6 加入的 smart HTTP 把 v0 的对话原样搬到了 HTTP 上，为了兼容静态服务器，它保留了 `info/refs` 这个地址，只在后面加上 `?service=git-upload-pack`。这就是第一个细节的来历。

**这样的协议没法升级。** 引用越来越多以后，v0 每次都先列全部引用就成了负担，能力列表挤在第一行的 `\0` 后面也不好扩展。可是服务器一连上就说话，只要它换一种说法，老客户端就看不懂了。

**下面一行：客户端先表明版本。** Git 2.16 让客户端先通过一条**老服务器会忽略的旁路**说明想用的版本，HTTP 用请求头 `Git-Protocol`，SSH 用环境变量 `GIT_PROTOCOL`，`git://` 放在请求行末尾多出来的一个 `\0` 之后。2.16 还定义了一个只多一行 `version 1` 的 v1，用来验证这条通道不会伤到老版本。真正重新设计的 v2 在 2.18 到来，几经反复，从 2.29 起一直是默认。这就是第二个细节 `Git-Protocol: version=2` 的来历。

把这两条旁路放在一起看，我们抓到的第一个请求其实同时照顾了三种服务器（图 2）：

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 532" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gs-negotiate-title gs-negotiate-desc">
<title id="gs-negotiate-title">同一个请求，三种服务器的回答</title>
<desc id="gs-negotiate-desc">客户端发出 GET /hello.git/info/refs?service=git-upload-pack，并带请求头 Git-Protocol: version=2。静态文件服务器忽略查询参数和请求头，返回静态的 info/refs 文件，客户端退回哑协议；不认识 Git-Protocol 的老智能服务器返回 # service=git-upload-pack 和全部引用，即 v0；新服务器以 version 2 开头，只报能力。</desc>
<defs>
<marker id="gs-negotiate-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="gs-negotiate-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="532" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="296" height="500" rx="8"/>
<text class="t-zone" x="36" y="44">客户端 · Git 2.43</text>
<rect class="dd-zone" x="328" y="16" width="456" height="500" rx="8"/>
<text class="t-zone" x="348" y="44">三种服务器</text>
<path class="dd-edge" d="M296 264 H312 Q320 264 320 256 V136 Q320 128 328 128 H342" marker-end="url(#gs-negotiate-arrow)"/>
<path class="dd-edge" d="M296 280 H342" marker-end="url(#gs-negotiate-arrow)"/>
<path class="dd-edge-acc" d="M296 296 H312 Q320 296 320 304 V424 Q320 432 328 432 H342" marker-end="url(#gs-negotiate-arrow-acc)"/>
<rect class="dd-card" x="32" y="212" width="264" height="136" rx="6"/>
<path class="dd-card-head" d="M32.6 244 V218 Q32.6 212.6 38 212.6 H290 Q295.4 212.6 295.4 218 V244 Z"/>
<line class="dd-edge" x1="32" y1="244" x2="296" y2="244"/>
<text class="t-name" x="48" y="233">第一次请求</text>
<text class="t-val" x="280" y="233" text-anchor="end">GET</text>
<line class="dd-hair" x1="48" y1="270" x2="280" y2="270"/>
<line class="dd-hair" x1="48" y1="296" x2="280" y2="296"/>
<line class="dd-hair" x1="48" y1="322" x2="280" y2="322"/>
<text class="t-mono" x="48" y="261">GET /hello.git/info/refs</text>
<text class="t-mono" x="48" y="287">?service=git-upload-pack</text>
<text class="t-mono" x="48" y="313">Git-Protocol: <tspan class="t-acc">version=2</tspan></text>
<text class="t-note" x="48" y="339">两处都是老服务器会忽略的</text>
<rect class="dd-card" x="344" y="60" width="424" height="136" rx="6"/>
<path class="dd-card-head" d="M344.6 92 V66 Q344.6 60.6 350 60.6 H762 Q767.4 60.6 767.4 66 V92 Z"/>
<line class="dd-edge" x1="344" y1="92" x2="768" y2="92"/>
<text class="t-name" x="360" y="81">静态文件服务器</text>
<text class="t-val" x="752" y="81" text-anchor="end">哑 HTTP</text>
<line class="dd-hair" x1="360" y1="118" x2="752" y2="118"/>
<line class="dd-hair" x1="360" y1="144" x2="752" y2="144"/>
<line class="dd-hair" x1="360" y1="170" x2="752" y2="170"/>
<text class="t-mono" x="360" y="109">385ce2c…  refs/heads/main</text>
<text class="t-mono" x="360" y="135">385ce2c…  refs/tags/v1.0</text>
<text class="t-note" x="360" y="161">忽略查询参数，返回 update-server-info 写好的文件</text>
<text class="t-note" x="360" y="187">没有能力列表，客户端只好退回哑协议</text>
<rect class="dd-card" x="344" y="212" width="424" height="136" rx="6"/>
<path class="dd-card-head" d="M344.6 244 V218 Q344.6 212.6 350 212.6 H762 Q767.4 212.6 767.4 218 V244 Z"/>
<line class="dd-edge" x1="344" y1="244" x2="768" y2="244"/>
<text class="t-name" x="360" y="233">老的智能服务器</text>
<text class="t-val" x="752" y="233" text-anchor="end">v0</text>
<line class="dd-hair" x1="360" y1="270" x2="752" y2="270"/>
<line class="dd-hair" x1="360" y1="296" x2="752" y2="296"/>
<line class="dd-hair" x1="360" y1="322" x2="752" y2="322"/>
<text class="t-mono" x="360" y="261">001e# service=git-upload-pack</text>
<text class="t-mono" x="360" y="287">0000010b385ce2c… HEAD\0multi_ack …</text>
<text class="t-mono" x="360" y="313">003d385ce2c… refs/heads/main</text>
<text class="t-note" x="360" y="339">不认识 Git-Protocol，照旧列出全部引用</text>
<rect class="dd-card" x="344" y="364" width="424" height="136" rx="6"/>
<path class="dd-card-head" d="M344.6 396 V370 Q344.6 364.6 350 364.6 H762 Q767.4 364.6 767.4 370 V396 Z"/>
<line class="dd-edge" x1="344" y1="396" x2="768" y2="396"/>
<text class="t-name" x="360" y="385">新服务器</text>
<text class="t-val" x="752" y="385" text-anchor="end"><tspan class="t-acc">v2</tspan></text>
<line class="dd-hair" x1="360" y1="422" x2="752" y2="422"/>
<line class="dd-hair" x1="360" y1="448" x2="752" y2="448"/>
<line class="dd-hair" x1="360" y1="474" x2="752" y2="474"/>
<text class="t-mono" x="360" y="413">000e<tspan class="t-acc">version 2</tspan></text>
<text class="t-mono" x="360" y="439">0013ls-refs=unborn</text>
<text class="t-mono" x="360" y="465">0020fetch=shallow wait-for-done</text>
<text class="t-note" x="360" y="491">不列引用，只报能力</text>
<text class="t-say" x="32" y="380">只有新服务器会读这个请求头。</text>
</svg>
</div>
<figcaption>图 2 · 同一个请求发给三种服务器：查询参数和请求头都是老服务器会忽略的旁路，所以谁都能给出自己听得懂的回答</figcaption>
</figure>

所以今天的 Git 默认说 v2，遇到只会 v0 的服务器就跟着退回 v0。v0 更直接，也正好能看清 v2 要解决的问题，所以下面先用 `-c protocol.version=0` 让客户端退回 v0，看一看老协议是怎么 clone 的，再回到 v2。

### 报文的格式：pkt-line

先看 v0 下第一次请求返回的原始字节：

```
001e# service=git-upload-pack
0000010b385ce2c3a5cb5625c9e0a291085a843e164d5c40 HEAD\0multi_ack thin-pack side-band side-band-64k ofs-delta shallow ... symref=HEAD:refs/heads/main object-format=sha1 agent=git/2.43.0
003d385ce2c3a5cb5625c9e0a291085a843e164d5c40 refs/heads/main
003c385ce2c3a5cb5625c9e0a291085a843e164d5c40 refs/tags/v1.0
0000
```

每一行开头的 4 个十六进制字符是这一行的长度（包括这 4 个字符本身）：`001e` 是 30，正好等于 4 加上 `# service=git-upload-pack\n` 的 26 个字节。`0000` 是一个特殊的“flush 包”，表示一段内容结束。第二行开头的 `0000` 就是紧跟在 service 行后面的 flush 包，后面的 `010b` 才是下一行的长度。Git 的所有网络对话都用这种叫 **pkt-line** 的格式分帧。

这段内容叫**引用广告**（ref advertisement）：服务器把自己所有的引用连同它们指向的提交哈希一起列出来，能力列表就藏在第一行的 `\0` 后面。

### 第一次尝试：协议 v0 的“先列出全部引用”

用 v0 clone 只需要两次请求：先 `GET info/refs` 拿到上面这份引用广告，再 `POST git-upload-pack` 告诉服务器想要哪些提交：

```
fetch-pack> want 385ce2c3a5cb5625c9e0a291085a843e164d5c40 multi_ack_detailed no-done side-band-64k thin-pack ofs-delta ...
fetch-pack> want 385ce2c3a5cb5625c9e0a291085a843e164d5c40
fetch-pack> 0000
fetch-pack> done
fetch-pack< NAK
            PACK ...
```

`want` 表示“我要这个提交以及它能走到的一切”，`done` 表示“我这边什么都没有，直接发吧”，服务器回一个 `NAK`（没有共同的提交），接着就是一个 **packfile**。

这套流程很简单，问题也很明显：**不管客户端想要什么，服务器都要先把所有引用列一遍。** 我们的演示仓库只有两个引用，无所谓；可一个托管在 Gerrit 上的大仓库，引用数可以到几十万（后面会看到 Android 的 86.6 万个）。哪怕只是 `git fetch` 一个分支，每次也要先下载这份几十 MB 的列表。

### 解决：协议 v2 把“列引用”变成一条命令

回到默认的 v2。客户端在第一次请求里带上 `Git-Protocol: version=2`，支持 v2 的服务器就以 `version 2` 开头回答，而且只返回能力，不再列引用：

```
version 2
agent=git/2.43.0
ls-refs=unborn
fetch=shallow wait-for-done
server-option
object-format=sha1
object-info
0000
```

客户端想看引用时，发一条 `ls-refs` 命令，并用 `ref-prefix` 说明只关心哪些前缀：

```
command=ls-refs
0001
peel
symrefs
ref-prefix HEAD
ref-prefix refs/heads/
ref-prefix refs/tags/
0000
```

服务器只回答这三个前缀下的引用（`0001` 是分隔包，把命令名和参数隔开）：

```
385ce2c3a5cb5625c9e0a291085a843e164d5c40 HEAD symref-target:refs/heads/main
385ce2c3a5cb5625c9e0a291085a843e164d5c40 refs/heads/main
385ce2c3a5cb5625c9e0a291085a843e164d5c40 refs/tags/v1.0
0000
```

最后一条 `fetch` 命令和 v0 的 `want ... done` 意思一样，返回的数据放在 `packfile` 段里。

<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="gs-clone-title gs-clone-desc">
<title id="gs-clone-title">协议 v2 下一次 git clone 的三次请求</title>
<desc id="gs-clone-desc">客户端依次发出三次请求：GET info/refs 拿到能力广告；POST ls-refs 拿到 HEAD、main、v1.0 三个引用，都指向 385ce2c；POST fetch 用 want 385ce2c 要回一个 552 字节、8 个对象的 packfile。</desc>
<defs>
<marker id="gs-clone-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="gs-clone-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="480" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="376" height="448" rx="8"/>
<text class="t-zone" x="36" y="44">客户端 · git clone</text>
<rect class="dd-zone" x="408" y="16" width="376" height="448" rx="8"/>
<text class="t-zone" x="428" y="44">服务器 · hello.git</text>
<path class="dd-edge" d="M376 119 H422" marker-end="url(#gs-clone-arrow)"/>
<path class="dd-edge" d="M376 255 H422" marker-end="url(#gs-clone-arrow)"/>
<path class="dd-edge" d="M376 391 H422" marker-end="url(#gs-clone-arrow)"/>
<path class="dd-edge-acc" d="M456 310 V315 Q456 323 448 323 H352 Q344 323 344 331 V334" marker-end="url(#gs-clone-arrow-acc)"/>
<rect class="dd-card" x="32" y="64" width="344" height="110" rx="6"/>
<path class="dd-card-head" d="M32.6 96 V70 Q32.6 64.6 38 64.6 H370 Q375.4 64.6 375.4 70 V96 Z"/>
<line class="dd-edge" x1="32" y1="96" x2="376" y2="96"/>
<text class="t-name" x="48" y="85">请求 1</text>
<text class="t-val" x="360" y="85" text-anchor="end">GET info/refs</text>
<line class="dd-hair" x1="48" y1="122" x2="360" y2="122"/>
<line class="dd-hair" x1="48" y1="148" x2="360" y2="148"/>
<text class="t-mono" x="48" y="113">Git-Protocol: version=2</text>
<text class="t-mono" x="48" y="139">?service=git-upload-pack</text>
<text class="t-note" x="48" y="165">只问一句：你支持哪些命令</text>
<rect class="dd-card" x="424" y="64" width="344" height="110" rx="6"/>
<path class="dd-card-head" d="M424.6 96 V70 Q424.6 64.6 430 64.6 H762 Q767.4 64.6 767.4 70 V96 Z"/>
<line class="dd-edge" x1="424" y1="96" x2="768" y2="96"/>
<text class="t-name" x="440" y="85">能力广告</text>
<text class="t-note" x="752" y="85" text-anchor="end">不再列出引用</text>
<line class="dd-hair" x1="440" y1="122" x2="752" y2="122"/>
<line class="dd-hair" x1="440" y1="148" x2="752" y2="148"/>
<text class="t-mono" x="440" y="113">version 2</text>
<text class="t-mono" x="440" y="139">ls-refs=unborn</text>
<text class="t-mono" x="440" y="165">fetch=shallow wait-for-done</text>
<rect class="dd-card" x="32" y="200" width="344" height="110" rx="6"/>
<path class="dd-card-head" d="M32.6 232 V206 Q32.6 200.6 38 200.6 H370 Q375.4 200.6 375.4 206 V232 Z"/>
<line class="dd-edge" x1="32" y1="232" x2="376" y2="232"/>
<text class="t-name" x="48" y="221">请求 2</text>
<text class="t-val" x="360" y="221" text-anchor="end">POST git-upload-pack</text>
<line class="dd-hair" x1="48" y1="258" x2="360" y2="258"/>
<line class="dd-hair" x1="48" y1="284" x2="360" y2="284"/>
<text class="t-mono" x="48" y="249">command=ls-refs</text>
<text class="t-mono" x="48" y="275">ref-prefix HEAD</text>
<text class="t-mono" x="48" y="301">ref-prefix refs/heads/ refs/tags/</text>
<rect class="dd-card" x="424" y="200" width="344" height="110" rx="6"/>
<path class="dd-card-head" d="M424.6 232 V206 Q424.6 200.6 430 200.6 H762 Q767.4 200.6 767.4 206 V232 Z"/>
<line class="dd-edge" x1="424" y1="232" x2="768" y2="232"/>
<text class="t-name" x="440" y="221">引用列表</text>
<text class="t-note" x="752" y="221" text-anchor="end">只含请求的前缀</text>
<line class="dd-hair" x1="440" y1="258" x2="752" y2="258"/>
<line class="dd-hair" x1="440" y1="284" x2="752" y2="284"/>
<text class="t-mono" x="440" y="249"><tspan class="t-acc">385ce2c</tspan> HEAD → refs/heads/main</text>
<text class="t-mono" x="440" y="275"><tspan class="t-acc">385ce2c</tspan> refs/heads/main</text>
<text class="t-mono" x="440" y="301"><tspan class="t-acc">385ce2c</tspan> refs/tags/v1.0</text>
<rect class="dd-card" x="32" y="336" width="344" height="110" rx="6"/>
<path class="dd-card-head" d="M32.6 368 V342 Q32.6 336.6 38 336.6 H370 Q375.4 336.6 375.4 342 V368 Z"/>
<line class="dd-edge" x1="32" y1="368" x2="376" y2="368"/>
<text class="t-name" x="48" y="357">请求 3</text>
<text class="t-val" x="360" y="357" text-anchor="end">POST git-upload-pack</text>
<line class="dd-hair" x1="48" y1="394" x2="360" y2="394"/>
<line class="dd-hair" x1="48" y1="420" x2="360" y2="420"/>
<text class="t-mono" x="48" y="385">command=fetch</text>
<text class="t-mono" x="48" y="411">want <tspan class="t-acc">385ce2c</tspan>…</text>
<text class="t-mono" x="48" y="437">done</text>
<rect class="dd-card" x="424" y="336" width="344" height="110" rx="6"/>
<path class="dd-card-head" d="M424.6 368 V342 Q424.6 336.6 430 336.6 H762 Q767.4 336.6 767.4 342 V368 Z"/>
<line class="dd-edge" x1="424" y1="368" x2="768" y2="368"/>
<text class="t-name" x="440" y="357">packfile</text>
<text class="t-val" x="752" y="357" text-anchor="end">552 字节</text>
<line class="dd-hair" x1="440" y1="394" x2="752" y2="394"/>
<line class="dd-hair" x1="440" y1="420" x2="752" y2="420"/>
<text class="t-mono" x="440" y="385">PACK · 8 个对象</text>
<text class="t-mono" x="440" y="411">2 commit · 3 tree · 3 blob</text>
<text class="t-note" x="440" y="437">从 <tspan class="t-acc">385ce2c</tspan> 出发能走到的全部对象</text>
</svg>
</div>
<figcaption>图 3 · 协议 v2 的 clone：先问能力，再按前缀列引用，最后只要一个提交，换回一整个 packfile</figcaption>
</figure>

### 收到的 packfile 里有什么

clone 完成后，用 `git verify-pack` 看一下收到的 pack：

```
385ce2c3 commit 204 144 12      second commit
02380f44 commit 155 115 156     first commit
45d9e6de tree    67  77 271     第二次提交的根目录
5d904224 tree    35  46 348     src/
d8904ec3 tree    67  77 394     第一次提交的根目录
3d768ebc blob    13  22 471     README.md（第二版）
f328e4d9 blob    13  22 493     README.md（第一版）
8954bb97 blob     8  17 515     src/main.rs
```

8 个对象，552 字节：两个提交，三个目录树，三个文件内容。也就是说，**clone 下载的不是“文件”，而是从 `want` 的那个提交出发、沿着父提交和目录树能走到的所有对象**，压缩后打成一个包。

一个值得注意的细节：服务器上的 `hello.git` 存的是 8 个松散对象（`git count-objects` 显示 `count: 8`，没有任何 pack）。客户端收到的这个 pack，是服务器**为这一次请求现场算出来的**。

### 增量 fetch：have 和 ACK

clone 是“我什么都没有”。如果本地已经有一部分历史呢？我们让另一个人往服务器推一个新提交 `063a07b`，再在本地 `git fetch`：

```
fetch> command=fetch
fetch> want 063a07b721d0530f8c88c4187c61f64034638de4
fetch> have 612f4c156e07fe1dba7c31b8b3d8bd110562e197
fetch> have 385ce2c3a5cb5625c9e0a291085a843e164d5c40
fetch> 0000
fetch< acknowledgments
fetch< ACK 612f4c156e07fe1dba7c31b8b3d8bd110562e197
fetch< ACK 385ce2c3a5cb5625c9e0a291085a843e164d5c40
fetch< ready
fetch< 0001
fetch< packfile
```

客户端用 `have` 报出自己已有的提交，服务器用 `ACK` 确认“这个我也有”，双方找到共同的祖先后，服务器回 `ready`，只打包 `want` 减去 `have` 的那部分。这个过程叫**协商**（negotiation）。

到这里，客户端这一侧就清楚了：先列引用，再协商，最后收一个 pack。**那服务器那一侧，是谁在回答这些请求？**

---

## 二、最小的 Git 服务器

### 服务器只需要两个程序

上面抓包用的服务器，其实只有 30 行 Python：它把 HTTP 请求的路径、方法、请求头整理成环境变量，然后执行 `git http-backend`，把输出原样返回。`git http-backend` 是 Git 自带的一个 CGI 程序，它做的事情也很少：

- `GET info/refs?service=git-upload-pack` → 执行 `git upload-pack --stateless-rpc --advertise-refs`，只输出引用广告（或 v2 的能力广告）。
- `POST git-upload-pack` → 执行 `git upload-pack --stateless-rpc`，把请求体喂给它的标准输入，标准输出就是响应。

`--stateless-rpc` 的意思是“每次请求都是独立的”：HTTP 没有长连接上的会话状态，所以协商的每一轮，客户端都要把之前的 `have` 重新带上。真正干活的是 `git upload-pack`：它读引用、跟客户端协商，然后调用 `git pack-objects` 计算需要发送的对象、做增量压缩、生成 pack。前面说的“现场算出来的 pack”，就是 `pack-objects` 算的；开头 clone 输出里以 `remote:` 开头的 Enumerating、Counting、Compressing 三行，正是服务器上这个 `pack-objects` 通过 side-band 发回来的进度。

push 走的是另一个程序 `git receive-pack`。同样打开跟踪，在本地提交一个 `add lib` 再推上去：

```
git> 385ce2c3a5cb5625c9e0a291085a843e164d5c40 612f4c156e07fe1dba7c31b8b3d8bd110562e197 refs/heads/main\0 report-status-v2 side-band-64k ...
git> 0000
git> PACK ...
sideband< unpack ok
sideband< ok refs/heads/main
```

客户端发的是一行命令“`旧值 新值 引用名`”加上一个 pack。服务器端的 `receive-pack` 依次做几件事：

1. 把 pack 里的对象收进一个临时的**隔离目录**（`objects/incoming-XXXX`），这时它们还不属于仓库。
2. 运行 `pre-receive` 钩子，钩子可以拒绝这次推送；拒绝了，隔离目录直接删掉。
3. 把对象并入仓库，然后**锁住** `refs/heads/main`（创建 `refs/heads/main.lock`），检查它当前的值是不是客户端说的旧值 `385ce2c`。是，就改成新值 `612f4c1`；不是，说明有人抢先推了，拒绝。
4. 运行 `post-receive` 钩子，回复 `unpack ok` 和 `ok refs/heads/main`。

### 分析：push 的提交点是引用更新

这里有一个后面会反复用到的观察：**一次 push 由两部分组成，一个 packfile 和一个引用事务**（reference transaction）。对象写进仓库并不会被任何人看见，因为没有引用指向它们；只有引用从旧值改成新值的那一刻，新的提交才算“推上去了”。引用更新就是 push 的**提交点**，而“检查旧值再写新值”本质上就是一次 compare-and-swap。

### 新的问题

这个最小服务器能正常 clone、fetch、push，但只要稍微想一下真正的托管场景，问题就出来了：

1. **谁能读写？** `http-backend` 没有用户的概念，也没法做到“这个分支只有组长能推”。
2. **仓库放在哪？** 所有仓库都在这台机器的一块磁盘上。盘满了、机器坏了怎么办？
3. **多台机器怎么保持一致？** 一旦有了副本，push 的那次 compare-and-swap 要在哪台机器上做、怎么让所有副本都同意？
4. **谁能看见哪些引用？** 如果引用本身也承载了数据（比如代码评审），引用会变得非常多，而且不是所有人都该看见所有引用。

GitLab、Gerrit 和 walgit 各自从不同的问题出发。**GitLab 最先撞上的是第 2 个问题。**

---

## 三、GitLab：存储与计算的拆分

### 现象：仓库放在 NFS 上

GitLab 最早的部署方式很直接：Rails 应用服务器挂载同一个 NFS 共享目录，所有仓库都在上面，应用要读仓库就直接在 NFS 上执行 `git`。机器不够就加应用服务器，每台都挂上 NFS。

这个方案在规模上去之后出了问题。Gitaly 的设计文档里写得很直白[^gitaly-design]：

> 一台 NFS 服务器出问题，会导致所有应用服务器都失效，严重到连 SSH 都登不上去。

### 原因：Git 的读写模式不适合网络文件系统

为什么会这样？Git 默认假设自己跑在本地磁盘上。packfile 为了“小”而组织，不是为了顺序读而组织，一次 `pack-objects` 或 `log` 往往是在几个 GB 的文件里来回随机访问；Git 还依赖文件锁、`rename` 的原子性这类本地文件系统语义。把这些操作搬到网络文件系统上，每一次随机读都变成一次网络往返，锁的语义也不再可靠。设计文档把“避免让 Git 在远程存储上运行”列为 Gitaly 的**第一个设计动机**[^gitaly-design]。

### 解决：Gitaly，让 Git 进程待在磁盘旁边

既然 Git 必须在本地磁盘上跑，那就**把 Git 进程搬到存储机器上**，其他组件不再碰文件，改成通过 gRPC 请求存储机器替它们执行 Git。这个服务就是 Gitaly。

拆完之后，一次 HTTPS 的 `git clone` 要经过这几个组件：

1. **Workhorse**（Go 写的反向代理）接住 `info/refs` 和 `git-upload-pack` 请求（路由在 `upstream/routes.go`）。它不急着处理，而是先调用 `PreAuthorize`：把**同一个 URL**、同样的请求头、去掉请求体，转发给 Rails（`api/api.go:428-462`）。
2. **Rails** 里的 `Repositories::GitHttpController` 执行 `Gitlab::GitAccess#check`，检查用户、项目、协议、能否下载。通过之后，`Gitlab::Workhorse.git_http_ok` 返回一段 JSON（`lib/gitlab/workhorse.rb:43-67`），里面有用户身份 `GL_ID`、仓库在哪个存储上的哪个路径 `Repository{storage_name, relative_path}`，以及那台 Gitaly 的地址和令牌 `GitalyServer{address, token}`。响应的 `Content-Type` 必须是 `application/vnd.gitlab-workhorse+json`，防止把一个普通网页误当成授权结果。
3. Workhorse 拿着这些信息直接调用 Gitaly 的 `InfoRefsUploadPack` 和 `PostUploadPackWithSidechannel`。
4. **Gitaly** 在本地磁盘上执行 `git upload-pack --stateless-rpc`（`smarthttp/upload_pack.go`），和我们的最小服务器一模一样。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 440" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gs-gitlab-title gs-gitlab-desc">
<title id="gs-gitlab-title">GitLab 处理一次 HTTPS clone 的路径</title>
<desc id="gs-gitlab-desc">git 客户端请求 Workhorse；Workhorse 先把同一 URL 转给 Rails 预授权，Rails 返回 GL_ID、GL_REPOSITORY、仓库路径 @hashed/6b/86/… 和 Gitaly 地址；Workhorse 带着仓库路径调用 Gitaly，Gitaly 在本地执行 git upload-pack，pack-objects 的结果按参数和标准输入的哈希缓存。</desc>
<defs>
<marker id="gs-gitlab-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="gs-gitlab-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="440" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="184" height="408" rx="8"/>
<text class="t-zone" x="36" y="44">开发者电脑</text>
<rect class="dd-zone" x="216" y="16" width="568" height="408" rx="8"/>
<text class="t-zone" x="236" y="44">GitLab</text>
<path class="dd-edge" d="M184 127 H230" marker-end="url(#gs-gitlab-arrow)"/>
<path class="dd-edge-acc" d="M400 127 H422" marker-end="url(#gs-gitlab-arrow-acc)"/>
<path class="dd-edge" d="M592 127 H614" marker-end="url(#gs-gitlab-arrow)"/>
<path class="dd-edge" d="M272 182 V230" marker-end="url(#gs-gitlab-arrow)"/>
<path class="dd-edge-acc" d="M352 232 V184" marker-end="url(#gs-gitlab-arrow-acc)"/>
<path class="dd-edge" d="M692 182 V230" marker-end="url(#gs-gitlab-arrow)"/>
<text class="t-note" x="280" y="212">预授权</text>
<text class="t-note" x="360" y="212">JSON</text>
<text class="t-note" x="684" y="212" text-anchor="end">pack-objects 钩子</text>
<rect class="dd-card" x="32" y="72" width="152" height="110" rx="6"/>
<path class="dd-card-head" d="M32.6 104 V78 Q32.6 72.6 38 72.6 H178 Q183.4 72.6 183.4 78 V104 Z"/>
<line class="dd-edge" x1="32" y1="104" x2="184" y2="104"/>
<text class="t-name" x="48" y="93">git clone</text>
<line class="dd-hair" x1="48" y1="130" x2="168" y2="130"/>
<line class="dd-hair" x1="48" y1="156" x2="168" y2="156"/>
<text class="t-mono" x="48" y="121">GET info/refs</text>
<text class="t-mono" x="48" y="147">POST upload-pack</text>
<text class="t-val" x="48" y="173">Basic / token</text>
<rect class="dd-card" x="232" y="72" width="168" height="110" rx="6"/>
<path class="dd-card-head" d="M232.6 104 V78 Q232.6 72.6 238 72.6 H394 Q399.4 72.6 399.4 78 V104 Z"/>
<line class="dd-edge" x1="232" y1="104" x2="400" y2="104"/>
<text class="t-name" x="248" y="93">Workhorse</text>
<text class="t-note" x="384" y="93" text-anchor="end">Go</text>
<line class="dd-hair" x1="248" y1="130" x2="384" y2="130"/>
<line class="dd-hair" x1="248" y1="156" x2="384" y2="156"/>
<text class="t-mono" x="248" y="121">PreAuthorize</text>
<text class="t-mono" x="248" y="147">gRPC → Gitaly</text>
<text class="t-note" x="248" y="173">搬运 pack 字节流</text>
<rect class="dd-card" x="424" y="72" width="168" height="110" rx="6"/>
<path class="dd-card-head" d="M424.6 104 V78 Q424.6 72.6 430 72.6 H586 Q591.4 72.6 591.4 78 V104 Z"/>
<line class="dd-edge" x1="424" y1="104" x2="592" y2="104"/>
<text class="t-name" x="440" y="93">Gitaly</text>
<text class="t-note" x="576" y="93" text-anchor="end">gitaly-1</text>
<line class="dd-hair" x1="440" y1="130" x2="576" y2="130"/>
<line class="dd-hair" x1="440" y1="156" x2="576" y2="156"/>
<text class="t-mono" x="440" y="121">PostUploadPack</text>
<text class="t-mono" x="440" y="147">WithSidechannel</text>
<text class="t-mono t-acc" x="440" y="173">@hashed/6b/86/…</text>
<rect class="dd-card" x="616" y="72" width="152" height="110" rx="6"/>
<path class="dd-card-head" d="M616.6 104 V78 Q616.6 72.6 622 72.6 H762 Q767.4 72.6 767.4 78 V104 Z"/>
<line class="dd-edge" x1="616" y1="104" x2="768" y2="104"/>
<text class="t-name" x="632" y="93">git</text>
<text class="t-note" x="752" y="93" text-anchor="end">本地磁盘</text>
<line class="dd-hair" x1="632" y1="130" x2="752" y2="130"/>
<line class="dd-hair" x1="632" y1="156" x2="752" y2="156"/>
<text class="t-mono" x="632" y="121">upload-pack</text>
<text class="t-mono" x="632" y="147">--stateless-rpc</text>
<text class="t-mono" x="632" y="173">pack-objects</text>
<rect class="dd-card" x="232" y="232" width="352" height="136" rx="6"/>
<path class="dd-card-head" d="M232.6 264 V238 Q232.6 232.6 238 232.6 H578 Q583.4 232.6 583.4 238 V264 Z"/>
<line class="dd-edge" x1="232" y1="264" x2="584" y2="264"/>
<text class="t-name" x="248" y="253">Rails 的授权结果</text>
<text class="t-note" x="568" y="253" text-anchor="end">GitHttpController</text>
<line class="dd-hair" x1="248" y1="290" x2="568" y2="290"/>
<line class="dd-hair" x1="248" y1="316" x2="568" y2="316"/>
<line class="dd-hair" x1="248" y1="342" x2="568" y2="342"/>
<text class="t-mono" x="248" y="281">GL_ID</text>
<text class="t-val" x="360" y="281">user-123</text>
<text class="t-mono" x="248" y="307">GL_REPOSITORY</text>
<text class="t-val" x="360" y="307">project-42</text>
<text class="t-mono" x="248" y="333">Repository</text>
<text class="t-val" x="360" y="333"><tspan class="t-acc">@hashed/6b/86/6b86b273….git</tspan></text>
<text class="t-mono" x="248" y="359">GitalyServer</text>
<text class="t-val" x="360" y="359">tcp://gitaly-1:8075</text>
<rect class="dd-derived" x="600" y="232" width="168" height="136" rx="6"/>
<text class="t-name" x="616" y="258">pack-objects 缓存</text>
<text class="t-mono" x="616" y="284">key = sha256(</text>
<text class="t-mono" x="616" y="304">  仓库 + 参数</text>
<text class="t-mono" x="616" y="324">  + stdin)</text>
<text class="t-note" x="616" y="352">命中就直接流回</text>
<text class="t-say" x="232" y="396">Rails 只出现在授权这一步，pack 的字节不经过它。</text>
<text class="t-say" x="232" y="414">虚线：缓存的 pack，并发 clone 同一个提交时只算一次。</text>
</svg>
</div>
<figcaption>图 4 · Rails 只回答“能不能、在哪里”，真正的 Git 进程跑在存放仓库的那台 Gitaly 上</figcaption>
</figure>

几个细节值得多看一眼。

**Rails 不在数据路径上。** Rails（Puma）处理不了慢请求和大流量，所以 Workhorse 只把“鉴权”这一步交给它，pack 的字节流由 Workhorse 和 Gitaly 之间直接搬运。即使在 gRPC 内部，pack 也不放在 protobuf 消息里：Gitaly 在同一条 TCP 连接上反向开一个 yamux 流，叫 **sidechannel**，原始字节直接走这条流（`doc/sidechannel.md`），原因是用 protobuf 封装字节流时吞吐上不去。

**仓库路径与项目名无关。** Rails 返回的 `relative_path` 不是 `acme/app.git`，而是项目 ID 的 SHA-256（`app/models/storage/hashed.rb`），项目 1 的仓库在：

```
@hashed/6b/86/6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b.git
```

这样项目改名、转移都不用移动目录，前两级目录还把仓库均匀打散到 256×256 个子目录里。Gitaly 也不知道“项目”是什么，它只认 `(storage_name, relative_path)`。

**CI 并发 clone 的问题。** 一次提交触发几十个 CI 任务，它们几乎同时 clone 同一个提交。按最小服务器的做法，每个请求都要跑一遍 `pack-objects`，CPU 很快被打满。Gitaly 的办法是给 `git upload-pack` 设置 `uploadpack.packObjectsHook`，让它不直接执行 `pack-objects`，而是执行 `gitaly-hooks git pack-objects`；这个小程序再通过 gRPC 回调 Gitaly 自己的 `PackObjectsHookWithSidechannel`。Gitaly 在这里算一个缓存键（`hook/pack_objects.go:117-145`）：

```go
cacheKeyPrefix, err := protojson.Marshal(&gitalypb.PackObjectsHookWithSidechannelRequest{
    Repository: repository, Args: req.GetArgs(), GitProtocol: req.GetGitProtocol(),
})
cacheHash.Write(cacheKeyPrefix)
stdin, err := bufferStdin(stdinReader, cacheHash)   // 边写临时文件边算哈希
cacheKey := hex.EncodeToString(cacheHash.Sum(nil))
```

`pack-objects` 的标准输入就是协商的结果（要哪些提交、排除哪些），所以同一时刻 100 个任务 clone 同一个提交，标准输入完全相同，缓存键也相同，pack 只生成一次，后面的请求边生成边读（`doc/design_pack_objects_cache.md` 说这个缓存就是为 GitLab.com 上“大规模并行 CI fetch 导致 Gitaly CPU 过高”而做的）。

### push 时的权限检查

最小服务器的第 1 个问题，“谁能推哪个分支”，在 GitLab 里分成了两步。HTTP 阶段 Rails 用 `GitAccess#check(cmd, ANY)` 只判断“能不能推任何东西”，因为这时还不知道客户端要改哪些引用。具体到每个引用的检查（保护分支、文件大小、push 规则）推迟到了 `pre-receive` 钩子里。

Gitaly 执行 `git receive-pack` 时把 `core.hooksPath` 指向 `gitaly-hooks`，钩子触发后再回调 Gitaly，Gitaly 带着真实的变更列表去调 Rails 的 `/api/v4/internal/allowed`（`hook/prereceive.go:102-148`）。有意思的是，Rails 做这些检查时需要读**刚推上来、还在隔离目录里**的对象，比如检查新提交里有没有超大文件。所以 Gitaly 把隔离目录的路径（`GIT_OBJECT_DIRECTORY`）一起发给 Rails，Rails 之后再调 Gitaly 的读接口时把这个路径带回去，Gitaly 就能看见隔离区里的对象。一次 push 在组件之间绕了一圈：Gitaly → git → gitaly-hooks → Gitaly → Rails → Gitaly。

### 新的问题：一台 Gitaly 坏了怎么办

把仓库从 NFS 上拿下来之后，每个仓库只在一台 Gitaly 的本地磁盘上，这台机器就成了单点。于是 GitLab 又加了一层 **Praefect**：它是 Gitaly 前面的 gRPC 代理，每个仓库在多台 Gitaly 上各存一份，副本信息记在 Praefect 自己的 PostgreSQL 里（`repositories` 和 `storage_repositories` 表，每个副本一个 `generation` 号）。

多副本带来了第 3 个问题：push 时怎么保证所有副本都改成了同样的引用？还记得第二节的观察吗：push 的提交点是引用事务。Git 2.28 起提供了 `reference-transaction` 钩子，在引用事务的 `prepared`（已经锁住所有要改的引用）和 `committed` 阶段都会调用它。Praefect 把同一个 push 同时转发给主副本和所有从副本，每台 Gitaly 各自执行 `receive-pack`，在钩子里把“要改哪些引用、从什么改成什么”的 SHA-1 作为一票投给 Praefect（`referencetransaction.go:27-97`）：

```go
case ReferenceTransactionPrepared:  phase = voting.Prepared    // 已拿到引用锁，还能中止
case ReferenceTransactionCommitted: phase = voting.Committed   // 已经写入
...
hash := sha1.Sum(changes)
m.voteOnTransaction(ctx, hash, phase, payload)
```

Praefect 用加权多数决定是否放行（`praefect/coordinator.go:372-404`）：主副本的票数是从副本数加一，阈值是“从副本数 + 1 + 一半从副本”。也就是说，没有主副本同意永远凑不够票，同时至少一半从副本要和主副本投出相同的哈希。投票不一致或者超时的副本，`generation` 不会增加，之后由 `replication_queue` 里的复制任务修复。

这和 GitHub 的 Spokes 是同一个思路：先把 pack 分发到所有副本，再对体积很小的引用事务做一次多节点的提交协议。它能保证一致，但代价也很明显：每次 push 都要等最慢的那个副本；每个仓库不管多冷都要占几份副本；副本的位置记在数据库里，每台机器上的每个仓库都是需要照看的“宠物”。第五节会回到这个问题。

GitLab 自己也在探索别的路。在当前的 Gitaly 源码里，有一套按分区组织的预写日志（`storagemgr/partition`），不过在这个版本里还没有接入服务路径；另有一个实验性的 MVCC 后端（`internal/git/mvcc`、`ee/mvcc/objectstorage`），把仓库的真相放到对象存储上。这两者都还不是 GitLab 的默认架构，这里先记下，第五节再对照。

> 这一节只走了 GitLab 的主干路径。HTTP 和 SSH 两个入口、pack 走的 sidechannel、命令工厂与 cgroup、push 时的隔离区往返、Praefect 的投票与副本修复，以及 WAL 和 MVCC 后端的真实接入状态，单独写在[《GitLab 的 Git 服务架构》](/blog/gitlab-git-architecture/)里。

在 GitLab 里，引用就是分支和标签，数量不多，权限也只管到“这个分支谁能推”。**可如果引用本身就是业务数据呢？**

---

## 四、Gerrit：引用本身就是数据

### 现象：push 到一个不存在的分支

用过 Gerrit 的人都熟悉这个命令：

```bash
$ git push origin HEAD:refs/for/main
remote: SUCCESS
remote:
remote:   https://gerrit.example.com/c/hello/+/1234 add lib [NEW]
```

推完之后，`main` 没有变化，服务器上也找不到 `refs/for/main` 这个引用。我们的提交去哪了？

### 原因：magic branch 和 change 引用

Gerrit 是 Java 写的，Git 协议部分用的是 JGit（Git 的 Java 实现）。HTTP 入口 `GitOverHttpServlet` 继承自 JGit 的 `GitServlet`，SSH 入口的 `git-upload-pack` 命令对应 `Upload` 类，最终都交给 JGit 的 `UploadPack` 和 `ReceivePack`。

push 时，JGit 的 `ReceivePack.service()` 先运行 `PreReceiveHook`，再执行那些仍处于 `NOT_ATTEMPTED` 状态的引用命令（`ReceivePack.java:2321-2327`）。Gerrit 的 `ReceiveCommits` 就挂在这个钩子上，它把 `refs/for/main` 识别为 **magic branch**，自己处理完整个推送，然后把这条命令标记为成功（`ReceiveCommits.java:1324-1326`）：

```java
if (magicBranchCmd != null) {
  magicBranchCmd.setResult(OK);
}
```

命令已经是 `OK`，JGit 就不会再去执行它，所以 `refs/for/main` 永远不会被真的创建。它只是一个“投递地址”。

Gerrit 在钩子里做的事情是：读提交信息里的 `Change-Id` 尾注（由客户端的 `commit-msg` 钩子生成），判断这是一个新的 change 还是已有 change 的新版本；新 change 从 `refs/sequences/changes` 里分配一个编号（比如 1234），然后创建引用 `refs/changes/34/1234/1`，指向推上来的提交。倒数第二级的 `34` 是编号的末两位，用来分片（`RefNames.shard`），这是松散引用时代为了不让一个目录里塞太多文件留下的设计。同一个 `Change-Id` 再推一次，就多出 `refs/changes/34/1234/2`。

### 评审数据也存在 Git 里：NoteDb

评审意见、打分、状态存在哪？Gerrit 3.0 起的答案是：**也存在 Git 里**，这就是 NoteDb。每个 change 有一个 `refs/changes/34/1234/meta` 引用，它是一条提交链，每次评审操作都追加一个提交，元数据写在提交信息的尾注里（`ChangeUpdate.java:746-806`）：

```java
addPatchSetFooter(msg, patchSetId);                          // Patch-set: 2
if (changeId != null) addFooter(msg, FOOTER_CHANGE_ID, changeId);
if (branch != null) addFooter(msg, FOOTER_BRANCH, branch);
if (status != null) addFooter(msg, FOOTER_STATUS, status.name().toLowerCase(Locale.US));
```

打分写成 `Label: Code-Review=+2`，行内评论放在 meta 分支的目录树里，用 git notes 的结构按 patch set 的提交哈希索引。读取时，`ChangeNotesParser` 从新到旧遍历这条提交链，重放出 change 的当前状态。草稿评论和用户信息放在 `All-Users` 仓库，项目配置放在 `refs/meta/config`。也就是说，Gerrit 没有单独的业务数据库，**整个评审系统的状态就是一堆引用**。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 452" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gs-gerrit-title gs-gerrit-desc">
<title id="gs-gerrit-title">push 到 refs/for/main 之后 Gerrit 仓库里的引用</title>
<desc id="gs-gerrit-desc">开发者把提交 612f4c1 推到 refs/for/main；这个 magic branch 从未被创建，Gerrit 改为创建 refs/changes/34/1234/1 指向该提交，并创建 refs/changes/34/1234/meta，它的提交尾注记录 Patch-set、Change-id、Status 和 Branch；refs/heads/main 保持 385ce2c 不变。</desc>
<defs>
<marker id="gs-gerrit-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="gs-gerrit-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="452" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="248" height="420" rx="8"/>
<text class="t-zone" x="36" y="44">开发者</text>
<rect class="dd-zone" x="280" y="16" width="504" height="420" rx="8"/>
<text class="t-zone" x="300" y="44">Gerrit · hello.git</text>
<path class="dd-edge" d="M248 94 H310" marker-end="url(#gs-gerrit-arrow)"/>
<path class="dd-edge" d="M396 124 V146" marker-end="url(#gs-gerrit-arrow)"/>
<path class="dd-edge-acc" d="M408 258 V280" marker-end="url(#gs-gerrit-arrow-acc)"/>
<rect class="dd-card" x="32" y="64" width="216" height="136" rx="6"/>
<path class="dd-card-head" d="M32.6 96 V70 Q32.6 64.6 38 64.6 H242 Q247.4 64.6 247.4 70 V96 Z"/>
<line class="dd-edge" x1="32" y1="96" x2="248" y2="96"/>
<text class="t-name" x="48" y="85">git push</text>
<line class="dd-hair" x1="48" y1="122" x2="232" y2="122"/>
<line class="dd-hair" x1="48" y1="148" x2="232" y2="148"/>
<line class="dd-hair" x1="48" y1="174" x2="232" y2="174"/>
<text class="t-mono" x="48" y="113">origin</text>
<text class="t-mono" x="48" y="139">HEAD:refs/for/main</text>
<text class="t-mono" x="48" y="165">612f4c1 add lib</text>
<text class="t-mono" x="48" y="191">Change-Id: I8473b9…</text>
<rect class="dd-derived" x="312" y="64" width="168" height="60" rx="6"/>
<text class="t-mono" x="328" y="99">refs/for/main</text>
<text class="t-say" x="500" y="88">虚线：只是一个投递地址，</text>
<text class="t-say" x="500" y="108">从来不会被写成真的引用。</text>
<rect class="dd-card" x="296" y="148" width="472" height="110" rx="6"/>
<path class="dd-card-head" d="M296.6 180 V154 Q296.6 148.6 302 148.6 H762 Q767.4 148.6 767.4 154 V180 Z"/>
<line class="dd-edge" x1="296" y1="180" x2="768" y2="180"/>
<text class="t-name" x="312" y="169">引用</text>
<text class="t-note" x="752" y="169" text-anchor="end">push 之后</text>
<line class="dd-hair" x1="312" y1="206" x2="752" y2="206"/>
<line class="dd-hair" x1="312" y1="232" x2="752" y2="232"/>
<text class="t-mono" x="312" y="197">refs/heads/main</text>
<text class="t-val" x="528" y="197">385ce2c</text>
<text class="t-note" x="752" y="197" text-anchor="end">没有变</text>
<text class="t-mono" x="312" y="223">refs/changes/34/<tspan class="t-acc">1234</tspan>/1</text>
<text class="t-val" x="528" y="223">612f4c1</text>
<text class="t-note" x="752" y="223" text-anchor="end">patch set 1</text>
<text class="t-mono" x="312" y="249">refs/changes/34/<tspan class="t-acc">1234</tspan>/meta</text>
<text class="t-val" x="528" y="249">评审元数据</text>
<text class="t-note" x="752" y="249" text-anchor="end">NoteDb</text>
<rect class="dd-card" x="296" y="282" width="472" height="136" rx="6"/>
<path class="dd-card-head" d="M296.6 314 V288 Q296.6 282.6 302 282.6 H762 Q767.4 282.6 767.4 288 V314 Z"/>
<line class="dd-edge" x1="296" y1="314" x2="768" y2="314"/>
<text class="t-name" x="312" y="303">meta 分支上的提交</text>
<text class="t-val" x="752" y="303" text-anchor="end">refs/changes/34/<tspan class="t-acc">1234</tspan>/meta</text>
<line class="dd-hair" x1="312" y1="340" x2="752" y2="340"/>
<line class="dd-hair" x1="312" y1="366" x2="752" y2="366"/>
<line class="dd-hair" x1="312" y1="392" x2="752" y2="392"/>
<text class="t-mono" x="312" y="331">Patch-set: 1</text>
<text class="t-mono" x="312" y="357">Change-id: I8473b95934b5732ac55d26311a706c9c2bde9940</text>
<text class="t-mono" x="312" y="383">Status: new</text>
<text class="t-mono" x="312" y="409">Branch: refs/heads/main</text>
</svg>
</div>
<figcaption>图 5 · 推到 refs/for/main 的提交变成了 change 1234：一个引用指向代码，一个引用记录评审（change 编号和 Change-Id 为示例）</figcaption>
</figure>

### 新的问题一：引用太多了

每个 change 至少两个引用，每次重新推送再多一个。一个活跃的大项目，引用数很快到几十万。JGit 的 reftable 设计文档里给过一组数字[^reftable]：Android 的仓库有 **86.6 万个引用**，用传统的 `packed-refs` 文件，冷启动时查找一个引用要 409,660 微秒；换成 reftable 格式，只要 33.9 微秒。文档明确把 Gerrit 的 `refs/changes/` 列为 reftable 要服务的场景。JGit 的 `FileRepository` 可以在 `RefDirectory`（松散引用 + `packed-refs`）和 `FileReftableDatabase` 之间选择，Gerrit 也提供了 `convert-ref-storage` 命令做迁移。

引用多，第一节里协议 v0 的毛病就被放大了：每次 fetch 都先把 86 万个引用列一遍。协议 v2 正好解决这个问题：JGit 处理 `ls-refs` 时直接按 `ref-prefix` 调用 `getRefsByPrefix`（`UploadPack.java:914-933`），客户端只要 `refs/heads/` 和 `refs/tags/`，就根本不会碰到 `refs/changes/`。

push 却没有协议 v2，`receive-pack` 仍然是“服务器先列出所有引用”。Gerrit 在源码注释里专门讨论了这件事（`ReceiveCommitsAdvertiseRefsHook.java`）：对大仓库，这份列表“可能有几十 MB”。它的做法是在 push 的引用广告里**去掉所有 `refs/changes/*`**；但这样客户端找到的共同祖先会更旧，要多传很多服务器早就有的对象，于是又把当前用户在这个仓库里的 open change（默认最多 32 个，`receive.advertiseOpenChangesRefs`）的最新提交作为额外的 `.have` 行广告出去，作为折中。注释里也老实写着，这是一个“启发式”方法，还有待度量。

### 新的问题二：不是所有人都该看见所有引用

引用承载了业务数据，权限就必须细到引用这一级：私有 change 的 `refs/changes/*`、别人的草稿评论、包含访问控制配置的 `refs/meta/config`，都不该出现在无权用户的引用列表里。

Gerrit 没有在钩子里挨个过滤，而是更彻底：给 `UploadPack` 的仓库**换掉整个引用数据库**。`PermissionAwareRepositoryManager.wrap()` 装上一个 `PermissionAwareReadOnlyRefDatabase`，它的 `getRefsByPrefix`、`exactRef` 等方法都先经过 `DefaultRefFilter` 和 `RefVisibilityControl.isVisible` 的判断（`DefaultRefFilter.java:194-284`）。对 JGit 来说，看不见的引用就像不存在一样，引用广告、`ls-refs`、`want` 的合法性检查都自然地只基于可见的引用。

### 存储：仍然在本地磁盘，但可以替换

默认情况下，Gerrit 的 `LocalDiskRepositoryManager` 打开的是本地磁盘上的 `FileRepository`。不过 JGit 留了一个抽象：`DfsRepository`，实现者只需要提供 `openFile`、`writeFile`、`listPacks`、`commitPack` 这几个操作，就能把 pack 和 reftable 放到任意的分布式存储上，JGit 自带的 `InMemoryRepository` 就是一个例子。Google 自己托管 Gerrit 用的就是这条路（这一点 JGit 源码里没有写，属于公开资料里的背景知识）。至于多副本，开源 Gerrit 主要依靠 replication 插件把 push 异步复制到其他机器，以及只读的 replica 模式。

> Gerrit 为什么用 JGit、一次 `refs/for/main` 的投递全过程、NoteDb 的存储格式、reftable、可见性、submit 的原子更新、索引缓存与多站点，详见[《Gerrit 的架构》](/blog/gerrit-architecture/)。

到这里，无论是 GitLab 还是 Gerrit，仓库最终都落在某台机器的磁盘上；多副本的一致性，要么靠投票，要么靠异步复制。**能不能把“真相”从这些磁盘上拿走？**

---

## 五、walgit：把仓库交给对象存储

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

第三节末尾提到，Praefect 和 Spokes 这类方案在每次 push 时都要等所有（或多数）副本投票。Cursor 的 Vicent Martí 在《Git at any scale》里总结了这类方案的问题[^cursor]：一致性协议的延迟取决于最慢的副本，副本越多，push 吞吐越差，所以副本数“下限太高，上限太低”。大仓库被 CI 挤爆需要几十份副本，做不到；AI 代理创建的海量小仓库每个也要三份，太浪费。此外，每台机器上的每个仓库都是真相的一部分，运维时只能当“宠物”养；repack 这种重 CPU 的维护要在每个副本上各做一次。

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

对象存储（S3、GCS）容量几乎无限、持久性极高，最直接的想法是把仓库直接放上去。但这就是第三节 NFS 问题的加强版：S3 一次 GET 要 60 到 80 毫秒，而 Git 读 packfile 是成千上万次随机访问。这条路走不通。

### 解决：对象存储里的预写日志，磁盘只是缓存

Cursor 的方案（他们叫 Continuity）换了一个角度：**Git 仍然在本地磁盘上跑，但本地磁盘上的仓库只是缓存；唯一的真相是对象存储里的一份预写日志（WAL）。** walgit[^walgit] 是这个架构的一个开源 Rust 实现，作者是 Tobias Lütke，README 的第一句话就是“一个 git 服务器，就是对象存储前面的一个二进制文件”。

walgit 在桶里为每个仓库保存这样一组对象（`AGENTS.md` §2.1）：

```
repos/<owner>/<repo>/
  manifest.pb                 # 很小，每次提交时用 CAS 改写：日志头序号、当前有效的 pack 集合、快照指针
  log/<seq>.pb                # 不可变：每次 push 的引用事务（PUSH）、压缩（COMPACT）、快照（CHECKPOINT）……
  wal/<checksum>.pack|.idx    # 不可变，按内容寻址的 pack 和索引
  checkpoints/<seq>/          # 折叠后的引用快照，冷启动 = 快照 + 日志尾巴
  leases/compact.pb           # 带过期时间的 CAS 租约，唯一的跨实例互斥
```

它对存储的全部要求，是三种带条件的操作（`crates/walgit-store/src/lib.rs`、`s3.rs`）：

- **只在不存在时创建**：`PUT` 带 `If-None-Match: *`，用于不可变对象。
- **只在版本没变时覆盖**：`PUT` 带 `If-Match: <ETag>`，失败返回 412，这就是 compare-and-swap。
- **条件读取**：`GET` 带 `If-None-Match: <ETag>`，没变就返回 304，没有正文。

### 一次 push：CAS 就是共识

walgit 自己实现了 `receive-pack`。一次 push 的过程（`crates/walgit-wal/src/publish.rs` 开头的注释写得很完整）：

1. 在临时目录里用 `git index-pack --fix-thin` 收下 pack，检查连通性和 push 策略。
2. 用本地的引用视图检查每条命令的旧值（`verify_txn`）。
3. 并行上传 pack、索引和一条新的日志 `log/42.pb`，都用“不存在才创建”。pack 按内容寻址，已经存在说明别人传过一模一样的字节，直接当成功。
4. **用 CAS 改写 `manifest.pb`**：把日志头从 41 改成 42，把新 pack 加进有效集合，条件是 ETag 还是自己读到的那个版本。
5. 成功了才在本地应用引用更新，并回复客户端 `ok`。

```rust
let mode = match &known_version {
    Some(v) => PutMode::Update(v.clone()),   // 对应 If-Match
    None => PutMode::Create,
};
let cas = handle.store.put(keys::MANIFEST, PutBody::Bytes(buf.into()), mode.into()).await;
```

如果 CAS 返回 412，说明另一台实例抢先提交了。walgit 会重新同步、**重新检查每个引用的旧值**（被别人改过的引用回复 `ng`）、换一个新的序号再试。这正是第二节里 `receive-pack` 对单个引用做的“检查旧值再写新值”，只不过锁不再是本地磁盘上的 `main.lock` 文件，而是对象存储对一个对象的原子覆盖。

在这之前写进桶里的 pack 和日志，没有被 manifest 引用，对任何读者都不可见；manifest 的 CAS 一旦成功，push 就提交了。所以**任何一台实例都可以接受 push，不需要选主，也不需要投票**，两台实例同时提交，只有一个能赢。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 468" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gs-walgit-title gs-walgit-desc">
<title id="gs-walgit-title">walgit 的一次 push 和一次读</title>
<desc id="gs-walgit-desc">实例 A 先把 log/42.pb 和 pack、idx 以不存在才创建的方式写进桶，再用 If-Match v41 改写 manifest.pb，使其 ETag 变为 v42、head_seq 变为 42；实例 B 读之前用 If-None-Match v42 条件读取 manifest，得到 304 后直接用本地缓存服务。</desc>
<defs>
<marker id="gs-walgit-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="gs-walgit-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="468" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="232" height="436" rx="8"/>
<text class="t-zone" x="36" y="44">walgit 实例</text>
<rect class="dd-zone" x="264" y="16" width="520" height="436" rx="8"/>
<text class="t-zone" x="284" y="44">对象存储桶 · repos/acme/app/</text>
<path class="dd-edge" d="M232 82 H278" marker-end="url(#gs-walgit-arrow)"/>
<text class="t-note" x="255" y="76" text-anchor="middle">①</text>
<path class="dd-edge-acc" d="M232 150 H278" marker-end="url(#gs-walgit-arrow-acc)"/>
<text class="t-note" x="255" y="144" text-anchor="middle">②</text>
<path class="dd-edge-acc" d="M232 356 H248 Q256 356 256 348 V224 Q256 216 264 216 H278" marker-end="url(#gs-walgit-arrow-acc)"/>
<text class="t-note" x="242" y="378" text-anchor="middle">③</text>
<path class="dd-edge dd-dash" d="M352 140 V102" marker-end="url(#gs-walgit-arrow)"/>
<path class="dd-edge dd-dash" d="M520 140 V102" marker-end="url(#gs-walgit-arrow)"/>
<rect class="dd-card" x="32" y="64" width="200" height="110" rx="6"/>
<path class="dd-card-head" d="M32.6 96 V70 Q32.6 64.6 38 64.6 H226 Q231.4 64.6 231.4 70 V96 Z"/>
<line class="dd-edge" x1="32" y1="96" x2="232" y2="96"/>
<text class="t-name" x="48" y="85">实例 A</text>
<text class="t-note" x="216" y="85" text-anchor="end">处理 push</text>
<line class="dd-hair" x1="48" y1="122" x2="216" y2="122"/>
<line class="dd-hair" x1="48" y1="148" x2="216" y2="148"/>
<text class="t-mono" x="48" y="113">index-pack 收包</text>
<text class="t-mono" x="48" y="139">检查 main 的旧值</text>
<text class="t-mono" x="48" y="165">CAS 成功才回 ok</text>
<rect class="dd-card" x="32" y="300" width="200" height="110" rx="6"/>
<path class="dd-card-head" d="M32.6 332 V306 Q32.6 300.6 38 300.6 H226 Q231.4 300.6 231.4 306 V332 Z"/>
<line class="dd-edge" x1="32" y1="332" x2="232" y2="332"/>
<text class="t-name" x="48" y="321">实例 B</text>
<text class="t-note" x="216" y="321" text-anchor="end">处理 clone</text>
<line class="dd-hair" x1="48" y1="358" x2="216" y2="358"/>
<line class="dd-hair" x1="48" y1="384" x2="216" y2="384"/>
<text class="t-mono" x="48" y="349">本地缓存 = <tspan class="t-acc">v42</tspan></text>
<text class="t-mono" x="48" y="375">条件读 manifest</text>
<text class="t-mono" x="48" y="401">304：直接服务</text>
<rect class="dd-card" x="280" y="64" width="144" height="36" rx="6"/>
<text class="t-mono" x="296" y="87">log/42.pb</text>
<rect class="dd-card" x="440" y="64" width="160" height="36" rx="6"/>
<text class="t-mono" x="456" y="87">wal/7f3a9c.pack</text>
<rect class="dd-card" x="616" y="64" width="152" height="36" rx="6"/>
<text class="t-mono" x="632" y="87">wal/7f3a9c.idx</text>
<text class="t-note" x="616" y="124">不可变，按内容寻址</text>
<rect class="dd-card" x="280" y="140" width="488" height="136" rx="6"/>
<path class="dd-card-head" d="M280.6 172 V146 Q280.6 140.6 286 140.6 H762 Q767.4 140.6 767.4 146 V172 Z"/>
<line class="dd-edge" x1="280" y1="172" x2="768" y2="172"/>
<text class="t-name" x="296" y="161">manifest.pb</text>
<text class="t-val" x="752" y="161" text-anchor="end">ETag v41 → <tspan class="t-acc">v42</tspan></text>
<line class="dd-hair" x1="296" y1="198" x2="752" y2="198"/>
<line class="dd-hair" x1="296" y1="224" x2="752" y2="224"/>
<line class="dd-hair" x1="296" y1="250" x2="752" y2="250"/>
<text class="t-mono" x="296" y="189">head_seq</text>
<text class="t-val" x="424" y="189">41 → 42</text>
<text class="t-mono" x="296" y="215">packs</text>
<text class="t-val" x="424" y="215">…, 7f3a9c（tier 0）</text>
<text class="t-mono" x="296" y="241">log_segments</text>
<text class="t-val" x="424" y="241">…, log/42.pb</text>
<text class="t-mono" x="296" y="267">writer</text>
<text class="t-val" x="424" y="267">实例 A</text>
<text class="t-mono" x="296" y="316">① PUT 日志、pack、idx    If-None-Match: *</text>
<text class="t-mono" x="296" y="342">② PUT manifest.pb    If-Match: v41 → 200</text>
<text class="t-mono" x="296" y="368">③ GET manifest.pb    If-None-Match: <tspan class="t-acc">v42</tspan> → 304</text>
<text class="t-say" x="296" y="404">② 这一次 CAS 就是提交点：它成功之前，</text>
<text class="t-say" x="296" y="424">① 写进去的对象对任何读者都不可见。</text>
</svg>
</div>
<figcaption>图 6 · 桶里只有一个对象会被改写：manifest 的 CAS 是 push 的提交点，它的 ETag 也是所有读者判断“有没有新东西”的依据</figcaption>
</figure>

### 一次读：先问一句“有新东西吗”

读的一侧同样简单。每个读请求（`info/refs`、`ls-refs`、fetch）开始前，实例对 `manifest.pb` 做一次条件 GET，带上自己缓存的 ETag。返回 304 说明本地缓存就是最新的，直接服务；返回 200 则拿到新的 manifest，把新增的日志条目应用到本地再服务。按 walgit 自己在 GCS 上的测量（`docs/ROUNDTRIPS.md`），普通 GET 要 60 到 80 毫秒，而 304 只要 15 到 18 毫秒。图 6 里的 v41、v42 就是 manifest 的 ETag，ETag 本身就是“版本号”。

这带来一个很强的保证：**push 一旦被确认，下一个请求不管落在哪台实例上都能看到它**，没有“最终一致”。实例之间也不需要互相通信。新加一台实例，它从桶里读快照加日志就能开始服务；所有实例都关掉，丢掉的只是缓存的热度。

walgit 还把读分成几个级别（`AGENTS.md` §2.3）：只需要引用的请求（`info/refs`、`ls-refs`）只读快照和日志，不下载任何 pack，目标是冷启动的实例在 1 秒内回答最大仓库的 `ls-remote`；真正的 fetch 才需要 pack。pack 的计算默认仍然交给上游的 `git upload-pack`，和最小服务器一样。

### 新的问题：一个对象每秒只能改一次

对象存储并不是万能的数据库。walgit 的文档里记着一个关键限制：**同一个对象的覆盖写是串行的，大约每秒一次**。manifest 是唯一被 CAS 改写的对象，这就成了一个仓库的 push 吞吐上限。

walgit 的应对是 **group commit**：每个仓库在每台实例上只有一个发布任务（`publisher_task`），在一个很短的窗口里把并发到达的多个 push 合成一批，各自校验旧值，写一个日志段，然后只做一次 CAS。某个 push 的旧值不对，只拒绝它自己，不影响同批的其他 push。

有意思的是，GitLab 那个实验性的对象存储后端遇到了同一个限制，却选了另一种做法。`mvcc/objectstorage/objectstorage.go` 的注释说，为了避开“每个对象每秒约一次更新”的上限，它**不改写**任何指针对象，而是每次提交都新建一个不可变的 generation 对象：

```
<repo-prefix>/generations/<MaxUint64 - generation，16 位十六进制>   # 内容是 manifest 的哈希
<repo-prefix>/manifests/<hash>                                         # manifest 本身
```

写下一个 generation 用的是“不存在才创建”，同样是一次无竞争的 CAS；编号倒过来存，是为了让最新的 generation 在 LIST 结果里排第一。一个是“改写同一个对象 + 批量提交”，一个是“只追加新对象 + 用 LIST 找最新的”，都是在“不可变对象加一个原子提交点”的框架里做的不同取舍。

### 另一个问题：仓库比机器还大

walgit 的目标场景是几十 GB 的 monorepo，跑在只有几 GB 内存盘的小机器上，base pack 根本放不下。它的办法是只把 pack 的**索引**、位图和提交图放在本地，pack 的数据按需用 HTTP 范围请求从桶里读（remote reader，每次 1 MiB，带进程内的块缓存）。repack 也不再是每个副本各做一次：持有 `leases/compact.pb` 租约的那台实例做一次压缩，把结果作为一条 `COMPACT` 日志发布出去，其他实例直接下载压缩好的 pack。

需要说明的是，walgit 是一个很新的项目（本文读的是 2026 年 9 月的版本），文档里的性能数字都是项目自述，我没有复现；Hacker News 的讨论里也有人质疑这类设计的安全性[^hn]。但作为一个“把一致性完全交给对象存储的 CAS”的完整实现，它的源码把这个思路的每一处代价都写得很清楚，很适合阅读。

> 桶内布局、并发 push 的冲突处理、group commit、分级同步、压缩与崩溃恢复，详见[《walgit 架构原理》](/blog/walgit-architecture/)。

---

## 六、回到 git clone

现在回到开头那三次 HTTP 请求。不管服务器是哪一个，客户端发出的报文都一模一样：先问能力，再按前缀列引用，最后 `want` 一个提交、收一个 pack。区别全在服务器那一侧：

<div style="overflow-x:auto">

| | 谁做鉴权 | 引用从哪里来 | 谁算 pack | 真相存在哪 |
|---|---|---|---|---|
| 最小服务器 | 没有 | 本地仓库 | `git upload-pack` | 一块本地磁盘 |
| GitLab | Workhorse 转给 Rails，push 时再由钩子逐个引用检查 | Gitaly 节点上的本地仓库 | Gitaly 上的 `git upload-pack`，pack-objects 结果按输入哈希缓存 | 多台 Gitaly 的本地磁盘，Praefect 投票保持一致 |
| Gerrit | JVM 内的权限后端，看不见的引用直接从引用数据库里消失 | `PermissionAwareReadOnlyRefDatabase`，可用 reftable | JGit 的 `PackWriter`（用位图计算 want 减 have） | 本地磁盘，或 JGit 的 DFS 抽象，评审数据也在引用里 |
| walgit | walgit 自己（token / OIDC） | 桶里的快照 + 日志，不需要 pack | 本地缓存上的 `git upload-pack` 或 gix | 对象存储，manifest 的 CAS 是唯一的提交点 |

</div>

Git 协议之所以能让这几种差别很大的实现并存，是因为它把服务器需要承诺的东西压缩得很小：**一份引用列表，加上“给我从这些提交出发、减去那些提交的所有对象”**。服务器怎么存、怎么复制、怎么鉴权，客户端完全看不见。

---

## 小结

顺着一次 `git clone` 往下追，问题是一个接一个冒出来的：

1. **clone 在网络上传了什么？** 先列引用，再用 `want`/`have` 协商，最后收一个现场算出来的 packfile。协议 v0 每次都列出全部引用，v2 用 `ls-refs` 和 `ref-prefix` 把它变成按需查询。
2. **服务器要做什么？** 最小的服务器只需要 `git upload-pack` 和 `git receive-pack`。push 由一个 pack 和一个引用事务组成，引用的“检查旧值再写新值”就是提交点。
3. **仓库放在哪？** GitLab 发现 NFS 撑不住 Git 的随机读写，于是用 Gitaly 把 Git 进程搬到磁盘旁边，Rails 只负责授权；并发 clone 用 pack-objects 缓存，单点问题用 Praefect 对引用事务投票。
4. **引用多了、引用即数据时怎么办？** Gerrit 把评审建模成引用（`refs/changes/*`、NoteDb），随之而来的是引用爆炸和引用级权限：用 reftable 和协议 v2 应对数量，用替换引用数据库的方式做过滤。
5. **能不能不靠副本投票？** walgit 把真相放进对象存储里的预写日志，push 的提交点变成 manifest 的一次 CAS，读之前用一次条件 GET 确认新鲜度，本地磁盘退化成可以随时丢弃的缓存；代价是要绕开对象存储“每个对象每秒约一次写”的限制。

想亲手看一遍第一节的报文，只需要一个裸仓库和下面这条命令：

```bash
$ git clone --bare <任意一个本地仓库> /tmp/demo.git
$ GIT_TRACE_PACKET=1 git clone file:///tmp/demo.git /tmp/demo 2>&1 | grep packet
```

---

## 参考资料
