Gerrit 的文档里有这样一条命令1:

Terminal window
git fetch https://gerrit.googlesource.com/gerrit refs/changes/70/98070/meta \
&& git log -p FETCH_HEAD

它拉下来的不是代码,而是 Gerrit 自己项目里第 98070 号 change(Gerrit 对“一份待评审的修改”的叫法)的评审记录。git log 打出来的每个提交,都是一次评审动作:谁加了评审人、谁投了什么票、什么时候被合并。Gerrit 的单元测试逐字断言了这种提交的格式,其中一个长这样2:

Update patch set 1
Patch-set: 1
Change-id: I…
Subject: Change subject
Branch: refs/heads/master
Commit: …
Reviewer: Gerrit User 1 <1@gerrit>
CC: Gerrit User 2 <2@gerrit>
Label: Code-Review=-1, 1_1_1_code_review__1_1
Label: Verified=+1, 1_1_1_verified_1_2

一次 Code-Review -1 和一次 Verified +1,就是一个 Git 提交。看到这里,我们大概会冒出一串疑问:一个代码评审系统,为什么要把投票写成提交?这些提交挂在哪个引用上,是在什么时候、由谁写进去的?每次评审都多出几个引用,一个活跃的项目最后会有多少引用,Git 扛得住吗?引用里既有代码又有评审数据,还有别人没发出去的草稿,权限又怎么划?

《从 git clone 说起》里讲 Gerrit 的那一节,只走到“push 到 refs/for/main 之后多出了两个引用”。这篇接着往下挖,从 Gerrit 为什么要自己实现 Git 服务端说起。


一、Git 服务端的选择:为什么是 JGit

现象:Gerrit 不调用 git

GitLab 的 Gitaly 处理 clone 和 push 时,最终会去执行 git upload-pack、git receive-pack 这些 Git 自带的程序。Gerrit 不一样:clone、fetch、push 都在它自己的 JVM 里完成,不去调用 git 程序。干活的是 JGit,一个用纯 Java 重新实现的 Git 库,提供对象读写、打包,以及服务端的 UploadPack(服务 fetch)和 ReceivePack(服务 push)。这和 Gerrit 的出身有关。按官方的说法,Gerrit 最早是给 Rietveld(Google App Engine 上的 Python 评审工具)打的一组补丁,为 Android 开源项目服务;到 2.x 才整个改写成跑在 Java 容器里的程序3。不过语言只解释了“为什么是 Java”,没有解释“为什么不在 Java 外面套一个 git 二进制”。

第一次尝试:在 git 外面挂钩子

最直觉的做法,是让 Git 自己的 receive-pack 收 push,Gerrit 通过钩子插手。Git 在更新引用之前会调用 pre-receive 钩子,把每条命令 <old> <new> <ref> 从标准输入喂给它:

# pre-receive 读到的一行(示意)
0000000000000000000000000000000000000000 612f4c1… refs/for/main

问题在于这个钩子能做的事太少。文档写得很清楚:钩子以非零状态退出,所有引用都不更新;以零退出,引用就照常更新4。也就是说,钩子只能对客户端的命令说“行”或“不行”。而 Gerrit 要做的是第三种事:客户端说“把提交写到 refs/for/main”,服务端回答“好的”,实际却写到 refs/changes/34/1234/1。 用 pre-receive 放行,Git 会真的创建 refs/for/main;拒绝,客户端就看到报错。

能让钩子接管命令的 proc-receive,要到 Git 2.29 才出现5,那时 Gerrit 早已在 JGit 上运行多年。

解决:把 JGit 当库嵌进进程

JGit 是一个库,ReceivePack 的每条命令都是一个 Java 对象,带着自己的结果状态。钩子不只能拒绝,还能把某条命令直接标成“已成功”,JGit 之后就不会再碰它(下一节细讲)。同样,UploadPack 读引用时调的是一个 Repository 对象,换掉这个对象,就能决定它看到什么。图 1 是 Gerrit 里这几层的样子:

Gerrit 进程里的 Git 服务端分层 git fetch 和 git push 经 HTTPS 或 SSH 进入 Gerrit,交给 JGit 的 UploadPack 和 ReceivePack;两者拿到的仓库都是 PermissionAwareRepository 包装过的视图,ReceivePack 的 PreReceiveHook 由 Gerrit 的 ReceiveCommits 接管;最底层是 GitRepositoryManager 打开的 FileRepository。 客户端 Gerrit 进程(一个 JVM) git fetch git push 协议入口 HTTPS / SSH 29418 GitOverHttpServlet extends GitServlet(JGit) sshd: git-upload-pack → Upload / Receive 命令 UploadPack JGit new UploadPack(wrap(repo)) ls-refs / fetch / want ReceivePack JGit new ReceivePack(wrap(repo)) setPreReceiveHook(asHook()) PermissionAwareRepository wrap(repo) 的结果 getRefsByPrefix() / exactRef() 先过 DefaultRefFilter,看不见就不返回 newBatchUpdate() 抛 UnsupportedOperationException GitRepositoryManager → FileRepository(RefDirectory 或 reftable) JGit 拿到的仓库是一个按当前用户过滤过的只读视图; push 的引用命令由 PreReceiveHook 里的 ReceiveCommits 自己执行。
图 1 · Gerrit 不重写 Git 协议,它把 JGit 当库嵌进自己的进程,在“仓库对象”和“push 钩子”两处插手

HTTP 入口 GitOverHttpServlet 直接继承 JGit 的 GitServlet,SSH 入口把 git-upload-pack 命令绑到 Upload 类;两条路最后都是 new UploadPack(...) 或 new ReceivePack(...)。Gerrit 插手的地方只有两处:传给它们的仓库先被 PermissionAwareRepositoryManager.wrap() 包了一层;ReceivePack 的 PreReceiveHook 换成了 Gerrit 自己的 ReceiveCommits(AsyncReceiveCommits.java:319-329)。

