﻿---
title: "GitLab 的 Git 服务架构：Gitaly、Praefect 与仓库的拆分"
description: "GitLab.com 早年打开一个仓库的 P99 超过 30 秒，CPU 却只用了 15 毫秒。顺着这个现象往下追：仓库为什么离开 NFS，一次 clone 由谁鉴权、pack 字节走哪条路，Gitaly 怎么把 RPC 变成受控的 git 进程、怎么扛住 CI 的并发 clone，push 怎么在 Gitaly 和 Rails 之间往返，Praefect 怎么投票、怎么修复落后的副本，最后看 WAL 和 MVCC 后端在当前源码里真正走到了哪一步。"
pubDate: 2026-10-17
tags: ["Git", "GitLab", "Gitaly", "源码分析", "分布式系统"]
refs:
  - id: design
    title: Gitaly design
    url: https://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/DESIGN.md
    site: GitLab
    note: "`doc/DESIGN.md`：Rugged 的 P99 数据、Decisions 一节里“为什么不做成库”"
  - id: sidechannel
    title: Gitaly sidechannels
    url: https://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/sidechannel.md
    site: GitLab
    note: "`doc/sidechannel.md`；实现在 `internal/grpc/sidechannel/sidechannel.go`"
  - id: yamux
    title: hashicorp/yamux
    url: https://github.com/hashicorp/yamux
    site: GitHub
  - id: cache
    title: Pack-objects cache design notes
    url: https://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/design_pack_objects_cache.md
    site: GitLab
    note: "`doc/design_pack_objects_cache.md`；缓存键在 `service/hook/pack_objects.go`"
  - id: quarantine
    title: Git object quarantine during git push
    url: https://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/object_quarantine.md
    site: GitLab
    note: "`doc/object_quarantine.md`"
  - id: ha
    title: Gitaly Cluster design
    url: https://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/design_ha.md
    site: GitLab
    note: "`doc/design_ha.md`：从最终一致到强一致的设计"
  - id: reftx-hook
    title: "githooks: reference-transaction"
    url: https://git-scm.com/docs/githooks#_reference_transaction
    site: Git 文档
  - id: transactions
    title: Transactions
    url: https://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/transactions.md
    site: GitLab
    note: "`doc/transactions.md`，代码在 `internal/gitaly/storage/storagemgr/`"
  - id: mvcc-flow
    title: MVCC Gitaly RPC flow
    url: https://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/mvcc_rpc_flow.md
    site: GitLab
    note: "`doc/mvcc_rpc_flow.md`，文中说明描述的是 POC 分支"
  - id: changelog
    title: Gitaly CHANGELOG
    url: https://gitlab.com/gitlab-org/gitaly/-/blob/master/CHANGELOG.md
    site: GitLab
    note: "19.4.0 的 Removed 一节"
  - id: storage-paths
    title: Repository storage
    url: https://docs.gitlab.com/administration/repository_storage_paths/
    site: GitLab 文档
  - id: gitaly-src
    title: gitlab-org/gitaly
    url: https://gitlab.com/gitlab-org/gitaly
    site: GitLab 源码
    note: "本文读的是 19.5.0-rc3，提交 `77bee05`（2026-10-14）"
    further: true
  - id: gitlab-src
    title: gitlab-org/gitlab
    url: https://gitlab.com/gitlab-org/gitlab
    site: GitLab 源码
    note: "`workhorse/internal/`、`lib/api/internal/base.rb`、`lib/gitlab/git_access.rb`，提交 `31a01d7a`"
    further: true
  - id: shell-src
    title: gitlab-org/gitlab-shell
    url: https://gitlab.com/gitlab-org/gitlab-shell
    site: GitLab 源码
    note: "`internal/sshd/`、`internal/command/uploadpack/`，提交 `bbd07a4`"
    further: true
  - id: grpc
    title: gRPC
    url: https://grpc.io/
    site: grpc.io
    further: true
---

Gitaly 的设计文档里记着这样一组数字[^design]，来自 GitLab.com 早年的监控：

```
Rugged::Repository.new      P99 墙钟时间   峰值超过 30 s
                            CPU 时间       约 15 ms
遍历 gitlab-ce 的所有分支                  2.4 s
```

`Rugged::Repository.new` 是 libgit2 的 Ruby 绑定里“打开一个仓库”的调用，它只是读几个文件、建几个对象，CPU 也确实只花了 15 毫秒。墙钟时间却能到 30 秒，中间的时间去了哪里？

今天的 GitLab 早就不这样打开仓库了，围绕仓库长出了好几个组件。它们为什么要拆成这样？一次 clone 的字节从哪里流到哪里？push 的时候，谁来判断这个分支能不能推？仓库有好几份副本时，又怎么保证它们一样？

在[《从 git clone 说起》](/blog/git-server-from-clone/)里，我们已经看过 Git 协议本身，也粗略走过一遍 GitLab 的路径。这篇文章从这 30 秒出发，把那条路径一段一段拆开来看。

---

## 一、仓库为什么离开了 NFS

### 现象：CPU 很闲，请求很慢

GitLab 早期的部署很朴素：应用服务器上跑着 Rails（当时是 Unicorn）、Sidekiq 和 gitlab-shell，所有机器挂载同一个 NFS 目录，仓库都放在上面。Rails 要读仓库，就在本机用 Rugged 打开 NFS 上的目录。机器不够就加应用服务器，每台都挂上同一个 NFS。

上面那组数字就出在这套部署上。CPU 时间和墙钟时间差了三个数量级，说明进程几乎一直在等。设计文档的判断是：**时间花在了文件系统访问上**。图 1 的左半边就是这套部署。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 420" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gl-split-title gl-split-desc">
<title id="gl-split-title">GitLab 拆分前后的 Git 访问路径</title>
<desc id="gl-split-desc">左边是早期部署：多台跑 Unicorn 和 Rugged 的应用服务器挂载同一个 NFS 目录，在本机直接读仓库。右边是今天：HTTP 由 Workhorse、SSH 由 gitlab-sshd 接住，先问 Rails 能不能、仓库在哪，再用 gRPC 直接找 Gitaly；只有 Gitaly 在本地磁盘上执行 git。</desc>
<defs>
<marker id="gl-split-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="gl-split-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="420" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="304" height="388" rx="8"/>
<text class="t-zone" x="36" y="44">过去 · 应用服务器挂 NFS</text>
<rect class="dd-zone" x="336" y="16" width="448" height="388" rx="8"/>
<text class="t-zone" x="356" y="44">现在 · git 进程搬到存储机器上</text>
<path class="dd-edge-acc" d="M56 89 H48 Q40 89 40 97 V250" marker-end="url(#gl-split-arrow-acc)"/>
<path class="dd-edge-acc" d="M64 190 V250" marker-end="url(#gl-split-arrow-acc)"/>
<path class="dd-edge" d="M472 118 V146" marker-end="url(#gl-split-arrow)"/>
<path class="dd-edge" d="M648 118 V146" marker-end="url(#gl-split-arrow)"/>
<path class="dd-edge" d="M560 206 V238" marker-end="url(#gl-split-arrow)"/>
<path class="dd-edge-acc" d="M368 118 V238" marker-end="url(#gl-split-arrow-acc)"/>
<path class="dd-edge-acc" d="M752 118 V238" marker-end="url(#gl-split-arrow-acc)"/>
<rect class="dd-card" x="56" y="60" width="248" height="58" rx="6"/>
<path class="dd-card-head" d="M56.6 92 V66 Q56.6 60.6 62 60.6 H298 Q303.4 60.6 303.4 66 V92 Z"/>
<line class="dd-edge" x1="56" y1="92" x2="304" y2="92"/>
<text class="t-name" x="72" y="81">Unicorn + Rugged</text>
<text class="t-val" x="288" y="81" text-anchor="end">app-01</text>
<text class="t-mono" x="72" y="109"><tspan class="t-acc">Rugged::Repository.new</tspan></text>
<rect class="dd-card" x="56" y="132" width="248" height="58" rx="6"/>
<path class="dd-card-head" d="M56.6 164 V138 Q56.6 132.6 62 132.6 H298 Q303.4 132.6 303.4 138 V164 Z"/>
<line class="dd-edge" x1="56" y1="164" x2="304" y2="164"/>
<text class="t-name" x="72" y="153">Unicorn + Rugged</text>
<text class="t-val" x="288" y="153" text-anchor="end">app-02</text>
<text class="t-mono" x="72" y="181"><tspan class="t-acc">Rugged::Repository.new</tspan></text>
<text class="t-note" x="180" y="216" text-anchor="middle">…还有 Sidekiq、gitlab-shell</text>
<rect class="dd-card" x="32" y="252" width="272" height="84" rx="6"/>
<path class="dd-card-head" d="M32.6 284 V258 Q32.6 252.6 38 252.6 H298 Q303.4 252.6 303.4 258 V284 Z"/>
<line class="dd-edge" x1="32" y1="284" x2="304" y2="284"/>
<text class="t-name" x="48" y="273">NFS 服务器</text>
<text class="t-val" x="288" y="273" text-anchor="end">nfs-01</text>
<text class="t-mono" x="48" y="301">/var/opt/gitlab/git-data</text>
<line class="dd-hair" x1="48" y1="310" x2="288" y2="310"/>
<text class="t-note" x="48" y="327">所有机器挂同一个目录</text>
<text class="t-say" x="32" y="364">P99 超过 30 秒，CPU 只用 15 毫秒：</text>
<text class="t-say" x="32" y="384">时间花在等文件系统上。</text>
<rect class="dd-card" x="352" y="60" width="196" height="58" rx="6"/>
<path class="dd-card-head" d="M352.6 92 V66 Q352.6 60.6 358 60.6 H542 Q547.4 60.6 547.4 66 V92 Z"/>
<line class="dd-edge" x1="352" y1="92" x2="548" y2="92"/>
<text class="t-name" x="368" y="81">Workhorse</text>
<text class="t-val" x="532" y="81" text-anchor="end">HTTP</text>
<text class="t-mono" x="368" y="109">git-upload-pack</text>
<rect class="dd-card" x="572" y="60" width="196" height="58" rx="6"/>
<path class="dd-card-head" d="M572.6 92 V66 Q572.6 60.6 578 60.6 H762 Q767.4 60.6 767.4 66 V92 Z"/>
<line class="dd-edge" x1="572" y1="92" x2="768" y2="92"/>
<text class="t-name" x="588" y="81">gitlab-sshd</text>
<text class="t-val" x="752" y="81" text-anchor="end">SSH</text>
<text class="t-mono" x="588" y="109">git-upload-pack</text>
<text class="t-note" x="480" y="138">预授权</text>
<text class="t-note" x="640" y="138" text-anchor="end">/allowed</text>
<rect class="dd-card" x="432" y="148" width="256" height="58" rx="6"/>
<path class="dd-card-head" d="M432.6 180 V154 Q432.6 148.6 438 148.6 H682 Q687.4 148.6 687.4 154 V180 Z"/>
<line class="dd-edge" x1="432" y1="180" x2="688" y2="180"/>
<text class="t-name" x="448" y="169">Rails · Puma</text>
<text class="t-note" x="448" y="197">能不能读？仓库在哪个 storage？</text>
<text class="t-note" x="568" y="228">网页和 API 的读请求</text>
<rect class="dd-card" x="352" y="240" width="416" height="110" rx="6"/>
<path class="dd-card-head" d="M352.6 272 V246 Q352.6 240.6 358 240.6 H762 Q767.4 240.6 767.4 246 V272 Z"/>
<line class="dd-edge" x1="352" y1="272" x2="768" y2="272"/>
<text class="t-name" x="368" y="261">Gitaly</text>
<text class="t-val" x="752" y="261" text-anchor="end">gitaly-1</text>
<text class="t-mono" x="368" y="289"><tspan class="t-acc">git upload-pack</tspan> --stateless-rpc</text>
<line class="dd-hair" x1="368" y1="298" x2="752" y2="298"/>
<text class="t-mono" x="368" y="315">/var/opt/gitlab/git-data/repositories</text>
<line class="dd-hair" x1="368" y1="324" x2="752" y2="324"/>
<text class="t-note" x="368" y="341">本地磁盘，只有 Gitaly 碰它</text>
<text class="t-say" x="352" y="384">带颜色的线是仓库数据走的路：右边它绕开了 Rails。</text>
</svg>
</div>
<figcaption>图 1 · 拆分之后，Rails 只回答“能不能、在哪里”，读写仓库的 git 进程只在 Gitaly 所在的机器上运行</figcaption>
</figure>

### 原因：Git 默认磁盘就在身边

设计文档列了一串 Git 的“架构假设”[^design]，最要紧的两条是：存储访问要低延迟、高带宽，接近总线速度；磁盘 I/O 是突发的，取决于仓库内容，没法预测。放到 NFS 上，打开仓库要读的 `HEAD`、`config`、`packed-refs`、pack 索引，生成 pack 时在几个 GB 文件里的随机读，每一次都变成一次网络往返。故障范围也跟着变大：一台 NFS 服务器出问题，所有应用服务器都会失效，连 SSH 都登不上。

### 第一次尝试：换硬件，或者做成一个库

设计文档考虑过两个直觉的办法[^design]。**换更快的机器**：用户大多跑在云上，GitLab.com 自己换硬件解决不了用户的问题。**把 Git 访问封装成一个库**：库装在调用方进程里，调用方在哪台机器上，NFS 就得挂到哪台机器上，存储扩展会被“每台 worker 都要挂所有 NFS”卡死；而且 gitlab-shell、Workhorse、Unicorn 都不适合承载它。

