卷 V · 省CH 21深度 21/24

松散对象与 packfile

第 5 章算过:改一个三层深的文件,一次提交只新增 5 个对象。听起来很省。但一千次提交之后,对象的数量会大到超出你的直觉——而且真正的问题不是它们的大小,是它们的个数

松散对象packfile4 KB 的块git gc

先数一数

假设一个不大的项目:40 个文件、平均 3.8 KB、目录 5 层深。每次提交改一个文件(第 5 章那个模型)。

跑一千次提交,对象库里会有多少东西?

类型数量怎么来的
BLOB约 1040初始 40 个 + 每次提交 1 个新版本
TREE约 5000每次提交要重建那个文件上面的每一层目录
COMMIT1000一次提交一个
合计 约 7000 个对象
◆ 注意 tree 比 blob 多得多

这是最容易被低估的一点。改一个文件,会重建它上面的每一层目录——这是 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 文件的第几个字节」。

这一步就解决了三个问题:

  1. 没有每文件的块浪费——7000 个对象现在是 2 个文件。
  2. 没有 inode 压力——文件系统不用管理 7000 个 inode。
  3. 能做 delta 压缩——相邻的相似对象可以只存差异(第 22 章)。

.idx 的存在保证了随机访问依然是 O(1):它是排好序的哈希表,二分查找就能定位到偏移量,然后直接 seek 过去。打包没有牺牲「按哈希取对象」这个核心操作的速度。

⌗ 掀开 .git 看一眼

看看你自己仓库的构成:

$ 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只重新打包,不做别的清理

第三条解释了一个常见困惑:

◆ 为什么 clone 下载量远小于 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 loggit 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 终于用上了差量。但它用在哪一层,以及这个顺序为什么至关重要,是这本书最后一个、也是最需要说清楚的技术问题。