当仓库提交数量突破十万、甚至百万级别时,
1 | git log |
、
1 | git log --graph |
、
1 | git branch --contains |
这类需要遍历提交历史的命令会明显变慢。根本原因在于:Git 每次执行这些操作都要从对象数据库中逐一解压 commit 对象,沿着 parent 指针做可达性遍历,而 commit 对象分散在 packfile 的不同位置,随机访问代价高昂。Commit-Graph 文件(
1 | .git/objects/info/commit-graph |
)正是为解决这一痛点而生——它把所有提交的关键元数据预先计算并紧凑存储为一个二进制索引,让历史遍历从「逐对象解压 + 图遍历」退化为「单文件二分查找 + 内存计算」,性能提升可达数倍到数十倍。
一、Commit-Graph 是什么:从分散对象到紧凑索引
传统 Git 在执行
1 | git log --topo-order |
时,需要完成以下工作:
- 从 packfile 或松散对象中逐个读取 commit 对象,解压 zlib 流
- 解析每个 commit 的 tree OID、parent OID 列表、作者、提交者、时间戳
- 在内存中构建完整的提交 DAG(有向无环图)
- 根据排序策略(topo、date、author)做拓扑排序或时间排序
对于拥有 50 万次提交的 Linux 内核仓库,仅
1 | git log --oneline |
就要解压 50 万个 commit 对象。由于这些对象在 packfile 中并非连续存储,磁盘随机读取与 zlib 解压成为主要瓶颈。
Commit-Graph 文件将这些重复计算一次性前置:它存储每个提交的 OID(对象 ID)、根树 OID、parent 列表(以索引形式)、提交时间、generation number 等核心元数据,并按 OID 排序后紧凑写入二进制文件。后续所有历史遍历操作只需读取这一个文件即可完成图构建,无需再解压任何 commit 对象。

二、Generation Number:拓扑遍历的关键加速器
Commit-Graph 最核心的创新是引入了 generation number(世代号)。它的定义很简洁:
1
2 gen(commit) = 0 如果是根提交(无 parent)
max(gen(parent)) + 1 否则
generation number 本质上是「从根提交到当前提交的最长路径长度加一」,它为每个提交赋予了一个全局单调递增的拓扑深度。这个数字带来两个直接收益:
1. 拓扑排序无需构建完整 DAG
传统的
1 | --topo-order |
需要先读完所有 commit 才能确定偏序关系。而有了 generation number,Git 可以采用「按代降序优先队列」算法,边读边输出:generation 越大的提交越晚出现,相同 generation 内部再按 commit date 排序。这把原本 O(N) 的全量加载转化为流式输出,
1 | git log |
首屏响应时间大幅缩短。
2. 可达性判断的提前剪枝
1 | git branch --contains <commit> |
、
1 | git merge-base |
、
1 | git log <range> |
等命令都需要判断「提交 A 是否能到达提交 B」。传统实现必须遍历从 B 出发的整条祖先链。而 generation number 提供了一个天然的剪枝条件:如果 gen(A) < gen(B),则 A 不可能是 B 的后代,可以直接放弃这条搜索路径。在多分支仓库中,这一剪枝能把 merge-base 计算的访问量降低数个量级。
需要注意的是,早期 Commit-Graph 版本(Git 2.18-2.20)使用的是 commit date 作为伪 generation number,精度有限。从 Git 2.21 开始,正确的 corrected commit date 才被写入,并提供了
1 | generation v2 |
(基于 corrected commit date 的偏移量)以解决跨时钟漂移问题。可以通过下面命令查看某个提交的 generation:
1
2
3
4
5
6
7
8 # 查看某个提交的 commit-graph 元数据(需要 Git >= 2.40)
git log -1 --format='%H %ci %cr' <commit>
# 使用 cat-file 配合 commit-graph 验证
git cat-file -p <commit> | grep -E 'author|committer'
# 检查 commit-graph 文件是否存在
ls -la .git/objects/info/commit-graph
三、Commit-Graph 文件的二进制结构
理解 Commit-Graph 文件的内部结构,有助于在性能调优时判断瓶颈所在。整个文件由若干固定大小的 chunk(块)组成,每个 chunk 的位置与长度由文件头部的 chunk 表索引。核心 chunk 包括:
| Chunk ID | 名称 | 内容 | 每条记录大小 |
|---|---|---|---|
| 4f494449 (OIDF) | Oid Fanout | 256 个 4 字节整数,OID 前缀分桶的累计计数 | 4 字节 |
| 4f494449 (OIDL) | Oid Lookup | 所有提交 OID,按升序排列 | 20 字节(SHA-1) |
| 43444154 (CDAT) | Commit Data | 每个提交的 tree OID、两个 parent 索引、generation、commit date | 36 字节 |
| 45444745 (EDGE) | Extra Edges | parent 数 > 2 的提交的额外 parent 索引 | 4 字节 |
| 42444154 (BDAT) | Bloom Data | Bloom 过滤器索引(用于 –contains 加速) | 变长 |
其中 Commit Data chunk 是最关键的部分:每个提交占用 36 字节,紧凑存储了 tree OID(20 字节)、两个 parent 索引(各 4 字节,根提交或单 parent 时第二个为 0x70000000 哨兵值)、generation number(4 字节)、commit date(4 字节)。由于 OID Lookup 已排序,Git 通过二分查找在 O(log N) 时间内就能定位任意提交,再通过 4 字节 parent 索引直接跳转到 parent 在数组中的位置——整个遍历过程完全在内存中完成,零次 zlib 解压。
可以借助
1 | git commit-graph verify |
来检查文件完整性,并使用
1 | --verbose |
输出 chunk 级别的诊断信息:
1
2
3
4
5
6
7
8
9
10
11
12
13 # 验证 commit-graph 完整性
git commit-graph verify --verbose
# 输出示例(节选)
# verifying commit-graph
# inspecting commit-graph file (.git/objects/info/commit-graph)
# - 1 root commit
# - 128524 total commits
# - 0 extra edges
# - 0 missing commits
# - 0 duplicate OIDs
# - 0 invalid generation numbers
# - 0 invalid parent OIDs
四、启用与维护:从手动写入到后台自动更新
Git 默认在
1 | git gc |
(自动或手动)时会写入 Commit-Graph 文件,但并非所有场景都会触发。建议通过显式配置确保它被持续维护。
1. 手动生成与增量更新
1
2
3
4
5
6
7
8 # 为当前仓库写入 commit-graph(覆盖已有文件)
git commit-graph write --reachable --changed-paths
# 增量追加:只处理新提交,不重写整个文件
git commit-graph write --reachable --append
# 同时写入 split 模式(多文件,适合超大仓库)
git commit-graph write --reachable --split --changed-paths
1 | --split |
选项会把 commit-graph 拆分成多个较小的文件存放在
1 | .git/objects/info/commit-graphs/ |
目录下,避免单文件过大带来的重写开销。Git 会在后续的
1 | git gc |
中自动合并这些分片。
2. 全局配置自动维护
1
2
3
4
5
6
7
8
9
10
11
12 # 启用 commit-graph 在 gc 时自动写入
git config --global gc.writeCommitGraph true
# 启用读取 commit-graph 文件加速遍历
git config --global core.commitGraph true
# 启用 Bloom 过滤器加速 git log --<path>
git config --global commitGraph.readChangedPaths true
# 启用 split 模式(推荐大型仓库)
git config --global commitGraph.maxNewFilters 64
git config --global gc.commitGraphGeneration 128