### 解决：Gitaly，让 git 进程待在磁盘旁边

剩下的办法是**把 git 进程搬到存储机器上**：在存放仓库的机器上跑一个常驻服务 Gitaly，其他组件不再碰仓库文件，改成通过网络请求让它代为执行。组件之间用 **gRPC** 通信，这是 Google 开源的远程调用框架，跑在 HTTP/2 上；接口写在 protobuf 的 `.proto` 文件里，由工具生成各语言的客户端和服务端代码，并且支持一个请求对应一串响应的流式调用。

图 1 右边，两种流量分开了：**控制面**是“这个人能不能读、仓库在哪”，由知道用户和权限的 Rails 回答；**数据面**是 pack 的字节，由 Workhorse 或 gitlab-sshd 直接和 Gitaly 交换。可是 **Workhorse 和 gitlab-sshd 自己不认识用户，它们怎么知道这次请求能不能放行、该去哪台 Gitaly？**

---

## 二、一次 clone 的两个入口

### 现象：同一个仓库，两种地址

同一个仓库可以用 `https://gitlab.example.com/acme/app.git` 克隆，也可以用 `git@gitlab.example.com:acme/app.git`。前一种由 Workhorse 接住，后一种由 gitlab-shell 接住。两条路的协议、认证方式都不一样，最后却要汇到同一台 Gitaly 上。图 2 把两边的请求和 Rails 的回答并排放在一起，下面分别看。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 620" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gl-auth-title gl-auth-desc">
<title id="gl-auth-title">两个入口的鉴权请求和 Rails 的回答</title>
<desc id="gl-auth-desc">HTTPS 入口：客户端 GET /acme/app.git/info/refs，Workhorse 把同一路径去掉请求体转给 Rails。SSH 入口：客户端 ssh git@gitlab.example.com git-upload-pack acme/app.git，gitlab-sshd 先用公钥换到 key 45，再 POST /api/v4/internal/allowed。两边从 Rails 拿到同一组字段：GL_ID、GL_REPOSITORY、storage_name、relative_path @hashed/73/47/…、Gitaly 地址和令牌。</desc>
<defs>
<marker id="gl-auth-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="gl-auth-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="620" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="376" height="296" rx="8"/>
<text class="t-zone" x="36" y="44">HTTPS · Workhorse</text>
<rect class="dd-zone" x="408" y="16" width="376" height="296" rx="8"/>
<text class="t-zone" x="428" y="44">SSH · gitlab-sshd</text>
<rect class="dd-zone" x="16" y="328" width="768" height="276" rx="8"/>
<text class="t-zone" x="36" y="356">Rails 的回答（两边相同）</text>
<path class="dd-edge" d="M300 296 V370" marker-end="url(#gl-auth-arrow)"/>
<path class="dd-edge" d="M700 296 V370" marker-end="url(#gl-auth-arrow)"/>
<rect class="dd-card" x="32" y="60" width="344" height="110" rx="6"/>
<path class="dd-card-head" d="M32.6 92 V66 Q32.6 60.6 38 60.6 H370 Q375.4 60.6 375.4 66 V92 Z"/>
<line class="dd-edge" x1="32" y1="92" x2="376" y2="92"/>
<text class="t-name" x="48" y="81">客户端请求</text>
<text class="t-val" x="360" y="81" text-anchor="end">GET</text>
<text class="t-mono" x="48" y="109">GET /<tspan class="t-acc">acme/app.git</tspan>/info/refs</text>
<line class="dd-hair" x1="48" y1="118" x2="360" y2="118"/>
<text class="t-mono" x="48" y="135">    ?service=git-upload-pack</text>
<line class="dd-hair" x1="48" y1="144" x2="360" y2="144"/>
<text class="t-mono" x="48" y="161">Authorization: Basic ZGV2Oi…</text>
<rect class="dd-card" x="32" y="186" width="344" height="110" rx="6"/>
<path class="dd-card-head" d="M32.6 218 V192 Q32.6 186.6 38 186.6 H370 Q375.4 186.6 375.4 192 V218 Z"/>
<line class="dd-edge" x1="32" y1="218" x2="376" y2="218"/>
<text class="t-name" x="48" y="207">Workhorse → Rails</text>
<text class="t-val" x="360" y="207" text-anchor="end">PreAuthorize</text>
<text class="t-mono" x="48" y="235">GET 同一路径，去掉请求体</text>
<line class="dd-hair" x1="48" y1="244" x2="360" y2="244"/>
<text class="t-mono" x="48" y="261">Gitlab-Workhorse-Api-Request: JWT</text>
<line class="dd-hair" x1="48" y1="270" x2="360" y2="270"/>
<text class="t-note" x="48" y="287">Rails 回 401 就原样转给 git，弹出密码框</text>
<rect class="dd-card" x="424" y="60" width="344" height="110" rx="6"/>
<path class="dd-card-head" d="M424.6 92 V66 Q424.6 60.6 430 60.6 H762 Q767.4 60.6 767.4 66 V92 Z"/>
<line class="dd-edge" x1="424" y1="92" x2="768" y2="92"/>
<text class="t-name" x="440" y="81">客户端请求</text>
<text class="t-val" x="752" y="81" text-anchor="end">ssh</text>
<text class="t-mono" x="440" y="109">ssh git@gitlab.example.com</text>
<line class="dd-hair" x1="440" y1="118" x2="752" y2="118"/>
<text class="t-mono" x="440" y="135">  git-upload-pack '<tspan class="t-acc">acme/app.git</tspan>'</text>
<line class="dd-hair" x1="440" y1="144" x2="752" y2="144"/>
<text class="t-mono" x="440" y="161">env GIT_PROTOCOL=version=2</text>
<rect class="dd-card" x="424" y="186" width="344" height="110" rx="6"/>
<path class="dd-card-head" d="M424.6 218 V192 Q424.6 186.6 430 186.6 H762 Q767.4 186.6 767.4 192 V218 Z"/>
<line class="dd-edge" x1="424" y1="218" x2="768" y2="218"/>
<text class="t-name" x="440" y="207">gitlab-sshd → Rails</text>
<text class="t-val" x="752" y="207" text-anchor="end">POST</text>
<text class="t-mono" x="440" y="235">公钥 → /internal/authorized_keys</text>
<line class="dd-hair" x1="440" y1="244" x2="752" y2="244"/>
<text class="t-mono" x="440" y="261">     → key-id 45</text>
<line class="dd-hair" x1="440" y1="270" x2="752" y2="270"/>
<text class="t-mono" x="440" y="287">/internal/allowed  changes=_any</text>
<rect class="dd-card" x="32" y="372" width="736" height="188" rx="6"/>
<path class="dd-card-head" d="M32.6 404 V378 Q32.6 372.6 38 372.6 H762 Q767.4 372.6 767.4 378 V404 Z"/>
<line class="dd-edge" x1="32" y1="404" x2="768" y2="404"/>
<text class="t-name" x="48" y="393">Rails 返回</text>
<text class="t-val" x="752" y="393" text-anchor="end">application/vnd.gitlab-workhorse+json · JSON</text>
<text class="t-mono" x="48" y="421">GL_ID</text>
<text class="t-val" x="232" y="421">user-17</text>
<line class="dd-hair" x1="48" y1="430" x2="752" y2="430"/>
<text class="t-mono" x="48" y="447">GL_REPOSITORY</text>
<text class="t-val" x="232" y="447">project-42</text>
<line class="dd-hair" x1="48" y1="456" x2="752" y2="456"/>
<text class="t-mono" x="48" y="473">storage_name</text>
<text class="t-val" x="232" y="473">default</text>
<line class="dd-hair" x1="48" y1="482" x2="752" y2="482"/>
<text class="t-mono" x="48" y="499">relative_path</text>
<text class="t-val" x="232" y="499"><tspan class="t-acc">@hashed/73/47/73475cb40a568e8da8a045ced110137e…8049.git</tspan></text>
<line class="dd-hair" x1="48" y1="508" x2="752" y2="508"/>
<text class="t-mono" x="48" y="525">GitalyServer.address</text>
<text class="t-val" x="232" y="525">tcp://gitaly-1.internal:8075</text>
<line class="dd-hair" x1="48" y1="534" x2="752" y2="534"/>
<text class="t-mono" x="48" y="551">GitalyServer.token</text>
<text class="t-val" x="232" y="551">（共享令牌，省略）</text>
<text class="t-say" x="32" y="592">项目 ID 42、用户 17、key 45 和主机名都是示例；relative_path 是 SHA-256("42") 的真实结果。</text>
</svg>
</div>
<figcaption>图 2 · 两个入口用不同的方式问同一个问题，Rails 给出同一组回答：用户是谁、仓库在哪台 Gitaly 的哪个路径</figcaption>
</figure>

### HTTP：把同一个 URL 先交给 Rails

Workhorse 是挡在 Rails 前面的 Go 反向代理。它用正则 `^/.+\.git/` 认出 Git 请求（`upstream/routes.go`），先不处理，而是调用 `PreAuthorize`：同样的方法、路径和请求头，去掉请求体，再发给 Rails 一次（`api/api.go`）。Rails 用共享密钥校验签名头，回答的内容类型必须是 `application/vnd.gitlab-workhorse+json`，防止把普通网页误当成授权结果。

### SSH：先用公钥换一个 key ID

SSH 这边，传统部署是 OpenSSH 加上 `authorized_keys` 里的强制命令 `command="…/gitlab-shell key-45"`；较新的是 GitLab 用 Go 写的 gitlab-sshd。它的 `handleUserKey` 只认用户名 `git`、拒绝 DSA 公钥，拿公钥去 `/internal/authorized_keys` 换回 key ID；会话里只接受 `exec`、`shell` 和只能设置 `GIT_PROTOCOL` 的 `env`。之后 gitlab-shell 用 `changes=_any` 调 `/internal/allowed`（`accessverifier/client.go`）。

**两边拿到的是同一组字段**（图 2 下半部分），鉴权走的都是 `Gitlab::GitAccess#check`。其中 `relative_path` 和项目名无关，是项目 ID 的 SHA-256 加两级目录，改名、转移项目不用移动目录；`@groups`、`@snippets`、`@pools` 用的是同样的规则[^storage-paths]。Gitaly 不知道“项目”是什么，只认 `(storage_name, relative_path)` 这一对。

### 两条路的一个差别：有状态与无状态

