Gitaly 的设计文档里记着这样一组数字1,来自 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 说起》里,我们已经看过 Git 协议本身,也粗略走过一遍 GitLab 的路径。这篇文章从这 30 秒出发,把那条路径一段一段拆开来看。


一、仓库为什么离开了 NFS

现象:CPU 很闲,请求很慢

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

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

GitLab 拆分前后的 Git 访问路径 左边是早期部署:多台跑 Unicorn 和 Rugged 的应用服务器挂载同一个 NFS 目录,在本机直接读仓库。右边是今天:HTTP 由 Workhorse、SSH 由 gitlab-sshd 接住,先问 Rails 能不能、仓库在哪,再用 gRPC 直接找 Gitaly;只有 Gitaly 在本地磁盘上执行 git。 过去 · 应用服务器挂 NFS 现在 · git 进程搬到存储机器上 Unicorn + Rugged app-01 Rugged::Repository.new Unicorn + Rugged app-02 Rugged::Repository.new …还有 Sidekiq、gitlab-shell NFS 服务器 nfs-01 /var/opt/gitlab/git-data 所有机器挂同一个目录 P99 超过 30 秒,CPU 只用 15 毫秒: 时间花在等文件系统上。 Workhorse HTTP git-upload-pack gitlab-sshd SSH git-upload-pack 预授权 /allowed Rails · Puma 能不能读?仓库在哪个 storage? 网页和 API 的读请求 Gitaly gitaly-1 git upload-pack --stateless-rpc /var/opt/gitlab/git-data/repositories 本地磁盘,只有 Gitaly 碰它 带颜色的线是仓库数据走的路:右边它绕开了 Rails。
图 1 · 拆分之后,Rails 只回答“能不能、在哪里”,读写仓库的 git 进程只在 Gitaly 所在的机器上运行

原因:Git 默认磁盘就在身边

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

第一次尝试:换硬件,或者做成一个库

设计文档考虑过两个直觉的办法1。换更快的机器:用户大多跑在云上,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 的回答并排放在一起,下面分别看。

两个入口的鉴权请求和 Rails 的回答 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 地址和令牌。 HTTPS · Workhorse SSH · gitlab-sshd Rails 的回答(两边相同) 客户端请求 GET GET /acme/app.git/info/refs ?service=git-upload-pack Authorization: Basic ZGV2Oi… Workhorse → Rails PreAuthorize GET 同一路径,去掉请求体 Gitlab-Workhorse-Api-Request: JWT Rails 回 401 就原样转给 git,弹出密码框 客户端请求 ssh ssh git@gitlab.example.com git-upload-pack 'acme/app.git' env GIT_PROTOCOL=version=2 gitlab-sshd → Rails POST 公钥 → /internal/authorized_keys → key-id 45 /internal/allowed changes=_any Rails 返回 application/vnd.gitlab-workhorse+json · JSON GL_ID user-17 GL_REPOSITORY project-42 storage_name default relative_path @hashed/73/47/73475cb40a568e8da8a045ced110137e…8049.git GitalyServer.address tcp://gitaly-1.internal:8075 GitalyServer.token (共享令牌,省略) 项目 ID 42、用户 17、key 45 和主机名都是示例;relative_path 是 SHA-256("42") 的真实结果。
图 2 · 两个入口用不同的方式问同一个问题,Rails 给出同一组回答:用户是谁、仓库在哪台 Gitaly 的哪个路径

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 用的是同样的规则2。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 把两种方式并排画出来。

