﻿---
title: "Gerrit 的架构：把代码评审写进 Git 引用"
description: "Gerrit 的评审记录可以用 git fetch 拉下来，一次投票就是一个提交。顺着这个现象往下追：Gerrit 为什么用 JGit 自己做 Git 服务端，push 到 refs/for/main 时发生了什么，评审数据为什么从 SQL 搬进 Git，引用多到几十万个之后怎么存、怎么按权限过滤，合并时怎么保证原子性，索引和缓存怎么配合，以及多台机器之间怎么同步。"
pubDate: 2026-10-17
tags: ["Git", "Gerrit", "源码分析", "分布式系统"]
refs:
  - id: about
    title: Gerrit's History
    url: https://gerritcodereview.com/about.html
    site: Gerrit Code Review
    note: "Gerrit 从 Rietveld 的补丁起步；2.x 用 Java 重写，3.x 用 NoteDb 取代 SQL 数据库"
  - id: dev-design
    title: Gerrit Code Review - System Design
    url: https://github.com/GerritCodeReview/gerrit/blob/master/Documentation/dev-design.txt
    site: Gerrit 源码
    note: "`Documentation/dev-design.txt`：NoteDb、读权限、索引、冗余与可靠性"
  - id: githooks
    title: githooks
    url: https://git-scm.com/docs/githooks
    site: Git 文档
    note: "pre-receive 只能整体放行或拒绝；proc-receive 可以接管匹配的命令"
  - id: relnotes-2.29
    title: Git 2.29 发布说明
    url: https://github.com/git/git/blob/master/Documentation/RelNotes/2.29.0.adoc
    note: "receive-pack 把引用更新外包给新的 proc-receive 钩子"
  - id: receivepack
    title: ReceivePack.java
    url: https://github.com/eclipse-jgit/jgit/blob/master/org.eclipse.jgit/src/org/eclipse/jgit/transport/ReceivePack.java
    site: Eclipse JGit
    note: "`service()`：`:2321` 调 PreReceiveHook；`executeCommands()`（`:1848`）只执行 `NOT_ATTEMPTED` 的命令"
  - id: receivecommits
    title: ReceiveCommits.java
    url: https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
    site: Gerrit 源码
    note: "`processCommandsUnsafe()`、`selectNewAndReplacedChangesFromMagicBranch()`（`:2693`）、`setResult(OK)`（`:1325`）"
  - id: commit-msg
    title: commit-msg 钩子
    url: https://github.com/GerritCodeReview/gerrit/blob/master/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
    site: Gerrit 源码
    note: "Change-Id = `I` + 对提交者身份、HEAD 和提交信息做 `git hash-object`"
  - id: reposequence
    title: RepoSequence.java
    url: https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/notedb/RepoSequence.java
    site: Gerrit 源码
    note: "change 编号存在 `refs/sequences/changes` 指向的 blob 里，按批领取"
  - id: notedb
    title: Gerrit Code Review - NoteDb Backend
    url: https://github.com/GerritCodeReview/gerrit/blob/master/Documentation/note-db.txt
    site: Gerrit 源码
    note: "`Documentation/note-db.txt`：NoteDb 的优点、格式和迁移"
  - id: backup
    title: Gerrit Code Review - Backup
    url: https://github.com/GerritCodeReview/gerrit/blob/master/Documentation/backup.txt
    site: Gerrit 源码
    note: "2.x 时代数据库和 Git 数据要放在同一个卷上做快照"
  - id: changeupdate
    title: ChangeUpdate.java
    url: https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/notedb/ChangeUpdate.java
    site: Gerrit 源码
    note: "`applyImpl()` 拼出 meta 提交的尾注；`addLabelFooter()` 写 `Label:`"
  - id: commit-msg-test
    title: CommitMessageOutputTest.java
    url: https://github.com/GerritCodeReview/gerrit/blob/master/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
    site: Gerrit 源码
    note: "meta 提交信息的逐字断言，包括投票和 Submit 两种"
  - id: notes-parser
    title: ChangeNotesParser.java
    url: https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
    site: Gerrit 源码
    note: "`parseAll()` 从 meta 的 tip 往旧遍历"
  - id: notedb-manager
    title: NoteDbUpdateManager.java
    url: https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
    site: Gerrit 源码
    note: "`:319-326`：先写项目仓库，再写 All-Users 的草稿，两者不是原子的"
  - id: batchupdate
    title: BatchUpdate.java
    url: https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/update/BatchUpdate.java
    site: Gerrit 源码
    note: "`:698`：引用库不支持原子批量更新就不能用 NoteDb"
  - id: notedb-executor
    title: NoteDbUpdateExecutor.java
    url: https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/notedb/NoteDbUpdateExecutor.java
    site: Gerrit 源码
    note: "`bru.setAtomic(true)`（`:72`）"
  - id: packed-batch
    title: PackedBatchRefUpdate.java
    url: https://github.com/eclipse-jgit/jgit/blob/master/org.eclipse.jgit/src/org/eclipse/jgit/internal/storage/file/PackedBatchRefUpdate.java
    site: Eclipse JGit
    note: "类注释：原子批量更新要重写 packed-refs，耗时与引用总数成线性"
  - id: reftable
    title: reftable
    url: https://github.com/eclipse-jgit/jgit/blob/master/Documentation/technical/reftable.md
    site: Eclipse JGit
    note: "`Documentation/technical/reftable.md`：格式、事务、压缩和性能数字"
  - id: convert-ref-storage
    title: gerrit convert-ref-storage
    url: https://github.com/GerritCodeReview/gerrit/blob/master/Documentation/cmd-convert-ref-storage.txt
    site: Gerrit 源码
  - id: relnotes-2.45
    title: Git 2.45 发布说明
    url: https://github.com/git/git/blob/master/Documentation/RelNotes/2.45.0.adoc
    note: "reftable 作为引用存储后端进入 Git 本身"
  - id: perm-refdb
    title: PermissionAwareReadOnlyRefDatabase.java
    url: https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/git/PermissionAwareReadOnlyRefDatabase.java
    site: Gerrit 源码
  - id: default-ref-filter
    title: DefaultRefFilter.java
    url: https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
    site: Gerrit 源码
    note: "`filterRefs()`；change 引用按可见 change 集合判断，tag 做可达性计算"
  - id: ref-visibility
    title: RefVisibilityControl.java
    url: https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
    site: Gerrit 源码
    note: "`isVisible()`：每类 Gerrit 引用的可见规则"
  - id: advertise-hook
    title: ReceiveCommitsAdvertiseRefsHook.java
    url: https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
    site: Gerrit 源码
    note: "push 广告去掉 `refs/changes/*`，补最近 32 个 open change 作为 `.have`"
  - id: submit-types
    title: Submit Types
    url: https://github.com/GerritCodeReview/gerrit/blob/master/Documentation/config-project-config.txt
    site: Gerrit 源码
    note: "`Documentation/config-project-config.txt` 的 submit-type 一节"
  - id: mergeop
    title: MergeOp.java
    url: https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/submit/MergeOp.java
    site: Gerrit 源码
    note: "`merge()`：索引算集合、NoteDb 重读、`integrateIntoHistory` 整体重试"
  - id: batchupdates
    title: BatchUpdates.java
    url: https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/update/BatchUpdates.java
    site: Gerrit 源码
    note: "`execute()`：多个项目的 BatchUpdate 依次执行，之后才建索引、发事件"
  - id: staleness
    title: StalenessChecker.java
    url: https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/index/change/StalenessChecker.java
    site: Gerrit 源码
    note: "比较索引里的 `ref_state` 与仓库里的引用值；字段定义见 `ChangeField.java`"
  - id: config-index
    title: "gerrit.config: index"
    url: https://github.com/GerritCodeReview/gerrit/blob/master/Documentation/config-gerrit.txt
    site: Gerrit 源码
    note: "`index.autoReindexIfStale`、`index.indexChangesAsync`、`receive.advertiseOpenChangesRefs`"
  - id: notes-cache
    title: ChangeNotesCache.java
    url: https://github.com/GerritCodeReview/gerrit/blob/master/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
    site: Gerrit 源码
    note: "缓存键 = (project, changeId, meta 的 ObjectId)；`change_notes` 的磁盘存储默认关闭（`config-gerrit.txt` 的 `cache.<name>.diskLimit`）"
  - id: glossary
    title: Gerrit Code Review - Glossary
    url: https://github.com/GerritCodeReview/gerrit/blob/master/Documentation/glossary.txt
    site: Gerrit 源码
    note: "primary、replica、multi-primary、multi-site 的定义"
  - id: replication
    title: Replication plugin configuration
    url: https://gerrit.googlesource.com/plugins/replication/+/refs/heads/master/src/main/resources/Documentation/config.md
    site: Gerrit 插件
    note: "`replicationDelay` 默认 15 秒；`push` 默认 `refs/*:refs/*`；`replicateNoteDbMetaRefs` 默认 true"
  - id: replication-queue
    title: "replication: Remove the NewProjectCreatedListener implementation"
    url: https://gerrit.googlesource.com/plugins/replication/+/c16fe9c5a6da6c23dbf45a533f788dcc527e209c%5E%21
    site: Gerrit 插件
    note: "这次提交的 diff 里可以看到 `ReplicationQueue implements ... GitReferenceUpdatedListener`"
  - id: multisite
    title: multi-site plugin DESIGN.md
    url: https://gerrit.googlesource.com/plugins/multi-site/+/HEAD/DESIGN.md
    site: Gerrit 插件
    note: "split brain、global ref-db（比较并设置）、events broker"
  - id: gerrit
    title: GerritCodeReview/gerrit
    url: https://github.com/GerritCodeReview/gerrit
    site: Gerrit 源码
    note: "本文对照的是 2026-10-14 的 master 浅克隆，行号以此为准"
    further: true
  - id: jgit
    title: eclipse-jgit/jgit
    url: https://github.com/eclipse-jgit/jgit
    site: Eclipse JGit
    further: true