还有一个好处是存储可以替换。仓库由 GitRepositoryManager 打开,开源版默认是本地磁盘上的 FileRepository;JGit 另有一套 DfsRepository 抽象,可以把 pack 和引用放到分布式存储上。协议层不用改,换的只是最底下那一层。

JGit 让 Gerrit 可以接管 push。接管之后,Gerrit 在钩子里到底做了什么?


二、push 到 refs/for/main:一次投递的全过程

现象:push 成功了,引用却不存在

Terminal window
$ git push origin HEAD:refs/for/main
remote: https://gerrit.example.com/c/hello/+/1234 add lib [NEW]
* [new reference] HEAD -> refs/for/main

客户端看到的是 [new reference],服务器上却查不到 refs/for/main。refs/for/ 这类分支在 Gerrit 里叫 magic branch:判断方法只有一行,名字以 refs/for/ 开头就是(MagicBranch.java)。它像一个投递地址,告诉 Gerrit“这是一份待评审的提交,目标分支是 main”。

原因:命令的结果由钩子决定

JGit 的 ReceivePack.service() 按固定顺序做事:读命令,收 pack 并检查对象是否连通,调用 PreReceiveHook,执行引用更新,回报结果。关键在执行这一步:executeCommands() 只处理结果还是“未尝试”(NOT_ATTEMPTED)的命令6。

Gerrit 就利用了这一点。图 2 左边是 JGit 的流程,右边是 ReceiveCommits.processCommandsUnsafe() 在钩子里做的事7,红色是 refs/for/main 这条命令的去向:

push 到 refs/for/main 时 JGit 与 Gerrit 的分工 JGit 的 ReceivePack 读到命令 0000000 → 612f4c1 refs/for/main,收下 pack 后调用 PreReceiveHook;Gerrit 的 ReceiveCommits 解析目标分支、按 Change-Id 查找、分配编号 1234,在一次 BatchRefUpdate 里写 refs/changes/34/1234/1 和 meta,然后把 refs/for/main 命令标记为 OK;JGit 的 executeCommands 只执行仍是 NOT_ATTEMPTED 的命令,于是跳过它,直接回报 ok。第二次 push 同一个 Change-Id 时生成 refs/changes/34/1234/2。 JGit · ReceivePack.service() Gerrit · ReceiveCommits(在钩子里) ReceiveCommand 客户端发来 ref refs/for/main old → new 0000000 → 612f4c1 result NOT_ATTEMPTED receivePackAndCheckConnectivity() preReceive.onPreReceive(cmds) executeCommands() 跳过已 OK 的 ok refs/for/main 回给客户端 第二次 push amend 之后 commit 9a0b7e2(哈希变了) Change-Id I8473b9…(没变) 结果 新增 refs/changes/34/1234/2 processCommandsUnsafe() 第一次 push parseMagicBranch 目标分支 refs/heads/main Change-Id I8473b9… 查无此 change nextChangeIds(1) 1234(refs/sequences/changes) BatchRefUpdate refs/changes/34/1234/1 → 612f4c1 refs/changes/34/1234/meta → 新提交 setResult(OK) refs/for/main 记为成功 Change-Id 没变,Gerrit 找到已有的 change 1234, 于是生成 ReplaceRequest,也就是一个新的 patch set。
图 2 · refs/for/main 只是一张“投递单”:Gerrit 在钩子里替它写了别的引用,再把它标成 OK,JGit 就不会去创建它(哈希、编号、Change-Id 为示例)

图里没画出来的有三点。第一,refs/for/main%topic=x,r=alice 里 % 之后是选项,parseMagicBranch() 会切出来,并检查推送者能不能往目标分支上传评审。第二,magic 命令和普通命令不能混在一次 push 里。第三,写引用用的是 BatchUpdate,Gerrit 把“改引用、改评审数据、发通知”打包执行的工具。

普通 push(HEAD:refs/heads/main)其实也不走 JGit 的执行:Gerrit 在钩子里做完权限检查,同样用 BatchUpdate 自己写引用。所以交给 JGit 的仓库可以是只读的,图 1 里 newBatchUpdate() 直接抛异常。

Change-Id 和 change 编号

图 2 里有两个值来历不明:让两次 push 对上号的 Change-Id,和新分配的编号 1234。前者由客户端生成,后者由服务器发放:

Change-Id 与 change 编号的来源 客户端的 commit-msg 钩子把提交者身份、HEAD 和提交信息交给 git hash-object,得到 Change-Id I8473b9…,写进提交信息末尾;amend 时已有 Change-Id 就不再改。服务器端,All-Projects 的 refs/sequences/changes 指向一个存着数字的 blob,进程一次领走 1234 到 1253 这一批,再逐个发放;change 1234 的 patch set 和 meta 引用都在 refs/changes/34/1234/ 下。 客户端 · commit-msg 钩子 服务器 git hash-object --stdin 输入 git var GIT_COMMITTER_IDENT git rev-parse HEAD 提交信息全文 Change-Id: I8473b9… 写在提交信息末尾 refs/sequences/changes All-Projects blob 内容 1254 本进程领到 1234 … 1253(一批 20 个) 这次发出 1234 同一个 change 的引用 I8473b9… refs/changes/34/1234/1 patch set 1 refs/changes/34/1234/2 patch set 2 refs/changes/34/1234/meta 评审记录 amend 时 Change-Id 已经存在,钩子不再改写: 提交哈希变了,Change-Id 没变。
图 3 · Change-Id 由客户端算出、跨 amend 不变;change 编号由服务器从一个引用里按批领取(Change-Id、编号为示例)