HTTP 是一问一答的，Gitaly 执行 `git upload-pack --stateless-rpc`，每轮协商一个 POST、一个进程。SSH 是全双工长连接，一个不带 `--stateless-rpc` 的 `git upload-pack` 跑完所有轮（`service/ssh/upload_pack.go`）。长连接带来一个麻烦：权限只在连接开始时检查，有人连上以后迟迟不协商，等权限被收回再开始下载，检查就失效了。所以 Gitaly 从 RPC 开始计时，读到 `done` 包才停表，默认 10 分钟内必须协商完（`ssh/upload_command.go`，注释称之为 time-of-check-to-time-of-use 竞争）。图 3 把两种方式并排画出来。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 452" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gl-rounds-title gl-rounds-desc">
<title id="gl-rounds-title">HTTP 和 SSH 上 upload-pack 的多轮协商</title>
<desc id="gl-rounds-desc">左边是 HTTP：每一轮协商是一个 POST，Gitaly 为它启动一个 upload-pack --stateless-rpc，回完就退出；下一轮客户端要把已确认的共同提交重新带上。右边是 SSH：一个不带 --stateless-rpc 的 upload-pack 进程跑完所有轮次；Gitaly 从 RPC 开始计时，读到 done 包才停表，默认 10 分钟内必须协商完。</desc>
<defs>
<marker id="gl-rounds-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="gl-rounds-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="452" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="376" height="420" rx="8"/>
<text class="t-zone" x="36" y="44">HTTP · 每轮一个 POST、一个进程</text>
<rect class="dd-zone" x="408" y="16" width="376" height="420" rx="8"/>
<text class="t-zone" x="428" y="44">SSH · 一个进程跑完所有轮</text>
<path class="dd-edge" d="M204 144 V158" marker-end="url(#gl-rounds-arrow)"/>
<path class="dd-edge" d="M204 244 V258" marker-end="url(#gl-rounds-arrow)"/>
<path class="dd-edge-acc" d="M596 248 V270" marker-end="url(#gl-rounds-arrow-acc)"/>
<rect class="dd-card" x="32" y="60" width="344" height="84" rx="6"/>
<path class="dd-card-head" d="M32.6 92 V66 Q32.6 60.6 38 60.6 H370 Q375.4 60.6 375.4 66 V92 Z"/>
<line class="dd-edge" x1="32" y1="92" x2="376" y2="92"/>
<text class="t-name" x="48" y="81">POST ①</text>
<text class="t-note" x="360" y="81" text-anchor="end">upload-pack 进程 A</text>
<text class="t-mono" x="48" y="109">want 612f4c1…  have ×32</text>
<line class="dd-hair" x1="48" y1="118" x2="360" y2="118"/>
<text class="t-note" x="48" y="135">回 ACK：其中 3 个是共同提交，进程退出</text>
<rect class="dd-card" x="32" y="160" width="344" height="84" rx="6"/>
<path class="dd-card-head" d="M32.6 192 V166 Q32.6 160.6 38 160.6 H370 Q375.4 160.6 375.4 166 V192 Z"/>
<line class="dd-edge" x1="32" y1="192" x2="376" y2="192"/>
<text class="t-name" x="48" y="181">POST ②</text>
<text class="t-note" x="360" y="181" text-anchor="end">upload-pack 进程 B</text>
<text class="t-mono" x="48" y="209">want 612f4c1…  have <tspan class="t-acc">×3</tspan> + ×32</text>
<line class="dd-hair" x1="48" y1="218" x2="360" y2="218"/>
<text class="t-note" x="48" y="235">上一轮确认的 3 个共同提交重新带上</text>
<rect class="dd-card" x="32" y="260" width="344" height="84" 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">POST ③</text>
<text class="t-note" x="360" y="281" text-anchor="end">upload-pack 进程 C</text>
<text class="t-mono" x="48" y="309">want 612f4c1…  have <tspan class="t-acc">×5</tspan> + ×8  done</text>
<line class="dd-hair" x1="48" y1="318" x2="360" y2="318"/>
<text class="t-note" x="48" y="335">回 packfile，进程退出</text>
<text class="t-say" x="32" y="384">每个进程都带 --stateless-rpc，</text>
<text class="t-say" x="32" y="404">服务端不留状态，状态由客户端重发。</text>
<rect class="dd-card" x="424" y="60" width="344" height="188" rx="6"/>
<path class="dd-card-head" d="M424.6 92 V66 Q424.6 60.6 430 60.6 H762 Q767.4 60.6 767.4 66 V92 Z"/>
<line class="dd-edge" x1="424" y1="92" x2="768" y2="92"/>
<text class="t-name" x="440" y="81">git upload-pack</text>
<text class="t-note" x="752" y="81" text-anchor="end">进程 D</text>
<text class="t-mono" x="440" y="109">→ want 612f4c1…  have ×32</text>
<line class="dd-hair" x1="440" y1="118" x2="752" y2="118"/>
<text class="t-note" x="440" y="135">← ACK 3 个共同提交</text>
<line class="dd-hair" x1="440" y1="144" x2="752" y2="144"/>
<text class="t-mono" x="440" y="161">→ have ×32</text>
<line class="dd-hair" x1="440" y1="170" x2="752" y2="170"/>
<text class="t-note" x="440" y="187">← ACK</text>
<line class="dd-hair" x1="440" y1="196" x2="752" y2="196"/>
<text class="t-mono" x="440" y="213">→ have ×8  <tspan class="t-acc">done</tspan></text>
<line class="dd-hair" x1="440" y1="222" x2="752" y2="222"/>
<text class="t-note" x="440" y="239">← packfile</text>
<rect class="dd-card" x="424" y="272" width="344" height="110" rx="6"/>
<path class="dd-card-head" d="M424.6 304 V278 Q424.6 272.6 430 272.6 H762 Q767.4 272.6 767.4 278 V304 Z"/>
<line class="dd-edge" x1="424" y1="304" x2="768" y2="304"/>
<text class="t-name" x="440" y="293">协商超时</text>
<text class="t-note" x="752" y="293" text-anchor="end">upload_pack_negotiation</text>
<text class="t-mono" x="440" y="321">开始计时</text>
<text class="t-val" x="520" y="321">RPC 开始时</text>
<line class="dd-hair" x1="440" y1="330" x2="752" y2="330"/>
<text class="t-mono" x="440" y="347">停表</text>
<text class="t-val" x="520" y="347">读到 <tspan class="t-acc">0009done</tspan></text>
<line class="dd-hair" x1="440" y1="356" x2="752" y2="356"/>
<text class="t-mono" x="440" y="373">默认</text>
<text class="t-val" x="520" y="373">10 分钟，超时返回 DeadlineExceeded</text>
<text class="t-say" x="424" y="404">鉴权只在开始时做一次，协商不能无限拖。</text>
<text class="t-note" x="32" y="428">have 的个数为示例。</text>
</svg>
</div>
<figcaption>图 3 · HTTP 每轮都是新进程，状态靠客户端重发；SSH 一个进程跑到底，所以要给“连上到说 done”限时</figcaption>
</figure>

鉴权解决了“能不能”。接下来是数据：一次 clone 的 pack 可能有几个 GB，**它怎么从 Gitaly 回到 Workhorse？**

---

## 三、pack 字节走哪条路：sidechannel

### 现象：gRPC 消息成了瓶颈

最直接的做法是用 gRPC 的流式响应：Gitaly 把 `git upload-pack` 的输出切成一块一块，每块装进一条 protobuf 消息发回去。早期的 Gitaly 就是这样做的，现在 push 用的 `PostReceivePack` 仍然是这种双向流，每条消息里一个 `data` 字段。

问题在于每条消息都有固定开销：编码、解码、分配内存、拷贝。对普通 RPC 这不算什么，可一次 clone 要传几个 GB，开销就一条一条累积起来。Gitaly 的文档说得很直接：因为每条消息的开销，这种封装限制了一台 Gitaly 能提供的 fetch 流量[^sidechannel]。

### 第一次尝试：另开一个端口？

绕开 protobuf 最简单的办法是另开一个端口，用裸 TCP 传 pack。可是这样就要多管一套端口、一套认证、一套 TLS，还要把这条连接和那次 RPC 对上号。

### 解决：同一条 TCP 连接上的反向流

Gitaly 从 GitLab 14.4 开始用的办法是**多路复用**[^sidechannel]。它借助 HashiCorp 的 yamux 库[^yamux]，在一条真实的 TCP 连接上开出多条虚拟的“流”。客户端连上 Gitaly 以后，流 1 用来跑 gRPC；如果对面是 Praefect，流 2 是给投票用的 backchannel；每次需要传 pack 时，**由 Gitaly 反向打开**一条短命的流，这条流就是 sidechannel。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 512" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gl-side-title gl-side-desc">
<title id="gl-side-title">sidechannel 的握手和数据流</title>
<desc id="gl-side-desc">Workhorse 在流 1 上发起 PostUploadPackWithSidechannel，metadata 里带 gitaly-sidechannel-id 7；Gitaly 在同一条 TCP 连接上反向打开一条 yamux 流，写入 sidechannel 和 7，Workhorse 回 ok；随后 HTTP 请求体经这条流进入 upload-pack 的标准输入，packfile 从同一条流回来；最后流 1 返回统计和状态。</desc>
<defs>
<marker id="gl-side-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="gl-side-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="512" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="480" rx="8"/>
<text class="t-zone" x="36" y="44">一条 TCP 连接 · Workhorse ↔ gitaly-1</text>
<line class="dd-edge dd-dash" x1="132" y1="140" x2="132" y2="452"/>
<line class="dd-edge dd-dash" x1="668" y1="140" x2="668" y2="452"/>
<path class="dd-edge" d="M132 180 H666" marker-end="url(#gl-side-arrow)"/>
<path class="dd-edge-acc" d="M668 232 H134" marker-end="url(#gl-side-arrow-acc)"/>
<path class="dd-edge-acc" d="M132 284 H666" marker-end="url(#gl-side-arrow-acc)"/>
<path class="dd-edge-acc" d="M132 336 H666" marker-end="url(#gl-side-arrow-acc)"/>
<path class="dd-edge-acc" d="M668 388 H134" marker-end="url(#gl-side-arrow-acc)"/>
<path class="dd-edge" d="M668 440 H134" marker-end="url(#gl-side-arrow)"/>
<rect class="dd-card" x="32" y="56" width="200" height="84" rx="6"/>
<path class="dd-card-head" d="M32.6 88 V62 Q32.6 56.6 38 56.6 H226 Q231.4 56.6 231.4 62 V88 Z"/>
<line class="dd-edge" x1="32" y1="88" x2="232" y2="88"/>
<text class="t-name" x="48" y="77">Workhorse</text>
<text class="t-mono" x="48" y="105">HTTP 请求体 → 流 <tspan class="t-acc">7</tspan></text>
<line class="dd-hair" x1="48" y1="114" x2="216" y2="114"/>
<text class="t-mono" x="48" y="131">流 <tspan class="t-acc">7</tspan> → HTTP 响应</text>
<rect class="dd-card" x="568" y="56" width="200" height="84" rx="6"/>
<path class="dd-card-head" d="M568.6 88 V62 Q568.6 56.6 574 56.6 H762 Q767.4 56.6 767.4 62 V88 Z"/>
<line class="dd-edge" x1="568" y1="88" x2="768" y2="88"/>
<text class="t-name" x="584" y="77">Gitaly</text>
<text class="t-val" x="752" y="77" text-anchor="end">gitaly-1</text>
<text class="t-mono" x="584" y="105">git upload-pack</text>
<line class="dd-hair" x1="584" y1="114" x2="752" y2="114"/>
<text class="t-note" x="584" y="131">stdin、stdout 都接在流 <tspan class="t-acc">7</tspan></text>
<text class="t-mono" x="400" y="172" text-anchor="middle">PostUploadPackWithSidechannel</text>
<text class="t-note" x="400" y="198" text-anchor="middle">metadata  gitaly-sidechannel-id: <tspan class="t-acc">7</tspan></text>
<text class="t-note" x="120" y="184" text-anchor="end">流 1</text>
<text class="t-mono" x="400" y="224" text-anchor="middle">打开新流：写入 "sidechannel" + int64 <tspan class="t-acc">7</tspan></text>
<text class="t-note" x="120" y="236" text-anchor="end">流 <tspan class="t-acc">7</tspan></text>
<text class="t-mono" x="400" y="276" text-anchor="middle">"ok"</text>
<text class="t-note" x="120" y="288" text-anchor="end">流 <tspan class="t-acc">7</tspan></text>
<text class="t-mono" x="400" y="328" text-anchor="middle">0012command=fetch … want 612f4c1… … done</text>
<text class="t-note" x="400" y="354" text-anchor="middle">CloseWrite()：请求写完了</text>
<text class="t-note" x="120" y="340" text-anchor="end">流 <tspan class="t-acc">7</tspan></text>
<text class="t-mono" x="400" y="380" text-anchor="middle">packfile 段：\x01PACK …（side-band 分帧）</text>
<text class="t-note" x="120" y="392" text-anchor="end">流 <tspan class="t-acc">7</tspan></text>
<text class="t-mono" x="400" y="432" text-anchor="middle">Response{PackfileNegotiationStatistics} · OK</text>
<text class="t-note" x="120" y="444" text-anchor="end">流 1</text>
<text class="t-say" x="32" y="484">这些流共用 Workhorse 到 Gitaly 的一条 TCP 连接；流 2 是 Praefect 投票用的 backchannel，图里没画。</text>
</svg>
</div>
<figcaption>图 4 · gRPC 只传参数和结果，pack 的原始字节走 Gitaly 反向打开的那条 yamux 流</figcaption>
</figure>

图 4 是一次 HTTP clone 在这条连接上的全部往来（`gitaly/smarthttp.go`、`sidechannel.go`）：

1. Workhorse 在本地登记一个等待者，拿到编号 7，把它放进 gRPC 的 metadata `gitaly-sidechannel-id`，然后在流 1 上发出 `PostUploadPackWithSidechannel`，请求里只有仓库和协议版本这类参数。
2. Gitaly 的处理函数调用 `OpenSidechannel`，在同一个 yamux 会话里反向开一条流，先写魔数 `sidechannel`，再写 8 字节的编号 7。
3. Workhorse 按编号找到等待者，回一个 `ok`，握手结束。
4. Workhorse 把 HTTP 请求体（v2 的 `command=fetch`、`want`、`done`）原样写进这条流，然后半关闭写方向。
5. Gitaly 把这条流同时当作 `git upload-pack` 的标准输入和标准输出，pack 从这里原样流回，Workhorse 再原样写进 HTTP 响应。
6. 流 1 上的 gRPC 响应只带回协商统计和状态码。

这样，pack 的字节在 Gitaly 和 Workhorse 两侧各被 `io.Copy` 一次，不再被切成 protobuf 消息。SSH 的 `SSHUploadPackWithSidechannel` 也是同样的结构，gitlab-shell 把 SSH 会话的标准输入输出直接接到 sidechannel 上。**push 没有用 sidechannel。** 文档列出的只有 `PostUploadPackWithSidechannel`、`SSHUploadPackWithSidechannel` 和后面会讲到的 `PackObjectsHookWithSidechannel`。为什么 push 不需要，源码和文档里都没有说；一个合理的推断是 fetch 和 clone 的字节量远大于 push，瓶颈在下载方向。这是推断。

Gitaly 收到了请求，也拿到了一条连向客户端的管道。**接下来它怎么启动 git？**

---

## 四、从 RPC 到 git 子进程

### 现象：几乎每个 RPC 都要 fork 一个 git

Gitaly 自己不解析 pack，也不实现协商算法，绝大多数 RPC 最后都是执行一个 git 子命令：clone 是 `upload-pack`，网页上看文件是 `cat-file`，列分支是 `for-each-ref`。一台繁忙的 Gitaly 每秒要启动大量 git 进程。

直接拼一个命令行去执行是危险的。参数可能来自用户，一个以 `-` 开头的分支名就可能被 git 当成选项；仓库自己的 `config` 可以设置钩子、别名；`refs/replace/` 下的替换引用能让一个恶意提交伪装成另一个；随便一次读请求都可能触发自动 `gc`；一个超大仓库的 `pack-objects` 还可能吃光整台机器的内存。

### 解决之一：所有 git 都从命令工厂出来

