在服务器上执行一条命令,就能得到一个可以被 clone 的仓库:
$ git init --bare /srv/git/demo.git$ ls /srv/git/demo.gitHEAD branches config description hooks info objects refs这就是裸仓库(bare repository)。平时 clone 下来的仓库有一个能编辑文件的工作区,.git 目录藏在工作区里面;裸仓库只有 .git 里的那些东西:objects/ 存提交、目录树和文件内容,refs/ 记录每个分支和标签指向哪个提交,HEAD 说明默认分支是哪个。它没有工作区,也就没有“改到一半”的文件,别人往里 push 时不会和谁的未提交改动打架,所以服务器上放的都是裸仓库。
有了裸仓库,只要别人能用 SSH 登录这台机器,它就已经是一个 Git 服务器了:
$ 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。评审的单位是一个提交,而且没评审过的提交根本进不了主干。
这种模型适合贡献者多、主干只有一条、每个提交都要能单独评审和回滚的大项目,Android、Chromium、OpenStack、Qt 的代码评审都跑在 Gerrit 上。GitLab 后来从仓库托管长成了带 CI、issue、制品库的一整套 DevOps 平台;Gerrit 一直只做两件事,管仓库的读写,管评审,CI 之类交给外部系统通过投票接进来。
图 1 右边最特别的是那张“meta 上的提交”:评审记录不在数据库里,而在 Git 里。这一点可以直接验证。
现象:评审记录也能 git fetch
Gerrit 的文档里有这样一条命令4:
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: 1Change-id: I…Subject: Change subjectBranch: refs/heads/masterCommit: …Reviewer: Gerrit User 1 <1@gerrit>CC: Gerrit User 2 <2@gerrit>Label: Code-Review=-1, 1_1_1_code_review__1_1Label: 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 里这几层的样子:
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 成功了,引用却不存在
$ git push origin HEAD:refs/for/mainremote: 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 这条命令的去向:
图里没画出来的有三点。第一,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 是 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 提交:
图里的作者只写账号号和服务器 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 把同一次事务在两种格式下的做法和代价放在一起:
解决:reftable
reftable 是一种二进制引用存储格式,设计文档就放在 JGit 仓库里,JGit 的 FileRepository 和 DFS 后端都能用它;Git 2.45 也把它作为可选的引用后端收了进去23。它的文件一旦写成就不再修改:写入是往 tables.list 列出的那叠表上追加一个小表,读取从最新的表往旧查,删除写一条“墓碑”记录,小表多了由后台合并。图 7 打开 push patch set 2 时写出的那个小表:
表内的 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 是一个例子:
规则里有两处为性能做了取舍。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/config9a0b7e2… .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:
原子批量更新里,每条命令都带着期望的旧值,这就是 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:
缓存:按哈希寻址,不用失效
从 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:
其中关键的是全局引用库(global-refdb,可以是 Zookeeper 这类支持原子比较并设置的存储),它为每个可变引用记着最新的哈希。这一节的机制来自插件文档,不在 core 源码里;core 留下的挂点是可替换的 GitRepositoryManager 和 DynamicRefDbRepository,文档说插件正是包装 GitRepositoryManager 来检查每次 Git 交互。插件还要求先迁移到 NoteDb,这是推断:只有所有状态都在引用里,按引用做 CAS 的全局库才管得住全部写入。
小结
从一个裸仓库出发,这篇文章的问题链是这样的:
- 有了 GitLab,为什么还有 Gerrit? 裸仓库加 SSH 只是最小的服务器,平台要补上权限和评审。Gerrit 出自 Google 的强制评审传统,比 GitLab 更早;它评审的是单个提交,没评审过的提交进不了主干,评审记录本身也写进 Git。
- 为什么用 JGit? Git 的
pre-receive只能整体放行或拒绝,Gerrit 需要接管命令、替换仓库视图,于是把 JGit 当库嵌进进程,只在仓库对象和 push 钩子两处插手。 - push 到
refs/for/main发生了什么? Gerrit 在钩子里按 Change-Id 认领提交、分配编号,原子地写下 patch set 引用和 meta 引用,再把原命令标成 OK,JGit 就不会创建它。 - 评审数据为什么进 Git? ReviewDb 和 Git 两份存储难以一起备份和复制;NoteDb 把每次评审操作写成 meta 分支上的一个提交,读时从新到旧重放。
- 引用爆炸怎么办? NoteDb 依赖原子批量更新,packed-refs 的代价与引用总数成线性;reftable 用不可变小表的追加、前缀压缩和块索引,把查找和更新都降到与引用总数基本无关。
- 谁能看见什么? 替换引用数据库,让 JGit 只看见过滤后的引用;push 广告去掉
refs/changes/*,用.have弥补协商。 - 合并怎么保证一致? 一个项目内,分支和所有 meta 引用在同一个原子批次里 CAS;冲突就整体重试;跨项目只能依次执行。
- 索引和缓存怎么配合? 索引负责找,用
ref_state发现自己过期;缓存按 meta 哈希寻址,不需要失效;真相永远在 NoteDb。 - 多台机器呢? 复制插件推引用就复制了评审数据;多站点再加全局引用库防脑裂、消息队列同步索引和缓存。
手边有一个 Gerrit 账号的话,可以自己看看这些引用:
# 某个 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
-
Gerrit Background收藏gerrit.googlesource.comMondrian 与 Perforce 触发器强制提交前评审;Rietveld 只是建议性的;AOSP 选了 Git;因访问控制分叉并改名 Gerrithttps://gerrit.googlesource.com/gerrit.wiki/+/2f105b4d4083c0c7d6037853b507a2f91f2abc89/Background.wiki ↩ ↩2
-
Gerrit's History收藏gerritcodereview.comGerrit 从 Rietveld 的补丁起步;2.x 用 Java 重写,3.x 用 NoteDb 取代 SQL 数据库https://gerritcodereview.com/about.html ↩ ↩2 ↩3
-
GitLab Inc.收藏en.wikipedia.orgGitLab 项目 2011 年由 Dmytro Zaporozhets 在乌克兰开始,公司 2014 年成立https://en.wikipedia.org/wiki/GitLab_Inc. ↩
-
Gerrit Code Review - NoteDb Backend收藏github.com
Documentation/note-db.txt:NoteDb 的优点、格式和迁移https://github.com/GerritCodeReview/gerrit/blob/master/Documentation/note-db.txt ↩ ↩2 -
CommitMessageOutputTest.java收藏github.commeta 提交信息的逐字断言,包括投票和 Submit 两种https://github.com/GerritCodeReview/gerrit/blob/master/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java ↩
-
eclipse-jgit/jgit收藏github.comhttps://github.com/eclipse-jgit/jgit ↩
-
githooks收藏git-scm.compre-receive 只能整体放行或拒绝;proc-receive 可以接管匹配的命令https://git-scm.com/docs/githooks ↩
-
Git 2.29 发布说明收藏github.comreceive-pack 把引用更新外包给新的 proc-receive 钩子https://github.com/git/git/blob/master/Documentation/RelNotes/2.29.0.adoc ↩
-
GerritCodeReview/gerrit收藏github.com本文对照的是 2026-10-14 的 master 浅克隆,行号以此为准https://github.com/GerritCodeReview/gerrit ↩
-
ReceivePack.java收藏github.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 ↩ -
ReceiveCommits.java收藏github.com
processCommandsUnsafe()、selectNewAndReplacedChangesFromMagicBranch()(:2693)、setResult(OK)(:1325)https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/git/receive/ReceiveCommits.java ↩ -
commit-msg 钩子收藏github.comChange-Id =
I+ 对提交者身份、HEAD 和提交信息做git hash-objecthttps://github.com/GerritCodeReview/gerrit/blob/master/resources/com/google/gerrit/server/tools/root/hooks/commit-msg ↩ -
RepoSequence.java收藏github.comchange 编号存在
refs/sequences/changes指向的 blob 里,按批领取https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/notedb/RepoSequence.java ↩ -
Gerrit Code Review - Backup收藏github.com2.x 时代数据库和 Git 数据要放在同一个卷上做快照https://github.com/GerritCodeReview/gerrit/blob/master/Documentation/backup.txt ↩
-
ChangeUpdate.java收藏github.com
applyImpl()拼出 meta 提交的尾注;addLabelFooter()写Label:https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/notedb/ChangeUpdate.java ↩ -
ChangeNotesParser.java收藏github.com
parseAll()从 meta 的 tip 往旧遍历https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/notedb/ChangeNotesParser.java ↩ -
NoteDbUpdateManager.java收藏github.com
:319-326:先写项目仓库,再写 All-Users 的草稿,两者不是原子的https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java ↩ -
Gerrit Code Review - System Design收藏github.com
Documentation/dev-design.txt:NoteDb、读权限、索引、冗余与可靠性https://github.com/GerritCodeReview/gerrit/blob/master/Documentation/dev-design.txt ↩ ↩2 ↩3 ↩4 -
BatchUpdate.java收藏github.com
:698:引用库不支持原子批量更新就不能用 NoteDbhttps://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/update/BatchUpdate.java ↩ -
NoteDbUpdateExecutor.java收藏github.com
bru.setAtomic(true)(:72)https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/notedb/NoteDbUpdateExecutor.java ↩ -
PackedBatchRefUpdate.java收藏github.com类注释:原子批量更新要重写 packed-refs,耗时与引用总数成线性https://github.com/eclipse-jgit/jgit/blob/master/org.eclipse.jgit/src/org/eclipse/jgit/internal/storage/file/PackedBatchRefUpdate.java ↩
-
reftable收藏github.com
Documentation/technical/reftable.md:格式、事务、压缩和性能数字https://github.com/eclipse-jgit/jgit/blob/master/Documentation/technical/reftable.md ↩ -
Git 2.45 发布说明收藏github.comreftable 作为引用存储后端进入 Git 本身https://github.com/git/git/blob/master/Documentation/RelNotes/2.45.0.adoc ↩
-
gerrit convert-ref-storage收藏github.comhttps://github.com/GerritCodeReview/gerrit/blob/master/Documentation/cmd-convert-ref-storage.txt ↩
-
PermissionAwareReadOnlyRefDatabase.java收藏github.comhttps://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/git/PermissionAwareReadOnlyRefDatabase.java ↩
-
DefaultRefFilter.java收藏github.com
filterRefs();change 引用按可见 change 集合判断,tag 做可达性计算https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/permissions/DefaultRefFilter.java ↩ -
RefVisibilityControl.java收藏github.com
isVisible():每类 Gerrit 引用的可见规则https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/permissions/RefVisibilityControl.java ↩ -
ReceiveCommitsAdvertiseRefsHook.java收藏github.compush 广告去掉
refs/changes/*,补最近 32 个 open change 作为.havehttps://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java ↩ -
Submit Types收藏github.com
Documentation/config-project-config.txt的 submit-type 一节https://github.com/GerritCodeReview/gerrit/blob/master/Documentation/config-project-config.txt ↩ -
MergeOp.java收藏github.com
merge():索引算集合、NoteDb 重读、integrateIntoHistory整体重试https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/submit/MergeOp.java ↩ -
BatchUpdates.java收藏github.com
execute():多个项目的 BatchUpdate 依次执行,之后才建索引、发事件https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/update/BatchUpdates.java ↩ -
StalenessChecker.java收藏github.com比较索引里的
ref_state与仓库里的引用值;字段定义见ChangeField.javahttps://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/index/change/StalenessChecker.java ↩ -
gerrit.config: index收藏github.com
index.autoReindexIfStale、index.indexChangesAsync、receive.advertiseOpenChangesRefshttps://github.com/GerritCodeReview/gerrit/blob/master/Documentation/config-gerrit.txt ↩ -
ChangeNotesCache.java收藏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 ↩ -
Gerrit Code Review - Glossary收藏github.comprimary、replica、multi-primary、multi-site 的定义https://github.com/GerritCodeReview/gerrit/blob/master/Documentation/glossary.txt ↩
-
replication: Remove the NewProjectCreatedListener implementation收藏gerrit.googlesource.com这次提交的 diff 里可以看到
ReplicationQueue implements ... GitReferenceUpdatedListenerhttps://gerrit.googlesource.com/plugins/replication/+/c16fe9c5a6da6c23dbf45a533f788dcc527e209c%5E%21 ↩ -
Replication plugin configuration收藏gerrit.googlesource.com
replicationDelay默认 15 秒;push默认refs/*:refs/*;replicateNoteDbMetaRefs默认 truehttps://gerrit.googlesource.com/plugins/replication/+/refs/heads/master/src/main/resources/Documentation/config.md ↩ -
multi-site plugin DESIGN.md收藏gerrit.googlesource.comsplit brain、global ref-db(比较并设置)、events brokerhttps://gerrit.googlesource.com/plugins/multi-site/+/HEAD/DESIGN.md ↩