松散对象与 packfile
第 5 章算过:改一个三层深的文件,一次提交只新增 5 个对象。听起来很省。但一千次提交之后,对象的数量会大到超出你的直觉——而且真正的问题不是它们的大小,是它们的个数。
先数一数
假设一个不大的项目:40 个文件、平均 3.8 KB、目录 5 层深。每次提交改一个文件(第 5 章那个模型)。
跑一千次提交,对象库里会有多少东西?
| 类型 | 数量 | 怎么来的 |
|---|---|---|
| BLOB | 约 1040 | 初始 40 个 + 每次提交 1 个新版本 |
| TREE | 约 5000 | 每次提交要重建那个文件上面的每一层目录 |
| COMMIT | 1000 | 一次提交一个 |
| 合计 约 7000 个对象 | ||
这是最容易被低估的一点。改一个文件,会重建它上面的每一层目录——这是 Merkle 树的必然代价(第 3 章那个「一路往上传染」)。
目录嵌套越深,每次提交产生的 tree 越多。一个 Java 项目动不动 src/main/java/com/company/module/ 七八层深,一次提交就是八棵新树。
而每棵 tree 压缩后可能只有一两百字节。小得可怜,但数量惊人。
真正的问题:每个对象一个文件
第 2 章看过对象在磁盘上的样子:
$ ls .git/objects/3b/ 18e512dba79e4c8300dd08aeb37f8e728b8dad
一个对象 = 一个文件。这叫松散对象(loose object)。
而文件系统有个规矩:再小的文件也要占一个块,通常是 4 KB。
于是:
一个 tree 对象,zlib 压缩后 180 字节
↓
在磁盘上占 4096 字节
↓
浪费 3916 字节 —— 95.6% 是空的
5000 棵树,光零头就浪费掉将近 19 MB。而它们的真实内容加起来不到 1 MB。
拖动提交数,看三条柱子的变化。
注意前两条的差距——那就是「每个文件占一个块」造成的浪费。
packfile:把它们塞进一个文件
Git 的解法直截了当:把很多对象打包进一个文件。
$ ls .git/objects/pack/
pack-a3f21c9e77b0d4c8135e6a9f02b4d7e8c1a5b3d6.idx pack-a3f21c9e77b0d4c8135e6a9f02b4d7e8c1a5b3d6.pack
.pack—— 所有对象的内容,一个接一个。.idx—— 索引:「哈希 X 在 pack 文件的第几个字节」。
这一步就解决了三个问题:
- 没有每文件的块浪费——7000 个对象现在是 2 个文件。
- 没有 inode 压力——文件系统不用管理 7000 个 inode。
- 能做 delta 压缩——相邻的相似对象可以只存差异(第 22 章)。
而 .idx 的存在保证了随机访问依然是 O(1):它是排好序的哈希表,二分查找就能定位到偏移量,然后直接 seek 过去。打包没有牺牲「按哈希取对象」这个核心操作的速度。
看看你自己仓库的构成:
$ git count-objects -vH
count: 127 ← 松散对象个数 size: 508.00 KiB ← 它们占的磁盘(注意是按块算的) in-pack: 14328 ← 已打包的对象个数 packs: 1 size-pack: 8.43 MiB ← packfile 的大小 prune-packable: 0 garbage: 0 size-garbage: 0 bytes
注意那个对比:14328 个打包对象只占 8.43 MiB(平均 616 字节/个),而 127 个松散对象就占了 508 KiB(平均 4 KB/个——正好是一个块)。
手动打包一下试试:
$ git gc $ git count-objects -vH # 松散对象应该归零了
看看 packfile 里到底有什么:
$ git verify-pack -v .git/objects/pack/pack-*.idx | head -20
1bada9988e4c142f884f13990c310e024c36f1ea commit 160 118 12
fe7944f85150c68ee7948bfe6f5449cb57ed591f tree 63 74 130
3b18e512dba79e4c8300dd08aeb37f8e728b8dad blob 12 21 204
533ba6aae425285855227a4a95c48e95553df491 blob 15 24 225 1 3b18e51...
↑ ↑ ↑ ↑ ↑
类型 原始 压缩后 偏移 delta 基准
最后一行那个多出来的字段就是 delta——它说「这个 blob 是基于 3b18e51 存的」。下一章专讲这个。
想知道仓库里最大的对象是什么:
$ git verify-pack -v .git/objects/pack/pack-*.idx \
| sort -k3 -n -r | head -10
什么时候会打包
| 时机 | 说明 |
|---|---|
| 自动 gc | 松散对象超过 gc.auto(默认 6700)时,某些命令后会自动触发 |
git gc | 手动。打包 + 清理不可达对象 + 过期 reflog |
git clone / fetch | 传输的本来就是 packfile——服务器现场打一个包发给你 |
git repack | 只重新打包,不做别的清理 |
第三条解释了一个常见困惑:
du -sh .git因为网络传输的是打包后的形式,而你本地那堆可能是松散的。
而且服务器打包时会做更激进的 delta 计算(它有时间慢慢算,而且结果能给所有人复用)。所以一个本地 .git 有 1.2 GB 的仓库,clone 下来可能只传 200 MB。
反过来,刚 clone 完的仓库是最紧凑的。用了几个月之后 .git 悄悄涨到几倍大,跑一下 git gc 通常能压回去一大半。
三个和体积有关的实用命令
# 1. 仓库到底大在哪 $ git count-objects -vH # 2. 历史上最大的 20 个对象(找出那个误提交的大文件) $ git rev-list --objects --all \ | git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' \ | awk '$1=="blob"' | sort -k3 -n -r | head -20 # 3. 打包 + 清理 $ git gc
第 2 条很值得记下来。它会输出对象类型、哈希、大小、路径——通常一眼就能看出问题在哪。
gc 不会让「删掉的大文件」消失这是第 1 章和第 5 章那个结论的再次登场,但很多人到这一步还是会搞错:
git gc 只回收不可达的对象(第 5 章)。而你「删掉」的那个 200 MB 文件,还被之前每一个提交的树指着——它完全可达。
所以 gc 一点忙都帮不上。
真要清掉只能改写历史:
$ git filter-repo --path 那个大文件 --invert-paths # 然后所有人重新 clone
代价是所有提交号都变(第 4 章那个连锁反应)。这是个需要先跟团队商量的操作,不是技术决定。
--aggressive 也帮不上:它只是花更多时间重算 delta,对可达的对象一个都不会删。而且它相当慢,大仓库上可能跑几十分钟,日常完全没必要。
当仓库真的大到 clone 要半小时,Git 这些年加了几个逃生舱:
| 手段 | 省掉什么 | 命令 |
|---|---|---|
| 浅克隆 | 历史(只要最近 N 个提交) | git clone --depth 1 |
| 单分支 | 其他分支 | git clone --single-branch |
| 部分克隆 | blob 内容(用到时再拉) | git clone --filter=blob:none |
| 稀疏检出 | 工作区的部分目录 | git sparse-checkout set 目录 |
| Git LFS | 大文件(换成指针 blob) | git lfs track "*.psd" |
--filter=blob:none(部分克隆)是这几年最有用的一个:它下载完整的提交和树(所以 git log、git branch 全都正常),但不下载文件内容,等你 checkout 或 diff 时按需拉取。
CI 里尤其合适——你只需要一个版本的代码,不需要十年的文件历史:
$ git clone --filter=blob:none --single-branch --depth 1 <url>
注意 --depth 1 的副作用:没有历史就没法 bisect、没法 blame、没法算 merge-base。CI 里没关系,本地开发别用。
「我的 .git 从 200 MB 涨到 1.5 GB 了,什么都没干啊。」
按这个顺序排查:
# 1. 先看是松散对象多,还是 pack 本身大 $ git count-objects -vH
count很大(几万) → 只是很久没打包。git gc,通常立刻小一半以上。size-pack很大 → 历史里真有大东西,往下走。
# 2. 找出最大的对象 $ git rev-list --objects --all \ | git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' \ | awk '$1=="blob"' | sort -k3 -n -r | head -20
看到结果之后,三种情况:
- 某次误提交的构建产物/数据集 → 只能
filter-repo,代价是全员重新 clone。 - 项目本身就有很多二进制资源 → 这是 Git 的天然弱项:二进制做不了有效 delta(下一章会解释为什么),每个版本都近乎全量。考虑 Git LFS。
- 都不是,就是历史长 → 那其实是正常的。十年的项目
.git有几个 GB 不奇怪,用部分克隆应付就行。
这一章的一句话
松散对象是「一个对象一个文件」,而再小的文件也占一个 4 KB 的块——tree 对象又特别多特别小(改一个深层文件要重建每一层目录),于是浪费惊人。packfile 把成千上万个对象塞进一个文件,配一份 .idx 索引保证按哈希取仍是 O(1)。但 gc 只回收不可达的东西,删掉的大文件依然可达,所以它一点忙都帮不上。
下一章:packfile 里还藏着最后一件事——Git 终于用上了差量。但它用在哪一层,以及这个顺序为什么至关重要,是这本书最后一个、也是最需要说清楚的技术问题。