- Authors

- Name
- Youngju Kim
- @fjvbn20031
引言 — 一次 status 要 12 秒
克隆要 40 分钟,切个分支够你去泡杯咖啡,看一次状态就过去 12 秒。
$ time git status
On branch main
nothing to commit, working tree clean
real 0m12.417s
user 0m1.902s
sys 0m9.884s
在这种状态下,人们最先尝试的是搜索结果顶部的处方:用浅克隆拉,把整理命令狠狠跑一遍,大文件挪到 LFS。这三条按情况都可能是对的处方,但不区分原因就照搬,多半是什么都没变快,只是更难回头了。
仓库变慢的原因大致分三条线索:提交历史长,跟踪的文件数多,里面有大的二进制文件。三者造成的瓶颈不同,处方也不重叠。
先测量 — 如何区分三条线索
最快的诊断是对象统计。
$ git count-objects -vH
count: 1042
size: 8.31 MiB
in-pack: 2841903
packs: 14
size-pack: 6.42 GiB
prune-packable: 0
garbage: 0
size-garbage: 0 bytes
如果包的体积相对提交数大得离谱,就该怀疑二进制文件。如果包的个数到了两位数,说明整理欠账了。接下来数另外两个维度。
$ git rev-list --count HEAD
84213
$ git ls-files | wc -l
212847
8 万个提交在如今的硬件上算不上什么问题。反过来,工作树里 21 万个文件,足以解释状态检查为什么要 12 秒。因为状态检查默认要对所有被跟踪的路径确认一遍文件信息。
大对象在哪里也可以直接数出来。
$ git rev-list --objects --all \
| git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' \
| awk '$1 == "blob"' | sort -k3 -n -r | head -5
blob 4d7a2146 104857600 design/hero.psd
blob 91c0ffee 87031808 assets/renders/intro-01.mov
blob 2b7d13aa 73400320 assets/renders/intro-02.mov
blob a04e1f39 52428800 vendor/sdk/ios-framework.zip
blob 77bc09d1 41943040 design/board-v3.psd
想连"哪条命令把时间花在哪儿"都看清楚,就打开内置的追踪。
$ GIT_TRACE2_PERF=1 git status 2>&1 | grep -E 'region|data' | tail -20
不做测量就选处方,往往会选中副作用最大的那一个。顺序永远是先数。
减少拉取量 — 浅克隆和部分克隆不是互相的替代品
最常见的建议是浅克隆。
$ git clone --depth=1 https://github.com/example/monorepo.git
传输量确实降了,但这个克隆没有历史。结果就是所有依赖历史的命令全都失效。日志停在一个提交,没法追踪某一行是谁在什么时候为什么改的,也找不到两个分支的共同祖先,于是合并和变基都会失败。基于标签生成版本字符串同样不工作。而且后来需要历史再去加深度,很多时候对服务器的负担比从头重拉还重。
部分克隆是另一样东西。提交和树全都拉下来,只有文件内容在需要时才取。
$ git clone --filter=blob:none https://github.com/example/monorepo.git
Cloning into 'monorepo'...
remote: Enumerating objects: 2841903, done.
remote: Counting objects: 100% (2841903/2841903), done.
Receiving objects: 100% (1204418/1204418), 412.55 MiB | 22.31 MiB/s, done.
Resolving deltas: 100% (812004/812004), done.
Updating files: 100% (48211/48211), done.
这一边日志、bisect、共同祖先计算全都正常。代价是需要旧文件内容的那一刻要走网络的延迟。追踪老代码行历史的操作会明显变慢。
还有一种按体积过滤的中间形态。让大文件延后再取,不需要额外服务器就能缓解相当一部分二进制问题。
$ git clone --filter=blob:limit=1m https://github.com/example/monorepo.git
| 方式 | 减少的东西 | 照常工作的东西 | 会坏掉或变慢的东西 | 适合的场合 |
|---|---|---|---|---|
| 浅克隆 | 提交历史 | 构建最新快照 | 日志、逐行历史、共同祖先、基于标签的版本 | 用完就丢的 CI 检出 |
| 部分克隆 blob:none | 文件内容 | 日志、bisect、共同祖先 | 访问旧文件时的网络延迟 | 开发者克隆的默认做法 |
| 部分克隆 tree:0 | 树和文件内容 | 查询提交元数据 | 指定路径的日志和大多数 diff | 只需要提交信息的工具 |
| 稀疏检出 | 工作树里的文件数 | 完整历史 | 编辑范围之外的路径 | 在单体仓库里只看一个区域时 |
归结起来是这样。浅克隆是给用完就丢的检出准备的工具,值得推荐为开发者机器默认做法的是部分克隆。就算在自动化流水线里,只要需要计算合并基准点,浅克隆就已经是错误的选择。
减少工作树 — 稀疏检出与文件监视
如果瓶颈是文件数,换克隆方式解决不了。必须减少真正铺开到工作树里的路径。
$ git clone --filter=blob:none --no-checkout https://github.com/example/monorepo.git
$ cd monorepo
$ git sparse-checkout init --cone --sparse-index
$ git sparse-checkout set services/payments libs/common
$ git checkout main
$ git ls-files | wc -l
2143
这里有两个选项很关键。锥形模式只按目录前缀判定,几乎把匹配开销降到没有。旧文档里那种忽略规则风格的自由模式,要对每条路径把整套模式跑一遍,文件越多反而越慢。稀疏索引选项则在索引文件本身里,把范围外的目录折叠成一个条目。只缩小工作树而把索引原样留着,状态检查照样要扫 21 万条目。
接下来是文件监视。状态检查慢的根本原因,是为了知道有没有变动而读取所有路径的信息;可要是操作系统会主动通知变更,就没这个必要了。从 Git 2.37 起,不用额外工具,内置的监视器就在里面。
$ git config core.fsmonitor true
$ git config core.untrackedCache true
$ git status # 第一次运行要填缓存,所以慢
$ time git status
real 0m0.512s
之所以要把未跟踪文件缓存一起打开,是因为监视器告诉你的只有被改动的路径,而发现新文件是另一趟目录遍历。两个一起开才有效果。
让读取变快 — commit-graph 与整理作业
历史长导致的慢,另有处方。日志和共同祖先计算,本质上是把提交对象一个个打开再顺着父提交走;把这些信息预先算好放进一个单独的文件,连解压这一步都能跳过。
$ git commit-graph write --reachable --changed-paths
Expanding reachable commits in commit graph: 84213, done.
Writing out commit graph in 4 passes: 100% (336852/336852), done.
$ time git log --oneline -- services/payments | wc -l
1841
real 0m0.63s
指定路径的日志变化尤其大。因为 changed-paths 选项会一并保存"每个提交改了哪些路径"的概率型过滤器,于是大部分提交连打开都不用打开就跳过去了。
这些辅助文件不需要手工维护。从 Git 2.31 起,把维护作业排进计划表已经成了标准功能,它取代了以前把整理命令挂到 cron 上的做法。
$ git maintenance start
$ git config --get-all maintenance.repo
/Users/dev/work/monorepo
这里要点出一条常见的错误建议。
# 流传很广,但大多是亏的
$ git gc --aggressive
这个选项会把现有的 delta 链全部丢掉,用大得多的搜索窗口从头重算压缩。在几个 GB 的仓库上要烧掉好几个小时的 CPU,而换来的体积收益通常并不大,因为 Git 平时生成的 delta 已经足够好了。如果问题是包太多导致查询慢,那需要的不是重算,而是重排。
$ git repack --geometric=2 -d --write-midx
$ git multi-pack-index write
几何式重打包只挑小包合并,大包原样保留。多包索引把多个包捆成一个查询索引,让包的数量不再影响查询开销。整理的目标不是把包合成一个,而是让一次查询就结束。
大二进制文件 — LFS 做什么,不做什么
设计文件或者视频一旦进了仓库,别的处方就全都失效了。二进制文件几乎吃不到 delta 压缩的好处,一个 100 MB 的文件改十次,仓库就一 GB 一 GB 地长。
Git LFS 干的事比想象中简单。对指定的路径,在提交时把文件内容替换成一小段指针文本,在检出时再从另一台服务器下载真正的内容。Git 保存的永远只是指针。
$ git lfs track "*.psd"
$ cat .gitattributes
*.psd filter=lfs diff=lfs merge=lfs -text
$ git cat-file -p HEAD:design/hero.psd
version https://git-lfs.github.com/spec/v1
oid sha256:4d7a214614ab2935c943f9e0ff69d22eafe21c1b8e4a6d0f0a2d0b1e6dbf3a9c
size 104857600
这里有个最容易被误解的地方。今天引入 LFS,昨天提交的二进制文件也不会从仓库里消失。已经生成的提交,它们的树仍然指向原来的 blob,而那些 blob 还老老实实待在 packfile 里。克隆体积一个字节都不会少。要连过去一起清理,就需要重写历史这项单独的工作,而它要付的代价和下一节讲的一样。
$ git lfs migrate import --include="*.psd,*.mov" --everything
migrate: Sorting commits: ..., done.
migrate: Rewriting commits: 100% (84213/84213), done.
migrate: Updating refs: ..., done.
局限也值得看清楚。服务器必须支持 LFS,而且容量和带宽通常要另外收费。本地缓存需要单独管理。还有,LFS 只是把二进制搬到别处,并不会让它变得可以合并。两个人改同一个设计文件,冲突还是得靠人手工挑。想真正减少这个问题,要么用锁定功能,要么干脆一开始就把产物放到仓库外面。
$ git lfs prune # 清理陈旧的本地缓存
$ GIT_LFS_SKIP_SMUDGE=1 git clone ... # 只拉指针,之后按需下载
$ git lfs pull --include="design/hero.psd"
补一句:前面看到的按体积过滤的部分克隆,不需要额外服务器就能做到类似效果的一部分。如果是新开的仓库,在引入 LFS 之前值得先考虑这个选项。
已经膨胀的仓库 — filter-repo 与所有哈希都改变的代价
要真正抹掉过去的二进制文件或者误入的大文件,就必须重写历史。旧文档里出现的 filter-branch,Git 官方文档自己都在劝退,如今推荐的工具是 filter-repo。
$ pip install git-filter-repo
$ git filter-repo --path assets/renders --invert-paths
Parsed 84213 commits
New history written in 132.71 seconds; now repacking/cleaning...
Completely finished after 218.04 seconds.
$ git count-objects -vH
in-pack: 1932450
size-pack: 1.14 GiB
6.42 GiB 变成了 1.14 GiB。到这里都是好消息,代价从现在开始。
重写历史意味着每个提交的父提交都变了,而父提交变了,所有提交哈希都会变。后果不止停留在一个仓库里。
- 所有打开着的 PR 都会指向不存在的提交。
- 标签和发布要重新推一遍,所有固定了具体提交哈希的部署配置和子模块引用全都会断。
- 团队所有人都得重新克隆。在原有克隆里拉取,会把旧历史和新历史一起拉进来,仓库直接翻倍。filter-repo 在作业结束后特意删掉远程配置,原因就在这里。
- 服务器那边也不会立刻消失。托管服务上 PR 引用和 fork 常常还抓着旧提交,真正删除往往需要另外提清理请求。
所以这项工作需要的不是一条命令,而是一个计划:锁住合并,公告时间点,重写,强制推送,全员重新克隆,清理老分支,按这个顺序来。不可逆的处方在本文的所有处方里只有这一个,所以它被放在了最后。
结语 — 分类先于处方
本文的命令不必全部背下来。要记住的是顺序:先数提交数、文件数和包的体积,再按结果一条条套用对应的处方。
历史长导致的慢,对应部分克隆和 commit-graph。文件多导致的慢,对应稀疏检出和文件监视,而且这时如果不把索引一起缩小,效果会砍掉一半。二进制是问题的话,就是 LFS 或者按体积过滤的部分克隆;要连过去一起抹掉,就得接受重写历史这个不可逆的决定。
对多数团队收益最大的两行,是打开定期维护和文件监视。没有副作用,容易回退,体感立竿见影。重写历史等到确认这两行解决不了问题之后再搬出来也不迟。
对象和 packfile 实际长什么样,在用 Git 内部结构理解命令一篇里接着讲。