---

Gerrit 的文档里有这样一条命令[^notedb]：

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

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

```text
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 说起》](/blog/git-server-from-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 容器里的程序[^about]。不过语言只解释了“为什么是 Java”，没有解释“为什么不在 Java 外面套一个 git 二进制”。

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

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

```text
# pre-receive 读到的一行（示意）
0000000000000000000000000000000000000000 612f4c1… refs/for/main
```

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

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

### 解决：把 JGit 当库嵌进进程

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

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 520" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gr-overview-title gr-overview-desc">
<title id="gr-overview-title">Gerrit 进程里的 Git 服务端分层</title>
<desc id="gr-overview-desc">git fetch 和 git push 经 HTTPS 或 SSH 进入 Gerrit，交给 JGit 的 UploadPack 和 ReceivePack；两者拿到的仓库都是 PermissionAwareRepository 包装过的视图，ReceivePack 的 PreReceiveHook 由 Gerrit 的 ReceiveCommits 接管；最底层是 GitRepositoryManager 打开的 FileRepository。</desc>
<defs>
<marker id="gr-overview-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="gr-overview-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="520" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="168" height="488" rx="8"/>
<text class="t-zone" x="32" y="44">客户端</text>
<rect class="dd-zone" x="200" y="16" width="584" height="488" rx="8"/>
<text class="t-zone" x="216" y="44">Gerrit 进程（一个 JVM）</text>
<path class="dd-edge" d="M168 78 H216" marker-end="url(#gr-overview-arrow)"/>
<path class="dd-edge" d="M168 130 H216" marker-end="url(#gr-overview-arrow)"/>
<path class="dd-edge" d="M348 148 V172" marker-end="url(#gr-overview-arrow)"/>
<path class="dd-edge" d="M636 148 V172" marker-end="url(#gr-overview-arrow)"/>
<path class="dd-edge-acc" d="M348 256 V280" marker-end="url(#gr-overview-arrow-acc)"/>
<path class="dd-edge-acc" d="M636 256 V280" marker-end="url(#gr-overview-arrow-acc)"/>
<path class="dd-edge" d="M492 364 V388" marker-end="url(#gr-overview-arrow)"/>
<rect class="dd-card" x="32" y="60" width="136" height="36" rx="6"/>
<text class="t-mono" x="48" y="83">git fetch</text>
<rect class="dd-card" x="32" y="112" width="136" height="36" rx="6"/>
<text class="t-mono" x="48" y="135">git push</text>
<rect class="dd-card" x="216" y="64" width="552" height="84" rx="6"/>
<path class="dd-card-head" d="M216.6 96 V70 Q216.6 64.6 222 64.6 H762 Q767.4 64.6 767.4 70 V96 Z"/>
<line class="dd-edge" x1="216" y1="96" x2="768" y2="96"/>
<text class="t-name" x="232" y="85">协议入口</text>
<text class="t-note" x="752" y="85" text-anchor="end">HTTPS / SSH 29418</text>
<text class="t-mono" x="232" y="113">GitOverHttpServlet</text>
<text class="t-val" x="448" y="113">extends GitServlet（JGit）</text>
<line class="dd-hair" x1="232" y1="122" x2="752" y2="122"/>
<text class="t-mono" x="232" y="139">sshd: git-upload-pack</text>
<text class="t-val" x="448" y="139">→ Upload / Receive 命令</text>
<rect class="dd-card" x="216" y="172" width="264" height="84" rx="6"/>
<path class="dd-card-head" d="M216.6 204 V178 Q216.6 172.6 222 172.6 H474 Q479.4 172.6 479.4 178 V204 Z"/>
<line class="dd-edge" x1="216" y1="204" x2="480" y2="204"/>
<text class="t-name" x="232" y="193">UploadPack</text>
<text class="t-note" x="464" y="193" text-anchor="end">JGit</text>
<text class="t-mono" x="232" y="221">new UploadPack(<tspan class="t-acc">wrap(repo)</tspan>)</text>
<line class="dd-hair" x1="232" y1="230" x2="464" y2="230"/>
<text class="t-mono" x="232" y="247">ls-refs / fetch / want</text>
<rect class="dd-card" x="504" y="172" width="264" height="84" rx="6"/>
<path class="dd-card-head" d="M504.6 204 V178 Q504.6 172.6 510 172.6 H762 Q767.4 172.6 767.4 178 V204 Z"/>
<line class="dd-edge" x1="504" y1="204" x2="768" y2="204"/>
<text class="t-name" x="520" y="193">ReceivePack</text>
<text class="t-note" x="752" y="193" text-anchor="end">JGit</text>
<text class="t-mono" x="520" y="221">new ReceivePack(<tspan class="t-acc">wrap(repo)</tspan>)</text>
<line class="dd-hair" x1="520" y1="230" x2="752" y2="230"/>
<text class="t-mono" x="520" y="247">setPreReceiveHook(asHook())</text>
<rect class="dd-card" x="216" y="280" width="552" height="84" rx="6"/>
<path class="dd-card-head" d="M216.6 312 V286 Q216.6 280.6 222 280.6 H762 Q767.4 280.6 767.4 286 V312 Z"/>
<line class="dd-edge" x1="216" y1="312" x2="768" y2="312"/>
<text class="t-name" x="232" y="301">PermissionAwareRepository</text>
<text class="t-note" x="752" y="301" text-anchor="end"><tspan class="t-acc">wrap(repo)</tspan> 的结果</text>
<text class="t-mono" x="232" y="329">getRefsByPrefix() / exactRef()</text>
<text class="t-val" x="480" y="329">先过 DefaultRefFilter，看不见就不返回</text>
<line class="dd-hair" x1="232" y1="338" x2="752" y2="338"/>
<text class="t-mono" x="232" y="355">newBatchUpdate()</text>
<text class="t-val" x="480" y="355">抛 UnsupportedOperationException</text>
<rect class="dd-card" x="216" y="388" width="552" height="36" rx="6"/>
<text class="t-name" x="232" y="411">GitRepositoryManager</text>
<text class="t-val" x="752" y="411" text-anchor="end">→ FileRepository（RefDirectory 或 reftable）</text>
<text class="t-say" x="216" y="456">JGit 拿到的仓库是一个按当前用户过滤过的只读视图；</text>
<text class="t-say" x="216" y="478">push 的引用命令由 PreReceiveHook 里的 ReceiveCommits 自己执行。</text>
</svg>
</div>
<figcaption>图 1 · Gerrit 不重写 Git 协议，它把 JGit 当库嵌进自己的进程，在“仓库对象”和“push 钩子”两处插手</figcaption>
</figure>

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 成功了，引用却不存在

```bash
$ 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`）的命令[^receivepack]。

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

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 528" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gr-push-title gr-push-desc">
<title id="gr-push-title">push 到 refs/for/main 时 JGit 与 Gerrit 的分工</title>
<desc id="gr-push-desc">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。</desc>
<defs>
<marker id="gr-push-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="gr-push-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="528" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="300" height="496" rx="8"/>
<text class="t-zone" x="32" y="44">JGit · ReceivePack.service()</text>
<rect class="dd-zone" x="332" y="16" width="452" height="496" rx="8"/>
<text class="t-zone" x="348" y="44">Gerrit · ReceiveCommits（在钩子里）</text>
<path class="dd-edge" d="M166 170 V194" marker-end="url(#gr-push-arrow)"/>
<path class="dd-edge" d="M166 230 V250" marker-end="url(#gr-push-arrow)"/>
<path class="dd-edge" d="M166 286 V407" marker-end="url(#gr-push-arrow)"/>
<path class="dd-edge" d="M166 443 V463" marker-end="url(#gr-push-arrow)"/>
<path class="dd-edge-acc" d="M300 268 H348" marker-end="url(#gr-push-arrow-acc)"/>
<path class="dd-edge-acc" d="M348 425 H300" marker-end="url(#gr-push-arrow-acc)"/>
<rect class="dd-card" x="32" y="60" width="268" height="110" rx="6"/>
<path class="dd-card-head" d="M32.6 92 V66 Q32.6 60.6 38 60.6 H294 Q299.4 60.6 299.4 66 V92 Z"/>
<line class="dd-edge" x1="32" y1="92" x2="300" y2="92"/>
<text class="t-name" x="48" y="81">ReceiveCommand</text>
<text class="t-note" x="284" y="81" text-anchor="end">客户端发来</text>
<text class="t-mono" x="48" y="109">ref</text>
<text class="t-val" x="128" y="109"><tspan class="t-acc">refs/for/main</tspan></text>
<line class="dd-hair" x1="48" y1="118" x2="284" y2="118"/>
<text class="t-mono" x="48" y="135">old → new</text>
<text class="t-val" x="128" y="135">0000000 → 612f4c1</text>
<line class="dd-hair" x1="48" y1="144" x2="284" y2="144"/>
<text class="t-mono" x="48" y="161">result</text>
<text class="t-val" x="128" y="161">NOT_ATTEMPTED</text>
<rect class="dd-card" x="32" y="194" width="268" height="36" rx="6"/>
<text class="t-mono" x="48" y="217">receivePackAndCheckConnectivity()</text>
<rect class="dd-card" x="32" y="250" width="268" height="36" rx="6"/>
<text class="t-mono" x="48" y="273">preReceive.onPreReceive(cmds)</text>
<rect class="dd-card" x="32" y="407" width="268" height="36" rx="6"/>
<text class="t-mono" x="48" y="430">executeCommands()</text>
<text class="t-note" x="284" y="430" text-anchor="end">跳过已 OK 的</text>
<rect class="dd-card" x="32" y="463" width="268" height="36" rx="6"/>
<text class="t-mono" x="48" y="486">ok <tspan class="t-acc">refs/for/main</tspan></text>
<text class="t-note" x="284" y="486" text-anchor="end">回给客户端</text>
<rect class="dd-card" x="348" y="60" width="420" height="110" rx="6"/>
<path class="dd-card-head" d="M348.6 92 V66 Q348.6 60.6 354 60.6 H762 Q767.4 60.6 767.4 66 V92 Z"/>
<line class="dd-edge" x1="348" y1="92" x2="768" y2="92"/>
<text class="t-name" x="364" y="81">第二次 push</text>
<text class="t-note" x="752" y="81" text-anchor="end">amend 之后</text>
<text class="t-mono" x="364" y="109">commit</text>
<text class="t-val" x="460" y="109">9a0b7e2（哈希变了）</text>
<line class="dd-hair" x1="364" y1="118" x2="752" y2="118"/>
<text class="t-mono" x="364" y="135">Change-Id</text>
<text class="t-val" x="460" y="135">I8473b9…（没变）</text>
<line class="dd-hair" x1="364" y1="144" x2="752" y2="144"/>
<text class="t-mono" x="364" y="161">结果</text>
<text class="t-val" x="460" y="161">新增 refs/changes/34/1234/2</text>
<rect class="dd-card" x="348" y="250" width="420" height="188" rx="6"/>
<path class="dd-card-head" d="M348.6 282 V256 Q348.6 250.6 354 250.6 H762 Q767.4 250.6 767.4 256 V282 Z"/>
<line class="dd-edge" x1="348" y1="282" x2="768" y2="282"/>
<text class="t-name" x="364" y="271">processCommandsUnsafe()</text>
<text class="t-note" x="752" y="271" text-anchor="end">第一次 push</text>
<text class="t-mono" x="364" y="299">parseMagicBranch</text>
<text class="t-val" x="492" y="299">目标分支 refs/heads/main</text>
<line class="dd-hair" x1="364" y1="308" x2="752" y2="308"/>
<text class="t-mono" x="364" y="325">Change-Id</text>
<text class="t-val" x="492" y="325">I8473b9… 查无此 change</text>
<line class="dd-hair" x1="364" y1="334" x2="752" y2="334"/>
<text class="t-mono" x="364" y="351">nextChangeIds(1)</text>
<text class="t-val" x="492" y="351">1234（refs/sequences/changes）</text>
<line class="dd-hair" x1="364" y1="360" x2="752" y2="360"/>
<text class="t-mono" x="364" y="377">BatchRefUpdate</text>
<text class="t-val" x="492" y="377">refs/changes/34/1234/1 → 612f4c1</text>
<line class="dd-hair" x1="364" y1="386" x2="752" y2="386"/>
<text class="t-val" x="492" y="403">refs/changes/34/1234/meta → 新提交</text>
<line class="dd-hair" x1="364" y1="412" x2="752" y2="412"/>
<text class="t-mono" x="364" y="429">setResult(OK)</text>
<text class="t-val" x="492" y="429"><tspan class="t-acc">refs/for/main</tspan> 记为成功</text>
<text class="t-say" x="348" y="200">Change-Id 没变，Gerrit 找到已有的 change 1234，</text>
<text class="t-say" x="348" y="222">于是生成 ReplaceRequest，也就是一个新的 patch set。</text>
</svg>
</div>
<figcaption>图 2 · refs/for/main 只是一张“投递单”：Gerrit 在钩子里替它写了别的引用，再把它标成 OK，JGit 就不会去创建它（哈希、编号、Change-Id 为示例）</figcaption>
</figure>

图里没画出来的有三点。第一，`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。前者由客户端生成，后者由服务器发放：

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 336" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gr-changeid-title gr-changeid-desc">
<title id="gr-changeid-title">Change-Id 与 change 编号的来源</title>
<desc id="gr-changeid-desc">客户端的 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/ 下。</desc>
<defs>
<marker id="gr-changeid-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="gr-changeid-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="336" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="372" height="304" rx="8"/>
<text class="t-zone" x="32" y="44">客户端 · commit-msg 钩子</text>
<rect class="dd-zone" x="404" y="16" width="380" height="304" rx="8"/>
<text class="t-zone" x="420" y="44">服务器</text>
<path class="dd-edge" d="M202 170 V194" marker-end="url(#gr-changeid-arrow)"/>
<path class="dd-edge-acc" d="M372 212 H420" marker-end="url(#gr-changeid-arrow-acc)"/>
<path class="dd-edge" d="M594 170 V194" marker-end="url(#gr-changeid-arrow)"/>
<rect class="dd-card" x="32" y="60" width="340" height="110" rx="6"/>
<path class="dd-card-head" d="M32.6 92 V66 Q32.6 60.6 38 60.6 H366 Q371.4 60.6 371.4 66 V92 Z"/>
<line class="dd-edge" x1="32" y1="92" x2="372" y2="92"/>
<text class="t-name" x="48" y="81">git hash-object --stdin</text>
<text class="t-note" x="356" y="81" text-anchor="end">输入</text>
<text class="t-mono" x="48" y="109">git var GIT_COMMITTER_IDENT</text>
<line class="dd-hair" x1="48" y1="118" x2="356" y2="118"/>
<text class="t-mono" x="48" y="135">git rev-parse HEAD</text>
<line class="dd-hair" x1="48" y1="144" x2="356" y2="144"/>
<text class="t-val" x="48" y="161">提交信息全文</text>
<rect class="dd-card" x="32" y="194" width="340" height="36" rx="6"/>
<text class="t-mono" x="48" y="217">Change-Id: <tspan class="t-acc">I8473b9…</tspan></text>
<text class="t-note" x="356" y="217" text-anchor="end">写在提交信息末尾</text>
<rect class="dd-card" x="420" y="60" width="348" height="110" rx="6"/>
<path class="dd-card-head" d="M420.6 92 V66 Q420.6 60.6 426 60.6 H762 Q767.4 60.6 767.4 66 V92 Z"/>
<line class="dd-edge" x1="420" y1="92" x2="768" y2="92"/>
<text class="t-name" x="436" y="81">refs/sequences/changes</text>
<text class="t-note" x="752" y="81" text-anchor="end">All-Projects</text>
<text class="t-mono" x="436" y="109">blob 内容</text>
<text class="t-val" x="532" y="109">1254</text>
<line class="dd-hair" x1="436" y1="118" x2="752" y2="118"/>
<text class="t-mono" x="436" y="135">本进程领到</text>
<text class="t-val" x="532" y="135">1234 … 1253（一批 20 个）</text>
<line class="dd-hair" x1="436" y1="144" x2="752" y2="144"/>
<text class="t-mono" x="436" y="161">这次发出</text>
<text class="t-val" x="532" y="161">1234</text>
<rect class="dd-card" x="420" y="194" width="348" height="110" rx="6"/>
<path class="dd-card-head" d="M420.6 226 V200 Q420.6 194.6 426 194.6 H762 Q767.4 194.6 767.4 200 V226 Z"/>
<line class="dd-edge" x1="420" y1="226" x2="768" y2="226"/>
<text class="t-name" x="436" y="215">同一个 change 的引用</text>
<text class="t-note" x="752" y="215" text-anchor="end"><tspan class="t-acc">I8473b9…</tspan></text>
<text class="t-mono" x="436" y="243">refs/changes/34/1234/1</text>
<text class="t-note" x="752" y="243" text-anchor="end">patch set 1</text>
<line class="dd-hair" x1="436" y1="252" x2="752" y2="252"/>
<text class="t-mono" x="436" y="269">refs/changes/34/1234/2</text>
<text class="t-note" x="752" y="269" text-anchor="end">patch set 2</text>
<line class="dd-hair" x1="436" y1="278" x2="752" y2="278"/>
<text class="t-mono" x="436" y="295">refs/changes/34/1234/meta</text>
<text class="t-note" x="752" y="295" text-anchor="end">评审记录</text>
<text class="t-say" x="32" y="262">amend 时 Change-Id 已经存在，钩子不再改写：</text>
<text class="t-say" x="32" y="284">提交哈希变了，Change-Id 没变。</text>
</svg>
</div>
<figcaption>图 3 · Change-Id 由客户端算出、跨 amend 不变；change 编号由服务器从一个引用里按批领取（Change-Id、编号为示例）</figcaption>
</figure>

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

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