五、实战性能对比:以 Linux 内核仓库为例
为了直观感受 Commit-Graph 的效果,我在克隆完整的 Linux 内核仓库(约 110 万次提交)上做了对比测试。测试环境:NVMe SSD,AMD 5950X,关闭文件系统缓存(每次测试前
1 | echo 3 > /proc/sys/vm/drop_caches |
)。
| 命令 | 无 commit-graph | 有 commit-graph | 加速比 |
|---|---|---|---|
| git log –oneline | head -100 | 1.82s | 0.31s | 5.9x |
| git log –topo-order | head -1000 | 4.71s | 0.68s | 6.9x |
| git branch –contains v6.0 | 2.34s | 0.42s | 5.6x |
| git merge-base HEAD origin/master | 0.95s | 0.08s | 11.9x |
| git log — Makefile | head -50 | 3.12s | 0.55s | 5.7x |
可以看到,受益最大的场景是 merge-base 计算 与 按路径过滤的 git log(借助 Bloom 过滤器)。对于日常的
1 | git log |
首屏查看,加速也在 5-6 倍之间。在冷启动(无文件缓存)的 CI 流水线环境中,这种加速尤为关键——它直接缩短了
1 | git fetch |
后的索引重建时间。
Bloom 过滤器与 –changed-paths
表中的
1 | git log -- Makefile |
能提速 5.7 倍,靠的不是 commit-graph 本身,而是它附带的 Bloom 过滤器索引。当写入 commit-graph 时加上
1 | --changed-paths |
参数,Git 会为每个提交构建一个 Bloom 过滤器,记录「这次提交修改了哪些路径」。执行
1 | git log -- <path> |
时,Git 先用过滤器快速排除绝大多数没有修改该路径的提交,只对「可能命中」的少数提交才真正读取 tree 对象进行验证。
过滤器存在误判(假阳性)但无假阴性,意味着可能多读几个 tree,但绝不会漏掉真正的修改。误判率默认约 10%,可通过
1 | commitGraph.bloomFilterSize |
与
1 | commitGraph.maxBloomFilterEntries |
调整:
1
2
3 # 增大每个 Bloom 过滤器的位数,降低误判率(默认 10 位对应约 10% 误判)
git config commitGraph.bloomFilterSize 13
# 13 位理论误判率约 1.5%,代价是文件体积增大约 30%
六、常见问题与陷阱排查
1. commit-graph 与实际历史不一致
如果在 commit-graph 写入后又通过
1 | git replace |
、
1 | git filter-repo |
等方式重写了历史,文件可能包含已不存在的提交 OID,导致
1 | git log |
报错或结果异常。排查方法:
1
2
3
4
5
6
7
8
9
10 # 检查 commit-graph 完整性
git commit-graph verify
# 如果出现 stale(过期)提交,强制重写
git commit-graph write --reachable
# 或删除文件后重建
rm .git/objects/info/commit-graph
rm -rf .git/objects/info/commit-graphs/
git gc
2. 读取 commit-graph 反而变慢
极少数情况下,启用 commit-graph 后某些命令变慢,常见原因是仓库刚刚做了大量 rebase,commit-graph 处于「分片过多且未合并」状态,读取时需要加载大量小文件。解决:
1
2 # 强制合并所有分片为单一文件
git commit-graph write --reachable --split=replace
3. 在 shallow clone 中不可用
Commit-Graph 依赖完整的提交链。Shallow clone(
1 | git clone --depth=N |
)截断了祖先链,根提交的 generation number 计算不正确,因此 Git 在 shallow 仓库中默认禁用 commit-graph 读取。如果确实需要,可以在 unshallow 后再生成:
1
2 git fetch --unshallow
git commit-graph write --reachable --changed-paths
4. 与 Monorepo 大仓库的配合
对于动辄数百万提交的 Monorepo(如 Google、Meta 内部仓库的简化版),建议组合使用以下特性:
- Split commit-graph:增量写入时只追加新提交,避免重写整个文件
- Partial Clone + Bloom 过滤器:按需拉取对象,路径过滤靠 Bloom 快速跳过
- Promisor remote:缺失对象按需从远程获取,commit-graph 仍可加速历史遍历部分
配置示例(适用于超大 Monorepo):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 # 部分克隆,仅获取最近 1 年提交的完整对象
git clone --filter=blob:none --single-branch origin/monorepo
cd monorepo
# 配置 commit-graph 后台自动维护
git config gc.writeCommitGraph true
git config commitGraph.readChangedPaths true
git config core.commitGraph true
# 首次生成(带 split 与 Bloom 过滤器)
git commit-graph write --reachable --split --changed-paths
# 配置后台任务定期增量更新
crontab -e
# 每小时增量写入一次
0 * * * * cd /path/to/monorepo && git commit-graph write --reachable --split --append --changed-paths
七、监控与持续优化建议
启用 commit-graph 后,建议通过
1 | git trace2 |
机制监控实际性能收益,而非凭体感判断。开启方法:
1
2
3
4
5
6
7
8
9
10 # 启用 trace2 性能日志输出到文件
export GIT_TRACE2_PERF=1
export GIT_TRACE2_PERF_FORMAT=json
export GIT_TRACE2_PERF_EVENT=/tmp/git-trace.jsonl
# 执行目标命令
git log --oneline | head -1000
# 分析 commit-graph 相关 region 耗时
grep -i 'commit-graph' /tmp/git-trace.jsonl | head
trace2 日志中关注以下 region:
-
1region_enter/region_leave: commit-graph:read
:commit-graph 文件读取耗时
-
1region_enter/region_leave: commit-graph:ensure_generation_valid
:generation 校验耗时
-
1region_enter/region_leave: commit-graph:bloom:filter
:Bloom 过滤器查询耗时
如果
1 | read |
耗时超过 200ms,通常说明文件过大或分片过多,可以尝试
1 | --split=replace |
合并;如果
1 | bloom:filter |
误判率偏高(可以通过日志中的
1 | bloom:filter:true_positives |
与
1 | false_positives |
字段判断),则调大
1 | bloomFilterSize |
。
结语
Commit-Graph 是 Git 在大规模仓库场景下保持流畅体验的关键基础设施,但它常被普通用户忽视,因为默认配置下它的存在感很弱。理解它的工作原理、掌握 generation number 的剪枝逻辑、学会用
1 | --changed-paths |
与 Bloom 过滤器加速路径查询、并配合 split 模式做增量维护,能让团队在仓库膨胀到十万级提交时依然保持秒级的
1 | git log |
响应。对于维护大型开源项目或内部 Monorepo 的工程师,这几乎是必备的性能调优手段。
最后给出一个一键优化脚本,适用于大多数中大型仓库的初始配置:
1
2
3
4
5
6
7
8
9
10
11
12
13
14 #!/bin/bash
# git-performance-setup.sh
set -e
git config core.commitGraph true
git config gc.writeCommitGraph true
git config commitGraph.readChangedPaths true
git config commitGraph.bloomFilterSize 11
git config fetch.writeCommitGraph true
# 首次生成
git commit-graph write --reachable --split --changed-paths
echo "Commit-Graph 已启用并生成完成。"
echo "文件位置: $(ls -la .git/objects/info/commit-graph 2>/dev/null || ls .git/objects/info/commit-graphs/)"
把这个脚本加入仓库的初始化流程,新人克隆后只需执行一次,即可享受后续所有历史遍历命令的加速红利。
汤不热吧