HTTP 和 SSH 上 upload-pack 的多轮协商 左边是 HTTP:每一轮协商是一个 POST,Gitaly 为它启动一个 upload-pack --stateless-rpc,回完就退出;下一轮客户端要把已确认的共同提交重新带上。右边是 SSH:一个不带 --stateless-rpc 的 upload-pack 进程跑完所有轮次;Gitaly 从 RPC 开始计时,读到 done 包才停表,默认 10 分钟内必须协商完。 HTTP · 每轮一个 POST、一个进程 SSH · 一个进程跑完所有轮 POST ① upload-pack 进程 A want 612f4c1… have ×32 回 ACK:其中 3 个是共同提交,进程退出 POST ② upload-pack 进程 B want 612f4c1… have ×3 + ×32 上一轮确认的 3 个共同提交重新带上 POST ③ upload-pack 进程 C want 612f4c1… have ×5 + ×8 done 回 packfile,进程退出 每个进程都带 --stateless-rpc, 服务端不留状态,状态由客户端重发。 git upload-pack 进程 D → want 612f4c1… have ×32 ← ACK 3 个共同提交 → have ×32 ← ACK → have ×8 done ← packfile 协商超时 upload_pack_negotiation 开始计时 RPC 开始时 停表 读到 0009done 默认 10 分钟,超时返回 DeadlineExceeded 鉴权只在开始时做一次,协商不能无限拖。 have 的个数为示例。
图 3 · HTTP 每轮都是新进程,状态靠客户端重发;SSH 一个进程跑到底,所以要给“连上到说 done”限时

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


三、pack 字节走哪条路:sidechannel

现象:gRPC 消息成了瓶颈

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

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

第一次尝试:另开一个端口?

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

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

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

sidechannel 的握手和数据流 Workhorse 在流 1 上发起 PostUploadPackWithSidechannel,metadata 里带 gitaly-sidechannel-id 7;Gitaly 在同一条 TCP 连接上反向打开一条 yamux 流,写入 sidechannel 和 7,Workhorse 回 ok;随后 HTTP 请求体经这条流进入 upload-pack 的标准输入,packfile 从同一条流回来;最后流 1 返回统计和状态。 一条 TCP 连接 · Workhorse ↔ gitaly-1 Workhorse HTTP 请求体 → 流 7 流 7 → HTTP 响应 Gitaly gitaly-1 git upload-pack stdin、stdout 都接在流 7 PostUploadPackWithSidechannel metadata gitaly-sidechannel-id: 7 流 1 打开新流:写入 "sidechannel" + int64 7 流 7 "ok" 流 7 0012command=fetch … want 612f4c1… … done CloseWrite():请求写完了 流 7 packfile 段:\x01PACK …(side-band 分帧) 流 7 Response{PackfileNegotiationStatistics} · OK 流 1 这些流共用 Workhorse 到 Gitaly 的一条 TCP 连接;流 2 是 Praefect 投票用的 backchannel,图里没画。
图 4 · gRPC 只传参数和结果,pack 的原始字节走 Gitaly 反向打开的那条 yamux 流

图 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 是一个具体的例子。

命令工厂把一个 RPC 变成什么样的 git 进程 左上是 PostUploadPackWithSidechannel 请求里的仓库字段,右上是 ExecCommandFactory.newCommand 依次做的事,下方是最终执行的进程:bundled git 的符号链接、--git-dir、一串 -c 配置、upload-pack --stateless-rpc、环境变量,以及由仓库路径的 crc32 决定的 cgroup repos-450。 gitaly-1 收到的请求与命令工厂 最终执行的进程(示例) 请求 PostUploadPackWithSidechannel storage_name default relative_path @hashed/73/47/7347…8049.git git_protocol version=2 ExecCommandFactory.newCommand 1 白名单 commandDescriptions["upload-pack"] 2 -c:全局 → 子命令 → 调用方 → config.toml 3 gitaly-git-prev 链接成 git-exec-*.d/git 4 crc32("default/@hashed/…") % 500 = 450 git upload-pack pid 18823 argv[0] /var/opt/gitlab/gitaly/run/git-exec-0451.d/git → gitaly-git-prev --git-dir /var/opt/gitlab/git-data/repositories/@hashed/73/47/7347…8049.git -c gc.auto=0 -c core.useReplaceRefs=false -c core.fsync=objects,… -c uploadpack.allowFilter=true -c uploadpack.packObjectsHook=…/gitaly-hooks upload-pack --stateless-rpc --end-of-options …/7347….git env GIT_EXEC_PATH=…/git-exec-0451.d GIT_PROTOCOL=version=2 cgroup /sys/fs/cgroup/gitaly/gitaly-4211/repos-450
图 5 · 同一个仓库路径决定了 --git-dir,也决定了进程被放进哪个 cgroup

