连评审记录都是提交 左边一条 meta 引用上挂着 Patch Set 1、Verified +1、Code-Review +2 三条评审记录,每条都是一个提交;下面是 change 1234 叠起的三张 patch set 卡片,拿到 +2 后 PS3 被 submit 进 main;右边淡淡的是其它还在评审中的 change。 Patch Set 1 Verified +1 Code-Review +2 PS3 1234

在服务器上执行一条命令,就能得到一个可以被 clone 的仓库:

Terminal window
$ git init --bare /srv/git/demo.git
$ ls /srv/git/demo.git
HEAD branches config description hooks info objects refs

这就是裸仓库(bare repository)。平时 clone 下来的仓库有一个能编辑文件的工作区,.git 目录藏在工作区里面;裸仓库只有 .git 里的那些东西:objects/ 存提交、目录树和文件内容,refs/ 记录每个分支和标签指向哪个提交,HEAD 说明默认分支是哪个。它没有工作区,也就没有“改到一半”的文件,别人往里 push 时不会和谁的未提交改动打架,所以服务器上放的都是裸仓库。

有了裸仓库,只要别人能用 SSH 登录这台机器,它就已经是一个 Git 服务器了:

Terminal window
$ git clone ssh://alice@git.example.com/srv/git/demo.git
# 客户端通过 SSH 让服务器执行:git-upload-pack '/srv/git/demo.git'
$ git push origin main
# 客户端通过 SSH 让服务器执行:git-receive-pack '/srv/git/demo.git'

git-upload-pack 把客户端缺的对象打成一个包发过去,git-receive-pack 收下客户端推来的对象,再更新 refs/heads/main。这两个程序随 Git 一起安装,服务器上不用再跑任何别的服务。

这个最小的服务器能用,但一个团队用起来,很快会碰到它管不了的事。能登录这台机器的人,就能往任何分支推任何东西,Git 本身没有“谁能改哪个分支”的概念。更要紧的是,提交从开发者的电脑直接进了 main,中间没有任何人看过。代码平台就是围绕这些缺口长出来的:账号和权限、代码评审,再加上网页界面、CI 触发和多机复制。

GitLab 是我们最熟悉的一种代码平台。那为什么还会有 Gerrit?两者看起来都是“托管仓库加代码评审”,Gerrit 的界面也远没有 GitLab 丰富。要回答这个问题,得先看 Gerrit 是在什么背景下、为了解决什么问题出现的。顺着它的来历往下挖,我们会碰到 Gerrit 在架构上最与众不同的地方。


一、Gerrit 的来历:先评审,后合并

背景:Google 的强制评审

Gerrit 的源头在 Google 内部。Google 早年用 Perforce 管理代码,配了一个叫 Mondrian 的评审工具。Mondrian 加上 Perforce 上的触发器,规定一个改动必须先经过同事评审,才能提交进中央仓库,Google 的工程师认为这套流程对日常工作很有帮助1。Mondrian 依赖 Perforce 和 Google 内部服务,没有开源。Python 的作者 Guido van Rossum 后来把它的一部分做成了开源的 Rietveld,跑在 Google App Engine 上,支持 Subversion。但 Rietveld 只是“建议性”的:它让大家方便地看代码、写意见,却拦不住没评审过的代码被提交2。

后来 Google 开源 Android,Android 开源项目(AOSP)选了 Git 做版本控制。用惯了 Mondrian 的工程师希望在 Git 上也有同样的流程,于是 Gerrit 一开始只是给 Rietveld 打的一组补丁,专门服务 AOSP。补丁里加的访问控制会让 Rietveld 的代码变复杂,Rietveld 的作者不愿合入,项目就分了出来。分出来就需要新名字:Rietveld 得名于荷兰建筑师 Gerrit Rietveld,新项目就取了他的名,叫 Gerrit1。之后的 Gerrit 2 是一次彻底重写,从 App Engine 上的 Python 改成了 Java 加 SQL 数据库;到 3.x,连 SQL 数据库也去掉了,所有元数据都存进 Git2。

GitLab 则是 2011 年才由 Dmytro Zaporozhets 开始写的3,比 Gerrit 晚了好几年。所以“有了 GitLab 为什么还有 Gerrit”这个问题要倒过来看:Gerrit 先出现,两者对“代码怎样进入主干”给了两种不同的回答。

两种评审模型

GitLab 沿用了 GitHub 的做法。开发者把提交推到一个分支上,比如 feature,再为它开一个合并请求(merge request)。评论、审批这些评审数据存在 GitLab 的数据库里,挂在这个合并请求上;要根据意见修改,就在分支上追加提交;评审通过后点“合并”,整个分支一起进入 main。评审的单位是一个分支。