---

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

### 现象：两份存储，两套一致性

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

### 解决：评审数据也是提交

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

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 532" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gr-notedb-title gr-notedb-desc">
<title id="gr-notedb-title">NoteDb：change 1234 的 meta 分支</title>
<desc id="gr-notedb-desc">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。</desc>
<defs>
<marker id="gr-notedb-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="gr-notedb-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="532" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="440" height="500" rx="8"/>
<text class="t-zone" x="32" y="44">refs/changes/34/1234/meta（git log 顺序）</text>
<rect class="dd-zone" x="472" y="16" width="312" height="500" rx="8"/>
<text class="t-zone" x="488" y="44">读出来的 change 状态</text>
<path class="dd-edge" d="M64 196 V220" marker-end="url(#gr-notedb-arrow)"/>
<path class="dd-edge" d="M64 330 V364" marker-end="url(#gr-notedb-arrow)"/>
<path class="dd-edge" d="M440 157 H444 Q448 157 448 149 V113 Q448 105 456 105 H488" marker-end="url(#gr-notedb-arrow)"/>
<path class="dd-edge-acc" d="M440 317 H452 Q460 317 460 309 V139 Q460 131 468 131 H488" marker-end="url(#gr-notedb-arrow-acc)"/>
<rect class="dd-card" x="32" y="60" width="408" height="136" rx="6"/>
<path class="dd-card-head" d="M32.6 92 V66 Q32.6 60.6 38 60.6 H434 Q439.4 60.6 439.4 66 V92 Z"/>
<line class="dd-edge" x1="32" y1="92" x2="440" y2="92"/>
<text class="t-name" x="48" y="81">Submit patch set 2</text>
<text class="t-val" x="424" y="81" text-anchor="end">c3d18f5</text>
<text class="t-mono" x="48" y="109">Author: Gerrit User 1000096 &lt;1000096@0e1f…&gt;</text>
<line class="dd-hair" x1="48" y1="118" x2="424" y2="118"/>
<text class="t-mono" x="48" y="135">Patch-set: 2</text>
<line class="dd-hair" x1="48" y1="144" x2="424" y2="144"/>
<text class="t-mono" x="48" y="161">Status: merged</text>
<line class="dd-hair" x1="48" y1="170" x2="424" y2="170"/>
<text class="t-mono" x="48" y="187">Submission-id: 1234</text>
<rect class="dd-card" x="32" y="220" width="408" height="110" rx="6"/>
<path class="dd-card-head" d="M32.6 252 V226 Q32.6 220.6 38 220.6 H434 Q439.4 220.6 439.4 226 V252 Z"/>
<line class="dd-edge" x1="32" y1="252" x2="440" y2="252"/>
<text class="t-name" x="48" y="241"><tspan class="t-acc">Update patch set 2</tspan></text>
<text class="t-val" x="424" y="241" text-anchor="end">8e21a04</text>
<text class="t-mono" x="48" y="269">Author: Gerrit User 1000233 &lt;1000233@0e1f…&gt;</text>
<line class="dd-hair" x1="48" y1="278" x2="424" y2="278"/>
<text class="t-mono" x="48" y="295">Patch-set: 2</text>
<line class="dd-hair" x1="48" y1="304" x2="424" y2="304"/>
<text class="t-mono" x="48" y="321"><tspan class="t-acc">Label: Code-Review=+2</tspan>, 4c1d…</text>
<rect class="dd-card" x="32" y="364" width="408" height="136" rx="6"/>
<path class="dd-card-head" d="M32.6 396 V370 Q32.6 364.6 38 364.6 H434 Q439.4 364.6 439.4 370 V396 Z"/>
<line class="dd-edge" x1="32" y1="396" x2="440" y2="396"/>
<text class="t-name" x="48" y="385">Update patch set 1</text>
<text class="t-val" x="424" y="385" text-anchor="end">5b7c9d2</text>
<text class="t-mono" x="48" y="413">Patch-set: 1</text>
<line class="dd-hair" x1="48" y1="422" x2="424" y2="422"/>
<text class="t-mono" x="48" y="439">Change-id: I8473b95934b5732ac55d26311a706c9c2bde9940</text>
<line class="dd-hair" x1="48" y1="448" x2="424" y2="448"/>
<text class="t-mono" x="48" y="465">Branch: refs/heads/main</text>
<line class="dd-hair" x1="48" y1="474" x2="424" y2="474"/>
<text class="t-mono" x="48" y="491">Status: new</text>
<rect class="dd-card" x="488" y="60" width="280" height="136" rx="6"/>
<path class="dd-card-head" d="M488.6 92 V66 Q488.6 60.6 494 60.6 H762 Q767.4 60.6 767.4 66 V92 Z"/>
<line class="dd-edge" x1="488" y1="92" x2="768" y2="92"/>
<text class="t-name" x="504" y="81">ChangeNotesState</text>
<text class="t-note" x="752" y="81" text-anchor="end">parseAll()</text>
<text class="t-mono" x="504" y="109">status</text>
<text class="t-val" x="624" y="109">MERGED</text>
<line class="dd-hair" x1="504" y1="118" x2="752" y2="118"/>
<text class="t-mono" x="504" y="135">approvals</text>
<text class="t-val" x="624" y="135"><tspan class="t-acc">Code-Review +2</tspan></text>
<line class="dd-hair" x1="504" y1="144" x2="752" y2="144"/>
<text class="t-mono" x="504" y="161">branch</text>
<text class="t-val" x="624" y="161">refs/heads/main</text>
<line class="dd-hair" x1="504" y1="170" x2="752" y2="170"/>
<text class="t-mono" x="504" y="187">currentPatchSet</text>
<text class="t-val" x="624" y="187">2</text>
<rect class="dd-card" x="488" y="284" width="280" height="136" rx="6"/>
<path class="dd-card-head" d="M488.6 316 V290 Q488.6 284.6 494 284.6 H762 Q767.4 284.6 767.4 290 V316 Z"/>
<line class="dd-edge" x1="488" y1="316" x2="768" y2="316"/>
<text class="t-name" x="504" y="305">meta 提交的 tree</text>
<text class="t-note" x="752" y="305" text-anchor="end">NoteMap</text>
<text class="t-mono" x="504" y="333">键 9a0b7e2…（patch set 2）</text>
<line class="dd-hair" x1="504" y1="342" x2="752" y2="342"/>
<text class="t-val" x="504" y="359">{"lineNbr": 158,</text>
<line class="dd-hair" x1="504" y1="368" x2="752" y2="368"/>
<text class="t-val" x="504" y="385"> "message": "nit: …",</text>
<line class="dd-hair" x1="504" y1="394" x2="752" y2="394"/>
<text class="t-val" x="504" y="411"> "unresolved": true}</text>
<text class="t-note" x="80" y="352">…（上传 patch set 2 的提交 a41c0e7，略）</text>
<text class="t-say" x="488" y="224">从 tip 往旧走，每个字段以</text>
<text class="t-say" x="488" y="244">第一次遇到的值为准。</text>
<text class="t-say" x="488" y="448">行内评论不在提交信息里，</text>
<text class="t-say" x="488" y="468">而在 tree 的 note 里。</text>
</svg>
</div>
<figcaption>图 4 · 一个 change 的评审状态就是一条提交链：每次操作追加一个提交，读时从新到旧重放（哈希、账号号、serverId 为示例）</figcaption>
</figure>

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

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