白名单。 子命令必须在 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 传回5。

三个 CI 任务同时 clone 同一个提交 三个 CI 任务从不同 IP 请求同一个提交,Gitaly 为每个请求启动 upload-pack,upload-pack 通过 gitaly-hooks 回调 PackObjectsHookWithSidechannel;三个请求算出相同的缓存键 9f2c41e0…,只有第一个请求在 cgroup repos-450 里启动 git pack-objects,另外两个从同一个缓存文件边写边读。下方标出两层并发限制:按 RPC 和仓库排队,按客户端 IP 限制 pack-objects。 CI 任务(IP 为示例) gitaly-1 CI #1 10.0.3.11 upload-pack → gitaly-hooks CI #2 10.0.3.12 upload-pack → gitaly-hooks CI #3 10.0.3.13 upload-pack → gitaly-hooks 每个请求各有一对进程 streamcache 条目 key 9f2c41e0… 生产者 CI #1 的请求 读者 #1、#2、#3 max_age 300s 文件 …/9f2c41e0… git pack-objects 只跑一次 --stdout --revs … cgroup repos-450 两层并发限制 RPC 级 按仓库路径排队 pack-objects 按客户端 IP 队满或等太久 → ResourceExhausted 键 = sha256(仓库、参数、协议 + 完整的标准输入); 标准输入就是协商出来的“要哪些、排除哪些”。
图 6 · 三份相同的标准输入算出同一个缓存键,pack-objects 只跑一次,其余请求从正在写的缓存文件里跟读

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 的数据5,按项目自己的测量:文件系统 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 钩子。钩子拒绝,隔离区直接删掉;钩子通过,对象才并进仓库,接着更新引用6。所以 GitLab 的逐引用检查只能挂在 pre-receive 上。

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

解决:一圈往返

一次 push 在 Gitaly 和 Rails 之间的往返 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。 gitaly-1 Rails Gitaly HookService PostReceivePack ← Workhorse PreReceiveHook ← gitaly-hooks ① exec git receive-pack 子进程 core.hooksPath gitaly-hooks 新对象写到 objects/incoming-Xy3k ② pre-receive gitaly-hooks pre-receive 385ce2c 612f4c1 refs/heads/main GIT_OBJECT_DIRECTORY …/incoming-Xy3k ③ ④ ⑤ POST /internal/allowed changes=385ce2c 612f4c1 refs/heads/main relative_path=@hashed/73/47/7347….git git_object_directory=objects/incoming-Xy3k Gitlab::Checks::ChangesAccess 保护分支、push 规则、文件大小…… 读 RPC Repository{…, incoming-Xy3k} 之后的两次回调 /pre_receive ReferenceCounter +1 /post_receive Sidekiq PostReceive 隔离区目录名 Xy3k 为示例;钩子被拒时,receive-pack 直接删掉整个 incoming 目录。
图 7 · 隔离区目录跟着 /allowed 进了 Rails,又跟着读 RPC 回到 Gitaly,Rails 才看得见还没入库的对象

命令工厂给 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 承担7。Praefect 是放在 Gitaly 前面的 gRPC 代理。对 Rails 来说它就是一个 storage(称为虚拟 storage),背后每个仓库在几台 Gitaly 上各存一份副本,副本信息记在 Praefect 自己的 PostgreSQL 里。

第一次尝试:先写主副本,再异步复制

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

