📚 Git 深入课(以 sourcepack 仓库为标本)· 第 10/12 章
全部示例均来自真机实测;实验请在隔离克隆里做。
本章目标:理解"Git 为什么不慢",以及什么时候它真的会慢。
学完你能:判断某个操作是"启动开销主导"还是"数据量主导"、
知道 commit-graph / bitmap / delta / partial clone 各自加速哪一段,
并且能用GIT_TRACE_PERFORMANCE自己做诊断。本章所有数据都来自本机实测(含一个用
git fast-import造的 3 万提交仓库),
并且包括我没能测出差异的部分——那同样是有价值的信息。
10.1 先看结论:三件事决定了 Git 快不快
| 层 | 加速手段 | 加速的是什么 | 本章实测 |
|---|---|---|---|
| 对象存储 | delta 压缩 | 磁盘体积(快照模型仍然省空间) | --depth=1 → 体积 5.6× |
| 提交图遍历 | commit-graph | 历史 walk(rev-list、log --graph) |
3.4× |
| 打包发送 | bitmap | 服务端算"要发哪些对象" | 机制 + 文件证据(本机规模下未测出差异) |
| 工作区状态 | stat 缓存 / untracked cache | git status |
启动开销主导,本机未测出差异 |
| 网络传输 | partial clone | 首次克隆体积 | 对象数 90k → 60k(去掉全部 blob) |
一句话:Git 的默认实现已经把"常见操作"优化到几十毫秒;
优化手段真正生效的地方是大历史(几十万提交)与大传输(几 GB 仓库)。
10.2 delta 压缩:快照模型为什么仍然省空间
第 2 章说过 Git 存快照;第 2 章的 verify-pack 也露出了 chain length = 4。
这里把它讲透:packfile 会把相似对象串成 delta 链——只存"与基准对象的差异"。
实测(本机 3 万提交 / 9 万对象的合成仓库):
| 打包方式 | pack 大小 | 相对默认 |
|---|---|---|
默认(--depth 未指定,实际 50) |
8,964,390 字节 | 1.0× |
--depth=50 --window=50 |
7,400,287 字节 | 0.83× |
--depth=1(禁止深链) |
49,806,711 字节 | 5.6× ← 灾难 |
--depth=1 的 5.6 倍就是"如果没有 delta"的代价:
每次提交都完整存一份文件内容。这就是 delta 存在的意义,
也是"Git 存快照却不臃肿"的关键。
delta 链长直方图(git verify-pack -v,同一个仓库):
chain length = 1: 402 objects
chain length = 2: 438 objects
...
chain length = 49: 3400 objects # 长链是"改了很多次的小文件"
chain length = 50: 1186 objects # 深度上限(默认 50)
非 delta: 60430 个 / delta: 29620 个
读法:深链省空间,但读取时要把链上的 delta 依次应用——
所以 Git 用 --depth 限制链长(空间换时间的旋钮)。
日常你不需要调它;只有"仓库特别大又很少读旧对象"(如归档镜像)才考虑加深。
10.3 commit-graph:加速历史遍历
commit-graph 是一个预计算的提交元数据文件(父提交、提交时间、根树位置),
让 Git 不必为每个提交去解压 commit 对象。
实测(3 万提交 / 9 万对象,三次取最快):
| 操作 | 无 commit-graph | 有 commit-graph | 提升 |
|---|---|---|---|
git rev-list --count --all |
110 ms | 32 ms | 3.4× |
git log --graph --oneline -n 1000 |
112 ms | 32 ms | 3.5× |
git log --oneline(全量、要输出 3 万行文本) |
144 ms | 140 ms | ~0 |
git log -1 --format=%H |
27 ms | 28 ms | ~0 |
$ git commit-graph write --reachable
$ ls -lh .git/objects/info/commit-graph
1.7M # 3 万提交 → 1.7MB
怎么解释"有的快 3 倍、有的没变":
rev-list/-graph是纯遍历(只读提交元数据)→ 直接受益;git log --oneline要逐个解压 commit 对象取出标题 → 瓶颈在对象读取;git log -1只读一个提交 → 整个耗时几乎全是进程启动(约 27ms 固定开销)。
这条"固定启动开销"对 sourcepack 特别重要——见 10.8。
10.4 bitmap:加速"要发哪些对象"
服务端在 fetch/clone 时要回答:"客户端缺哪些对象?"
朴素做法是从提交一路走到每个 tree/blob(可能几百万次),
bitmap 预先把"某个提交可达的对象集合"编码成位图(每个对象一位),
于是这一步变成位运算。
$ git repack -adb # -a 全部重打、-d 删旧包、-b 写 bitmap
$ ls -lh .git/objects/pack/*.bitmap
399K # 3 万提交 / 9 万对象 → 399KB
诚实的实测结果:在这台机器、这个规模下,我没能测出 bitmap 带来的差异:
| 操作 | 无 bitmap | 有 bitmap |
|---|---|---|
git rev-list --objects --all(本地遍历) |
282 ms | 279 ms |
| file:// 克隆 | 962 ms | 1026 ms |
原因:bitmap 优化的是 pack-objects 在服务端的可达性计算(大仓库上能省掉几百万次对象访问),
而本机 9 万对象、本地克隆的瓶颈在别处(进程与磁盘),差异落在噪声里。
所以:机制确定存在,量化收益需要几十万对象以上的仓库才显出来——
这本身是个重要认知:别在小仓库上做"性能优化"。
10.5 git status 为什么快:stat 缓存(第 4 章那条线)
第 4 章拆过索引条目里的 ctime/mtime/dev/ino/size。它的用途就是这里:
git status 先比 stat,只有对不上的才去读内容算哈希。
实测(2000 文件仓库):git status --porcelain 首次 31ms、二次 32ms、
开启 core.untrackedCache 后 30ms —— 同样落在噪声里。
原因:2000 个文件的 stat 检查本身就只要几毫秒,其余是进程启动。
什么时候这块会真的痛:
| 场景 | 症状 | 手段 |
|---|---|---|
| 十万级文件 | git status 变成秒级 |
core.untrackedCache=true、core.fsmonitor=true(macOS 有 fsmonitor) |
| 网络文件系统(NFS/SMB) | stat 极慢 | core.checkStat=minimal |
| 巨型 monorepo | 检出本身很慢 | git sparse-checkout(见 10.7) |
10.6 partial clone:只要历史,不要 blob
--filter=blob:none 让 clone 不下载文件内容,只在需要时按需拉取(promisor 机制)。
实测(3 万提交 / 9 万对象;注意服务端必须开 uploadpack.allowFilter):
# 服务端(提供克隆的那个仓库)
$ git config uploadpack.allowFilter true
# 客户端
$ git clone --bare --filter=blob:none file:///tmp/big part.git
$ du -sh part.git ; git -C part.git count-objects -v | grep in-pack
8.9M
in-pack: 60000 # ← 完整克隆是 90000(少了 3 万个 blob)
$ git -C part.git log --oneline -1
66f65d24 c29999 # ← 历史在本地,log 正常
$ git -C part.git config --get remote.origin.promisor
true
两个我踩到的坑(都很常见):
git clone --filter=blob:none /local/path→
warning: --filter is ignored in local clones; use file:// instead.
本地路径克隆会走硬链接优化,过滤被忽略;要用file://才会走真正的传输路径;- 即使
file://,服务端没开uploadpack.allowFilter时也只是
warning: filtering not recognized by server, ignoring—— 静默退化成完整克隆。
用途:CI 里只需要读历史/跑 git log、大仓库的代码搜索
(配合 git grep 时按需取 blob)。
10.7 sparse checkout:只检出需要的部分
--filter 省的是对象,sparse-checkout 省的是工作区文件(大 monorepo 常配一起用)。
$ git clone -q /tmp/big sp && cd sp && ls | wc -l
200
$ git sparse-checkout set --no-cone '/f1*.txt'
$ ls | wc -l
111 # ← 工作区只留下匹配的文件
| 模式 | 说明 |
|---|---|
--cone(默认) |
按目录(git sparse-checkout set dirA dirB),最常用 |
--no-cone |
按 gitignore 风格模式(更灵活,也更容易写错) |
10.8 回到 sourcepack:我们的性能账本
前几章的结论在这里汇总成一张"账":
| 事实 | 数据 | 来源 |
|---|---|---|
| 每次 git 调用的固定开销 | 约 11 ms(进程启动) | 第 8 章实测 |
-g 一次要发起几次 git 调用 |
约 10 次:rev-parse ×4(show-toplevel / show-prefix / HEAD / verify)、symbolic-ref、log -1、diff --name-status ×2(暂存/未暂存)、ls-files --others、diff --patch(大块输出) |
changeset.go:282-393 |
| 于是"纯 spawn 开销" | ≈ 110 ms(10 × 11ms) | 第 1 章 + 第 8 章 |
-g 全流程(500 文件变更) |
≈ 210 ms | 第 1 章 |
| 目录扫描耗时(2 万文件 / 10 万行) | 3.85 s | TODO.md 实测 |
| 同一规模的 100 万行(5000 文件) | 仅 190 ms | 同上 |
三个工程结论:
- 不要为小项目做 git 层优化:
-g里近一半时间就是"启动 8 个 git 进程",
而这是"调 CLI 换取行为一致性"的既定代价(第 1 章的设计取舍); - 真正的瓶颈是"文件数":2 万文件的仓库扫描要 3.85 秒,
而 100 万行但只有 5000 文件的仓库只要 190ms——
所以扫描并发的收益只在"文件数极多"时才出现(已记入TODO.md,暂不做); - 我们用到的 git 优化都是"免费"的:
- 只读命令 + 一次性拿清单(
--name-status -z一次拿全)→ 减少 spawn 次数; --no-ext-diff --no-textconv避免外部程序(第 6 章);- 体积上限(
--max-diff-lines+ 字节上限)防止"单行 20MB"这类病态输入; - 反向的:我们不动 commit-graph / bitmap / repack——
那是仓库所有者的选择,我们只读、不改(第 9 章)。
- 只读命令 + 一次性拿清单(
诊断工具(我们自己在排查时用的):
| 命令 | 看什么 |
|---|---|
GIT_TRACE_PERFORMANCE=1 git <cmd> |
Git 内部各相位耗时(最后一行是总计) |
GIT_TRACE2_PERF=1 git <cmd> |
更细的 trace2 事件(含是否读 commit-graph) |
git count-objects -v |
松散/打包对象数、体积 |
git verify-pack -v <idx> |
delta 链分布 |
/usr/bin/time -p + 多次取最快 |
命令级基准(避开单次抖动) |
诚实提醒:本章有三项没能测出差异(bitmap、stat 缓存、
log --oneline)。
原因已分析(规模不够 / 启动开销主导),而不是"这些技术无效"。
把"没测出差异"如实写出来,比编一个好看的数字有用——
10.9 动手实验 [lab]
# 0) 用 fast-import 快速造大仓库(这也是一个值得知道的工具)
mkdir -p /tmp/l10 && cd /tmp/l10 && git init -q -b main .
python3 - <<'PY' > stream.fi
files=200; commits=5000
print('blob\nmark :1\ndata <<EOM\nf0\nl0\nEOM')
print('commit refs/heads/main\nmark :1000000\nauthor P <p@x.c> 1700000000 +0800\ncommitter P <p@x.c> 1700000000 +0800\ndata <<EOM\ninit\nEOM')
for i in range(files): print(f'M 100644 :1 f{i}.txt')
prev=':1000000'
for c in range(1,commits):
idx=(c*7)%files; m=2000000+c*2
print(f'blob\nmark :{m}\ndata <<EOM\nf{idx} rev {c}\nl0\nEOM')
print(f'commit refs/heads/main\nmark :{m+1}\nauthor P <p@x.c> {1700000000+c} +0800\ncommitter P <p@x.c> {1700000000+c} +0800\ndata <<EOM\nc{c}\nEOM\nfrom {prev}')
print(f'M 100644 :{m} f{idx}.txt'); prev=f':{m+1}'
print('done')
PY
git fast-import --quiet < stream.fi && git reset -q --hard main
git rev-list --count HEAD
# 1) delta 深度对体积的影响
git repack -adf -q --depth=1 ; ls -l .git/objects/pack/*.pack | awk '{print "depth=1: "$5}'
git repack -adf -q --depth=50 ; ls -l .git/objects/pack/*.pack | awk '{print "depth=50: "$5}'
# 2) commit-graph 的效果
rm -f .git/objects/info/commit-graph
time git rev-list --count --all
git commit-graph write --reachable
time git rev-list --count --all # 应明显更快
# 3) delta 链分布
git verify-pack -v .git/objects/pack/*.idx | grep '^chain length' | head
# 4) partial clone(服务端要开 allowFilter)
git config uploadpack.allowFilter true
git clone -q --bare --filter=blob:none file://$PWD ../l10-part.git
git -C ../l10-part.git count-objects -v | grep in-pack # 比完整克隆少掉全部 blob
# 5) 诊断
GIT_TRACE_PERFORMANCE=1 git rev-list --count --all 2>&1 | tail -2
自测题:
--depth=1为什么会让仓库体积暴涨 5 倍以上?- 为什么 commit-graph 让
rev-list快 3 倍,却几乎不影响git log -1? - bitmap 加速的是哪一侧的哪一步?为什么小仓库测不出差异?
--filter=blob:none在本地路径克隆时为什么被忽略?怎么让它生效?sparse-checkout与partial clone分别省的是什么?- sourcepack 的
-g里,最大的固定开销是什么?为什么这是"刻意接受"的代价?
10.10 小结
- delta 压缩是"快照模型不臃肿"的原因:禁止深链(
--depth=1)会让体积涨 5.6×;
链长直方图可用verify-pack -v观察(本机 3 万提交仓库链长 1–50); - commit-graph 预计算提交元数据:纯遍历类操作(
rev-list、log --graph)
实测 3.4× 提升,而"读对象取内容"和"单次短命令"不受益(后者被进程启动主导); - bitmap 让服务端算可达集合变成位运算;本机规模下未测出差异——
它是为大仓库/大传输准备的; - stat 缓存 / untracked cache 是
git status快的原因(第 4 章那条线),
本机 2000 文件规模同样测不出差异,十万级文件才显出来; - partial clone(
--filter=blob:none)能把对象数从 90k 降到 60k(去掉全部 blob),
但有两个静默陷阱:本地路径克隆会忽略过滤(要用file://)、
服务端要开uploadpack.allowFilter;sparse-checkout 省的是工作区文件; - 诊断三件套:
GIT_TRACE_PERFORMANCE/git count-objects -v/ 多次取最快的计时; - sourcepack 的性能账本:
-g的约 90ms 花在 8 次 git 进程启动上(刻意的设计取舍),
真正瓶颈是文件数(2 万文件 3.85s vs 100 万行 5000 文件 190ms),
而我们不碰 repo 级的优化(commit-graph/bitmap/repack)——只读、不改。
下一章:钩子、签名与发布——pre-commit/pre-push 能做什么与不能做什么、
为什么钩子不能替代 CI、GPG/SSH 签名的意义,以及 sourcepack 自己的发布链路
(release.yml:tag → gate → goreleaser → npm publish → 冒烟)。