---

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

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

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

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

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 428" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gr-packed-title gr-packed-desc">
<title id="gr-packed-title">同一次 2 个引用的原子更新：packed-refs 与 reftable</title>
<desc id="gr-packed-desc">左边 packed-refs：锁住相关 loose 引用，读入整份 62.2 MB、86.6 万行的文件，在内存里改 2 行，再整份写出并改名，代价与引用总数成正比；按名字查一个引用冷缓存 409,660 微秒。右边 reftable：写一个只含这 2 个引用的小表，原子替换 tables.list，代价与改动大小成正比；按名字查一个引用冷缓存 33.9 微秒，文件 36.1 MB。</desc>
<defs>
<marker id="gr-packed-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="gr-packed-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="428" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="376" height="396" rx="8"/>
<text class="t-zone" x="32" y="44">packed-refs（RefDirectory）</text>
<rect class="dd-zone" x="408" y="16" width="376" height="396" rx="8"/>
<text class="t-zone" x="424" y="44">reftable</text>
<path class="dd-edge" d="M204 96 V108" marker-end="url(#gr-packed-arrow)"/>
<path class="dd-edge" d="M204 144 V156" marker-end="url(#gr-packed-arrow)"/>
<path class="dd-edge" d="M204 192 V204" marker-end="url(#gr-packed-arrow)"/>
<path class="dd-edge" d="M596 96 V108" marker-end="url(#gr-packed-arrow)"/>
<rect class="dd-card" x="32" y="60" width="344" height="36" rx="6"/>
<text class="t-name" x="48" y="83">锁住涉及的 loose 引用</text>
<rect class="dd-card" x="32" y="108" width="344" height="36" rx="6"/>
<text class="t-name" x="48" y="131">读入整份 packed-refs</text>
<text class="t-val" x="360" y="131" text-anchor="end">62.2 MB · 86.6 万行</text>
<rect class="dd-card" x="32" y="156" width="344" height="36" rx="6"/>
<text class="t-name" x="48" y="179">在内存里改 2 行</text>
<rect class="dd-card" x="32" y="204" width="344" height="36" rx="6"/>
<text class="t-name" x="48" y="227">整份写出，再改名替换</text>
<text class="t-val" x="360" y="227" text-anchor="end">62.2 MB</text>
<rect class="dd-card" x="424" y="60" width="344" height="36" rx="6"/>
<text class="t-name" x="440" y="83">写一个新的小表</text>
<text class="t-note" x="752" y="83" text-anchor="end">只含这 2 个引用</text>
<rect class="dd-card" x="424" y="108" width="344" height="36" rx="6"/>
<text class="t-name" x="440" y="131">改名替换 tables.list</text>
<text class="t-note" x="752" y="131" text-anchor="end">旧表一字节不动</text>
<rect class="dd-card" x="32" y="260" width="344" height="136" rx="6"/>
<path class="dd-card-head" d="M32.6 292 V266 Q32.6 260.6 38 260.6 H370 Q375.4 260.6 375.4 266 V292 Z"/>
<line class="dd-edge" x1="32" y1="292" x2="376" y2="292"/>
<text class="t-name" x="48" y="281">代价</text>
<text class="t-note" x="360" y="281" text-anchor="end">packed-refs</text>
<text class="t-mono" x="48" y="309">写入</text>
<text class="t-val" x="208" y="309"><tspan class="t-acc">与引用总数成正比</tspan></text>
<line class="dd-hair" x1="48" y1="318" x2="360" y2="318"/>
<text class="t-mono" x="48" y="335">查一个引用（冷）</text>
<text class="t-val" x="208" y="335">409,660 µs</text>
<line class="dd-hair" x1="48" y1="344" x2="360" y2="344"/>
<text class="t-mono" x="48" y="361">按 SHA-1 反查（冷）</text>
<text class="t-val" x="208" y="361">412,535 µs</text>
<line class="dd-hair" x1="48" y1="370" x2="360" y2="370"/>
<text class="t-mono" x="48" y="387">文件大小</text>
<text class="t-val" x="208" y="387">62.2 MB</text>
<rect class="dd-card" x="424" y="260" width="344" height="136" rx="6"/>
<path class="dd-card-head" d="M424.6 292 V266 Q424.6 260.6 430 260.6 H762 Q767.4 260.6 767.4 266 V292 Z"/>
<line class="dd-edge" x1="424" y1="292" x2="768" y2="292"/>
<text class="t-name" x="440" y="281">代价</text>
<text class="t-note" x="752" y="281" text-anchor="end">reftable</text>
<text class="t-mono" x="440" y="309">写入</text>
<text class="t-val" x="600" y="309"><tspan class="t-acc">与本次改动成正比</tspan></text>
<line class="dd-hair" x1="440" y1="318" x2="752" y2="318"/>
<text class="t-mono" x="440" y="335">查一个引用（冷）</text>
<text class="t-val" x="600" y="335">33.9 µs</text>
<line class="dd-hair" x1="440" y1="344" x2="752" y2="344"/>
<text class="t-mono" x="440" y="361">按 SHA-1 反查（冷）</text>
<text class="t-val" x="600" y="361">323.2 µs</text>
<line class="dd-hair" x1="440" y1="370" x2="752" y2="370"/>
<text class="t-mono" x="440" y="387">文件大小</text>
<text class="t-val" x="600" y="387">36.1 MB</text>
<text class="t-say" x="424" y="184">小表越积越多时，由后台的</text>
<text class="t-say" x="424" y="204">压缩器把相邻几个合成一个。</text>
</svg>
</div>
<figcaption>图 5 · 只改 2 个引用：packed-refs 要整份读写，reftable 只追加一个小表（数字为 JGit 文档在 Android 仓库上的测量）</figcaption>
</figure>