Change-Id 是 commit-msg 钩子写在提交信息末尾的一个尾注(Key: value 形式的行,Git 叫 trailer)8。钩子只在没有 Change-Id 时才添加,所以 git commit --amend 改了内容、哈希变了,Change-Id 照旧,Gerrit 就把新提交认作同一个 change 的下一个 patch set。

编号存在 All-Projects 仓库 refs/sequences/changes 指向的 blob 里。每次领号都是一次引用更新,为了少写几次,进程一次领一批(默认 20 个)放在内存里发,所以并发时编号不严格递增9。push 之后,仓库里多了两个引用,一个指向代码,一个叫 meta。meta 里装的是什么?评审数据为什么要放进 Git?


三、评审数据的存储:从 ReviewDb 到 NoteDb

现象:两份存储,两套一致性

Gerrit 2.x 的评审数据存在一个 SQL 数据库里,叫 ReviewDb;代码存在 Git 仓库里。一次上传要同时改两边:Git 里多一个 patch set 引用,数据库里多几行记录。两份存储带来的麻烦,在备份文档里看得最清楚:2.x 的数据库应当和 Git 数据放在同一台机器的同一个卷上,这样文件系统快照才能同时覆盖两者10。复制也一样,把 Git 仓库推到另一台机器很容易,数据库得另想办法;而一个 patch set 引用如果到了,对应的数据库行没到,另一边看到的就是一个半成品。

解决:评审数据也是提交

Gerrit 3.0 起只剩 NoteDb 一种后端,2.16 是迁移的最后一站1。文档列的好处正对着上面的问题:所有数据在一个地方;复制和备份只要对引用做快照,分支、patch set 和指向它们的元数据就一起到了;元数据的修改以提交的形式保存,历史天然留着;插件可以加字段,不用改数据库表结构。每个 change 的评审状态放在 refs/changes/34/1234/meta 这条“分支”上,每次操作(投票、加评审人、合并)都往上追加一个提交,尾注由 ChangeUpdate.applyImpl() 拼出11,读时由 ChangeNotesParser.parseAll() 从新到旧重放12。图 4 是一个 change 从创建到合并的 meta 提交:

NoteDb:change 1234 的 meta 分支 refs/changes/34/1234/meta 是一条提交链:5b7c9d2 创建 change(Patch-set 1、Change-id、Branch、Status new),8e21a04 记录一次 Code-Review+2 投票,c3d18f5 记录提交(Status merged、Submission-id 1234)。ChangeNotesParser 从最新提交往旧遍历,得到 status MERGED、approvals Code-Review +2 等状态;行内评论存在 meta 提交的 tree 里,是一个以 patch set 提交哈希为键的 NoteMap。 refs/changes/34/1234/meta(git log 顺序) 读出来的 change 状态 Submit patch set 2 c3d18f5 Author: Gerrit User 1000096 <1000096@0e1f…> Patch-set: 2 Status: merged Submission-id: 1234 Update patch set 2 8e21a04 Author: Gerrit User 1000233 <1000233@0e1f…> Patch-set: 2 Label: Code-Review=+2, 4c1d… Update patch set 1 5b7c9d2 Patch-set: 1 Change-id: I8473b95934b5732ac55d26311a706c9c2bde9940 Branch: refs/heads/main Status: new ChangeNotesState parseAll() status MERGED approvals Code-Review +2 branch refs/heads/main currentPatchSet 2 meta 提交的 tree NoteMap 键 9a0b7e2…(patch set 2) {"lineNbr": 158, "message": "nit: …", "unresolved": true} …(上传 patch set 2 的提交 a41c0e7,略) 从 tip 往旧走,每个字段以 第一次遇到的值为准。 行内评论不在提交信息里, 而在 tree 的 note 里。
图 4 · 一个 change 的评审状态就是一条提交链:每次操作追加一个提交,读时从新到旧重放(哈希、账号号、serverId 为示例)

图里的作者只写账号号和服务器 ID,不带真名和邮箱;投票尾注后的 4c1d… 是这张票的 UUID,供后续复制投票时引用。图里没有的是草稿:没发出的草稿评论放在 All-Users 仓库的 refs/draft-comments/34/1234/<账号号>,发布评论要同时改两个仓库,这两步不是原子的;NoteDbUpdateManager 特意先写项目仓库、再删草稿,失败时最多留下一份可以被过滤掉的旧草稿13。

