git clone 大概是我们每天敲得最多的命令之一。终端里能看到的只有几行进度:
$ git clone http://127.0.0.1:8765/hello.gitCloning 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 的报文跟踪:
$ 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=2POST /hello.git/git-upload-pack command=ls-refsPOST /hello.git/git-upload-pack command=fetch第一次问服务器“你支持什么”,第二次问“你有哪些分支和标签”,第三次说“我要这些,打包发给我”。
这里有两个细节不太好理解。第一次请求的地址 info/refs?service=git-upload-pack 看起来像是在取一个文件,后面却跟着查询参数;请求头里还带着一个 Git-Protocol: version=2。为什么是 version=2?之前的版本又是什么样的? 这两个细节都是 Git 网络协议一路演进留下的痕迹,我们先把这段历史讲清楚。
背景:Git 网络协议的几代
Git 的网络协议前后换过几代,每一代都得照顾已经装在各处的老服务器和老客户端,第一次请求里的这两个细节就是这样留下来的。图 1 按时间把它们排了一遍。
上面一行:服务器先开口。 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):
所以今天的 Git 默认说 v2,遇到只会 v0 的服务器就跟着退回 v0。v0 更直接,也正好能看清 v2 要解决的问题,所以下面先用 -c protocol.version=0 让客户端退回 v0,看一看老协议是怎么 clone 的,再回到 v2。
报文的格式:pkt-line
先看 v0 下第一次请求返回的原始字节:
001e# service=git-upload-pack0000010b385ce2c3a5cb5625c9e0a291085a843e164d5c40 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.0003d385ce2c3a5cb5625c9e0a291085a843e164d5c40 refs/heads/main003c385ce2c3a5cb5625c9e0a291085a843e164d5c40 refs/tags/v1.00000每一行开头的 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 385ce2c3a5cb5625c9e0a291085a843e164d5c40fetch-pack> 0000fetch-pack> donefetch-pack< NAK PACK ...want 表示“我要这个提交以及它能走到的一切”,done 表示“我这边什么都没有,直接发吧”,服务器回一个 NAK(没有共同的提交),接着就是一个 packfile。
这套流程很简单,问题也很明显:不管客户端想要什么,服务器都要先把所有引用列一遍。 我们的演示仓库只有两个引用,无所谓;可一个托管在 Gerrit 上的大仓库,引用数可以到几十万(后面会看到 Android 的 86.6 万个)。哪怕只是 git fetch 一个分支,每次也要先下载这份几十 MB 的列表。
解决:协议 v2 把“列引用”变成一条命令
回到默认的 v2。客户端在第一次请求里带上 Git-Protocol: version=2,支持 v2 的服务器就以 version 2 开头回答,而且只返回能力,不再列引用:
version 2agent=git/2.43.0ls-refs=unbornfetch=shallow wait-for-doneserver-optionobject-format=sha1object-info0000客户端想看引用时,发一条 ls-refs 命令,并用 ref-prefix 说明只关心哪些前缀:
command=ls-refs0001peelsymrefsref-prefix HEADref-prefix refs/heads/ref-prefix refs/tags/0000服务器只回答这三个前缀下的引用(0001 是分隔包,把命令名和参数隔开):
385ce2c3a5cb5625c9e0a291085a843e164d5c40 HEAD symref-target:refs/heads/main385ce2c3a5cb5625c9e0a291085a843e164d5c40 refs/heads/main385ce2c3a5cb5625c9e0a291085a843e164d5c40 refs/tags/v1.00000最后一条 fetch 命令和 v0 的 want ... done 意思一样,返回的数据放在 packfile 段里。
收到的 packfile 里有什么
clone 完成后,用 git verify-pack 看一下收到的 pack:
385ce2c3 commit 204 144 12 second commit02380f44 commit 155 115 156 first commit45d9e6de 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.rs8 个对象,552 字节:两个提交,三个目录树,三个文件内容。也就是说,clone 下载的不是“文件”,而是从 want 的那个提交出发、沿着父提交和目录树能走到的所有对象,压缩后打成一个包。
一个值得注意的细节:服务器上的 hello.git 存的是 8 个松散对象(git count-objects 显示 count: 8,没有任何 pack)。客户端收到的这个 pack,是服务器为这一次请求现场算出来的。
增量 fetch:have 和 ACK
clone 是“我什么都没有”。如果本地已经有一部分历史呢?我们让另一个人往服务器推一个新提交 063a07b,再在本地 git fetch:
fetch> command=fetchfetch> want 063a07b721d0530f8c88c4187c61f64034638de4fetch> have 612f4c156e07fe1dba7c31b8b3d8bd110562e197fetch> have 385ce2c3a5cb5625c9e0a291085a843e164d5c40fetch> 0000fetch< acknowledgmentsfetch< ACK 612f4c156e07fe1dba7c31b8b3d8bd110562e197fetch< ACK 385ce2c3a5cb5625c9e0a291085a843e164d5c40fetch< readyfetch< 0001fetch< 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> 0000git> PACK ...sideband< unpack oksideband< ok refs/heads/main客户端发的是一行命令“旧值 新值 引用名”加上一个 pack。服务器端的 receive-pack 依次做几件事:
- 把 pack 里的对象收进一个临时的隔离目录(
objects/incoming-XXXX),这时它们还不属于仓库。 - 运行
pre-receive钩子,钩子可以拒绝这次推送;拒绝了,隔离目录直接删掉。 - 把对象并入仓库,然后锁住
refs/heads/main(创建refs/heads/main.lock),检查它当前的值是不是客户端说的旧值385ce2c。是,就改成新值612f4c1;不是,说明有人抢先推了,拒绝。 - 运行
post-receive钩子,回复unpack ok和ok refs/heads/main。
分析:push 的提交点是引用更新
这里有一个后面会反复用到的观察:一次 push 由两部分组成,一个 packfile 和一个引用事务(reference transaction)。对象写进仓库并不会被任何人看见,因为没有引用指向它们;只有引用从旧值改成新值的那一刻,新的提交才算“推上去了”。引用更新就是 push 的提交点,而“检查旧值再写新值”本质上就是一次 compare-and-swap。
新的问题
这个最小服务器能正常 clone、fetch、push,但只要稍微想一下真正的托管场景,问题就出来了:
- 谁能读写?
http-backend没有用户的概念,也没法做到“这个分支只有组长能推”。 - 仓库放在哪? 所有仓库都在这台机器的一块磁盘上。盘满了、机器坏了怎么办?
- 多台机器怎么保持一致? 一旦有了副本,push 的那次 compare-and-swap 要在哪台机器上做、怎么让所有副本都同意?
- 谁能看见哪些引用? 如果引用本身也承载了数据(比如代码评审),引用会变得非常多,而且不是所有人都该看见所有引用。
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 要经过这几个组件:
- Workhorse(Go 写的反向代理)接住
info/refs和git-upload-pack请求(路由在upstream/routes.go)。它不急着处理,而是先调用PreAuthorize:把同一个 URL、同样的请求头、去掉请求体,转发给 Rails(api/api.go:428-462)。 - 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,防止把一个普通网页误当成授权结果。 - Workhorse 拿着这些信息直接调用 Gitaly 的
InfoRefsUploadPack和PostUploadPackWithSidechannel。 - Gitaly 在本地磁盘上执行
git upload-pack --stateless-rpc(smarthttp/upload_pack.go),和我们的最小服务器一模一样。
几个细节值得多看一眼。
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):
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):
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 的人都熟悉这个命令:
$ git push origin HEAD:refs/for/mainremote: SUCCESSremote: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):
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):
addPatchSetFooter(msg, patchSetId); // Patch-set: 2if (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 没有单独的业务数据库,整个评审系统的状态就是一堆引用。
新的问题一:引用太多了
每个 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 开头的注释写得很完整):
- 在临时目录里用
git index-pack --fix-thin收下 pack,检查连通性和 push 策略。 - 用本地的引用视图检查每条命令的旧值(
verify_txn)。 - 并行上传 pack、索引和一条新的日志
log/42.pb,都用“不存在才创建”。pack 按内容寻址,已经存在说明别人传过一模一样的字节,直接当成功。 - 用 CAS 改写
manifest.pb:把日志头从 41 改成 42,把新 pack 加进有效集合,条件是 ETag 还是自己读到的那个版本。 - 成功了才在本地应用引用更新,并回复客户端
ok。
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,不需要选主,也不需要投票,两台实例同时提交,只有一个能赢。
一次读:先问一句“有新东西吗”
读的一侧同样简单。每个读请求(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 | 一块本地磁盘 |
| 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 是唯一的提交点 |
Git 协议之所以能让这几种差别很大的实现并存,是因为它把服务器需要承诺的东西压缩得很小:一份引用列表,加上“给我从这些提交出发、减去那些提交的所有对象”。服务器怎么存、怎么复制、怎么鉴权,客户端完全看不见。
小结
顺着一次 git clone 往下追,问题是一个接一个冒出来的:
- clone 在网络上传了什么? 先列引用,再用
want/have协商,最后收一个现场算出来的 packfile。协议 v0 每次都列出全部引用,v2 用ls-refs和ref-prefix把它变成按需查询。 - 服务器要做什么? 最小的服务器只需要
git upload-pack和git receive-pack。push 由一个 pack 和一个引用事务组成,引用的“检查旧值再写新值”就是提交点。 - 仓库放在哪? GitLab 发现 NFS 撑不住 Git 的随机读写,于是用 Gitaly 把 Git 进程搬到磁盘旁边,Rails 只负责授权;并发 clone 用 pack-objects 缓存,单点问题用 Praefect 对引用事务投票。
- 引用多了、引用即数据时怎么办? Gerrit 把评审建模成引用(
refs/changes/*、NoteDb),随之而来的是引用爆炸和引用级权限:用 reftable 和协议 v2 应对数量,用替换引用数据库的方式做过滤。 - 能不能不靠副本投票? walgit 把真相放进对象存储里的预写日志,push 的提交点变成 manifest 的一次 CAS,读之前用一次条件 GET 确认新鲜度,本地磁盘退化成可以随时丢弃的缓存;代价是要绕开对象存储“每个对象每秒约一次写”的限制。
想亲手看一遍第一节的报文,只需要一个裸仓库和下面这条命令:
$ git clone --bare <任意一个本地仓库> /tmp/demo.git$ GIT_TRACE_PACKET=1 git clone file:///tmp/demo.git /tmp/demo 2>&1 | grep packet参考资料
Footnotes
-
Gitaly designgitlab.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 -
reftablegithub.com ↗
Documentation/technical/reftable.md,Android 86.6 万个引用的性能对比https://github.com/eclipse-jgit/jgit/blob/master/Documentation/technical/reftable.md ↩ -
Git at any scalecursor.com ↗https://cursor.com/blog/git-at-any-scale ↩
-
tobi/walgitgithub.com ↗README、
AGENTS.md、docs/ROUNDTRIPS.mdhttps://github.com/tobi/walgit ↩ -
Walgit: a Git server that runs as one binary in front of an object storenews.ycombinator.com ↗https://news.ycombinator.com/item?id=49420598 ↩
延伸阅读
- Git 1.6.6 发布说明github.com ↗smart HTTPhttps://github.com/git/git/blob/master/Documentation/RelNotes/1.6.6.adoc
- Git 2.16 发布说明github.com ↗协议就地升级机制https://github.com/git/git/blob/master/Documentation/RelNotes/2.16.0.adoc
- Git 2.26 发布说明github.com ↗v2 设为默认https://github.com/git/git/blob/master/Documentation/RelNotes/2.26.0.adoc
- Git 2.27 发布说明github.com ↗v2 默认值退回https://github.com/git/git/blob/master/Documentation/RelNotes/2.27.0.adoc
- Git 2.29 发布说明github.com ↗v2 再次设为默认https://github.com/git/git/blob/master/Documentation/RelNotes/2.29.0.adoc
- Protocol v2git-scm.com ↗https://git-scm.com/docs/protocol-v2
- pack-protocolgit-scm.com ↗https://git-scm.com/docs/pack-protocol
- http-protocolgit-scm.com ↗https://git-scm.com/docs/http-protocol
- git-http-backendgit-scm.com ↗https://git-scm.com/docs/git-http-backend
- githooks: reference-transactiongit-scm.com ↗https://git-scm.com/docs/githooks#_reference_transaction
- gitlab-org/gitalygitlab.com ↗
doc/sidechannel.md、doc/design_pack_objects_cache.md、doc/design_ha.md、doc/object_quarantine.mdhttps://gitlab.com/gitlab-org/gitaly - gitlab-org/gitlabgitlab.com ↗
workhorse/、lib/gitlab/git_access.rbhttps://gitlab.com/gitlab-org/gitlab - gitlab-org/gitlab-shellgitlab.com ↗https://gitlab.com/gitlab-org/gitlab-shell
- GerritCodeReview/gerritgithub.com ↗
Documentation/note-db.txt、server/git/receive/https://github.com/GerritCodeReview/gerrit - eclipse-jgit/jgitgithub.com ↗https://github.com/eclipse-jgit/jgit