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 的报文跟踪:

Terminal window
$ 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 按时间把它们排了一遍。

Git 网络协议的几代 上一行是服务器一连上就开口的三种传输:2005 年的 SSH 和 git://(后来叫 v0)、早期的哑 HTTP、Git 1.6.6 的 smart HTTP。下一行是客户端先表明版本的三步:Git 2.16 的 v1、2.18 的 v2、2.29 起 v2 成为默认。 2005–2017 · 服务器一连上就先开口 2018 起 · 客户端先说自己会哪个版本 SSH / git:// 2005 < 385ce2c HEAD\0能力… < 385ce2c refs/heads/main 先列全部引用,后来叫 v0 哑 HTTP 早期 > GET info/refs < 385ce2c refs/heads/main 静态文件,对象要自己逐个下载 smart HTTP 1.6.6 · 2009 > GET info/refs?service=… < # service=git-upload-pack 接下来和 v0 一样列全部引用 v1 2.16 · 2018 > Git-Protocol: version=1 < version 1 其余同 v0,只为验证升级通道 v2 2.18 · 2018 > Git-Protocol: version=2 < version 2 只报能力,引用用 ls-refs 按需取 v2 成为默认 2.29 · 2020 2.26 第一次设为默认 2.27 发现问题,暂时退回 v0 2.29 起一直是默认 > 是客户端发出的,< 是服务器的回答。上一行的三种传输,服务器都是一连上就先开口。
图 1 · Git 网络协议的几代:前十几年都是服务器先开口;2018 年起,客户端先通过旁路说明想用的版本,v2 才有了落脚的地方

上面一行:服务器先开口。 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):

同一个请求,三种服务器的回答 客户端发出 GET /hello.git/info/refs?service=git-upload-pack,并带请求头 Git-Protocol: version=2。静态文件服务器忽略查询参数和请求头,返回静态的 info/refs 文件,客户端退回哑协议;不认识 Git-Protocol 的老智能服务器返回 # service=git-upload-pack 和全部引用,即 v0;新服务器以 version 2 开头,只报能力。 客户端 · Git 2.43 三种服务器 第一次请求 GET GET /hello.git/info/refs ?service=git-upload-pack Git-Protocol: version=2 两处都是老服务器会忽略的 静态文件服务器 哑 HTTP 385ce2c… refs/heads/main 385ce2c… refs/tags/v1.0 忽略查询参数,返回 update-server-info 写好的文件 没有能力列表,客户端只好退回哑协议 老的智能服务器 v0 001e# service=git-upload-pack 0000010b385ce2c… HEAD\0multi_ack … 003d385ce2c… refs/heads/main 不认识 Git-Protocol,照旧列出全部引用 新服务器 v2 000eversion 2 0013ls-refs=unborn 0020fetch=shallow wait-for-done 不列引用,只报能力 只有新服务器会读这个请求头。
图 2 · 同一个请求发给三种服务器:查询参数和请求头都是老服务器会忽略的旁路,所以谁都能给出自己听得懂的回答

所以今天的 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 段里。

协议 v2 下一次 git clone 的三次请求 客户端依次发出三次请求:GET info/refs 拿到能力广告;POST ls-refs 拿到 HEAD、main、v1.0 三个引用,都指向 385ce2c;POST fetch 用 want 385ce2c 要回一个 552 字节、8 个对象的 packfile。 客户端 · git clone 服务器 · hello.git 请求 1 GET info/refs Git-Protocol: version=2 ?service=git-upload-pack 只问一句:你支持哪些命令 能力广告 不再列出引用 version 2 ls-refs=unborn fetch=shallow wait-for-done 请求 2 POST git-upload-pack command=ls-refs ref-prefix HEAD ref-prefix refs/heads/ refs/tags/ 引用列表 只含请求的前缀 385ce2c HEAD → refs/heads/main 385ce2c refs/heads/main 385ce2c refs/tags/v1.0 请求 3 POST git-upload-pack command=fetch want 385ce2c… done packfile 552 字节 PACK · 8 个对象 2 commit · 3 tree · 3 blob 从 385ce2c 出发能走到的全部对象
图 3 · 协议 v2 的 clone:先问能力,再按前缀列引用,最后只要一个提交,换回一整个 packfile

收到的 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 的设计文档里写得很直白1:

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

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

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

解决: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),和我们的最小服务器一模一样。
GitLab 处理一次 HTTPS clone 的路径 git 客户端请求 Workhorse;Workhorse 先把同一 URL 转给 Rails 预授权,Rails 返回 GL_ID、GL_REPOSITORY、仓库路径 @hashed/6b/86/… 和 Gitaly 地址;Workhorse 带着仓库路径调用 Gitaly,Gitaly 在本地执行 git upload-pack,pack-objects 的结果按参数和标准输入的哈希缓存。 开发者电脑 GitLab 预授权 JSON pack-objects 钩子 git clone GET info/refs POST upload-pack Basic / token Workhorse Go PreAuthorize gRPC → Gitaly 搬运 pack 字节流 Gitaly gitaly-1 PostUploadPack WithSidechannel @hashed/6b/86/… git 本地磁盘 upload-pack --stateless-rpc pack-objects Rails 的授权结果 GitHttpController GL_ID user-123 GL_REPOSITORY project-42 Repository @hashed/6b/86/6b86b273….git GitalyServer tcp://gitaly-1:8075 pack-objects 缓存 key = sha256( 仓库 + 参数 + stdin) 命中就直接流回 Rails 只出现在授权这一步,pack 的字节不经过它。 虚线:缓存的 pack,并发 clone 同一个提交时只算一次。
图 4 · Rails 只回答“能不能、在哪里”,真正的 Git 进程跑在存放仓库的那台 Gitaly 上