Gerrit 继承的是 Mondrian 那一套。开发者不建分支,直接执行 git push origin HEAD:refs/for/main,意思是“我想把这个提交放进 main,请先评审”。Gerrit 接住它,变成一个 change;要修改,就 git commit --amend 之后再推一次,同一个 change 多出一个新版本(patch set)。每个提交单独评审、单独投票,拿到项目要求的票(比如 Code-Review +2)之后,才能 submit 进 main。评审的单位是一个提交,而且没评审过的提交根本进不了主干。

两种评审模型:GitLab 的合并请求与 Gerrit 的 change 左边 GitLab:git push origin feature 推到 refs/heads/feature 分支,分支上有三个提交,合并请求 !42 的评论和审批存在数据库里,评审通过后整个分支合并进 main。右边 Gerrit:git push origin HEAD:refs/for/main 生成 change 1234,每次 amend 后重推多一个 patch set,评审记录写在 meta 引用上的 Git 提交里,submit 后这一个提交进入 main。 GitLab · 评审一个分支 Gerrit · 评审一个提交 git push origin feature 分支 refs/heads/feature a1f3c2e 加缓存 7be09d4 修测试 c4d2a81 按评审意见修改 合并请求 !42 存在 数据库 里 评论 3 条 审批 1 / 1,可以合并 合并整个分支 主干 refs/heads/main 评审挂在分支上,记录在平台的数据库里。 git push origin HEAD:refs/for/main change 1234 refs/changes/34/1234/ 1 patch set 1 → 612f4c1 2 amend 后重推 → 9e07b3a meta 评审记录 meta 上的提交 存在 Git 里 Label: Code-Review=+2 Submit patch set 2 submit 这一个提交 主干 refs/heads/main 评审挂在单个提交上,记录本身也是 Git 提交。
图 1 · 同样是“评审后进主干”:GitLab 评审一个分支,记录放在数据库;Gerrit 评审一个提交,记录也写进 Git(哈希、编号为示例)

这种模型适合贡献者多、主干只有一条、每个提交都要能单独评审和回滚的大项目,Android、Chromium、OpenStack、Qt 的代码评审都跑在 Gerrit 上。GitLab 后来从仓库托管长成了带 CI、issue、制品库的一整套 DevOps 平台;Gerrit 一直只做两件事,管仓库的读写,管评审,CI 之类交给外部系统通过投票接进来。

图 1 右边最特别的是那张“meta 上的提交”:评审记录不在数据库里,而在 Git 里。这一点可以直接验证。

现象:评审记录也能 git fetch

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

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

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

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 提交,挂在 Git 引用上,这就是 Gerrit 在平台架构上最大的创新,后面的设计几乎都是围绕它展开的。我们从最底层说起:Gerrit 要在 push 时做这么多事,为什么不直接用 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)6。这和 Gerrit 的出身有关。按官方的说法,Gerrit 最早是给 Rietveld(Google App Engine 上的 Python 评审工具)打的一组补丁,为 Android 开源项目服务;到 2.x 才整个改写成跑在 Java 容器里的程序2。不过语言只解释了“为什么是 Java”,没有解释“为什么不在 Java 外面套一个 git 二进制”。

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

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

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

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

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

解决:把 JGit 当库嵌进进程

JGit 是一个库,ReceivePack 的每条命令都是一个 Java 对象,带着自己的结果状态。钩子不只能拒绝,还能把某条命令直接标成“已成功”,JGit 之后就不会再碰它(下一节细讲)。同样,UploadPack 读引用时调的是一个 Repository 对象,换掉这个对象,就能决定它看到什么。图 2 是 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 自己执行。
图 2 · 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)9。

还有一个好处是存储可以替换。仓库由 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)的命令10。

Gerrit 就利用了这一点。图 3 左边是 JGit 的流程,右边是 ReceiveCommits.processCommandsUnsafe() 在钩子里做的事11,红色是 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。
图 3 · 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 的仓库可以是只读的,图 2 里 newBatchUpdate() 直接抛异常。

Change-Id 和 change 编号

图 3 里有两个值来历不明:让两次 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 没变。
图 4 · Change-Id 由客户端算出、跨 amend 不变;change 编号由服务器从一个引用里按批领取(Change-Id、编号为示例)

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

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


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

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

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

解决:评审数据也是提交

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

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

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


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

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

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

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

同一次 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 小表越积越多时,由后台的 压缩器把相邻几个合成一个。
图 6 · 只改 2 个引用:packed-refs 要整份读写,reftable 只追加一个小表(数字为 JGit 文档在 Android 仓库上的测量)

解决:reftable

reftable 是一种二进制引用存储格式,设计文档就放在 JGit 仓库里,JGit 的 FileRepository 和 DFS 后端都能用它;Git 2.45 也把它作为可选的引用后端收了进去23。它的文件一旦写成就不再修改:写入是往 tables.list 列出的那叠表上追加一个小表,读取从最新的表往旧查,删除写一条“墓碑”记录,小表多了由后台合并。图 7 打开 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 点存完整名字。
图 7 · 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 <项目> 转成 reftable24。引用数量的问题有了答案,可这些引用里有私有 change、ACL 配置,还有 All-Users 里别人的账号数据。同一个仓库,怎么让不同的人看到不同的引用?


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

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

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

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

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