同样的思路贯穿整个 Gerrit:权限配置在每个项目的 refs/meta/config 分支里,账号、群组在 All-Users 的 refs/users/*、refs/groups/* 下。整个系统没有独立的业务数据库,状态就是一组 Git 仓库里的引用14。代价也很直接:一次上传至少要动两个引用,而且必须一起成功。每次操作都要原子地改好几个引用,引用又越积越多,Git 的引用存储扛得住吗?


四、引用的存储:packed-refs 的瓶颈与 reftable

现象:原子更新的代价和引用总数成正比

BatchUpdate 执行前有一句检查:引用库不支持原子批量更新,就报错 “cannot use NoteDb with a repository that does not support atomic batch ref updates”15;真正写引用时,NoteDbUpdateExecutor 也总是 setAtomic(true)16。传统的引用存储(JGit 里叫 RefDirectory)把引用放在一个个小文件(loose ref)和一个按名字排序的文本文件 packed-refs 里。原子地改几个引用,要锁住相关文件、把 packed-refs 整份读进来改好、再整份写回。类注释直说:耗时与引用总数成线性,与这次改了几个无关17。

而 Gerrit 的引用数恰恰随评审历史线性增长。JGit 的 reftable 设计文档拿 Android 举例:86.6 万个引用,packed-refs 有 62 MB,改 2 个引用也要读进 62 MB、写出 62 MB18。图 5 把同一次事务在两种格式下的做法和代价放在一起:

同一次 2 个引用的原子更新:packed-refs 与 reftable 左边 packed-refs:锁住相关 loose 引用,读入整份 62.2 MB、86.6 万行的文件,在内存里改 2 行,再整份写出并改名,代价与引用总数成正比;按名字查一个引用冷缓存 409,660 微秒。右边 reftable:写一个只含这 2 个引用的小表,原子替换 tables.list,代价与改动大小成正比;按名字查一个引用冷缓存 33.9 微秒,文件 36.1 MB。 packed-refs(RefDirectory) reftable 锁住涉及的 loose 引用 读入整份 packed-refs 62.2 MB · 86.6 万行 在内存里改 2 行 整份写出,再改名替换 62.2 MB 写一个新的小表 只含这 2 个引用 改名替换 tables.list 旧表一字节不动 代价 packed-refs 写入 与引用总数成正比 查一个引用(冷) 409,660 µs 按 SHA-1 反查(冷) 412,535 µs 文件大小 62.2 MB 代价 reftable 写入 与本次改动成正比 查一个引用(冷) 33.9 µs 按 SHA-1 反查(冷) 323.2 µs 文件大小 36.1 MB 小表越积越多时,由后台的 压缩器把相邻几个合成一个。
图 5 · 只改 2 个引用:packed-refs 要整份读写,reftable 只追加一个小表(数字为 JGit 文档在 Android 仓库上的测量)

解决:reftable

reftable 是一种二进制引用存储格式,设计文档就放在 JGit 仓库里,JGit 的 FileRepository 和 DFS 后端都能用它;Git 2.45 也把它作为可选的引用后端收了进去19。它的文件一旦写成就不再修改:写入是往 tables.list 列出的那叠表上追加一个小表,读取从最新的表往旧查,删除写一条“墓碑”记录,小表多了由后台合并。图 6 打开 push patch set 2 时写出的那个小表:

一次 push 在 reftable 里写下的东西 reftable 目录下有一叠不可变的表,tables.list 从旧到新列出它们。push patch set 2 时只新写一个小表 00000123-00000123.ref,再原子地替换 tables.list。小表内部依次是 24 字节的 header、ref 块、log 块和 68 字节的 footer;ref 块里第一条记录存完整名字 refs/changes/34/1234/2,第二条只存后缀 meta,前缀长度 21。 $GIT_DIR/reftable/ 00000123-00000123.ref 的内部 tables.list 从旧到新 00000001-00000120.ref 压缩过的基表 00000121-00000121.ref 00000122-00000122.ref 00000123-00000123.ref 本次 push ReftableCompactor 后台合并小表 ref 块('r') 引用按名字排序 prefix_len suffix value 0 refs/changes/34/1234/2 9a0b7e2 21 meta a41c0e7 restart offset [28],count 1 log 块('g',zlib 压缩) update_index 123 refs/changes/34/1234/2 0000000 → 9a0b7e2 refs/changes/34/1234/meta 5b7c9d2 → a41c0e7 写:生成一个只含本次改动的小表, 再原子地替换 tables.list。 header 24B ref 块 'r' log 块 'g' footer 68B 引用很少,所以没有 ref index 和 obj 块 第二条只存后缀 meta,前 21 字节与上一条相同, 读的时候从上一条照抄;restart 点存完整名字。
图 6 · reftable 的写入只追加一个只含本次改动的小表,表内按名字排序并做前缀压缩(文件编号、哈希为示例)

表内的 ref 块按名字排序并做前缀压缩,块尾的 restart 表让读者可以二分查找;大文件还会有 ref index(每块最后一个名字)和 obj 块(从对象哈希反查引用)。文档明确说,这个格式假定“绝大多数引用只有一个 SHA-1 值,并且共享前缀,比如 Gerrit 的 refs/changes/”,并建议这类仓库用 64 KiB 的块。协议 v2 的 ls-refs ref-prefix refs/heads/ 这类前缀扫描,也只需读相关的块。已有仓库可以用 gerrit convert-ref-storage -p <项目> 转成 reftable20。引用数量的问题有了答案,可这些引用里有私有 change、ACL 配置,还有 All-Users 里别人的账号数据。同一个仓库,怎么让不同的人看到不同的引用?


五、引用的可见性:按权限过滤

现象:引用列表本身就是数据

Git 协议里,服务端先报出“我有哪些引用、各指向哪个哈希”,客户端再按哈希要对象。看得见 refs/changes/35/1235/1,就知道有一个 1235 号 change;拿到它的哈希,就能 fetch 到还没公开的代码。设计文档因此要求三件事:没有读权限的引用不出现在广告里;上传时拒绝基于看不见的提交;tag 只有在它指向的提交能从某个可见的非 tag 引用到达时才可见14。

第一次尝试:在广告时过滤

JGit 提供了 AdvertiseRefsHook,可以在发引用列表之前删掉一些。可列表只是读引用的入口之一。协议 v2 的 ls-refs 按前缀读,want-ref 按名字解析,客户端直接 want 一个哈希时,服务端还要检查它是不是某个可见引用的 tip(JGit 的 getTipsWithSha1())。只在广告那一刻过滤,就要保证这几条路径都不漏;这是推断,但 Gerrit 选择的做法正好绕开了这个问题。

解决:换掉引用数据库

Gerrit 交给 JGit 的仓库里,引用数据库是 PermissionAwareReadOnlyRefDatabase:getRefsByPrefix()、exactRef()、getTipsWithSha1() 都先过一遍权限过滤,写操作直接抛异常21。JGit 不管走哪条路径读引用,读到的都是过滤后的结果。判断规则在 DefaultRefFilter 和 RefVisibilityControl 里22 23,图 7 是一个例子:

同一个仓库,alice 看到的引用 仓库 hello.git 里有 8 个引用。PermissionAwareReadOnlyRefDatabase 把它们交给 DefaultRefFilter:分支看 READ 权限,change 引用看 change 是否可见,tag 看指向的提交能否从可见分支到达。alice 看到 main、v1.0 和 change 1234 的两个引用;refs/heads/secret、refs/meta/config、私有 change 1235 和 refs/cache-automerge 被过滤,对 JGit 来说不存在。 仓库里的全部引用 alice 执行 ls-refs 得到的 RefDatabase hello.git refs/heads/main refs/heads/secret refs/meta/config refs/tags/v1.0 refs/changes/34/1234/1 refs/changes/34/1234/meta refs/changes/35/1235/1 refs/cache-automerge/ab/… DefaultRefFilter 逐个引用判断 refs/heads/* 这个分支的 READ 权限 refs/changes/* change 是否可见(查索引) refs/tags/* 指向的提交从可见分支可达 可见 JGit 只拿到这些 refs/heads/main refs/tags/v1.0 refs/changes/34/1234/1 refs/changes/34/1234/meta 被过滤掉 refs/heads/secret 没有 READ 权限 refs/meta/config 没有 READ 权限 refs/changes/35/1235/1 别人的私有 change refs/cache-automerge/ab/… 对任何人都不可见 同一个仓库,不同的人 拿到不同的引用列表。 虚线:被过滤的引用, 对 JGit 来说就像不存在。
图 7 · 权限过滤发生在引用数据库这一层:看不见的引用,JGit 根本拿不到(分支名、change 号为示例)

规则里有两处为性能做了取舍。change 引用不逐个算权限,而是先从索引和缓存取出当前用户可见的 change 集合,再看编号在不在里面;用户对 refs/* 都有 READ 时走快路径,只单独判断 refs/meta/config。

这一层只管读。push 没有协议 v2,receive-pack 仍要先列出所有引用,大仓库会有几十 MB。Gerrit 在 push 的广告里去掉全部 refs/changes/*24;可这样客户端协商出的共同祖先会更旧,要多传服务端早有的对象。于是又补一招:把当前用户在这个项目里最近的 open change(默认 32 个,receive.advertiseOpenChangesRefs)的当前 patch set 作为 .have 行发出去。.have 的意思是“我有这个对象,但它不对应可以拉取的引用名”。广告大致长这样(示意):

385ce2c… refs/heads/main\0report-status delete-refs side-band-64k …
7f3d9b2… refs/meta/config
9a0b7e2… .have

用户刚推过的 change 往往是下一次 push 的基础,客户端就不必重传这部分对象。源码注释也坦白,这是一个启发式办法,有待度量。评审通过以后,change 要合进目标分支。分支、change 状态、可能还有好几个相关的 change,都得一起变。合并时怎么保证它们一起变?


六、提交(submit):合并策略与原子更新

现象:合并不只是移动一个分支

在 Gerrit 里点 Submit,至少要做三件事:按项目设定的方式把代码并进分支;把 change 的状态改成 MERGED;如果它依赖同一条提交链上的前几个 change,或者属于同一个 topic,要把它们一起合进去。要是分支先动了、meta 还没写 MERGED 时服务器挂掉,就会出现“代码已经在 main 上,change 却还是 open”的状态。所以分支引用和所有 meta 引用必须放进同一次原子更新。

合并方式叫提交类型(submit type),每个项目选一种25:MERGE_IF_NECESSARY(能快进就快进,否则生成合并提交,项目没配置时默认用它)、MERGE_ALWAYS(总是生成合并提交)、REBASE_IF_NECESSARY 和 REBASE_ALWAYS(变基到分支最新提交上,真的变了基就生成一个新 patch set)、FAST_FORWARD_ONLY(只允许快进),以及忽略依赖关系、逐个 cherry-pick 的 CHERRY_PICK。

解决:一个项目一批,失败整体重试

MergeOp.merge() 先用索引算出要一起合并的 change 集合(注释提醒索引可能落后几秒),再从 NoteDb 重读一遍,剔除状态已不是 NEW 的26。然后每个 SubmitStrategyOp 算出新的分支 tip,并把 change 标成 MERGED。同一个项目的这些改动,最后进同一个 setAtomic(true) 的 BatchRefUpdate:

submit 时的原子引用更新 MergeOp 先用索引算出要一起合并的 change,再从 NoteDb 重读;按 MERGE_IF_NECESSARY 生成操作,记下 main 当前值 385ce2c。hello.git 的三条命令(main 385ce2c → e4f0a17,两个 meta 引用)放在一个 atomic 的 BatchRefUpdate 里:旧值都对得上就一起生效;若 main 已被别人推进到 7d01e9b,整批以 LOCK_FAILURE 失败,由 RetryHelper 整体重试。跨项目 topic 时,每个仓库各自一批,依次执行。 MergeOp.merge() hello.git:一个 atomic 的 BatchRefUpdate 跨项目的 topic(hello 和 lib) completeChangeSet() 查索引,可能过期 reloadChanges() 从 NoteDb 重读 MergeIfNecessary 项目的提交类型 mergeTip = 385ce2c main 当前值 BatchUpdates.execute() 每个项目一批 ReceiveCommand × 3 全部成功或全部失败 refs/heads/main 385ce2c → e4f0a17 refs/changes/34/1234/meta 8e21a04 → c3d18f5 refs/changes/36/1236/meta 77a2c10 → 4f5d8e1 旧值都对得上 三个引用一起更新 main 前进到 e4f0a17 main 已是 7d01e9b LOCK_FAILURE 整批作废,整体重试 hello.git: BatchRefUpdate lib.git: BatchRefUpdate 两个 meta 上的新提交写着 Status: merged 和 Submission-id,与分支一起生效。 各自原子, 合起来不是一个事务。
图 8 · 一个仓库内,分支和所有 meta 引用在同一个原子批次里前进;跨仓库则只是依次执行(哈希、change 号为示例)

原子批量更新里,每条命令都带着期望的旧值,这就是 CAS(compare-and-swap,比较并交换):当前值等于旧值才写入。图 8 右边那种情况,MergeOp 用 RetryHelper 把整个 integrateIntoHistory 重做一遍,超时还按涉及的项目数放大。

跨项目的 topic 就没这么干净。BatchUpdates.execute() 对各项目依次执行27,MergeOp 的注释也承认失败时可能有一部分 change 已经合并;重试时它们被识别为 ALREADY_MERGED 跳过。这是“最终补齐”,不是跨仓库事务。引用写完之后,BatchUpdates 才去更新索引、发事件、发邮件。第一步查的是索引,第二步又回 NoteDb 重读。既然真相在 NoteDb 里,索引是干什么的?它和缓存怎么配合?


七、索引与缓存:在 NoteDb 之上查得快

现象:Git 只会按名字和哈希查找

设计文档说得很直白:Git 只支持按 SHA-1 或按引用名快速查找14。可评审系统天天要回答的是另一类问题:“我名下所有 open 的 change”“这个文件最近被哪些 change 改过”。在 NoteDb 上回答它们,只能把所有 meta 分支都重放一遍。

解决:Lucene 索引负责“找”

Gerrit 内置了四个 Lucene 索引:项目、账号、群组和 change。Lucene 是一个 Java 全文检索库,这里用来按字段查询。change 索引的每个文档存着状态、所有者、文件列表等字段,查询先在索引里找出 change 编号,再按需从 NoteDb 读取。索引是从 NoteDb 派生出来的,会落后。Gerrit 在每个文档里多存一个字段 ref_state,格式是 项目:引用名:哈希,记下建索引时读过的每个引用的值。StalenessChecker 把它和仓库里的当前值一比,就知道这份文档是不是过期了28。配置文档还提到,两次写入同时发生时存在竞争,其中一次可能没反映到索引里;打开 index.autoReindexIfStale,每次建完索引都再查一次是否过期,默认关闭29:

索引、NoteDb 与缓存的配合 查询 status:open 从 Lucene 索引里找到 change 1234,索引文档的 ref_state 记着 meta 引用当时的值 8e21a04。Gerrit 随后按 change 号读 NoteDb:exactRef 得到 meta 当前值 c3d18f5,用 (hello, 1234, c3d18f5) 查 change_notes 缓存,未命中才让 ChangeNotesParser 重放。StalenessChecker 发现 8e21a04 与 c3d18f5 不同,判定索引文档过期并重新索引。 Lucene 索引(change 文档) 按 change 号读 NoteDb status:open owner:self 查询 doc 1234 stored fields _id 1234 project hello status new ref_state …/1234/meta:8e21a04 exactRef("refs/changes/34/1234/meta") c3d18f5 change_notes 缓存 按 SHA-1 寻址 key (hello, 1234, c3d18f5) value ChangeNotesState:MERGED 未命中 ChangeNotesParser 从 c3d18f5 往旧走 StalenessChecker 比较 ref_state 索引里 8e21a04 仓库里 c3d18f5 结论 不一致 → 重新索引 change 1234 索引只负责找出“哪些 change”, 字段可能落后于 NoteDb。
图 9 · 索引负责“找”,NoteDb 负责“真”:缓存按 meta 的 SHA-1 寻址,索引用 ref_state 发现自己落后(哈希为示例)

缓存:按哈希寻址,不用失效

从 meta 重放出一个 change 的状态并不便宜。ChangeNotesCache 的键是 (项目, change 编号, meta 引用的哈希),值是解析好的 ChangeNotesState30。它被声明成可以落盘的缓存,不过 change_notes 的磁盘存储默认关闭,平时只在内存里。键里带着哈希,意味着同一个键对应的内容永远不会变:meta 一旦追加了新提交,哈希就变了,自然命中不到旧条目。读一个 change 的流程于是变成:先 exactRef 读 meta 的当前哈希(reftable 上几十微秒),再用它查缓存,未命中才真正遍历提交链。缓存不需要失效机制,这正是“状态即引用”带来的便利。到这里,一台 Gerrit 的全部状态是一组 Git 仓库,外加可以重建的索引和缓存。如果有好几台机器呢?


八、多台机器:复制与多站点

现象:一个 JVM,一个文件系统

设计文档对单机毫不含糊:Gerrit 是一个 JVM 进程,读写一个文件系统;机器或存储坏了,客户端只会收到错误14。最简单的扩展是只读副本(replica):用 --replica 启动,只服务 Git 读操作,没有 REST API、网页和搜索31,SSH 上的 git-receive-pack 直接回错误(DefaultCommandModule.java:87-91)。

复制:状态即引用的红利

副本上的数据由 replication 插件推过去。它的 ReplicationQueue 实现了 core 的 GitReferenceUpdatedListener,引用一更新就收到通知32,默认等 15 秒、按 refs/*:refs/* 推送,NoteDb 的 meta 引用也一起推33。评审数据就是引用,所以复制 Git 仓库就等于复制了评审系统。但复制是异步的,而且索引、缓存、网页会话不在 Git 里,它根本不管。

多站点:谁有资格写这个引用

如果不止一个站点能写,异步复制就会制造脑裂(split brain):site A 确认了一次 push,还没复制给 site B 就挂了;B 不知道自己落后,继续在旧值上接受写入,两边从此分叉。multi-site 插件的设计文档34给出三条通道,见图 10:

复制与多站点:引用、事件和全局引用库 site A 提交 change 后,main 从 385ce2c 变成 e4f0a17。启用 multi-site 时,这次更新要经过 global-refdb 的比较并设置;本地更新后触发 GitReferenceUpdated,replication 插件默认等 15 秒把引用推到 site B;索引和缓存事件经消息队列送到 site B。若 site B 还以为 main 是 385ce2c 并尝试写入,global-refdb 的比较失败,该引用暂时只读,直到复制追上。 site A(primary) 共享组件(multi-site 插件) site B site B 还没追上就想写 main BatchRefUpdate submit refs/heads/main 385ce2c → e4f0a17 GitReferenceUpdated 事件 replication 默认等 15 秒 索引 / 缓存事件 global-refdb 比较并设置 hello:refs/heads/main 385ce2c → e4f0a17 events broker 如 Kafka index: change 1234 cache-eviction stream: change-merged hello.git 复制目标 refs/heads/main 385ce2c → e4f0a17 Lucene 索引 重新索引 change 1234 site B:main = 385ce2c 期望 385ce2c,实际 e4f0a17 拒绝:这个引用暂时只读 改引用前先在这里比较, 旧值对不上就拒绝。 push refs/*:refs/*
图 10 · 多站点时,Git 数据靠复制,索引和缓存靠事件,谁有资格写某个引用靠全局引用库的比较并设置(哈希为示例)

其中关键的是全局引用库(global-refdb,可以是 Zookeeper 这类支持原子比较并设置的存储),它为每个可变引用记着最新的哈希。这一节的机制来自插件文档,不在 core 源码里;core 留下的挂点是可替换的 GitRepositoryManager 和 DynamicRefDbRepository,文档说插件正是包装 GitRepositoryManager 来检查每次 Git 交互。插件还要求先迁移到 NoteDb,这是推断:只有所有状态都在引用里,按引用做 CAS 的全局库才管得住全部写入。


小结

回到开头那条 git log,它背后的问题链是这样的:

  1. 为什么用 JGit? Git 的 pre-receive 只能整体放行或拒绝,Gerrit 需要接管命令、替换仓库视图,于是把 JGit 当库嵌进进程,只在仓库对象和 push 钩子两处插手。
  2. push 到 refs/for/main 发生了什么? Gerrit 在钩子里按 Change-Id 认领提交、分配编号,原子地写下 patch set 引用和 meta 引用,再把原命令标成 OK,JGit 就不会创建它。
  3. 评审数据为什么进 Git? ReviewDb 和 Git 两份存储难以一起备份和复制;NoteDb 把每次评审操作写成 meta 分支上的一个提交,读时从新到旧重放。
  4. 引用爆炸怎么办? NoteDb 依赖原子批量更新,packed-refs 的代价与引用总数成线性;reftable 用不可变小表的追加、前缀压缩和块索引,把查找和更新都降到与引用总数基本无关。
  5. 谁能看见什么? 替换引用数据库,让 JGit 只看见过滤后的引用;push 广告去掉 refs/changes/*,用 .have 弥补协商。
  6. 合并怎么保证一致? 一个项目内,分支和所有 meta 引用在同一个原子批次里 CAS;冲突就整体重试;跨项目只能依次执行。
  7. 索引和缓存怎么配合? 索引负责找,用 ref_state 发现自己过期;缓存按 meta 哈希寻址,不需要失效;真相永远在 NoteDb。
  8. 多台机器呢? 复制插件推引用就复制了评审数据;多站点再加全局引用库防脑裂、消息队列同步索引和缓存。

手边有一个 Gerrit 账号的话,可以自己看看这些引用:

Terminal window
# 某个 change 的所有引用(把 34/1234 换成真实的末两位/编号)
git ls-remote origin 'refs/changes/34/1234/*'
# 读它的评审记录
git fetch origin refs/changes/34/1234/meta && git log --format='%an%n%B' FETCH_HEAD
# 推送时带上 topic 和评审人
git push origin HEAD:refs/for/main%topic=demo,r=alice@example.com

参考资料

Footnotes

  1. Gerrit Code Review - NoteDb BackendGerrit 源码github.com ↗Documentation/note-db.txt:NoteDb 的优点、格式和迁移https://github.com/GerritCodeReview/gerrit/blob/master/Documentation/note-db.txt ↩ ↩2

  2. CommitMessageOutputTest.javaGerrit 源码github.com ↗meta 提交信息的逐字断言,包括投票和 Submit 两种https://github.com/GerritCodeReview/gerrit/blob/master/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java ↩

  3. Gerrit's HistoryGerrit Code Reviewgerritcodereview.com ↗Gerrit 从 Rietveld 的补丁起步;2.x 用 Java 重写,3.x 用 NoteDb 取代 SQL 数据库https://gerritcodereview.com/about.html ↩

  4. githooksGit 文档git-scm.com ↗pre-receive 只能整体放行或拒绝;proc-receive 可以接管匹配的命令https://git-scm.com/docs/githooks ↩

  5. Git 2.29 发布说明github.com ↗receive-pack 把引用更新外包给新的 proc-receive 钩子https://github.com/git/git/blob/master/Documentation/RelNotes/2.29.0.adoc ↩

  6. ReceivePack.javaEclipse JGitgithub.com ↗service()::2321 调 PreReceiveHook;executeCommands()(:1848)只执行 NOT_ATTEMPTED 的命令https://github.com/eclipse-jgit/jgit/blob/master/org.eclipse.jgit/src/org/eclipse/jgit/transport/ReceivePack.java ↩

  7. ReceiveCommits.javaGerrit 源码github.com ↗processCommandsUnsafe()、selectNewAndReplacedChangesFromMagicBranch()(:2693)、setResult(OK)(:1325)https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/git/receive/ReceiveCommits.java ↩

  8. commit-msg 钩子Gerrit 源码github.com ↗Change-Id = I + 对提交者身份、HEAD 和提交信息做 git hash-objecthttps://github.com/GerritCodeReview/gerrit/blob/master/resources/com/google/gerrit/server/tools/root/hooks/commit-msg ↩

  9. RepoSequence.javaGerrit 源码github.com ↗change 编号存在 refs/sequences/changes 指向的 blob 里,按批领取https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/notedb/RepoSequence.java ↩

  10. Gerrit Code Review - BackupGerrit 源码github.com ↗2.x 时代数据库和 Git 数据要放在同一个卷上做快照https://github.com/GerritCodeReview/gerrit/blob/master/Documentation/backup.txt ↩

  11. ChangeUpdate.javaGerrit 源码github.com ↗applyImpl() 拼出 meta 提交的尾注;addLabelFooter() 写 Label:https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/notedb/ChangeUpdate.java ↩

  12. ChangeNotesParser.javaGerrit 源码github.com ↗parseAll() 从 meta 的 tip 往旧遍历https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/notedb/ChangeNotesParser.java ↩

  13. NoteDbUpdateManager.javaGerrit 源码github.com ↗:319-326:先写项目仓库,再写 All-Users 的草稿,两者不是原子的https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java ↩

  14. Gerrit Code Review - System DesignGerrit 源码github.com ↗Documentation/dev-design.txt:NoteDb、读权限、索引、冗余与可靠性https://github.com/GerritCodeReview/gerrit/blob/master/Documentation/dev-design.txt ↩ ↩2 ↩3 ↩4

  15. BatchUpdate.javaGerrit 源码github.com ↗:698:引用库不支持原子批量更新就不能用 NoteDbhttps://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/update/BatchUpdate.java ↩

  16. NoteDbUpdateExecutor.javaGerrit 源码github.com ↗bru.setAtomic(true)(:72)https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/notedb/NoteDbUpdateExecutor.java ↩

  17. PackedBatchRefUpdate.javaEclipse JGitgithub.com ↗类注释:原子批量更新要重写 packed-refs,耗时与引用总数成线性https://github.com/eclipse-jgit/jgit/blob/master/org.eclipse.jgit/src/org/eclipse/jgit/internal/storage/file/PackedBatchRefUpdate.java ↩

  18. reftableEclipse JGitgithub.com ↗Documentation/technical/reftable.md:格式、事务、压缩和性能数字https://github.com/eclipse-jgit/jgit/blob/master/Documentation/technical/reftable.md ↩

  19. Git 2.45 发布说明github.com ↗reftable 作为引用存储后端进入 Git 本身https://github.com/git/git/blob/master/Documentation/RelNotes/2.45.0.adoc ↩

  20. gerrit convert-ref-storageGerrit 源码github.com ↗https://github.com/GerritCodeReview/gerrit/blob/master/Documentation/cmd-convert-ref-storage.txt ↩

  21. PermissionAwareReadOnlyRefDatabase.javaGerrit 源码github.com ↗https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/git/PermissionAwareReadOnlyRefDatabase.java ↩

  22. DefaultRefFilter.javaGerrit 源码github.com ↗filterRefs();change 引用按可见 change 集合判断,tag 做可达性计算https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/permissions/DefaultRefFilter.java ↩

  23. RefVisibilityControl.javaGerrit 源码github.com ↗isVisible():每类 Gerrit 引用的可见规则https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/permissions/RefVisibilityControl.java ↩

  24. ReceiveCommitsAdvertiseRefsHook.javaGerrit 源码github.com ↗push 广告去掉 refs/changes/*,补最近 32 个 open change 作为 .havehttps://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java ↩

  25. Submit TypesGerrit 源码github.com ↗Documentation/config-project-config.txt 的 submit-type 一节https://github.com/GerritCodeReview/gerrit/blob/master/Documentation/config-project-config.txt ↩

  26. MergeOp.javaGerrit 源码github.com ↗merge():索引算集合、NoteDb 重读、integrateIntoHistory 整体重试https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/submit/MergeOp.java ↩

  27. BatchUpdates.javaGerrit 源码github.com ↗execute():多个项目的 BatchUpdate 依次执行,之后才建索引、发事件https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/update/BatchUpdates.java ↩

  28. StalenessChecker.javaGerrit 源码github.com ↗比较索引里的 ref_state 与仓库里的引用值;字段定义见 ChangeField.javahttps://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/index/change/StalenessChecker.java ↩

  29. gerrit.config: indexGerrit 源码github.com ↗index.autoReindexIfStale、index.indexChangesAsync、receive.advertiseOpenChangesRefshttps://github.com/GerritCodeReview/gerrit/blob/master/Documentation/config-gerrit.txt ↩

  30. ChangeNotesCache.javaGerrit 源码github.com ↗缓存键 = (project, changeId, meta 的 ObjectId);change_notes 的磁盘存储默认关闭(config-gerrit.txt 的 cache.<name>.diskLimit)https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/notedb/ChangeNotesCache.java ↩

  31. Gerrit Code Review - GlossaryGerrit 源码github.com ↗primary、replica、multi-primary、multi-site 的定义https://github.com/GerritCodeReview/gerrit/blob/master/Documentation/glossary.txt ↩

  32. replication: Remove the NewProjectCreatedListener implementationGerrit 插件gerrit.googlesource.com ↗这次提交的 diff 里可以看到 ReplicationQueue implements ... GitReferenceUpdatedListenerhttps://gerrit.googlesource.com/plugins/replication/+/c16fe9c5a6da6c23dbf45a533f788dcc527e209c%5E%21 ↩

  33. Replication plugin configurationGerrit 插件gerrit.googlesource.com ↗replicationDelay 默认 15 秒;push 默认 refs/*:refs/*;replicateNoteDbMetaRefs 默认 truehttps://gerrit.googlesource.com/plugins/replication/+/refs/heads/master/src/main/resources/Documentation/config.md ↩

  34. multi-site plugin DESIGN.mdGerrit 插件gerrit.googlesource.com ↗split brain、global ref-db(比较并设置)、events brokerhttps://gerrit.googlesource.com/plugins/multi-site/+/HEAD/DESIGN.md ↩

延伸阅读