Gitaly 的设计文档里记着这样一组数字1,来自 GitLab.com 早年的监控:
Rugged::Repository.new P99 墙钟时间 峰值超过 30 s CPU 时间 约 15 ms遍历 gitlab-ce 的所有分支 2.4 sRugged::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 的左半边就是这套部署。
原因: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 的回答并排放在一起,下面分别看。
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 把两种方式并排画出来。
鉴权解决了“能不能”。接下来是数据:一次 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。
图 4 是一次 HTTP clone 在这条连接上的全部往来(gitaly/smarthttp.go、sidechannel.go):
- Workhorse 在本地登记一个等待者,拿到编号 7,把它放进 gRPC 的 metadata
gitaly-sidechannel-id,然后在流 1 上发出PostUploadPackWithSidechannel,请求里只有仓库和协议版本这类参数。 - Gitaly 的处理函数调用
OpenSidechannel,在同一个 yamux 会话里反向开一条流,先写魔数sidechannel,再写 8 字节的编号 7。 - Workhorse 按编号找到等待者,回一个
ok,握手结束。 - Workhorse 把 HTTP 请求体(v2 的
command=fetch、want、done)原样写进这条流,然后半关闭写方向。 - Gitaly 把这条流同时当作
git upload-pack的标准输入和标准输出,pack 从这里原样流回,Workhorse 再原样写进 HTTP 响应。 - 流 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 是一个具体的例子。
白名单。 子命令必须在 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)是这样的:
[cgroups]mountpoint = "/sys/fs/cgroup"hierarchy_root = "gitaly"memory_bytes = 64424509440 # 60 GB,整个 Gitalycpu_shares = 1024
[cgroups.repositories]count = 500memory_bytes = 12884901888 # 12 GB,每个 repos-Ncpu_shares = 512cgroup 能把一个仓库的破坏限制住,但它只管“别人不受牵连”。如果几十个请求同时打在同一个仓库上呢?
五、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。
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 机器上启动的一个子进程。这两者怎么接上?
解决:一圈往返
命令工厂给 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 的编号:
git receive-pack把 pack 解进隔离区objects/incoming-Xy3k。- 它调
pre-receive,也就是gitaly-hooks,GIT_OBJECT_DIRECTORY指向隔离区。 gitaly-hooks经内部 socket 回调 Gitaly 的PreReceiveHook。- Gitaly 带上具体的变更和相对的隔离区目录
POST /internal/allowed(hook/prereceive.go)。 - 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/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。
落后的副本不接读请求
读请求的路由很保守(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 的真实状态
图 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 的一种二进制引用存储格式,引用分层写在几个文件里,新文件覆盖旧文件)。
一次写 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 秒出发,问题是一个接一个冒出来的:
- 打开仓库为什么要 30 秒? Git 默认磁盘就在身边,NFS 把每次随机读都变成网络往返。做成库仍然要到处挂 NFS,于是 GitLab 把 git 进程搬到存储机器上,组件之间用 gRPC 请求 Gitaly。
- 谁来决定能不能读、去哪台 Gitaly? Workhorse 把同一个 URL 先交给 Rails 预授权,gitlab-sshd 用公钥换 key ID 再调
/allowed;两边拿到同一组字段,Rails 只在控制面上。 - 几个 GB 的 pack 走哪条路? 不装进 protobuf 消息,而是走 Gitaly 在同一条 TCP 连接上反向打开的 yamux 流,也就是 sidechannel。
- Gitaly 怎么安全地启动 git? 所有 git 都从命令工厂出来:白名单、分层配置、自带的 git 二进制,再按仓库路径的 crc32 放进 cgroup。
- 几十个 CI 同时 clone 怎么办? 先按仓库和 IP 排队,再用 pack-objects 缓存:标准输入相同,pack 只算一次,其余请求跟读。
- push 时谁来检查? pre-receive 钩子经 gitaly-hooks 回到 Gitaly,Gitaly 带着隔离区目录调 Rails 的
/allowed,Rails 再带着这个目录读回 Gitaly。 - 一台 Gitaly 坏了怎么办? Praefect 把写请求发给所有副本,在
reference-transaction的prepared阶段加权投票,主副本加一半从副本一致才提交。 - 落后的副本怎么修? generation 记录谁落后,落后者不接读请求,复制任务和每 5 分钟一次的对账让它从最新副本 fetch 追平。
- 接下来往哪走? 分区 WAL 的代码还在但没有接入,Raft 配置已删除;MVCC 版本模型已接入,但指针仍在本地磁盘,对象存储后端尚未接上。
想在自己的环境里看到第四节的那些进程,可以在一台跑着 Gitaly 的机器上 clone 一次,同时执行:
$ ps -o pid,args -C git | grep -- '--git-dir'$ cat /proc/<pid>/cgroup # 换成上一条输出里的 pid第一条能看到 --git-dir 和一串 -c 配置,第二条能看到进程所在的 repos-N(需要在 config.toml 里配置了 [cgroups])。
参考资料
Footnotes
-
Gitaly designgitlab.com ↗
doc/DESIGN.md:Rugged 的 P99 数据、Decisions 一节里“为什么不做成库”https://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/DESIGN.md ↩ ↩2 ↩3 -
Repository storagedocs.gitlab.com ↗https://docs.gitlab.com/administration/repository_storage_paths/ ↩
-
Gitaly sidechannelsgitlab.com ↗
doc/sidechannel.md;实现在internal/grpc/sidechannel/sidechannel.gohttps://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/sidechannel.md ↩ ↩2 -
hashicorp/yamuxgithub.com ↗https://github.com/hashicorp/yamux ↩
-
Pack-objects cache design notesgitlab.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 -
Git object quarantine during git pushgitlab.com ↗
doc/object_quarantine.mdhttps://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/object_quarantine.md ↩ -
Gitaly Cluster designgitlab.com ↗
doc/design_ha.md:从最终一致到强一致的设计https://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/design_ha.md ↩ ↩2 ↩3 -
githooks: reference-transactiongit-scm.com ↗https://git-scm.com/docs/githooks#_reference_transaction ↩
-
Transactionsgitlab.com ↗
doc/transactions.md,代码在internal/gitaly/storage/storagemgr/https://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/transactions.md ↩ -
Gitaly CHANGELOGgitlab.com ↗19.4.0 的 Removed 一节https://gitlab.com/gitlab-org/gitaly/-/blob/master/CHANGELOG.md ↩
-
MVCC Gitaly RPC flowgitlab.com ↗
doc/mvcc_rpc_flow.md,文中说明描述的是 POC 分支https://gitlab.com/gitlab-org/gitaly/-/blob/master/doc/mvcc_rpc_flow.md ↩
延伸阅读
- gitlab-org/gitalygitlab.com ↗本文读的是 19.5.0-rc3,提交
77bee05(2026-10-14)https://gitlab.com/gitlab-org/gitaly - gitlab-org/gitlabgitlab.com ↗
workhorse/internal/、lib/api/internal/base.rb、lib/gitlab/git_access.rb,提交31a01d7ahttps://gitlab.com/gitlab-org/gitlab - gitlab-org/gitlab-shellgitlab.com ↗
internal/sshd/、internal/command/uploadpack/,提交bbd07a4https://gitlab.com/gitlab-org/gitlab-shell - gRPCgrpc.io ↗https://grpc.io/