Gitaly 里启动 git 的入口只有一个：`ExecCommandFactory.newCommand`（`gitcmd/command_factory.go`）。它按固定的顺序把一个请求变成一条命令行，图 5 是一个具体的例子。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 536" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gl-exec-title gl-exec-desc">
<title id="gl-exec-title">命令工厂把一个 RPC 变成什么样的 git 进程</title>
<desc id="gl-exec-desc">左上是 PostUploadPackWithSidechannel 请求里的仓库字段，右上是 ExecCommandFactory.newCommand 依次做的事，下方是最终执行的进程：bundled git 的符号链接、--git-dir、一串 -c 配置、upload-pack --stateless-rpc、环境变量，以及由仓库路径的 crc32 决定的 cgroup repos-450。</desc>
<defs>
<marker id="gl-exec-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="gl-exec-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="536" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="196" rx="8"/>
<text class="t-zone" x="36" y="44">gitaly-1 收到的请求与命令工厂</text>
<rect class="dd-zone" x="16" y="228" width="768" height="292" rx="8"/>
<text class="t-zone" x="36" y="256">最终执行的进程（示例）</text>
<path class="dd-edge-acc" d="M300 170 V286" marker-end="url(#gl-exec-arrow-acc)"/>
<path class="dd-edge" d="M584 196 V286" marker-end="url(#gl-exec-arrow)"/>
<rect class="dd-card" x="32" y="60" width="336" height="110" rx="6"/>
<path class="dd-card-head" d="M32.6 92 V66 Q32.6 60.6 38 60.6 H362 Q367.4 60.6 367.4 66 V92 Z"/>
<line class="dd-edge" x1="32" y1="92" x2="368" y2="92"/>
<text class="t-name" x="48" y="81">请求</text>
<text class="t-val" x="352" y="81" text-anchor="end">PostUploadPackWithSidechannel</text>
<text class="t-mono" x="48" y="109">storage_name</text>
<text class="t-val" x="160" y="109">default</text>
<line class="dd-hair" x1="48" y1="118" x2="352" y2="118"/>
<text class="t-mono" x="48" y="135">relative_path</text>
<text class="t-val" x="160" y="135"><tspan class="t-acc">@hashed/73/47/7347…8049.git</tspan></text>
<line class="dd-hair" x1="48" y1="144" x2="352" y2="144"/>
<text class="t-mono" x="48" y="161">git_protocol</text>
<text class="t-val" x="160" y="161">version=2</text>
<rect class="dd-card" x="400" y="60" width="368" height="136" rx="6"/>
<path class="dd-card-head" d="M400.6 92 V66 Q400.6 60.6 406 60.6 H762 Q767.4 60.6 767.4 66 V92 Z"/>
<line class="dd-edge" x1="400" y1="92" x2="768" y2="92"/>
<text class="t-name" x="416" y="81">ExecCommandFactory.newCommand</text>
<text class="t-mono" x="416" y="109">1 白名单 commandDescriptions["upload-pack"]</text>
<line class="dd-hair" x1="416" y1="118" x2="752" y2="118"/>
<text class="t-mono" x="416" y="135">2 -c：全局 → 子命令 → 调用方 → config.toml</text>
<line class="dd-hair" x1="416" y1="144" x2="752" y2="144"/>
<text class="t-mono" x="416" y="161">3 gitaly-git-prev 链接成 git-exec-*.d/git</text>
<line class="dd-hair" x1="416" y1="170" x2="752" y2="170"/>
<text class="t-mono" x="416" y="187">4 crc32("default/@hashed/…") % 500 = 450</text>
<rect class="dd-card" x="32" y="288" width="736" height="214" rx="6"/>
<path class="dd-card-head" d="M32.6 320 V294 Q32.6 288.6 38 288.6 H762 Q767.4 288.6 767.4 294 V320 Z"/>
<line class="dd-edge" x1="32" y1="320" x2="768" y2="320"/>
<text class="t-name" x="48" y="309">git upload-pack</text>
<text class="t-val" x="752" y="309" text-anchor="end">pid 18823</text>
<text class="t-mono" x="48" y="337">argv[0]</text>
<text class="t-val" x="128" y="337">/var/opt/gitlab/gitaly/run/git-exec-0451.d/git</text>
<text class="t-note" x="752" y="337" text-anchor="end">→ gitaly-git-prev</text>
<line class="dd-hair" x1="48" y1="346" x2="752" y2="346"/>
<text class="t-val" x="128" y="363">--git-dir /var/opt/gitlab/git-data/repositories/<tspan class="t-acc">@hashed/73/47/7347…8049.git</tspan></text>
<line class="dd-hair" x1="48" y1="372" x2="752" y2="372"/>
<text class="t-val" x="128" y="389">-c gc.auto=0 -c core.useReplaceRefs=false -c core.fsync=objects,…</text>
<line class="dd-hair" x1="48" y1="398" x2="752" y2="398"/>
<text class="t-val" x="128" y="415">-c uploadpack.allowFilter=true -c uploadpack.packObjectsHook=…/gitaly-hooks</text>
<line class="dd-hair" x1="48" y1="424" x2="752" y2="424"/>
<text class="t-val" x="128" y="441">upload-pack --stateless-rpc --end-of-options …/7347….git</text>
<line class="dd-hair" x1="48" y1="450" x2="752" y2="450"/>
<text class="t-mono" x="48" y="467">env</text>
<text class="t-val" x="128" y="467">GIT_EXEC_PATH=…/git-exec-0451.d  GIT_PROTOCOL=version=2</text>
<line class="dd-hair" x1="48" y1="476" x2="752" y2="476"/>
<text class="t-mono" x="48" y="493">cgroup</text>
<text class="t-val" x="128" y="493">/sys/fs/cgroup/gitaly/gitaly-4211/<tspan class="t-acc">repos-450</tspan></text>
</svg>
</div>
<figcaption>图 5 · 同一个仓库路径决定了 --git-dir，也决定了进程被放进哪个 cgroup</figcaption>
</figure>

**白名单。** 子命令必须在 `commandDescriptions` 表里登记过，否则报错 `invalid sub command name`；表里还记着它会不会改引用、支不支持 `--end-of-options`，位置参数一律不允许以 `-` 开头。`--git-dir` 指向仓库的真实路径；请求里的 `Repository` 带着隔离区目录时，还会设置 `GIT_OBJECT_DIRECTORY`，这一点到 push 那一节会变得很关键。

**配置和二进制都由 Gitaly 决定。** 图 5 右上第 2 步的四层配置，后面覆盖前面，全部以 `-c` 放在命令行上，仓库自己的 `config` 改不掉。全局层的 `gc.auto=0` 是因为垃圾回收由 Gitaly 自己安排，`core.useReplaceRefs=false` 的注释直说替换引用是安全威胁。git 本身也是安装包自带的：默认用 `gitaly-git-prev`，打开特性开关才用跟主线的 `-master`，MVCC 后端用 `-mvcc`（`gitcmd/execution_environment.go`），通过 `git-exec-*.d` 里的符号链接和 `GIT_EXEC_PATH` 生效，与系统里装了什么无关。

### 解决之二：用 cgroup 把每个仓库关起来

**cgroup**（control group）是 Linux 内核的资源控制机制：一组进程放进一个 cgroup，就能限制它们合计的 CPU 和内存，超了内存只会杀掉这一组里的进程。Gitaly 启动时建一个父 cgroup `gitaly-<pid>`，下面建 N 个子 cgroup `repos-0` 到 `repos-(N-1)`；一个 git 进程放进哪一个，取决于 `storage_name/relative_path` 的 crc32 对 N 取模（`cgroups/manager_linux.go`）。在 cgroup v2 和 Linux 5.7 以上，进程用 `CLONE_INTO_CGROUP` 直接在目标 cgroup 里诞生。

回到图 5：同一个仓库的所有 git 进程落进同一个 `repos-450`，一个超大仓库最多用完自己那一格的 12 GB，被杀掉的是它自己的进程，而不是整个 Gitaly。`max_cgroups_per_repo` 大于 1 时，还会从起点再随机偏移几格。配置示例（`config.toml.example`）是这样的：

```toml
[cgroups]
mountpoint = "/sys/fs/cgroup"
hierarchy_root = "gitaly"
memory_bytes = 64424509440   # 60 GB，整个 Gitaly
cpu_shares = 1024

[cgroups.repositories]
count = 500
memory_bytes = 12884901888   # 12 GB，每个 repos-N
cpu_shares = 512
```

cgroup 能把一个仓库的破坏限制住，但它只管“别人不受牵连”。**如果几十个请求同时打在同一个仓库上呢？**

---

## 五、CI 的并发 clone

### 现象：一次提交，几十个 clone

一次 push 触发一条流水线，流水线里几十个 job 几乎同时启动，每个 job 第一件事都是 clone 或 fetch 同一个提交。按上一节的做法，每个请求都会启动一个 `upload-pack`，再由它启动一个 `pack-objects`，把同样的对象压缩同样的一遍。它们全都挤在同一个 `repos-450` 里，cgroup 只会让它们一起变慢。

### 第一次尝试：排队

Gitaly 有一个按 RPC 配置的并发限制中间件（`middleware/limithandler`）：`config.toml` 里的 `[[concurrency]]` 指定 RPC 名、每个仓库最多同时执行几个（`max_per_repo`）、队列多长（`max_queue_size`）、最多等多久（`max_queue_wait`），队满或超时返回 `ResourceExhausted`。限制还可以是自适应的：CPU 被节流超过一半或内存用到 90% 就减半，否则加一（`limiter/adaptive_calculator.go`）。可是排队只保护了机器，没有减少工作量：几十个请求要的是同一个 pack，排完队仍然要算几十遍。

### 解决：pack-objects 缓存

关键的观察是：**这些请求的 `pack-objects` 输入完全相同。** `upload-pack` 协商完以后，把“要哪些、排除哪些”写进 `pack-objects` 的标准输入，同一个提交、同样的空 `have`，就是同样的字节。Gitaly 给 `upload-pack` 设置 `uploadpack.packObjectsHook`，指向 `gitaly-hooks`，它通过内部 unix socket 回调 Gitaly 的 `PackObjectsHookWithSidechannel`，pack 同样走 sidechannel 传回[^cache]。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 452" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gl-cache-title gl-cache-desc">
<title id="gl-cache-title">三个 CI 任务同时 clone 同一个提交</title>
<desc id="gl-cache-desc">三个 CI 任务从不同 IP 请求同一个提交，Gitaly 为每个请求启动 upload-pack，upload-pack 通过 gitaly-hooks 回调 PackObjectsHookWithSidechannel；三个请求算出相同的缓存键 9f2c41e0…，只有第一个请求在 cgroup repos-450 里启动 git pack-objects，另外两个从同一个缓存文件边写边读。下方标出两层并发限制：按 RPC 和仓库排队，按客户端 IP 限制 pack-objects。</desc>
<defs>
<marker id="gl-cache-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="gl-cache-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="452" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="200" height="420" rx="8"/>
<text class="t-zone" x="36" y="44">CI 任务（IP 为示例）</text>
<rect class="dd-zone" x="232" y="16" width="552" height="420" rx="8"/>
<text class="t-zone" x="252" y="44">gitaly-1</text>
<path class="dd-edge" d="M200 110 H246" marker-end="url(#gl-cache-arrow)"/>
<path class="dd-edge-acc" d="M488 110 H518" marker-end="url(#gl-cache-arrow-acc)"/>
<path class="dd-edge" d="M200 170 H246" marker-end="url(#gl-cache-arrow)"/>
<path class="dd-edge-acc" d="M488 170 H518" marker-end="url(#gl-cache-arrow-acc)"/>
<path class="dd-edge" d="M200 230 H246" marker-end="url(#gl-cache-arrow)"/>
<path class="dd-edge-acc" d="M488 230 H518" marker-end="url(#gl-cache-arrow-acc)"/>
<path class="dd-edge" d="M644 254 V284" marker-end="url(#gl-cache-arrow)"/>
<rect class="dd-card" x="32" y="92" width="168" height="36" rx="6"/>
<text class="t-name" x="48" y="115">CI #1</text>
<text class="t-note" x="184" y="115" text-anchor="end">10.0.3.11</text>
<rect class="dd-card" x="248" y="92" width="240" height="36" rx="6"/>
<text class="t-mono" x="264" y="115">upload-pack → gitaly-hooks</text>
<rect class="dd-card" x="32" y="152" width="168" height="36" rx="6"/>
<text class="t-name" x="48" y="175">CI #2</text>
<text class="t-note" x="184" y="175" text-anchor="end">10.0.3.12</text>
<rect class="dd-card" x="248" y="152" width="240" height="36" rx="6"/>
<text class="t-mono" x="264" y="175">upload-pack → gitaly-hooks</text>
<rect class="dd-card" x="32" y="212" width="168" height="36" rx="6"/>
<text class="t-name" x="48" y="235">CI #3</text>
<text class="t-note" x="184" y="235" text-anchor="end">10.0.3.13</text>
<rect class="dd-card" x="248" y="212" width="240" height="36" rx="6"/>
<text class="t-mono" x="264" y="235">upload-pack → gitaly-hooks</text>
<text class="t-note" x="248" y="80">每个请求各有一对进程</text>
<rect class="dd-card" x="520" y="92" width="248" height="162" rx="6"/>
<path class="dd-card-head" d="M520.6 124 V98 Q520.6 92.6 526 92.6 H762 Q767.4 92.6 767.4 98 V124 Z"/>
<line class="dd-edge" x1="520" y1="124" x2="768" y2="124"/>
<text class="t-name" x="536" y="113">streamcache 条目</text>
<text class="t-mono" x="536" y="141">key</text>
<text class="t-val" x="608" y="141"><tspan class="t-acc">9f2c41e0…</tspan></text>
<line class="dd-hair" x1="536" y1="150" x2="752" y2="150"/>
<text class="t-mono" x="536" y="167">生产者</text>
<text class="t-val" x="608" y="167">CI #1 的请求</text>
<line class="dd-hair" x1="536" y1="176" x2="752" y2="176"/>
<text class="t-mono" x="536" y="193">读者</text>
<text class="t-val" x="608" y="193">#1、#2、#3</text>
<line class="dd-hair" x1="536" y1="202" x2="752" y2="202"/>
<text class="t-mono" x="536" y="219">max_age</text>
<text class="t-val" x="608" y="219">300s</text>
<line class="dd-hair" x1="536" y1="228" x2="752" y2="228"/>
<text class="t-mono" x="536" y="245">文件</text>
<text class="t-val" x="608" y="245">…/<tspan class="t-acc">9f2c41e0…</tspan></text>
<rect class="dd-card" x="520" y="286" width="248" height="84" rx="6"/>
<path class="dd-card-head" d="M520.6 318 V292 Q520.6 286.6 526 286.6 H762 Q767.4 286.6 767.4 292 V318 Z"/>
<line class="dd-edge" x1="520" y1="318" x2="768" y2="318"/>
<text class="t-name" x="536" y="307">git pack-objects</text>
<text class="t-val" x="752" y="307" text-anchor="end">只跑一次</text>
<text class="t-mono" x="536" y="335">--stdout --revs …</text>
<line class="dd-hair" x1="536" y1="344" x2="752" y2="344"/>
<text class="t-mono" x="536" y="361">cgroup</text>
<text class="t-val" x="608" y="361">repos-450</text>
<rect class="dd-card" x="248" y="268" width="240" height="110" rx="6"/>
<path class="dd-card-head" d="M248.6 300 V274 Q248.6 268.6 254 268.6 H482 Q487.4 268.6 487.4 274 V300 Z"/>
<line class="dd-edge" x1="248" y1="300" x2="488" y2="300"/>
<text class="t-name" x="264" y="289">两层并发限制</text>
<text class="t-mono" x="264" y="317">RPC 级</text>
<text class="t-note" x="360" y="317">按仓库路径排队</text>
<line class="dd-hair" x1="264" y1="326" x2="472" y2="326"/>
<text class="t-mono" x="264" y="343">pack-objects</text>
<text class="t-note" x="360" y="343">按客户端 IP</text>
<line class="dd-hair" x1="264" y1="352" x2="472" y2="352"/>
<text class="t-note" x="264" y="369">队满或等太久 → ResourceExhausted</text>
<text class="t-say" x="248" y="404">键 = sha256(仓库、参数、协议 + 完整的标准输入)；</text>
<text class="t-say" x="248" y="424">标准输入就是协商出来的“要哪些、排除哪些”。</text>
</svg>
</div>
<figcaption>图 6 · 三份相同的标准输入算出同一个缓存键，pack-objects 只跑一次，其余请求从正在写的缓存文件里跟读</figcaption>
</figure>