### 解决：reftable

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

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 464" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gr-reftable-title gr-reftable-desc">
<title id="gr-reftable-title">一次 push 在 reftable 里写下的东西</title>
<desc id="gr-reftable-desc">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。</desc>
<defs>
<marker id="gr-reftable-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="gr-reftable-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="464" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="300" height="432" rx="8"/>
<text class="t-zone" x="32" y="44">$GIT_DIR/reftable/</text>
<rect class="dd-zone" x="332" y="16" width="452" height="432" rx="8"/>
<text class="t-zone" x="348" y="44"><tspan class="t-acc">00000123-00000123.ref</tspan> 的内部</text>
<path class="dd-edge-acc" d="M300 183 H316 Q324 183 324 175 V90 Q324 82 332 82 H348" marker-end="url(#gr-reftable-arrow-acc)"/>
<path class="dd-edge" d="M498 100 V140" marker-end="url(#gr-reftable-arrow)"/>
<rect class="dd-card" x="32" y="60" width="268" height="136" rx="6"/>
<path class="dd-card-head" d="M32.6 92 V66 Q32.6 60.6 38 60.6 H294 Q299.4 60.6 299.4 66 V92 Z"/>
<line class="dd-edge" x1="32" y1="92" x2="300" y2="92"/>
<text class="t-name" x="48" y="81">tables.list</text>
<text class="t-note" x="284" y="81" text-anchor="end">从旧到新</text>
<text class="t-mono" x="48" y="109">00000001-00000120.ref</text>
<text class="t-note" x="284" y="109" text-anchor="end">压缩过的基表</text>
<line class="dd-hair" x1="48" y1="118" x2="284" y2="118"/>
<text class="t-mono" x="48" y="135">00000121-00000121.ref</text>
<line class="dd-hair" x1="48" y1="144" x2="284" y2="144"/>
<text class="t-mono" x="48" y="161">00000122-00000122.ref</text>
<line class="dd-hair" x1="48" y1="170" x2="284" y2="170"/>
<text class="t-mono" x="48" y="187"><tspan class="t-acc">00000123-00000123.ref</tspan></text>
<text class="t-note" x="284" y="187" text-anchor="end">本次 push</text>
<rect class="dd-card" x="32" y="268" width="268" height="36" rx="6"/>
<text class="t-mono" x="48" y="291">ReftableCompactor</text>
<text class="t-note" x="284" y="291" text-anchor="end">后台合并小表</text>
<rect class="dd-card" x="348" y="64" width="88" height="36" rx="4"/>
<rect class="dd-card" x="436" y="64" width="156" height="36" rx="4"/>
<rect class="dd-card" x="592" y="64" width="96" height="36" rx="4"/>
<rect class="dd-card" x="688" y="64" width="80" height="36" rx="4"/>
<rect class="dd-card" x="348" y="140" width="420" height="136" rx="6"/>
<path class="dd-card-head" d="M348.6 172 V146 Q348.6 140.6 354 140.6 H762 Q767.4 140.6 767.4 146 V172 Z"/>
<line class="dd-edge" x1="348" y1="172" x2="768" y2="172"/>
<text class="t-name" x="364" y="161">ref 块（'r'）</text>
<text class="t-note" x="752" y="161" text-anchor="end">引用按名字排序</text>
<text class="t-note" x="364" y="189">prefix_len</text>
<text class="t-note" x="444" y="189">suffix</text>
<text class="t-note" x="644" y="189">value</text>
<line class="dd-hair" x1="364" y1="198" x2="752" y2="198"/>
<text class="t-mono" x="364" y="215">0</text>
<text class="t-val" x="444" y="215">refs/changes/34/1234/2</text>
<text class="t-val" x="644" y="215">9a0b7e2</text>
<line class="dd-hair" x1="364" y1="224" x2="752" y2="224"/>
<text class="t-mono" x="364" y="241">21</text>
<text class="t-val" x="444" y="241"><tspan class="t-acc">meta</tspan></text>
<text class="t-val" x="644" y="241">a41c0e7</text>
<line class="dd-hair" x1="364" y1="250" x2="752" y2="250"/>
<text class="t-mono" x="364" y="267">restart</text>
<text class="t-val" x="444" y="267">offset [28]，count 1</text>
<rect class="dd-card" x="348" y="344" width="420" height="84" rx="6"/>
<path class="dd-card-head" d="M348.6 376 V350 Q348.6 344.6 354 344.6 H762 Q767.4 344.6 767.4 350 V376 Z"/>
<line class="dd-edge" x1="348" y1="376" x2="768" y2="376"/>
<text class="t-name" x="364" y="365">log 块（'g'，zlib 压缩）</text>
<text class="t-note" x="752" y="365" text-anchor="end">update_index 123</text>
<text class="t-mono" x="364" y="393">refs/changes/34/1234/2</text>
<text class="t-val" x="564" y="393">0000000 → 9a0b7e2</text>
<line class="dd-hair" x1="364" y1="402" x2="752" y2="402"/>
<text class="t-mono" x="364" y="419">refs/changes/34/1234/meta</text>
<text class="t-val" x="564" y="419">5b7c9d2 → a41c0e7</text>
<text class="t-say" x="32" y="224">写：生成一个只含本次改动的小表，</text>
<text class="t-say" x="32" y="244">再原子地替换 tables.list。</text>
<text class="t-small" x="392.0" y="87" text-anchor="middle">header 24B</text>
<text class="t-mono" x="514.0" y="87" text-anchor="middle">ref 块 'r'</text>
<text class="t-mono" x="640.0" y="87" text-anchor="middle">log 块 'g'</text>
<text class="t-small" x="728.0" y="87" text-anchor="middle">footer 68B</text>
<text class="t-note" x="768" y="124" text-anchor="end">引用很少，所以没有 ref index 和 obj 块</text>
<text class="t-say" x="348" y="304">第二条只存后缀 meta，前 21 字节与上一条相同，</text>
<text class="t-say" x="348" y="324">读的时候从上一条照抄；restart 点存完整名字。</text>
</svg>
</div>
<figcaption>图 6 · reftable 的写入只追加一个只含本次改动的小表，表内按名字排序并做前缀压缩（文件编号、哈希为示例）</figcaption>
</figure>

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