解决:对引用事务投票

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

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

Praefect 对一次 push 的加权投票 Praefect 为一个主副本和两个从副本登记事务:gitaly-1 三票,gitaly-2 和 gitaly-3 各一票,阈值四票。gitaly-1 和 gitaly-2 在 reference-transaction 的 prepared 阶段都投出 sha1(stdin) = 12b66d2…,合计四票,提交;gitaly-3 解包失败没有投票,被记为落后。 Praefect 事务 4182(示例) gitaly-1(主) 3 票 secondaries + 1 gitaly-2 1 票 gitaly-3 1 票 阈值 4 (2+1) + ⌈2/2⌉ 12b66d2… 3 + 1 = 4 → 提交 prepared 没有票 gitaly-1 主 sha1(stdin) = 12b66d2… prepared 投票 → 等 结果 VoteCommitted gitaly-2 从 sha1(stdin) = 12b66d2… prepared 投票 → 等 结果 VoteCommitted gitaly-3 从 unpack 失败(示例) prepared 没到这一步 结果 记为落后 每台 Gitaly 各自执行 receive-pack;钩子的 stdin 是 “385ce2c… 612f4c1… refs/heads/main”。
图 8 · 主副本 3 票加任意一个从副本 1 票才到阈值 4:主副本单独凑不够,从副本联合起来也凑不够