Gitaly 在这里算图 6 里的缓存键（总览文章贴过这段代码）。第一个请求成为**生产者**，真正启动 `pack-objects`，输出一边写进缓存文件、一边流给自己的客户端；后来键相同的请求成为这个文件的**读者**，从头跟着读，不必等它写完（`internal/streamcache`）。

几处细节：**生产者不能被第一个客户端拖死**，CI #1 中途断开时，`runPackObjects` 用 `context.WithoutCancel` 切断请求的取消信号（`hook/pack_objects.go`）；默认开启**反压**，读者慢生产者就慢；**pack-objects 自己按客户端 IP 限流**，默认每个 IP 同时 200 个、队列 200 个，同一份数据的多个请求只算一个，回环地址不受限（`config/config.go`）。

缓存为什么放本地磁盘？设计文档给了 GitLab.com 的数据[^cache]，按项目自己的测量：文件系统 16 TB、至少留 2 TB 空闲，预估最坏的平均缓存大小 30 GB，只占空闲空间的 1.5%；一台缓存吞吐很高的服务器，从缓存读出的数据每小时峰值超过 100 MB/s，磁盘读却接近 0，几乎全由页缓存供给。因为会显著增加磁盘写入，这个缓存默认关闭。

clone 这一侧就到这里。**push 的方向反过来：对象是用户送来的，在它们进入仓库之前，谁来检查？**

---

## 六、push 的往返

### 现象：HTTP 阶段只检查了“能不能推”

总览文章提到过：push 的 HTTP 请求到达时，Rails 用 `GitAccess#check(cmd, ANY)` 只判断这个用户能不能往这个项目推任何东西。原因很实际：这时请求体还没读，Workhorse 不知道客户端要改哪些引用。可是保护分支、push 规则、文件大小限制都是针对具体引用和具体对象的，**它们要等 git 真正收下数据以后才能检查**。

### 原因：检查必须发生在 receive-pack 内部

`git receive-pack` 收完数据后，会先把对象写到 `objects/incoming-XXXX` 这个**隔离区**，再把“旧值 新值 引用名”喂给 `pre-receive` 钩子。钩子拒绝，隔离区直接删掉；钩子通过，对象才并进仓库，接着更新引用[^quarantine]。所以 GitLab 的逐引用检查只能挂在 `pre-receive` 上。

可是检查逻辑在 Rails 里，钩子却是 git 在 Gitaly 机器上启动的一个子进程。这两者怎么接上？

### 解决：一圈往返

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 488" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gl-push-title gl-push-desc">
<title id="gl-push-title">一次 push 在 Gitaly 和 Rails 之间的往返</title>
<desc id="gl-push-desc">Gitaly 执行 git receive-pack（①），新对象先写进 objects/incoming-Xy3k；receive-pack 调 pre-receive 钩子 gitaly-hooks（②），gitaly-hooks 经内部 socket 回调 Gitaly 的 PreReceiveHook（③）；Gitaly 带着变更和隔离区目录 POST /internal/allowed（④）；Rails 的检查需要读新对象，于是调 Gitaly 的读 RPC 并带上隔离区目录（⑤）；通过后再调 /internal/pre_receive，引用更新完成后 post-receive 调 /internal/post_receive。</desc>
<defs>
<marker id="gl-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="gl-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="488" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="384" height="424" rx="8"/>
<text class="t-zone" x="36" y="44">gitaly-1</text>
<rect class="dd-zone" x="416" y="16" width="368" height="424" rx="8"/>
<text class="t-zone" x="436" y="44">Rails</text>
<path class="dd-edge" d="M152 144 V198" marker-end="url(#gl-push-arrow)"/>
<path class="dd-edge" d="M152 284 V338" marker-end="url(#gl-push-arrow)"/>
<path class="dd-edge" d="M56 382 H48 Q40 382 40 374 V124 Q40 116 48 116 H54" marker-end="url(#gl-push-arrow)"/>
<path class="dd-edge" d="M376 80 H430" marker-end="url(#gl-push-arrow)"/>
<path class="dd-edge-acc" d="M432 242 H416 Q408 242 408 234 V128 Q408 120 400 120 H378" marker-end="url(#gl-push-arrow-acc)"/>
<path class="dd-edge" d="M600 170 V198" marker-end="url(#gl-push-arrow)"/>
<rect class="dd-card" x="56" y="60" width="320" height="84" rx="6"/>
<path class="dd-card-head" d="M56.6 92 V66 Q56.6 60.6 62 60.6 H370 Q375.4 60.6 375.4 66 V92 Z"/>
<line class="dd-edge" x1="56" y1="92" x2="376" y2="92"/>
<text class="t-name" x="72" y="81">Gitaly</text>
<text class="t-val" x="360" y="81" text-anchor="end">HookService</text>
<text class="t-mono" x="72" y="109">PostReceivePack ← Workhorse</text>
<line class="dd-hair" x1="72" y1="118" x2="360" y2="118"/>
<text class="t-mono" x="72" y="135">PreReceiveHook  ← gitaly-hooks</text>
<text class="t-note" x="160" y="176">① exec</text>
<rect class="dd-card" x="56" y="200" width="320" height="84" rx="6"/>
<path class="dd-card-head" d="M56.6 232 V206 Q56.6 200.6 62 200.6 H370 Q375.4 200.6 375.4 206 V232 Z"/>
<line class="dd-edge" x1="56" y1="232" x2="376" y2="232"/>
<text class="t-name" x="72" y="221">git receive-pack</text>
<text class="t-val" x="360" y="221" text-anchor="end">子进程</text>
<text class="t-mono" x="72" y="249">core.hooksPath</text>
<text class="t-val" x="192" y="249">gitaly-hooks</text>
<line class="dd-hair" x1="72" y1="258" x2="360" y2="258"/>
<text class="t-mono" x="72" y="275">新对象写到</text>
<text class="t-val" x="192" y="275">objects/<tspan class="t-acc">incoming-Xy3k</tspan></text>
<text class="t-note" x="160" y="316">② pre-receive</text>
<rect class="dd-card" x="56" y="340" width="320" height="84" rx="6"/>
<path class="dd-card-head" d="M56.6 372 V346 Q56.6 340.6 62 340.6 H370 Q375.4 340.6 375.4 346 V372 Z"/>
<line class="dd-edge" x1="56" y1="372" x2="376" y2="372"/>
<text class="t-name" x="72" y="361">gitaly-hooks</text>
<text class="t-val" x="360" y="361" text-anchor="end">pre-receive</text>
<text class="t-mono" x="72" y="389">385ce2c 612f4c1 refs/heads/main</text>
<line class="dd-hair" x1="72" y1="398" x2="360" y2="398"/>
<text class="t-mono" x="72" y="415">GIT_OBJECT_DIRECTORY</text>
<text class="t-val" x="224" y="415">…/<tspan class="t-acc">incoming-Xy3k</tspan></text>
<text class="t-note" x="24" y="254">③</text>
<text class="t-note" x="388" y="72" text-anchor="middle">④</text>
<text class="t-note" x="388" y="186" text-anchor="end">⑤</text>
<rect class="dd-card" x="432" y="60" width="336" height="110" rx="6"/>
<path class="dd-card-head" d="M432.6 92 V66 Q432.6 60.6 438 60.6 H762 Q767.4 60.6 767.4 66 V92 Z"/>
<line class="dd-edge" x1="432" y1="92" x2="768" y2="92"/>
<text class="t-name" x="448" y="81">POST /internal/allowed</text>
<text class="t-mono" x="448" y="109">changes=385ce2c 612f4c1 refs/heads/main</text>
<line class="dd-hair" x1="448" y1="118" x2="752" y2="118"/>
<text class="t-mono" x="448" y="135">relative_path=@hashed/73/47/7347….git</text>
<line class="dd-hair" x1="448" y1="144" x2="752" y2="144"/>
<text class="t-mono" x="448" y="161">git_object_directory=objects/<tspan class="t-acc">incoming-Xy3k</tspan></text>
<rect class="dd-card" x="432" y="200" width="336" height="84" rx="6"/>
<path class="dd-card-head" d="M432.6 232 V206 Q432.6 200.6 438 200.6 H762 Q767.4 200.6 767.4 206 V232 Z"/>
<line class="dd-edge" x1="432" y1="232" x2="768" y2="232"/>
<text class="t-name" x="448" y="221">Gitlab::Checks::ChangesAccess</text>
<text class="t-note" x="448" y="249">保护分支、push 规则、文件大小……</text>
<line class="dd-hair" x1="448" y1="258" x2="752" y2="258"/>
<text class="t-mono" x="448" y="275">读 RPC</text>
<text class="t-val" x="512" y="275">Repository{…, <tspan class="t-acc">incoming-Xy3k</tspan>}</text>
<rect class="dd-card" x="432" y="340" width="336" height="84" rx="6"/>
<path class="dd-card-head" d="M432.6 372 V346 Q432.6 340.6 438 340.6 H762 Q767.4 340.6 767.4 346 V372 Z"/>
<line class="dd-edge" x1="432" y1="372" x2="768" y2="372"/>
<text class="t-name" x="448" y="361">之后的两次回调</text>
<text class="t-mono" x="448" y="389">/pre_receive</text>
<text class="t-val" x="560" y="389">ReferenceCounter +1</text>
<line class="dd-hair" x1="448" y1="398" x2="752" y2="398"/>
<text class="t-mono" x="448" y="415">/post_receive</text>
<text class="t-val" x="560" y="415">Sidekiq PostReceive</text>
<text class="t-say" x="32" y="468">隔离区目录名 Xy3k 为示例；钩子被拒时，receive-pack 直接删掉整个 incoming 目录。</text>
</svg>
</div>
<figcaption>图 7 · 隔离区目录跟着 /allowed 进了 Rails，又跟着读 RPC 回到 Gitaly，Rails 才看得见还没入库的对象</figcaption>
</figure>

命令工厂给 `receive-pack` 设置的 `core.hooksPath` 指向 Gitaly 管理的目录，里面的 `pre-receive`、`update`、`post-receive`、`reference-transaction` 都是 `gitaly-hooks`，仓库自己的 `hooks/` 不用；环境变量 `GITALY_HOOKS_PAYLOAD` 是一段 JSON，带着内部 socket 的路径和令牌、用户信息和 Praefect 事务编号（`gitcmd/hooks_payload.go`）。图 7 的编号：

1. `git receive-pack` 把 pack 解进隔离区 `objects/incoming-Xy3k`。
2. 它调 `pre-receive`，也就是 `gitaly-hooks`，`GIT_OBJECT_DIRECTORY` 指向隔离区。
3. `gitaly-hooks` 经内部 socket 回调 Gitaly 的 `PreReceiveHook`。
4. Gitaly 带上具体的变更和相对的隔离区目录 `POST /internal/allowed`（`hook/prereceive.go`）。
5. Rails 把隔离区目录记进 `Gitlab::Git::HookEnv`，检查时发出的读 RPC 都带着它，**git 才看得见还在隔离区里的对象**。