---

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

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

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

### 第一次尝试：在广告时过滤

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

### 解决：换掉引用数据库

Gerrit 交给 JGit 的仓库里，引用数据库是 `PermissionAwareReadOnlyRefDatabase`：`getRefsByPrefix()`、`exactRef()`、`getTipsWithSha1()` 都先过一遍权限过滤，写操作直接抛异常[^perm-refdb]。JGit 不管走哪条路径读引用，读到的都是过滤后的结果。判断规则在 `DefaultRefFilter` 和 `RefVisibilityControl` 里[^default-ref-filter] [^ref-visibility]，图 7 是一个例子：

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 514" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gr-visibility-title gr-visibility-desc">
<title id="gr-visibility-title">同一个仓库，alice 看到的引用</title>
<desc id="gr-visibility-desc">仓库 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 来说不存在。</desc>
<defs>
<marker id="gr-visibility-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="gr-visibility-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="514" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="300" height="482" rx="8"/>
<text class="t-zone" x="32" y="44">仓库里的全部引用</text>
<rect class="dd-zone" x="332" y="16" width="452" height="482" rx="8"/>
<text class="t-zone" x="348" y="44">alice 执行 ls-refs 得到的</text>
<path class="dd-edge" d="M300 180 H316 Q324 180 324 172 V123 Q324 115 332 115 H348" marker-end="url(#gr-visibility-arrow)"/>
<path class="dd-edge-acc" d="M300 261 H316 Q324 261 324 269 V435 Q324 443 332 443 H348" marker-end="url(#gr-visibility-arrow-acc)"/>
<path class="dd-edge" d="M558 170 V194" marker-end="url(#gr-visibility-arrow)"/>
<rect class="dd-card" x="32" y="60" width="268" height="240" rx="6"/>
<path class="dd-card-head" d="M32.6 92 V66 Q32.6 60.6 38 60.6 H294 Q299.4 60.6 299.4 66 V92 Z"/>
<line class="dd-edge" x1="32" y1="92" x2="300" y2="92"/>
<text class="t-name" x="48" y="81">RefDatabase</text>
<text class="t-note" x="284" y="81" text-anchor="end">hello.git</text>
<text class="t-mono" x="48" y="109">refs/heads/main</text>
<line class="dd-hair" x1="48" y1="118" x2="284" y2="118"/>
<text class="t-mono" x="48" y="135">refs/heads/secret</text>
<line class="dd-hair" x1="48" y1="144" x2="284" y2="144"/>
<text class="t-mono" x="48" y="161">refs/meta/config</text>
<line class="dd-hair" x1="48" y1="170" x2="284" y2="170"/>
<text class="t-mono" x="48" y="187">refs/tags/v1.0</text>
<line class="dd-hair" x1="48" y1="196" x2="284" y2="196"/>
<text class="t-mono" x="48" y="213">refs/changes/34/1234/1</text>
<line class="dd-hair" x1="48" y1="222" x2="284" y2="222"/>
<text class="t-mono" x="48" y="239">refs/changes/34/1234/meta</text>
<line class="dd-hair" x1="48" y1="248" x2="284" y2="248"/>
<text class="t-mono" x="48" y="265"><tspan class="t-acc">refs/changes/35/1235/1</tspan></text>
<line class="dd-hair" x1="48" y1="274" x2="284" y2="274"/>
<text class="t-mono" x="48" y="291">refs/cache-automerge/ab/…</text>
<rect class="dd-card" x="348" y="60" width="420" height="110" rx="6"/>
<path class="dd-card-head" d="M348.6 92 V66 Q348.6 60.6 354 60.6 H762 Q767.4 60.6 767.4 66 V92 Z"/>
<line class="dd-edge" x1="348" y1="92" x2="768" y2="92"/>
<text class="t-name" x="364" y="81">DefaultRefFilter</text>
<text class="t-note" x="752" y="81" text-anchor="end">逐个引用判断</text>
<text class="t-mono" x="364" y="109">refs/heads/*</text>
<text class="t-val" x="484" y="109">这个分支的 READ 权限</text>
<line class="dd-hair" x1="364" y1="118" x2="752" y2="118"/>
<text class="t-mono" x="364" y="135">refs/changes/*</text>
<text class="t-val" x="484" y="135">change 是否可见（查索引）</text>
<line class="dd-hair" x1="364" y1="144" x2="752" y2="144"/>
<text class="t-mono" x="364" y="161">refs/tags/*</text>
<text class="t-val" x="484" y="161">指向的提交从可见分支可达</text>
<rect class="dd-card" x="348" y="194" width="420" height="136" rx="6"/>
<path class="dd-card-head" d="M348.6 226 V200 Q348.6 194.6 354 194.6 H762 Q767.4 194.6 767.4 200 V226 Z"/>
<line class="dd-edge" x1="348" y1="226" x2="768" y2="226"/>
<text class="t-name" x="364" y="215">可见</text>
<text class="t-note" x="752" y="215" text-anchor="end">JGit 只拿到这些</text>
<text class="t-mono" x="364" y="243">refs/heads/main</text>
<line class="dd-hair" x1="364" y1="252" x2="752" y2="252"/>
<text class="t-mono" x="364" y="269">refs/tags/v1.0</text>
<line class="dd-hair" x1="364" y1="278" x2="752" y2="278"/>
<text class="t-mono" x="364" y="295">refs/changes/34/1234/1</text>
<line class="dd-hair" x1="364" y1="304" x2="752" y2="304"/>
<text class="t-mono" x="364" y="321">refs/changes/34/1234/meta</text>
<rect class="dd-derived" x="348" y="346" width="420" height="136" rx="6"/>
<text class="t-name" x="364" y="367">被过滤掉</text>
<text class="t-mono" x="364" y="395">refs/heads/secret</text>
<text class="t-note" x="752" y="395" text-anchor="end">没有 READ 权限</text>
<line class="dd-hair" x1="364" y1="404" x2="752" y2="404"/>
<text class="t-mono" x="364" y="421">refs/meta/config</text>
<text class="t-note" x="752" y="421" text-anchor="end">没有 READ 权限</text>
<line class="dd-hair" x1="364" y1="430" x2="752" y2="430"/>
<text class="t-mono" x="364" y="447"><tspan class="t-acc">refs/changes/35/1235/1</tspan></text>
<text class="t-note" x="752" y="447" text-anchor="end">别人的私有 change</text>
<line class="dd-hair" x1="364" y1="456" x2="752" y2="456"/>
<text class="t-mono" x="364" y="473">refs/cache-automerge/ab/…</text>
<text class="t-note" x="752" y="473" text-anchor="end">对任何人都不可见</text>
<text class="t-say" x="32" y="340">同一个仓库，不同的人</text>
<text class="t-say" x="32" y="360">拿到不同的引用列表。</text>
<text class="t-say" x="32" y="446">虚线：被过滤的引用，</text>
<text class="t-say" x="32" y="466">对 JGit 来说就像不存在。</text>
</svg>
</div>
<figcaption>图 7 · 权限过滤发生在引用数据库这一层：看不见的引用，JGit 根本拿不到（分支名、change 号为示例）</figcaption>
</figure>

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

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

```text
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），每个项目选一种[^submit-types]：`MERGE_IF_NECESSARY`（能快进就快进，否则生成合并提交，项目没配置时默认用它）、`MERGE_ALWAYS`（总是生成合并提交）、`REBASE_IF_NECESSARY` 和 `REBASE_ALWAYS`（变基到分支最新提交上，真的变了基就生成一个新 patch set）、`FAST_FORWARD_ONLY`（只允许快进），以及忽略依赖关系、逐个 cherry-pick 的 `CHERRY_PICK`。

### 解决：一个项目一批，失败整体重试

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

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 516" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gr-submit-title gr-submit-desc">
<title id="gr-submit-title">submit 时的原子引用更新</title>
<desc id="gr-submit-desc">MergeOp 先用索引算出要一起合并的 change，再从 NoteDb 重读；按 MERGE_IF_NECESSARY 生成操作，记下 main 当前值 385ce2c。hello.git 的三条命令（main 385ce2c → e4f0a17，两个 meta 引用）放在一个 atomic 的 BatchRefUpdate 里：旧值都对得上就一起生效；若 main 已被别人推进到 7d01e9b，整批以 LOCK_FAILURE 失败，由 RetryHelper 整体重试。跨项目 topic 时，每个仓库各自一批，依次执行。</desc>
<defs>
<marker id="gr-submit-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="gr-submit-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="516" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="320" height="368" rx="8"/>
<text class="t-zone" x="32" y="44">MergeOp.merge()</text>
<rect class="dd-zone" x="352" y="16" width="432" height="368" rx="8"/>
<text class="t-zone" x="368" y="44">hello.git：一个 atomic 的 BatchRefUpdate</text>
<rect class="dd-zone" x="16" y="400" width="768" height="100" rx="8"/>
<text class="t-zone" x="32" y="428">跨项目的 topic（hello 和 lib）</text>
<path class="dd-edge" d="M176 96 V112" marker-end="url(#gr-submit-arrow)"/>
<path class="dd-edge" d="M176 148 V164" marker-end="url(#gr-submit-arrow)"/>
<path class="dd-edge" d="M176 200 V216" marker-end="url(#gr-submit-arrow)"/>
<path class="dd-edge" d="M176 252 V268" marker-end="url(#gr-submit-arrow)"/>
<path class="dd-edge-acc" d="M320 234 H336 Q344 234 344 226 V113 Q344 105 352 105 H368" marker-end="url(#gr-submit-arrow-acc)"/>
<path class="dd-edge" d="M464 170 V240" marker-end="url(#gr-submit-arrow)"/>
<path class="dd-edge-acc" d="M672 170 V240" marker-end="url(#gr-submit-arrow-acc)"/>
<path class="dd-edge" d="M272 466 H320" marker-end="url(#gr-submit-arrow)"/>
<rect class="dd-card" x="32" y="60" width="288" height="36" rx="6"/>
<text class="t-mono" x="48" y="83">completeChangeSet()</text>
<text class="t-note" x="304" y="83" text-anchor="end">查索引，可能过期</text>
<rect class="dd-card" x="32" y="112" width="288" height="36" rx="6"/>
<text class="t-mono" x="48" y="135">reloadChanges()</text>
<text class="t-note" x="304" y="135" text-anchor="end">从 NoteDb 重读</text>
<rect class="dd-card" x="32" y="164" width="288" height="36" rx="6"/>
<text class="t-mono" x="48" y="187">MergeIfNecessary</text>
<text class="t-note" x="304" y="187" text-anchor="end">项目的提交类型</text>
<rect class="dd-card" x="32" y="216" width="288" height="36" rx="6"/>
<text class="t-mono" x="48" y="239">mergeTip = <tspan class="t-acc">385ce2c</tspan></text>
<text class="t-note" x="304" y="239" text-anchor="end">main 当前值</text>
<rect class="dd-card" x="32" y="268" width="288" height="36" rx="6"/>
<text class="t-mono" x="48" y="291">BatchUpdates.execute()</text>
<text class="t-note" x="304" y="291" text-anchor="end">每个项目一批</text>
<rect class="dd-card" x="368" y="60" width="400" height="110" rx="6"/>
<path class="dd-card-head" d="M368.6 92 V66 Q368.6 60.6 374 60.6 H762 Q767.4 60.6 767.4 66 V92 Z"/>
<line class="dd-edge" x1="368" y1="92" x2="768" y2="92"/>
<text class="t-name" x="384" y="81">ReceiveCommand × 3</text>
<text class="t-note" x="752" y="81" text-anchor="end">全部成功或全部失败</text>
<text class="t-mono" x="384" y="109">refs/heads/main</text>
<text class="t-val" x="584" y="109"><tspan class="t-acc">385ce2c</tspan> → e4f0a17</text>
<line class="dd-hair" x1="384" y1="118" x2="752" y2="118"/>
<text class="t-mono" x="384" y="135">refs/changes/34/1234/meta</text>
<text class="t-val" x="584" y="135">8e21a04 → c3d18f5</text>
<line class="dd-hair" x1="384" y1="144" x2="752" y2="144"/>
<text class="t-mono" x="384" y="161">refs/changes/36/1236/meta</text>
<text class="t-val" x="584" y="161">77a2c10 → 4f5d8e1</text>
<rect class="dd-card" x="368" y="240" width="192" height="84" rx="6"/>
<path class="dd-card-head" d="M368.6 272 V246 Q368.6 240.6 374 240.6 H554 Q559.4 240.6 559.4 246 V272 Z"/>
<line class="dd-edge" x1="368" y1="272" x2="560" y2="272"/>
<text class="t-name" x="384" y="261">旧值都对得上</text>
<text class="t-val" x="384" y="289">三个引用一起更新</text>
<line class="dd-hair" x1="384" y1="298" x2="544" y2="298"/>
<text class="t-val" x="384" y="315">main 前进到 e4f0a17</text>
<rect class="dd-card" x="576" y="240" width="192" height="84" rx="6"/>
<path class="dd-card-head" d="M576.6 272 V246 Q576.6 240.6 582 240.6 H762 Q767.4 240.6 767.4 246 V272 Z"/>
<line class="dd-edge" x1="576" y1="272" x2="768" y2="272"/>
<text class="t-name" x="592" y="261">main 已是 7d01e9b</text>
<text class="t-mono" x="592" y="289">LOCK_FAILURE</text>
<line class="dd-hair" x1="592" y1="298" x2="752" y2="298"/>
<text class="t-val" x="592" y="315">整批作废，整体重试</text>
<rect class="dd-card" x="32" y="448" width="240" height="36" rx="6"/>
<text class="t-mono" x="48" y="471">hello.git: BatchRefUpdate</text>
<rect class="dd-card" x="320" y="448" width="240" height="36" rx="6"/>
<text class="t-mono" x="336" y="471">lib.git: BatchRefUpdate</text>
<text class="t-say" x="368" y="348">两个 meta 上的新提交写着 Status: merged</text>
<text class="t-say" x="368" y="368">和 Submission-id，与分支一起生效。</text>
<text class="t-say" x="584" y="462">各自原子，</text>
<text class="t-say" x="584" y="482">合起来不是一个事务。</text>
</svg>
</div>
<figcaption>图 8 · 一个仓库内，分支和所有 meta 引用在同一个原子批次里前进；跨仓库则只是依次执行（哈希、change 号为示例）</figcaption>
</figure>

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

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

---

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

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

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

### 解决：Lucene 索引负责“找”

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

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 398" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gr-index-title gr-index-desc">
<title id="gr-index-title">索引、NoteDb 与缓存的配合</title>
<desc id="gr-index-desc">查询 status:open 从 Lucene 索引里找到 change 1234，索引文档的 ref_state 记着 meta 引用当时的值 8e21a04。Gerrit 随后按 change 号读 NoteDb：exactRef 得到 meta 当前值 c3d18f5，用 (hello, 1234, c3d18f5) 查 change_notes 缓存，未命中才让 ChangeNotesParser 重放。StalenessChecker 发现 8e21a04 与 c3d18f5 不同，判定索引文档过期并重新索引。</desc>
<defs>
<marker id="gr-index-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="gr-index-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="398" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="300" height="366" rx="8"/>
<text class="t-zone" x="32" y="44">Lucene 索引（change 文档）</text>
<rect class="dd-zone" x="332" y="16" width="452" height="366" rx="8"/>
<text class="t-zone" x="348" y="44">按 change 号读 NoteDb</text>
<path class="dd-edge" d="M166 96 V120" marker-end="url(#gr-index-arrow)"/>
<path class="dd-edge" d="M300 188 H316 Q324 188 324 180 V86 Q324 78 332 78 H348" marker-end="url(#gr-index-arrow)"/>
<path class="dd-edge" d="M300 243 H316 Q324 243 324 251 V293 Q324 301 332 301 H348" marker-end="url(#gr-index-arrow)"/>
<path class="dd-edge-acc" d="M558 96 V120" marker-end="url(#gr-index-arrow-acc)"/>
<path class="dd-edge-acc" d="M558 230 V256" marker-end="url(#gr-index-arrow-acc)"/>
<rect class="dd-card" x="32" y="60" width="268" height="36" rx="6"/>
<text class="t-mono" x="48" y="83">status:open owner:self</text>
<text class="t-note" x="284" y="83" text-anchor="end">查询</text>
<rect class="dd-card" x="32" y="120" width="268" height="136" rx="6"/>
<path class="dd-card-head" d="M32.6 152 V126 Q32.6 120.6 38 120.6 H294 Q299.4 120.6 299.4 126 V152 Z"/>
<line class="dd-edge" x1="32" y1="152" x2="300" y2="152"/>
<text class="t-name" x="48" y="141">doc 1234</text>
<text class="t-note" x="284" y="141" text-anchor="end">stored fields</text>
<text class="t-mono" x="48" y="169">_id</text>
<text class="t-val" x="120" y="169">1234</text>
<line class="dd-hair" x1="48" y1="178" x2="284" y2="178"/>
<text class="t-mono" x="48" y="195">project</text>
<text class="t-val" x="120" y="195">hello</text>
<line class="dd-hair" x1="48" y1="204" x2="284" y2="204"/>
<text class="t-mono" x="48" y="221">status</text>
<text class="t-val" x="120" y="221">new</text>
<line class="dd-hair" x1="48" y1="230" x2="284" y2="230"/>
<text class="t-mono" x="48" y="247">ref_state</text>
<text class="t-val" x="120" y="247">…/1234/meta:8e21a04</text>
<rect class="dd-card" x="348" y="60" width="420" height="36" rx="6"/>
<text class="t-mono" x="364" y="83">exactRef("refs/changes/34/1234/meta")</text>
<text class="t-val" x="752" y="83" text-anchor="end"><tspan class="t-acc">c3d18f5</tspan></text>
<rect class="dd-card" x="348" y="120" width="420" height="110" rx="6"/>
<path class="dd-card-head" d="M348.6 152 V126 Q348.6 120.6 354 120.6 H762 Q767.4 120.6 767.4 126 V152 Z"/>
<line class="dd-edge" x1="348" y1="152" x2="768" y2="152"/>
<text class="t-name" x="364" y="141">change_notes 缓存</text>
<text class="t-note" x="752" y="141" text-anchor="end">按 SHA-1 寻址</text>
<text class="t-mono" x="364" y="169">key</text>
<text class="t-val" x="444" y="169">(hello, 1234, <tspan class="t-acc">c3d18f5</tspan>)</text>
<line class="dd-hair" x1="364" y1="178" x2="752" y2="178"/>
<text class="t-mono" x="364" y="195">value</text>
<text class="t-val" x="444" y="195">ChangeNotesState：MERGED</text>
<line class="dd-hair" x1="364" y1="204" x2="752" y2="204"/>
<text class="t-mono" x="364" y="221">未命中</text>
<text class="t-val" x="444" y="221">ChangeNotesParser 从 c3d18f5 往旧走</text>
<rect class="dd-card" x="348" y="256" width="420" height="110" rx="6"/>
<path class="dd-card-head" d="M348.6 288 V262 Q348.6 256.6 354 256.6 H762 Q767.4 256.6 767.4 262 V288 Z"/>
<line class="dd-edge" x1="348" y1="288" x2="768" y2="288"/>
<text class="t-name" x="364" y="277">StalenessChecker</text>
<text class="t-note" x="752" y="277" text-anchor="end">比较 ref_state</text>
<text class="t-mono" x="364" y="305">索引里</text>
<text class="t-val" x="444" y="305">8e21a04</text>
<line class="dd-hair" x1="364" y1="314" x2="752" y2="314"/>
<text class="t-mono" x="364" y="331">仓库里</text>
<text class="t-val" x="444" y="331"><tspan class="t-acc">c3d18f5</tspan></text>
<line class="dd-hair" x1="364" y1="340" x2="752" y2="340"/>
<text class="t-mono" x="364" y="357">结论</text>
<text class="t-val" x="444" y="357">不一致 → 重新索引 change 1234</text>
<text class="t-say" x="32" y="288">索引只负责找出“哪些 change”，</text>
<text class="t-say" x="32" y="308">字段可能落后于 NoteDb。</text>
</svg>
</div>
<figcaption>图 9 · 索引负责“找”，NoteDb 负责“真”：缓存按 meta 的 SHA-1 寻址，索引用 ref_state 发现自己落后（哈希为示例）</figcaption>
</figure>

### 缓存：按哈希寻址，不用失效

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

---

## 八、多台机器：复制与多站点

### 现象：一个 JVM，一个文件系统

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

### 复制：状态即引用的红利

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

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

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

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 522" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gr-multisite-title gr-multisite-desc">
<title id="gr-multisite-title">复制与多站点：引用、事件和全局引用库</title>
<desc id="gr-multisite-desc">site A 提交 change 后，main 从 385ce2c 变成 e4f0a17。启用 multi-site 时，这次更新要经过 global-refdb 的比较并设置；本地更新后触发 GitReferenceUpdated，replication 插件默认等 15 秒把引用推到 site B；索引和缓存事件经消息队列送到 site B。若 site B 还以为 main 是 385ce2c 并尝试写入，global-refdb 的比较失败，该引用暂时只读，直到复制追上。</desc>
<defs>
<marker id="gr-multisite-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="gr-multisite-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="522" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="248" height="382" rx="8"/>
<text class="t-zone" x="32" y="44">site A（primary）</text>
<rect class="dd-zone" x="280" y="16" width="240" height="382" rx="8"/>
<text class="t-zone" x="296" y="44">共享组件（multi-site 插件）</text>
<rect class="dd-zone" x="536" y="16" width="248" height="382" rx="8"/>
<text class="t-zone" x="552" y="44">site B</text>
<rect class="dd-zone" x="16" y="414" width="768" height="92" rx="8"/>
<text class="t-zone" x="32" y="442">site B 还没追上就想写 main</text>
<path class="dd-edge-acc" d="M248 102 H296" marker-end="url(#gr-multisite-arrow-acc)"/>
<path class="dd-edge" d="M140 144 V168" marker-end="url(#gr-multisite-arrow)"/>
<path class="dd-edge" d="M140 204 V220" marker-end="url(#gr-multisite-arrow)"/>
<path class="dd-edge-acc" d="M248 238 H652 Q660 238 660 230 V144" marker-end="url(#gr-multisite-arrow-acc)"/>
<path class="dd-edge" d="M248 290 H296" marker-end="url(#gr-multisite-arrow)"/>
<path class="dd-edge" d="M504 300 H552" marker-end="url(#gr-multisite-arrow)"/>
<path class="dd-edge" d="M256 472 H288" marker-end="url(#gr-multisite-arrow)"/>
<path class="dd-edge" d="M536 472 H568" marker-end="url(#gr-multisite-arrow)"/>
<rect class="dd-card" x="32" y="60" width="216" height="84" rx="6"/>
<path class="dd-card-head" d="M32.6 92 V66 Q32.6 60.6 38 60.6 H242 Q247.4 60.6 247.4 66 V92 Z"/>
<line class="dd-edge" x1="32" y1="92" x2="248" y2="92"/>
<text class="t-name" x="48" y="81">BatchRefUpdate</text>
<text class="t-note" x="232" y="81" text-anchor="end">submit</text>
<text class="t-mono" x="48" y="109">refs/heads/main</text>
<line class="dd-hair" x1="48" y1="118" x2="232" y2="118"/>
<text class="t-mono" x="48" y="135">385ce2c → <tspan class="t-acc">e4f0a17</tspan></text>
<rect class="dd-card" x="32" y="168" width="216" height="36" rx="6"/>
<text class="t-mono" x="48" y="191">GitReferenceUpdated</text>
<text class="t-note" x="232" y="191" text-anchor="end">事件</text>
<rect class="dd-card" x="32" y="220" width="216" height="36" rx="6"/>
<text class="t-mono" x="48" y="243">replication</text>
<text class="t-note" x="232" y="243" text-anchor="end">默认等 15 秒</text>
<rect class="dd-card" x="32" y="272" width="216" height="36" rx="6"/>
<text class="t-name" x="48" y="295">索引 / 缓存事件</text>
<rect class="dd-card" x="296" y="60" width="208" height="84" rx="6"/>
<path class="dd-card-head" d="M296.6 92 V66 Q296.6 60.6 302 60.6 H498 Q503.4 60.6 503.4 66 V92 Z"/>
<line class="dd-edge" x1="296" y1="92" x2="504" y2="92"/>
<text class="t-name" x="312" y="81">global-refdb</text>
<text class="t-note" x="488" y="81" text-anchor="end">比较并设置</text>
<text class="t-mono" x="312" y="109">hello:refs/heads/main</text>
<line class="dd-hair" x1="312" y1="118" x2="488" y2="118"/>
<text class="t-mono" x="312" y="135">385ce2c → <tspan class="t-acc">e4f0a17</tspan></text>
<rect class="dd-card" x="296" y="272" width="208" height="110" rx="6"/>
<path class="dd-card-head" d="M296.6 304 V278 Q296.6 272.6 302 272.6 H498 Q503.4 272.6 503.4 278 V304 Z"/>
<line class="dd-edge" x1="296" y1="304" x2="504" y2="304"/>
<text class="t-name" x="312" y="293">events broker</text>
<text class="t-note" x="488" y="293" text-anchor="end">如 Kafka</text>
<text class="t-mono" x="312" y="321">index: change 1234</text>
<line class="dd-hair" x1="312" y1="330" x2="488" y2="330"/>
<text class="t-mono" x="312" y="347">cache-eviction</text>
<line class="dd-hair" x1="312" y1="356" x2="488" y2="356"/>
<text class="t-mono" x="312" y="373">stream: change-merged</text>
<rect class="dd-card" x="552" y="60" width="216" height="84" rx="6"/>
<path class="dd-card-head" d="M552.6 92 V66 Q552.6 60.6 558 60.6 H762 Q767.4 60.6 767.4 66 V92 Z"/>
<line class="dd-edge" x1="552" y1="92" x2="768" y2="92"/>
<text class="t-name" x="568" y="81">hello.git</text>
<text class="t-note" x="752" y="81" text-anchor="end">复制目标</text>
<text class="t-mono" x="568" y="109">refs/heads/main</text>
<line class="dd-hair" x1="568" y1="118" x2="752" y2="118"/>
<text class="t-mono" x="568" y="135">385ce2c → <tspan class="t-acc">e4f0a17</tspan></text>
<rect class="dd-card" x="552" y="272" width="216" height="58" rx="6"/>
<path class="dd-card-head" d="M552.6 304 V278 Q552.6 272.6 558 272.6 H762 Q767.4 272.6 767.4 278 V304 Z"/>
<line class="dd-edge" x1="552" y1="304" x2="768" y2="304"/>
<text class="t-name" x="568" y="293">Lucene 索引</text>
<text class="t-mono" x="568" y="321">重新索引 change 1234</text>
<rect class="dd-card" x="32" y="454" width="224" height="36" rx="6"/>
<text class="t-mono" x="48" y="477">site B：main = 385ce2c</text>
<rect class="dd-card" x="288" y="454" width="248" height="36" rx="6"/>
<text class="t-mono" x="304" y="477">期望 385ce2c，实际 <tspan class="t-acc">e4f0a17</tspan></text>
<rect class="dd-card" x="568" y="454" width="200" height="36" rx="6"/>
<text class="t-name" x="584" y="477">拒绝：这个引用暂时只读</text>
<text class="t-say" x="296" y="172">改引用前先在这里比较，</text>
<text class="t-say" x="296" y="192">旧值对不上就拒绝。</text>
<text class="t-note" x="768" y="262" text-anchor="end">push refs/*:refs/*</text>
</svg>
</div>
<figcaption>图 10 · 多站点时，Git 数据靠复制，索引和缓存靠事件，谁有资格写某个引用靠全局引用库的比较并设置（哈希为示例）</figcaption>
</figure>

其中关键的是全局引用库（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 账号的话，可以自己看看这些引用：

```bash
# 某个 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
```

---

## 参考资料