解决:换掉引用数据库

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

同一个仓库,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 来说就像不存在。
图 8 · 权限过滤发生在引用数据库这一层:看不见的引用,JGit 根本拿不到(分支名、change 号为示例)

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

这一层只管读。push 没有协议 v2,receive-pack 仍要先列出所有引用,大仓库会有几十 MB。Gerrit 在 push 的广告里去掉全部 refs/changes/*28;可这样客户端协商出的共同祖先会更旧,要多传服务端早有的对象。于是又补一招:把当前用户在这个项目里最近的 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),每个项目选一种29:MERGE_IF_NECESSARY(能快进就快进,否则生成合并提交,项目没配置时默认用它)、MERGE_ALWAYS(总是生成合并提交)、REBASE_IF_NECESSARY 和 REBASE_ALWAYS(变基到分支最新提交上,真的变了基就生成一个新 patch set)、FAST_FORWARD_ONLY(只允许快进),以及忽略依赖关系、逐个 cherry-pick 的 CHERRY_PICK。

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

MergeOp.merge() 先用索引算出要一起合并的 change 集合(注释提醒索引可能落后几秒),再从 NoteDb 重读一遍,剔除状态已不是 NEW 的30。然后每个 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,与分支一起生效。 各自原子, 合起来不是一个事务。
图 9 · 一个仓库内,分支和所有 meta 引用在同一个原子批次里前进;跨仓库则只是依次执行(哈希、change 号为示例)

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

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


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

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

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

解决:Lucene 索引负责“找”

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

索引、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。
图 10 · 索引负责“找”,NoteDb 负责“真”:缓存按 meta 的 SHA-1 寻址,索引用 ref_state 发现自己落后(哈希为示例)

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

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


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

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

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

复制:状态即引用的红利

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

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

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

复制与多站点:引用、事件和全局引用库 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/*
图 11 · 多站点时,Git 数据靠复制,索引和缓存靠事件,谁有资格写某个引用靠全局引用库的比较并设置(哈希为示例)

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


小结

从一个裸仓库出发,这篇文章的问题链是这样的:

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

手边有一个 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 BackgroundGerrit Wiki收藏gerrit.googlesource.comMondrian 与 Perforce 触发器强制提交前评审;Rietveld 只是建议性的;AOSP 选了 Git;因访问控制分叉并改名 Gerrithttps://gerrit.googlesource.com/gerrit.wiki/+/2f105b4d4083c0c7d6037853b507a2f91f2abc89/Background.wiki ↩ ↩2

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

  3. GitLab Inc.Wikipedia收藏en.wikipedia.orgGitLab 项目 2011 年由 Dmytro Zaporozhets 在乌克兰开始,公司 2014 年成立https://en.wikipedia.org/wiki/GitLab_Inc. ↩

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

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

  6. eclipse-jgit/jgitEclipse JGit收藏github.comhttps://github.com/eclipse-jgit/jgit ↩

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

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

  9. GerritCodeReview/gerritGerrit 源码收藏github.com本文对照的是 2026-10-14 的 master 浅克隆,行号以此为准https://github.com/GerritCodeReview/gerrit ↩

  10. ReceivePack.javaEclipse JGit收藏github.comservice()::2321 调 PreReceiveHook;executeCommands()(:1848)只执行 NOT_ATTEMPTED 的命令https://github.com/eclipse-jgit/jgit/blob/master/org.eclipse.jgit/src/org/eclipse/jgit/transport/ReceivePack.java ↩

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

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

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

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

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

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

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

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

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

  20. NoteDbUpdateExecutor.javaGerrit 源码收藏github.combru.setAtomic(true)(:72)https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/notedb/NoteDbUpdateExecutor.java ↩

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

  22. reftableEclipse JGit收藏github.comDocumentation/technical/reftable.md:格式、事务、压缩和性能数字https://github.com/eclipse-jgit/jgit/blob/master/Documentation/technical/reftable.md ↩

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

  24. gerrit convert-ref-storageGerrit 源码收藏github.comhttps://github.com/GerritCodeReview/gerrit/blob/master/Documentation/cmd-convert-ref-storage.txt ↩

  25. PermissionAwareReadOnlyRefDatabase.javaGerrit 源码收藏github.comhttps://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/git/PermissionAwareReadOnlyRefDatabase.java ↩

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

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

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

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

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

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

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

  33. gerrit.config: indexGerrit 源码收藏github.comindex.autoReindexIfStale、index.indexChangesAsync、receive.advertiseOpenChangesRefshttps://github.com/GerritCodeReview/gerrit/blob/master/Documentation/config-gerrit.txt ↩

  34. 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 ↩

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

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

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

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