检查通过后，Gitaly 执行管理员的自定义钩子，再调 `/internal/pre_receive` 把仓库的 `ReferenceCounter` 加一，防止 push 期间仓库被迁移或删除（`api/internal/base.rb`）。之后 receive-pack 把隔离区并入仓库、锁住并更新引用，期间 `reference-transaction` 钩子在 `prepared` 和 `committed` 各被调用一次（下一节的主角）；`post-receive` 再调 `/internal/post_receive`，Rails 把 `PostReceive` 任务放进 Sidekiq，CI、合并请求、webhook 都从这里开始，Rails 返回的提示被打印成客户端的 `remote:` 行。

一次 push 就这样绕了一圈：Gitaly → git → gitaly-hooks → Gitaly → Rails → Gitaly。这一切都发生在一台 Gitaly 上。**这台 Gitaly 坏了怎么办？**

---

## 七、多副本：Praefect 的加权投票

### 现象：每个仓库只有一份

离开 NFS 以后，每个仓库只存在于一台 Gitaly 的本地磁盘上，这台机器就是单点。高可用原本是靠 NFS 自己的冗余做的，Gitaly 的 HA 设计文档承认，这个方案已经被弃用，改由 Praefect 承担[^ha]。Praefect 是放在 Gitaly 前面的 gRPC 代理。对 Rails 来说它就是一个 storage（称为虚拟 storage），背后每个仓库在几台 Gitaly 上各存一份副本，副本信息记在 Praefect 自己的 PostgreSQL 里。

### 第一次尝试：先写主副本，再异步复制

最早的 Praefect 是**最终一致**的：写请求只发给主副本，成功后往复制队列里放一个任务，从副本稍后追上[^ha]。问题很明显：主副本在复制完成前坏掉，最近的几次 push 就丢了；读请求如果落到还没追上的从副本，看到的是旧数据。设计文档把下一步写成“强一致”：一个修改请求，所有节点先达成一致，再真正落盘。

### 解决：对引用事务投票

要让所有副本对“落盘”达成一致，先得找到一个统一的提交点。总览文章讲过，push 的提交点是引用更新；更一般地，Gitaly 里所有用户看得见的修改，最终都是引用的变化[^ha]。Git 2.28 加入的 `reference-transaction` 钩子[^reftx-hook]正好挂在这里：引用事务锁住所有要改的引用以后进入 `prepared` 状态，写入以后进入 `committed` 状态，每个状态都会调用一次钩子，标准输入是这次要改的全部“旧值 新值 引用名”。

于是 Praefect 把同一个 push 原样转发给主副本和所有一致的从副本，每台 Gitaly 各自执行 `receive-pack`。到了 `prepared`，每台的钩子把标准输入的 SHA-1 当作一票，经第三节提到的 backchannel 投给 Praefect（`hook/referencetransaction.go`）。Praefect 等票数够了才放行；票数不够、或者自己这一票输了，钩子以非零状态退出，git 就放弃这次引用更新。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 456" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gl-vote-title gl-vote-desc">
<title id="gl-vote-title">Praefect 对一次 push 的加权投票</title>
<desc id="gl-vote-desc">Praefect 为一个主副本和两个从副本登记事务：gitaly-1 三票，gitaly-2 和 gitaly-3 各一票，阈值四票。gitaly-1 和 gitaly-2 在 reference-transaction 的 prepared 阶段都投出 sha1(stdin) = 12b66d2…，合计四票，提交；gitaly-3 解包失败没有投票，被记为落后。</desc>
<defs>
<marker id="gl-vote-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="gl-vote-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="456" rx="10"/>
<path class="dd-edge-acc" d="M144 280 V144 Q144 136 152 136 H230" marker-end="url(#gl-vote-arrow-acc)"/>
<path class="dd-edge-acc" d="M400 280 V220" marker-end="url(#gl-vote-arrow-acc)"/>
<path class="dd-edge dd-dash" d="M656 280 V144 Q656 136 648 136 H570"/>
<rect class="dd-card" x="232" y="56" width="336" height="162" rx="6"/>
<path class="dd-card-head" d="M232.6 88 V62 Q232.6 56.6 238 56.6 H562 Q567.4 56.6 567.4 62 V88 Z"/>
<line class="dd-edge" x1="232" y1="88" x2="568" y2="88"/>
<text class="t-name" x="248" y="77">Praefect</text>
<text class="t-val" x="552" y="77" text-anchor="end">事务 4182（示例）</text>
<text class="t-mono" x="248" y="105">gitaly-1（主）</text>
<text class="t-val" x="368" y="105">3 票</text>
<text class="t-note" x="552" y="105" text-anchor="end">secondaries + 1</text>
<line class="dd-hair" x1="248" y1="114" x2="552" y2="114"/>
<text class="t-mono" x="248" y="131">gitaly-2</text>
<text class="t-val" x="368" y="131">1 票</text>
<line class="dd-hair" x1="248" y1="140" x2="552" y2="140"/>
<text class="t-mono" x="248" y="157">gitaly-3</text>
<text class="t-val" x="368" y="157">1 票</text>
<line class="dd-hair" x1="248" y1="166" x2="552" y2="166"/>
<text class="t-mono" x="248" y="183">阈值</text>
<text class="t-val" x="368" y="183">4</text>
<text class="t-note" x="552" y="183" text-anchor="end">(2+1) + ⌈2/2⌉</text>
<line class="dd-hair" x1="248" y1="192" x2="552" y2="192"/>
<text class="t-mono" x="248" y="209"><tspan class="t-acc">12b66d2…</tspan></text>
<text class="t-val" x="368" y="209">3 + 1 = 4</text>
<text class="t-note" x="552" y="209" text-anchor="end">→ 提交</text>
<text class="t-note" x="152" y="128">prepared</text>
<text class="t-note" x="664" y="128">没有票</text>
<rect class="dd-card" x="32" y="282" width="224" height="110" rx="6"/>
<path class="dd-card-head" d="M32.6 314 V288 Q32.6 282.6 38 282.6 H250 Q255.4 282.6 255.4 288 V314 Z"/>
<line class="dd-edge" x1="32" y1="314" x2="256" y2="314"/>
<text class="t-name" x="48" y="303">gitaly-1</text>
<text class="t-val" x="240" y="303" text-anchor="end">主</text>
<text class="t-mono" x="48" y="331">sha1(stdin) = <tspan class="t-acc">12b66d2…</tspan></text>
<line class="dd-hair" x1="48" y1="340" x2="240" y2="340"/>
<text class="t-mono" x="48" y="357">prepared</text>
<text class="t-val" x="128" y="357">投票 → 等</text>
<line class="dd-hair" x1="48" y1="366" x2="240" y2="366"/>
<text class="t-mono" x="48" y="383">结果</text>
<text class="t-val" x="128" y="383">VoteCommitted</text>
<rect class="dd-card" x="288" y="282" width="224" height="110" rx="6"/>
<path class="dd-card-head" d="M288.6 314 V288 Q288.6 282.6 294 282.6 H506 Q511.4 282.6 511.4 288 V314 Z"/>
<line class="dd-edge" x1="288" y1="314" x2="512" y2="314"/>
<text class="t-name" x="304" y="303">gitaly-2</text>
<text class="t-val" x="496" y="303" text-anchor="end">从</text>
<text class="t-mono" x="304" y="331">sha1(stdin) = <tspan class="t-acc">12b66d2…</tspan></text>
<line class="dd-hair" x1="304" y1="340" x2="496" y2="340"/>
<text class="t-mono" x="304" y="357">prepared</text>
<text class="t-val" x="384" y="357">投票 → 等</text>
<line class="dd-hair" x1="304" y1="366" x2="496" y2="366"/>
<text class="t-mono" x="304" y="383">结果</text>
<text class="t-val" x="384" y="383">VoteCommitted</text>
<rect class="dd-card" x="544" y="282" width="224" height="110" rx="6"/>
<path class="dd-card-head" d="M544.6 314 V288 Q544.6 282.6 550 282.6 H762 Q767.4 282.6 767.4 288 V314 Z"/>
<line class="dd-edge" x1="544" y1="314" x2="768" y2="314"/>
<text class="t-name" x="560" y="303">gitaly-3</text>
<text class="t-val" x="752" y="303" text-anchor="end">从</text>
<text class="t-mono" x="560" y="331">unpack 失败（示例）</text>
<line class="dd-hair" x1="560" y1="340" x2="752" y2="340"/>
<text class="t-mono" x="560" y="357">prepared</text>
<text class="t-val" x="640" y="357">没到这一步</text>
<line class="dd-hair" x1="560" y1="366" x2="752" y2="366"/>
<text class="t-mono" x="560" y="383">结果</text>
<text class="t-val" x="640" y="383">记为落后</text>
<text class="t-say" x="32" y="428">每台 Gitaly 各自执行 receive-pack；钩子的 stdin 是 “385ce2c… 612f4c1… refs/heads/main”。</text>
</svg>
</div>
<figcaption>图 8 · 主副本 3 票加任意一个从副本 1 票才到阈值 4：主副本单独凑不够，从副本联合起来也凑不够</figcaption>
</figure>

票数怎么算（`praefect/coordinator.go` 的 `registerTransaction`）：主副本的票数是“从副本数 + 1”，阈值从“从副本数 + 1”起步；如果从副本多于一个，再加上从副本数的一半（向上取整）。图 8 是两个从副本的情况：主副本 3 票，从副本各 1 票，阈值 4。主副本单独只有 3 票，到不了阈值；两个从副本加起来只有 2 票，也到不了；**必须主副本加至少一半从副本投出同一个哈希**。注释管这叫 majority-wins。只有一个从副本时阈值就是 2，主副本自己就够，从副本跟不上也不会阻塞 push。

还有几处值得注意：

- **钩子只在主副本上执行。** `pre-receive` 要调 Rails 的 `/allowed`，如果三台都调就是三次。所以只有主副本真正调用，从副本通过一次额外的“同步票”等它跑完，缩短从副本持锁的时间（`hook/prereceive.go`、`hook/transactions.go`）。主副本的钩子拒绝了，它会主动停掉整个事务。
- **`committed` 也投一票。** 有些 RPC 把错误写在响应体里而不是状态码里，Praefect 看不出它是否真的写入了；多投一次就能区分“写了”和“没写”。
- **纯删除不投票。** git 删除引用时可能先开一个只清理 `packed-refs` 的事务，各副本的打包状态未必相同，所以全是强制删除的事务直接跳过投票。

RPC 结束后，Praefect 逐个判断从副本的状态（`getUpdatedAndOutdatedSecondaries`）：错误和主副本不一样、或者没有到达 `VoteCommitted` 的，都被记为**落后**。图 8 里的 gitaly-3 解包失败，根本没投票，就是这种情况。**落后的副本接下来怎么办？**

---

## 八、落后副本的发现与修复

### 现象：副本之间不再一样

被记为落后的副本，磁盘上的内容和主副本不一样了，可能少了这次 push，也可能更糟。Praefect 需要回答三个问题：怎么知道谁落后、落后的副本还能不能读、怎么让它追上。

### 原因与记录：generation

Praefect 的答案是一个单调递增的数字 **generation**。`repositories` 表里每个仓库有一个最新的 generation，`storage_repositories` 表里每个副本各有自己的 generation。一次写入成功后，主副本和投票一致的从副本一起加一（`IncrementGeneration`），落后的副本不变。于是“谁落后”就是一条 SQL：generation 比最新值小的那些。