几个细节值得多看一眼。

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 服务架构》里。

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


四、Gerrit:引用本身就是数据

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

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

Terminal window
$ 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 没有单独的业务数据库,整个评审系统的状态就是一堆引用。

push 到 refs/for/main 之后 Gerrit 仓库里的引用 开发者把提交 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 不变。 开发者 Gerrit · hello.git git push origin HEAD:refs/for/main 612f4c1 add lib Change-Id: I8473b9… refs/for/main 虚线:只是一个投递地址, 从来不会被写成真的引用。 引用 push 之后 refs/heads/main 385ce2c 没有变 refs/changes/34/1234/1 612f4c1 patch set 1 refs/changes/34/1234/meta 评审元数据 NoteDb meta 分支上的提交 refs/changes/34/1234/meta Patch-set: 1 Change-id: I8473b95934b5732ac55d26311a706c9c2bde9940 Status: new Branch: refs/heads/main
图 5 · 推到 refs/for/main 的提交变成了 change 1234:一个引用指向代码,一个引用记录评审(change 编号和 Change-Id 为示例)

新的问题一:引用太多了

每个 change 至少两个引用,每次重新推送再多一个。一个活跃的大项目,引用数很快到几十万。JGit 的 reftable 设计文档里给过一组数字2: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 的架构》。

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


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

现象:副本越多,push 越慢

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

第一次尝试:让 Git 直接读对象存储?

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

解决:对象存储里的预写日志,磁盘只是缓存

Cursor 的方案(他们叫 Continuity)换了一个角度:Git 仍然在本地磁盘上跑,但本地磁盘上的仓库只是缓存;唯一的真相是对象存储里的一份预写日志(WAL)。 walgit4 是这个架构的一个开源 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,不需要选主,也不需要投票,两台实例同时提交,只有一个能赢。

walgit 的一次 push 和一次读 实例 A 先把 log/42.pb 和 pack、idx 以不存在才创建的方式写进桶,再用 If-Match v41 改写 manifest.pb,使其 ETag 变为 v42、head_seq 变为 42;实例 B 读之前用 If-None-Match v42 条件读取 manifest,得到 304 后直接用本地缓存服务。 walgit 实例 对象存储桶 · repos/acme/app/ ① ② ③ 实例 A 处理 push index-pack 收包 检查 main 的旧值 CAS 成功才回 ok 实例 B 处理 clone 本地缓存 = v42 条件读 manifest 304:直接服务 log/42.pb wal/7f3a9c.pack wal/7f3a9c.idx 不可变,按内容寻址 manifest.pb ETag v41 → v42 head_seq 41 → 42 packs …, 7f3a9c(tier 0) log_segments …, log/42.pb writer 实例 A ① PUT 日志、pack、idx If-None-Match: * ② PUT manifest.pb If-Match: v41 → 200 ③ GET manifest.pb If-None-Match: v42 → 304 ② 这一次 CAS 就是提交点:它成功之前, ① 写进去的对象对任何读者都不可见。
图 6 · 桶里只有一个对象会被改写:manifest 的 CAS 是 push 的提交点,它的 ETag 也是所有读者判断“有没有新东西”的依据

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

读的一侧同样简单。每个读请求(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 的讨论里也有人质疑这类设计的安全性5。但作为一个“把一致性完全交给对象存储的 CAS”的完整实现,它的源码把这个思路的每一处代价都写得很清楚,很适合阅读。

桶内布局、并发 push 的冲突处理、group commit、分级同步、压缩与崩溃恢复,详见《walgit 架构原理》。


六、回到 git clone

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

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

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 确认新鲜度,本地磁盘退化成可以随时丢弃的缓存;代价是要绕开对象存储“每个对象每秒约一次写”的限制。

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

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

参考资料

Footnotes

  1. Gitaly designGitLabgitlab.com ↗doc/DESIGN.md:“the first design reason for Gitaly - to avoid having the Git binary operate on remote storage”https://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/DESIGN.md ↩ ↩2

  2. reftableEclipse JGitgithub.com ↗Documentation/technical/reftable.md,Android 86.6 万个引用的性能对比https://github.com/eclipse-jgit/jgit/blob/master/Documentation/technical/reftable.md ↩

  3. Git at any scaleVicent Martí · Cursor · 2026-08-18cursor.com ↗https://cursor.com/blog/git-at-any-scale ↩

  4. tobi/walgitTobias Lütkegithub.com ↗README、AGENTS.md、docs/ROUNDTRIPS.mdhttps://github.com/tobi/walgit ↩

  5. Walgit: a Git server that runs as one binary in front of an object storeHacker Newsnews.ycombinator.com ↗https://news.ycombinator.com/item?id=49420598 ↩

延伸阅读