票数怎么算(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。

Praefect 如何记录和修复落后副本 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 并补任务。 Praefect 的 PostgreSQL 存储节点 repositories repository_id 1307 relative_path @hashed/73/47/7347….git replica_path @cluster/repositories/8c/14/1307 primary gitaly-1 generation 8 storage_repositories gitaly-1 generation 8 最新 gitaly-2 generation 8 最新 gitaly-3 generation 7 落后 replication_queue state ready change=update source=gitaly-1 target=gitaly-3 读请求只发给 generation 等于 8 的副本。 gitaly-1 主 @cluster/repositories/8c/14/1307 git fetch gitaly-3 ReplicateRepository git fetch --prune --no-tags gitaly-1 +refs/*:refs/* gitaly-2 从 @cluster/repositories/8c/14/1307 reconciler(默认每 5 分钟) generation 不一致 → 补一条 update 虚线:后台定时任务,不在请求路径上。
图 9 · generation 比最新值小的副本不接读请求,复制任务让它从最新副本 fetch 一遍,追平后 generation 才更新

落后的副本不接读请求

读请求的路由很保守(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.md9,思路来自数据库。预写日志(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 的真实状态

gitaly serve 实际接入的拦截器与未接入的代码 左边是 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 配置。 gitaly serve 挂上的(serve.go) 源码里有,但没接上 limithandler 并发限制 housekeeping 后台优化 mvcc.CacheInterceptor 仓库是 mvcc 后端才介入 Prepare → handler 跑 git → Commit GIT_MVCC_MANIFEST_PATH=… TransactionMiddleware{} 空的,给事务拦截器留的位置 LocalStorage NewCacheFactory 固定用它 <storage>/<repo>/mvcc/manifest CAS:加锁写新文件,比较旧哈希 storagemgr 拦截器 WAL NewUnaryInterceptor NewStorageManager 只在测试里调用 [transactions] 仅供开发 ee/mvcc/objectstorage 对象存储 s3:// gs:// azblob:// generations/<MaxUint64−n> 非测试代码没有调用方 Raft 配置 19.4 已删除 同时删除:分区备份 CLI、 副本分区迁移器 新仓库用 mvcc 后端,还要打开默认关闭的 特性开关 new_repo_mvcc_backend。 虚线:源码里存在,serve 没有接上。
图 10 · 当前 HEAD 里,MVCC 拦截器已经挂上但指针仍在本地磁盘;给 WAL 留的拦截器位置是空的,对象存储后端只是库代码

图 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 配置、分区备份命令行和副本分区迁移器10。

这里有一处说法对不上: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 的一种二进制引用存储格式,引用分层写在几个文件里,新文件覆盖旧文件)。

MVCC manifest 的格式与指针的两种存法 上方是 manifest 的二进制布局:魔数 MVCC、版本与块目录、PATH、OBJS、REFS 三个块、SHA-256 校验和。左下是 serve 实际使用的 LocalStorage:manifest 按哈希存在仓库目录的 mvcc/manifests 下,指针文件 mvcc/manifest 在提交时加锁、比较旧哈希、改成新哈希。右下是未接入的对象存储实现:每次提交新建一个 generations/<MaxUint64−n> 对象,不存在才创建,键倒序使最新一代排在最前。 manifest 文件(gitformat-chunk 格式) LocalStorage · serve 实际用的 objectstorage · 写好了,没接上 MVCC 魔数 0x4D564343 版本·目录 版本 1 块的偏移 PATH 所有文件路径 pack/… refs/… OBJS 哪一段 是 pack REFS reftable 栈的顺序 SHA-256 校验和 32 字节 <repo>/mvcc/ gitaly-1 本地盘 manifests/a1f3… manifests/7c09… manifest:a1f3… → 7c09… 加锁,比较旧哈希仍是 a1f3… 不一致 → ErrManifestPointerConflict RPC 开始记下 a1f3…,结束时 CAS。 哈希为示例。 s3:// gs:// azblob:// 对象存储 manifests/<hash> generations/fffffffffffffff5 n=10 generations/fffffffffffffff6 n=9 IfNotExist 新建一代,不改写旧对象 键是 MaxUint64−n,最新一代排最前 非测试代码里没有调用方。
图 11 · 一个版本就是一份 manifest;本地实现靠文件锁改指针,对象存储实现靠“不存在才创建”追加新一代

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

这一套已经接进了 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 一次,同时执行:

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

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

参考资料

Footnotes

  1. Gitaly designGitLabgitlab.com ↗doc/DESIGN.md:Rugged 的 P99 数据、Decisions 一节里“为什么不做成库”https://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/DESIGN.md ↩ ↩2 ↩3

  2. Repository storageGitLab 文档docs.gitlab.com ↗https://docs.gitlab.com/administration/repository_storage_paths/ ↩

  3. Gitaly sidechannelsGitLabgitlab.com ↗doc/sidechannel.md;实现在 internal/grpc/sidechannel/sidechannel.gohttps://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/sidechannel.md ↩ ↩2

  4. hashicorp/yamuxGitHubgithub.com ↗https://github.com/hashicorp/yamux ↩

  5. Pack-objects cache design notesGitLabgitlab.com ↗doc/design_pack_objects_cache.md;缓存键在 service/hook/pack_objects.gohttps://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/design_pack_objects_cache.md ↩ ↩2

  6. Git object quarantine during git pushGitLabgitlab.com ↗doc/object_quarantine.mdhttps://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/object_quarantine.md ↩

  7. Gitaly Cluster designGitLabgitlab.com ↗doc/design_ha.md:从最终一致到强一致的设计https://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/design_ha.md ↩ ↩2 ↩3

  8. githooks: reference-transactionGit 文档git-scm.com ↗https://git-scm.com/docs/githooks#_reference_transaction ↩

  9. TransactionsGitLabgitlab.com ↗doc/transactions.md,代码在 internal/gitaly/storage/storagemgr/https://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/transactions.md ↩

  10. Gitaly CHANGELOGGitLabgitlab.com ↗19.4.0 的 Removed 一节https://gitlab.com/gitlab-org/gitaly/-/blob/master/CHANGELOG.md ↩

  11. MVCC Gitaly RPC flowGitLabgitlab.com ↗doc/mvcc_rpc_flow.md,文中说明描述的是 POC 分支https://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/mvcc_rpc_flow.md ↩

延伸阅读