这张表里还有一个路径的转换。Rails 给的是 `@hashed/73/47/….git`，Praefect 给每个仓库分配自己的 `repository_id`，在磁盘上用 `@cluster/…/<repository_id>` 存放（图 9 里有一个例子），中间两级目录取自 `repository_id` 的 SHA-256 前两个字节（`storage/repository_path.go`）。Gitaly 节点上看不到 `@hashed`，只有 `replica_path`。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 484" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gl-repair-title gl-repair-desc">
<title id="gl-repair-title">Praefect 如何记录和修复落后副本</title>
<desc id="gl-repair-desc">Praefect 的 PostgreSQL 里，repositories 表记着仓库 1307 的 relative_path、replica_path @cluster/repositories/8c/14/1307 和 generation 8；storage_repositories 表里 gitaly-1 和 gitaly-2 是 8，gitaly-3 是 7。replication_queue 里有一条从 gitaly-1 到 gitaly-3 的 update 任务，执行时 gitaly-3 从 gitaly-1 做 git fetch +refs/*:refs/*。后台的 reconciler 默认每 5 分钟比较 generation 并补任务。</desc>
<defs>
<marker id="gl-repair-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="gl-repair-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="484" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="440" height="452" rx="8"/>
<text class="t-zone" x="36" y="44">Praefect 的 PostgreSQL</text>
<rect class="dd-zone" x="472" y="16" width="312" height="452" rx="8"/>
<text class="t-zone" x="492" y="44">存储节点</text>
<path class="dd-edge-acc" d="M440 368 H464 Q472 368 472 360 V208 Q472 200 480 200 H486" marker-end="url(#gl-repair-arrow-acc)"/>
<path class="dd-edge" d="M488 400 H442" marker-end="url(#gl-repair-arrow)"/>
<path class="dd-edge" d="M628 118 V156" marker-end="url(#gl-repair-arrow)"/>
<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">repositories</text>
<text class="t-val" x="424" y="81" text-anchor="end">repository_id 1307</text>
<text class="t-mono" x="48" y="109">relative_path</text>
<text class="t-val" x="168" y="109">@hashed/73/47/7347….git</text>
<line class="dd-hair" x1="48" y1="118" x2="424" y2="118"/>
<text class="t-mono" x="48" y="135">replica_path</text>
<text class="t-val" x="168" y="135">@cluster/repositories/8c/14/1307</text>
<line class="dd-hair" x1="48" y1="144" x2="424" y2="144"/>
<text class="t-mono" x="48" y="161">primary</text>
<text class="t-val" x="168" y="161">gitaly-1</text>
<line class="dd-hair" x1="48" y1="170" x2="424" y2="170"/>
<text class="t-mono" x="48" y="187">generation</text>
<text class="t-val" x="168" y="187">8</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">storage_repositories</text>
<text class="t-mono" x="48" y="269">gitaly-1</text>
<text class="t-val" x="168" y="269">generation 8</text>
<text class="t-note" x="424" y="269" text-anchor="end">最新</text>
<line class="dd-hair" x1="48" y1="278" x2="424" y2="278"/>
<text class="t-mono" x="48" y="295">gitaly-2</text>
<text class="t-val" x="168" y="295">generation 8</text>
<text class="t-note" x="424" y="295" text-anchor="end">最新</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">gitaly-3</tspan></text>
<text class="t-val" x="168" y="321">generation <tspan class="t-acc">7</tspan></text>
<text class="t-note" x="424" y="321" text-anchor="end">落后</text>
<rect class="dd-card" x="32" y="354" width="408" height="58" rx="6"/>
<path class="dd-card-head" d="M32.6 386 V360 Q32.6 354.6 38 354.6 H434 Q439.4 354.6 439.4 360 V386 Z"/>
<line class="dd-edge" x1="32" y1="386" x2="440" y2="386"/>
<text class="t-name" x="48" y="375">replication_queue</text>
<text class="t-val" x="424" y="375" text-anchor="end">state ready</text>
<text class="t-mono" x="48" y="403">change=update source=gitaly-1 target=<tspan class="t-acc">gitaly-3</tspan></text>
<text class="t-say" x="32" y="444">读请求只发给 generation 等于 8 的副本。</text>
<rect class="dd-card" x="488" y="60" width="280" height="58" 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">gitaly-1</text>
<text class="t-val" x="752" y="81" text-anchor="end">主</text>
<text class="t-mono" x="504" y="109">@cluster/repositories/8c/14/1307</text>
<text class="t-note" x="636" y="142">git fetch</text>
<rect class="dd-card" x="488" y="158" width="280" height="84" rx="6"/>
<path class="dd-card-head" d="M488.6 190 V164 Q488.6 158.6 494 158.6 H762 Q767.4 158.6 767.4 164 V190 Z"/>
<line class="dd-edge" x1="488" y1="190" x2="768" y2="190"/>
<text class="t-name" x="504" y="179"><tspan class="t-acc">gitaly-3</tspan></text>
<text class="t-val" x="752" y="179" text-anchor="end">ReplicateRepository</text>
<text class="t-mono" x="504" y="207">git fetch --prune --no-tags</text>
<line class="dd-hair" x1="504" y1="216" x2="752" y2="216"/>
<text class="t-mono" x="504" y="233">  gitaly-1 +refs/*:refs/*</text>
<rect class="dd-card" x="488" y="266" width="280" height="58" rx="6"/>
<path class="dd-card-head" d="M488.6 298 V272 Q488.6 266.6 494 266.6 H762 Q767.4 266.6 767.4 272 V298 Z"/>
<line class="dd-edge" x1="488" y1="298" x2="768" y2="298"/>
<text class="t-name" x="504" y="287">gitaly-2</text>
<text class="t-val" x="752" y="287" text-anchor="end">从</text>
<text class="t-mono" x="504" y="315">@cluster/repositories/8c/14/1307</text>
<rect class="dd-derived" x="488" y="372" width="280" height="56" rx="6"/>
<text class="t-name" x="504" y="396">reconciler（默认每 5 分钟）</text>
<text class="t-note" x="504" y="416">generation 不一致 → 补一条 update</text>
<text class="t-say" x="488" y="452">虚线：后台定时任务，不在请求路径上。</text>
</svg>
</div>
<figcaption>图 9 · generation 比最新值小的副本不接读请求，复制任务让它从最新副本 fetch 一遍，追平后 generation 才更新</figcaption>
</figure>

### 落后的副本不接读请求

读请求的路由很保守（`router_per_repository.go`）：只在 generation 等于最新值、并且健康的副本里随机挑一个。主副本坏了需要换主时，也只从“健康且 generation 最新”的副本里选（`nodes/per_repository.go`）。所以落后的副本在追上之前，既不会被读到，也不会被选为主。

### 修复：复制任务和定时对账

写入结束时，Praefect 给每个落后的副本往 `replication_queue` 里放一个 `update` 任务（`performDBWriteAndReplication`）。复制器取出任务后，调用目标节点的 `ReplicateRepository`，源是最新的副本（`replicator.go`）。目标节点上的做法分两种（`service/repository/replicate.go`）：副本还不存在时，从源节点取一份仓库快照的 tar 包解开；已经存在时，就从源节点 `git fetch`，refspec 是 `+refs/*:refs/*`，带 `--prune`、不额外抓标签、关掉 fsck，相当于把引用整个镜像一遍。完成后更新这个副本的 generation；如果数据库里它已经是同一代或者更新，任务直接跳过，防止“降级”。

只靠写入时入队还不够：Praefect 可能在入队前崩溃，节点可能下线了很久。所以还有一个**reconciler** 做定时对账，默认每 5 分钟一次（`reconciler/reconciler.go`）。它用一条大 SQL 找出所有“健康节点上 generation 不等于最新值”的副本，为它们挑一个健康的最新副本作源，补一条 `update` 任务；同时清理那些不再分配给某个节点、又已经不比别人新的多余副本。这条 SQL 先拿一个 PostgreSQL advisory lock，多台 Praefect 同时运行时只有一台在对账。

这套机制能保证一致，代价也摆在那里：每次 push 要等主副本加一半从副本都写完；每个仓库不论冷热都要占几份副本；副本的位置和代数记在数据库里，每一份都需要照看。**GitLab 自己正在往哪个方向改？**

---

## 九、正在进行的改造：WAL 与 MVCC 后端

在当前的 Gitaly 源码里，有两套和“怎么持久化”有关的新代码。读源码之前先说结论：**它们在 19.5.0-rc3 里的接入程度很不一样，而且都还不是默认路径。**

### 每个分区一份预写日志

第一套在 `internal/gitaly/storage/storagemgr/`，文档是 `doc/transactions.md`[^transactions]，思路来自数据库。**预写日志**（write-ahead log，WAL）的意思是：任何修改先作为一条日志持久化，再应用到真正的数据上；崩溃以后，重放日志就能恢复到一致状态。Gitaly 把数据分成**分区**，一个仓库一个分区，对象池和它的所有 fork 放在同一个分区里。每个分区有自己的日志，状态目录在 storage 下的 `+gitaly/partitions/ab/cd/<分区号>/`，每条日志是 `wal/<LSN>/` 这样一个目录，里面是一个 `MANIFEST`（要做哪些文件操作）和若干新文件。

事务提供**快照隔离**：事务开始时，把仓库用硬链接复制一份快照，RPC 只在快照上执行，看不到别人的并发修改。提交时用**乐观并发控制**检查冲突，也就是先不加锁，提交时再看有没有别人改过同一个引用或同一个文件，有就中止。通过检查的事务，日志目录从暂存区 `rename` 到 `wal/<LSN>`，再对父目录做一次 `fsync`，这一刻就算提交（`partition/log/log_manager.go`）。每个分区由一个单独的 goroutine 先把已提交、未应用的日志应用完，再处理下一个事务（`TransactionManager.run`）。

**Raft** 是一种共识算法，多台机器通过选主和日志复制，对“日志的第几条是什么”达成一致。Gitaly 的配置里曾经有过 Raft 一节，从这个痕迹看，这套设计原本打算让每个分区的 WAL 由 Raft 复制，从而取代 Praefect 的投票；这是根据代码痕迹的推断。

### 当前 HEAD 的真实状态

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 500" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gl-status-title gl-status-desc">
<title id="gl-status-title">gitaly serve 实际接入的拦截器与未接入的代码</title>
<desc id="gl-status-desc">左边是 19.5.0-rc3 的 gitaly serve 挂上的拦截器：limithandler、housekeeping、mvcc.CacheInterceptor，以及一个空的 TransactionMiddleware 位置；MVCC 缓存工厂固定使用 LocalStorage，manifest 指针在仓库目录下的 mvcc/manifest。右边虚线框是源码里存在但 serve 没有引用的部分：storagemgr 的 WAL 事务拦截器、ee/mvcc/objectstorage 的对象存储后端，以及 19.4 已删除的 Raft 配置。</desc>
<defs>
<marker id="gl-status-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="gl-status-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="500" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="440" height="468" rx="8"/>
<text class="t-zone" x="36" y="44">gitaly serve 挂上的（serve.go）</text>
<rect class="dd-zone" x="472" y="16" width="312" height="468" rx="8"/>
<text class="t-zone" x="492" y="44">源码里有，但没接上</text>
<path class="dd-edge" d="M160 96 V110" marker-end="url(#gl-status-arrow)"/>
<path class="dd-edge" d="M160 148 V162" marker-end="url(#gl-status-arrow)"/>
<path class="dd-edge" d="M160 248 V266" marker-end="url(#gl-status-arrow)"/>
<path class="dd-edge-acc" d="M48 220 H40 Q32 220 32 228 V374 Q32 382 40 382 H46" marker-end="url(#gl-status-arrow-acc)"/>
<path class="dd-edge dd-dash" d="M488 88 H472 Q464 88 464 96 V278 Q464 286 456 286 H442" marker-end="url(#gl-status-arrow)"/>
<rect class="dd-card" x="48" y="60" width="392" height="36" rx="6"/>
<text class="t-name" x="64" y="83">limithandler</text>
<text class="t-note" x="424" y="83" text-anchor="end">并发限制</text>
<rect class="dd-card" x="48" y="112" width="392" height="36" rx="6"/>
<text class="t-name" x="64" y="135">housekeeping</text>
<text class="t-note" x="424" y="135" text-anchor="end">后台优化</text>
<rect class="dd-card" x="48" y="164" width="392" height="84" rx="6"/>
<path class="dd-card-head" d="M48.6 196 V170 Q48.6 164.6 54 164.6 H434 Q439.4 164.6 439.4 170 V196 Z"/>
<line class="dd-edge" x1="48" y1="196" x2="440" y2="196"/>
<text class="t-name" x="64" y="185"><tspan class="t-acc">mvcc.CacheInterceptor</tspan></text>
<text class="t-note" x="424" y="185" text-anchor="end">仓库是 mvcc 后端才介入</text>
<text class="t-mono" x="64" y="213">Prepare → handler 跑 git → Commit</text>
<line class="dd-hair" x1="64" y1="222" x2="424" y2="222"/>
<text class="t-mono" x="64" y="239">GIT_MVCC_MANIFEST_PATH=…</text>
<rect class="dd-derived" x="48" y="268" width="392" height="36" rx="6"/>
<text class="t-name" x="64" y="291">TransactionMiddleware{}</text>
<text class="t-note" x="424" y="291" text-anchor="end">空的，给事务拦截器留的位置</text>
<rect class="dd-card" x="48" y="340" width="392" height="84" rx="6"/>
<path class="dd-card-head" d="M48.6 372 V346 Q48.6 340.6 54 340.6 H434 Q439.4 340.6 439.4 346 V372 Z"/>
<line class="dd-edge" x1="48" y1="372" x2="440" y2="372"/>
<text class="t-name" x="64" y="361"><tspan class="t-acc">LocalStorage</tspan></text>
<text class="t-note" x="424" y="361" text-anchor="end">NewCacheFactory 固定用它</text>
<text class="t-mono" x="64" y="389">&lt;storage&gt;/&lt;repo&gt;/mvcc/manifest</text>
<line class="dd-hair" x1="64" y1="398" x2="424" y2="398"/>
<text class="t-note" x="64" y="415">CAS：加锁写新文件，比较旧哈希</text>
<rect class="dd-derived" x="488" y="60" width="280" height="110" rx="6"/>
<line class="dd-hair" x1="488" y1="92" x2="768" y2="92"/>
<text class="t-name" x="504" y="81">storagemgr 拦截器</text>
<text class="t-note" x="752" y="81" text-anchor="end">WAL</text>
<text class="t-mono" x="504" y="109">NewUnaryInterceptor</text>
<line class="dd-hair" x1="504" y1="118" x2="752" y2="118"/>
<text class="t-note" x="504" y="135">NewStorageManager 只在测试里调用</text>
<line class="dd-hair" x1="504" y1="144" x2="752" y2="144"/>
<text class="t-note" x="504" y="161">[transactions] 仅供开发</text>
<rect class="dd-derived" x="488" y="196" width="280" height="110" rx="6"/>
<line class="dd-hair" x1="488" y1="228" x2="768" y2="228"/>
<text class="t-name" x="504" y="217">ee/mvcc/objectstorage</text>
<text class="t-note" x="752" y="217" text-anchor="end">对象存储</text>
<text class="t-mono" x="504" y="245">s3:// gs:// azblob://</text>
<line class="dd-hair" x1="504" y1="254" x2="752" y2="254"/>
<text class="t-mono" x="504" y="271">generations/&lt;MaxUint64−n&gt;</text>
<line class="dd-hair" x1="504" y1="280" x2="752" y2="280"/>
<text class="t-note" x="504" y="297">非测试代码没有调用方</text>
<rect class="dd-derived" x="488" y="332" width="280" height="84" rx="6"/>
<line class="dd-hair" x1="488" y1="364" x2="768" y2="364"/>
<text class="t-name" x="504" y="353">Raft 配置</text>
<text class="t-note" x="752" y="353" text-anchor="end">19.4 已删除</text>
<text class="t-note" x="504" y="381">同时删除：分区备份 CLI、</text>
<line class="dd-hair" x1="504" y1="390" x2="752" y2="390"/>
<text class="t-note" x="504" y="407">副本分区迁移器</text>
<text class="t-say" x="32" y="452">新仓库用 mvcc 后端，还要打开默认关闭的</text>
<text class="t-say" x="32" y="472">特性开关 new_repo_mvcc_backend。</text>
<text class="t-say" x="488" y="460">虚线：源码里存在，serve 没有接上。</text>
</svg>
</div>
<figcaption>图 10 · 当前 HEAD 里，MVCC 拦截器已经挂上但指针仍在本地磁盘；给 WAL 留的拦截器位置是空的，对象存储后端只是库代码</figcaption>
</figure>

图 10 左边是 `gitaly serve` 在 19.5.0-rc3 里真正挂上的 gRPC 拦截器（`cli/gitaly/serve.go`、`server/server.go`）。给事务拦截器留的位置 `TransactionMiddleware` 是空的；serve 里的 `transaction.NewManager` 是第七节钩子向 Praefect 投票用的客户端，和 WAL 无关。storagemgr 的 `NewUnaryInterceptor` 和 `NewStorageManager` 在非测试代码里没有调用方，`[transactions] enabled` 的注释写着“实验性，仅供开发使用”；19.4.0 还删掉了 Raft 配置、分区备份命令行和副本分区迁移器[^changelog]。

这里有一处说法对不上：`doc/transactions.md` 写着事务“正在 GitLab.com 的生产环境里测试”。在我们读的这份源码里，`serve` 没有引用 storagemgr，我们找不到它被接入的路径。可能文档描述的是更早的状态，也可能有我们没看到的构建方式；这是推断，没有核实。

### MVCC 后端：真相是一份 manifest

第二套是 MVCC 后端（`internal/git/mvcc/`）。MVCC 即多版本并发控制：数据的每个版本都保留，读者固定在某个版本上读，写者产生新版本。在这个后端里，一个仓库的一个版本就是一份二进制 **manifest**（`manifest.go`），图 11 上方是它的布局：`PATH` 块列出构成这个版本的所有 pack 和 reftable 文件，`OBJS` 标出其中哪一段是 pack，`REFS` 给出 reftable 的栈顺序（reftable 是 Git 的一种二进制引用存储格式，引用分层写在几个文件里，新文件覆盖旧文件）。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 488" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="gl-manifest-title gl-manifest-desc">
<title id="gl-manifest-title">MVCC manifest 的格式与指针的两种存法</title>
<desc id="gl-manifest-desc">上方是 manifest 的二进制布局：魔数 MVCC、版本与块目录、PATH、OBJS、REFS 三个块、SHA-256 校验和。左下是 serve 实际使用的 LocalStorage：manifest 按哈希存在仓库目录的 mvcc/manifests 下，指针文件 mvcc/manifest 在提交时加锁、比较旧哈希、改成新哈希。右下是未接入的对象存储实现：每次提交新建一个 generations/&lt;MaxUint64−n&gt; 对象，不存在才创建，键倒序使最新一代排在最前。</desc>
<defs>
<marker id="gl-manifest-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="gl-manifest-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="488" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="152" rx="8"/>
<text class="t-zone" x="36" y="44">manifest 文件（gitformat-chunk 格式）</text>
<rect class="dd-zone" x="16" y="184" width="376" height="288" rx="8"/>
<text class="t-zone" x="36" y="212">LocalStorage · serve 实际用的</text>
<rect class="dd-zone" x="408" y="184" width="376" height="288" rx="8"/>
<text class="t-zone" x="428" y="212">objectstorage · 写好了，没接上</text>
<rect class="dd-card" x="32" y="60" width="96" height="36" rx="6"/>
<text class="t-mono" x="48" y="83">MVCC</text>
<text class="t-note" x="80.0" y="120" text-anchor="middle">魔数</text>
<text class="t-note" x="80.0" y="140" text-anchor="middle">0x4D564343</text>
<rect class="dd-card" x="136" y="60" width="104" height="36" rx="6"/>
<text class="t-mono" x="152" y="83">版本·目录</text>
<text class="t-note" x="188.0" y="120" text-anchor="middle">版本 1</text>
<text class="t-note" x="188.0" y="140" text-anchor="middle">块的偏移</text>
<rect class="dd-card" x="248" y="60" width="168" height="36" rx="6"/>
<text class="t-mono" x="264" y="83">PATH</text>
<text class="t-note" x="332.0" y="120" text-anchor="middle">所有文件路径</text>
<text class="t-note" x="332.0" y="140" text-anchor="middle">pack/…  refs/…</text>
<rect class="dd-card" x="424" y="60" width="112" height="36" rx="6"/>
<text class="t-mono" x="440" y="83">OBJS</text>
<text class="t-note" x="480.0" y="120" text-anchor="middle">哪一段</text>
<text class="t-note" x="480.0" y="140" text-anchor="middle">是 pack</text>
<rect class="dd-card" x="544" y="60" width="112" height="36" rx="6"/>
<text class="t-mono" x="560" y="83">REFS</text>
<text class="t-note" x="600.0" y="120" text-anchor="middle">reftable</text>
<text class="t-note" x="600.0" y="140" text-anchor="middle">栈的顺序</text>
<rect class="dd-card" x="664" y="60" width="104" height="36" rx="6"/>
<text class="t-mono" x="680" y="83">SHA-256</text>
<text class="t-note" x="716.0" y="120" text-anchor="middle">校验和</text>
<text class="t-note" x="716.0" y="140" text-anchor="middle">32 字节</text>
<rect class="dd-card" x="32" y="228" width="344" height="162" rx="6"/>
<path class="dd-card-head" d="M32.6 260 V234 Q32.6 228.6 38 228.6 H370 Q375.4 228.6 375.4 234 V260 Z"/>
<line class="dd-edge" x1="32" y1="260" x2="376" y2="260"/>
<text class="t-name" x="48" y="249">&lt;repo&gt;/mvcc/</text>
<text class="t-note" x="360" y="249" text-anchor="end">gitaly-1 本地盘</text>
<text class="t-mono" x="48" y="277">manifests/a1f3…</text>
<line class="dd-hair" x1="48" y1="286" x2="360" y2="286"/>
<text class="t-mono" x="48" y="303">manifests/<tspan class="t-acc">7c09…</tspan></text>
<line class="dd-hair" x1="48" y1="312" x2="360" y2="312"/>
<text class="t-mono" x="48" y="329">manifest：a1f3… → <tspan class="t-acc">7c09…</tspan></text>
<line class="dd-hair" x1="48" y1="338" x2="360" y2="338"/>
<text class="t-note" x="48" y="355">加锁，比较旧哈希仍是 a1f3…</text>
<line class="dd-hair" x1="48" y1="364" x2="360" y2="364"/>
<text class="t-note" x="48" y="381">不一致 → ErrManifestPointerConflict</text>
<text class="t-say" x="32" y="420">RPC 开始记下 a1f3…，结束时 CAS。</text>
<text class="t-note" x="32" y="444">哈希为示例。</text>
<rect class="dd-derived" x="424" y="228" width="344" height="162" rx="6"/>
<line class="dd-hair" x1="424" y1="260" x2="768" y2="260"/>
<text class="t-name" x="440" y="249">s3:// gs:// azblob://</text>
<text class="t-note" x="752" y="249" text-anchor="end">对象存储</text>
<text class="t-mono" x="440" y="277">manifests/&lt;hash&gt;</text>
<line class="dd-hair" x1="440" y1="286" x2="752" y2="286"/>
<text class="t-mono" x="440" y="303">generations/<tspan class="t-acc">fffffffffffffff5</tspan></text>
<text class="t-val" x="716" y="303">n=10</text>
<line class="dd-hair" x1="440" y1="312" x2="752" y2="312"/>
<text class="t-mono" x="440" y="329">generations/fffffffffffffff6</text>
<text class="t-val" x="716" y="329">n=9</text>
<line class="dd-hair" x1="440" y1="338" x2="752" y2="338"/>
<text class="t-note" x="440" y="355">IfNotExist 新建一代，不改写旧对象</text>
<line class="dd-hair" x1="440" y1="364" x2="752" y2="364"/>
<text class="t-note" x="440" y="381">键是 MaxUint64−n，最新一代排最前</text>
<text class="t-say" x="424" y="420">非测试代码里没有调用方。</text>
</svg>
</div>
<figcaption>图 11 · 一个版本就是一份 manifest；本地实现靠文件锁改指针，对象存储实现靠“不存在才创建”追加新一代</figcaption>
</figure>

一次写 RPC 开始时记下指针指向的哈希，git 在这个 manifest 描述的视图上执行（环境变量 `GIT_MVCC_MANIFEST_PATH`，需要 GitLab 维护的 Git 分支 `gitaly-mvcc-backend`，即 `gitaly-git-mvcc`），结束时写好新 manifest，用比较并交换（CAS）把指针从旧哈希改到新哈希；指针已经被别人改过，这次 RPC 就失败[^mvcc-flow]。

这一套**已经接进了 `serve`**：`mvcc.CacheInterceptor` 只在目标仓库的引用后端是 `mvcc` 时介入；Rails 的 `/internal/post_receive` 也会把刚上传的 manifest 钉住，让随后的读请求和 Sidekiq 任务读到同一个版本（`api/internal/base.rb`）。新仓库要用它，还得打开 v19.3.0 引入、默认关闭的特性开关 `new_repo_mvcc_backend`（`featureflag/ff_reference_backend.go`）。

但指针存在哪里是另一回事。`NewCacheFactory` 给每个 storage 创建的固定是图 11 左下的 `LocalStorage`（`mvcc/factory.go`）：仓库目录就是真相，指针是 `mvcc/manifest` 文件，CAS 用文件锁加比较哈希实现。右下的 `ee/mvcc/objectstorage/` 通过 gocloud 支持三家对象存储，为了避开“同一个对象每秒约一次更新”的限制，它不改写指针，而是每次提交用“不存在才创建”新建一个 `generations/<MaxUint64−n>` 对象；这个包在非测试代码里没有调用方。所以准确的说法是：**MVCC 的版本模型已经跑在 Gitaly 里，但版本的“真相”仍在本地磁盘上，对象存储后端是写好了、有测试、还没接上的库代码。** 它要走向的“对象存储是真相、本地盘只是缓存”，正是总览文章里 walgit 的思路，两者在绕开写入频率限制上的取舍，总览文章第五节做过对照。

---

## 小结

从那 30 秒出发，问题是一个接一个冒出来的：

1. **打开仓库为什么要 30 秒？** Git 默认磁盘就在身边，NFS 把每次随机读都变成网络往返。做成库仍然要到处挂 NFS，于是 GitLab 把 git 进程搬到存储机器上，组件之间用 gRPC 请求 Gitaly。
2. **谁来决定能不能读、去哪台 Gitaly？** Workhorse 把同一个 URL 先交给 Rails 预授权，gitlab-sshd 用公钥换 key ID 再调 `/allowed`；两边拿到同一组字段，Rails 只在控制面上。
3. **几个 GB 的 pack 走哪条路？** 不装进 protobuf 消息，而是走 Gitaly 在同一条 TCP 连接上反向打开的 yamux 流，也就是 sidechannel。
4. **Gitaly 怎么安全地启动 git？** 所有 git 都从命令工厂出来：白名单、分层配置、自带的 git 二进制，再按仓库路径的 crc32 放进 cgroup。
5. **几十个 CI 同时 clone 怎么办？** 先按仓库和 IP 排队，再用 pack-objects 缓存：标准输入相同，pack 只算一次，其余请求跟读。
6. **push 时谁来检查？** pre-receive 钩子经 gitaly-hooks 回到 Gitaly，Gitaly 带着隔离区目录调 Rails 的 `/allowed`，Rails 再带着这个目录读回 Gitaly。
7. **一台 Gitaly 坏了怎么办？** Praefect 把写请求发给所有副本，在 `reference-transaction` 的 `prepared` 阶段加权投票，主副本加一半从副本一致才提交。
8. **落后的副本怎么修？** generation 记录谁落后，落后者不接读请求，复制任务和每 5 分钟一次的对账让它从最新副本 fetch 追平。
9. **接下来往哪走？** 分区 WAL 的代码还在但没有接入，Raft 配置已删除；MVCC 版本模型已接入，但指针仍在本地磁盘，对象存储后端尚未接上。

想在自己的环境里看到第四节的那些进程，可以在一台跑着 Gitaly 的机器上 clone 一次，同时执行：

```bash
$ ps -o pid,args -C git | grep -- '--git-dir'
$ cat /proc/<pid>/cgroup     # 换成上一条输出里的 pid
```

第一条能看到 `--git-dir` 和一串 `-c` 配置，第二条能看到进程所在的 `repos-N`（需要在 `config.toml` 里配置了 `[cgroups]`）。

## 参考资料
