delta:Git 最后还是用了差量
全书从「Git 不存 diff」开始。现在要说一件看起来矛盾的事:packfile 里,Git 确实存了差量。这不是打脸——它揭示的是一个更重要的东西:分层。而层与层的顺序,决定了整个系统的性质。
先看它长什么样
上一章那条 verify-pack 的输出,最后一行有个特别的东西:
3b18e512dba79e4c8300dd08aeb37f8e728b8dad blob 12 21 204
533ba6aae425285855227a4a95c48e95553df491 blob 15 24 225 1 3b18e512...
↑ ↑
delta 深度 基准对象
第二个 blob 是基于第一个存的。它在 packfile 里不是完整内容,而是一串「怎么从基准变成我」的指令。
指令只有两种:
COPY(偏移, 长度)—— 从基准对象的某个位置抄一段过来INSERT(数据)—— 插入一段全新的字节
这台编码器是真的:在基准里找最长匹配,产出 copy/insert 指令流,按 git 的编码规矩量体积。
三个案例都点一遍,特别是第三个「几乎全改了」——看看 delta 什么时候就不划算了。
那么「Git 不存 diff」到底还成不成立
成立。因为这是两层不同的事,而且必须分清楚。
数据模型层(第 1–5 章):提交指向一棵完整的树,树指向完整的 blob。概念上永远是快照,没有例外。
存储层(这一章):打包时,Git 发现 blob X 和 blob Y 很像,就把 Y 存成「基于 X 的一串指令」。
关键在于:这一层对上面完全透明。
你 git cat-file -p 一个被 delta 化的对象,Git 当场把它还原出来,你看到的永远是完整内容。你甚至无法从 API 层面察觉它是不是 delta 存的。
而顺序至关重要。对比一下反过来的系统(先有差量,再想办法加速):
| Git(快照 → 顺手压) | 差量优先的系统 | |
|---|---|---|
| 取任意版本 | O(1):直接按哈希取 | O(叠加层数):要从某个基点重放 |
| delta 基准是谁 | 任何长得像的对象,甚至可以是「更晚」的版本 | 必须是它的前一个版本 |
| delta 链断了 | 重新打包即可,信息不会丢 | 后面所有版本都取不出来 |
| 去重 | 天然的(内容寻址) | 要额外机制 |
第二行特别值得玩味:Git 的 delta 基准不必是「上一个版本」。它挑的是「最像的那个」,可能来自完全不相干的提交、甚至是时间上更晚的版本(Git 实际上偏好用更新的版本当基准,因为新版本更常被取用,让它保持完整更划算)。
这种自由度,只有在「模型上不依赖 delta」的前提下才可能存在。
Git 怎么挑基准
打包时它要给成千上万个对象两两配对,穷举显然不行。实际做法:
- 排序:按「类型 + 路径名 + 大小」把对象排在一起——同一个文件的不同版本自然就挨着了。
- 开一个滑动窗口(默认 10),只在窗口内尝试配对。
- 对每一对,算一下 delta 的大小,划算才用。
- 限制 delta 深度(默认 50)——链太长会让还原变慢。
那个「按路径名排序」的启发式很朴素但极其有效:同名文件的历代版本大概率彼此相似。
$ git config pack.window 10 # 窗口大小,越大越省但越慢 $ git config pack.depth 50 # delta 链最大深度
你在 demo 里点第三个案例时应该看到了:两份完全不相干的内容,delta 反而可能更大。
Git 遇到这种情况会直接存全量。
没有教条,只有算账。这是个很务实的设计——它意味着 delta 是一个纯粹的优化,最坏情况下退化成「不优化」,永远不会变成负担。
为什么二进制文件是 Git 的天敌
现在可以精确解释这件事了,而且它有两个独立的原因:
原因一:delta 做不动
delta 靠的是找长的相同片段。而大多数二进制格式:
- 本身已经压缩过(PNG、JPG、ZIP、PDF、docx)。压缩的本质就是消除冗余——压完的数据看起来接近随机,找不到长匹配。
- 改一点就全变。压缩算法的输出对输入极其敏感:改一个像素,后面的字节流可能整体错位。
结果:一个 5 MB 的 PSD 文件改了一点点,delta 之后还是接近 5 MB。提交 100 次就是 500 MB。
原因二:合并做不了
第 17 章那套三路合并是按行的。二进制文件没有「行」这个概念,所以:
- 两个人同时改一个二进制文件 → 必然冲突,而且没法解决,只能二选一。
git diff只会说一句Binary files differ。
这两个问题叠加,就是 Git LFS 存在的全部理由:
$ git lfs track "*.psd"
它把大文件替换成一个几十字节的指针 blob:
version https://git-lfs.github.com/spec/v1 oid sha256:4d7e8c1a5b3d6f2e9c0a7b4d1e8f3c6a9b2d5e8f1c4a7b0d3e6f9c2a5b8d1e4f size 5242880
真正的内容存到另一个服务上,按需下载。Git 只管那个几十字节的指针——它是文本,delta 得动,也能正常参与所有操作。
看看你的仓库里 delta 用得怎么样:
$ git verify-pack -v .git/objects/pack/pack-*.idx | head -30
输出里最后两列有值的就是 delta 对象(深度 + 基准哈希)。
统计一下 delta 的效果:
# 有多少对象是 delta 存的
$ git verify-pack -v .git/objects/pack/pack-*.idx \
| grep -c ' delta '
# 看 delta 链的深度分布
$ git verify-pack -v .git/objects/pack/pack-*.idx \
| awk 'NF>=6 {print $6}' | sort -n | uniq -c | tail
在一个正常的代码仓库里,通常 70%–90% 的对象是 delta 存的,整体压缩率能到十几倍甚至更高。
做个对照实验,直观感受二进制的差别:
# 文本:改一行
$ for i in 1 2 3 4 5; do
echo "line $i" >> text.txt; git add . ; git commit -qm "t$i"
done
# 二进制:改一点
$ for i in 1 2 3 4 5; do
head -c 1000000 /dev/urandom > bin.dat; git add . ; git commit -qm "b$i"
done
$ git gc -q && git count-objects -vH
五个版本的随机二进制文件(各 1 MB)会让 packfile 涨将近 5 MB——delta 完全没起作用,因为随机数据之间找不到任何相同片段。而五次文本追加加起来可能就几百字节。
Git 实际上用了两层压缩,容易混淆,这里理清:
- zlib —— 每个对象单独压缩。松散对象和 packfile 里都有(第 2 章那个解压实验)。这是对象内部的冗余消除。
- delta —— 只在 packfile 里,对象之间的冗余消除。
顺序是:先算 delta,再对 delta 结果做 zlib。
而哈希算的是原始未压缩内容(第 2 章)——所以对象的名字跟压缩方式、压缩级别、是不是 delta 存的全都无关。
这个解耦极其重要:Git 可以随时改变存储方式,而不影响任何一个对象的名字。换 zlib 版本、调压缩级别、重新打包、改 delta 策略——对象哈希一个都不会变,所有仓库依然彼此兼容。
这正是「分层」这个设计给出的最大红利:存储层可以自由演进,模型层稳如磐石。
「我们的仓库里有设计稿和视频,clone 一次要 40 分钟。」
这是 Git 的经典痛点,按投入产出排序:
一、先量一量到底是什么占地方(第 21 章那条命令):
$ git rev-list --objects --all \ | git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' \ | awk '$1=="blob"' | sort -k3 -n -r | head -20
二、新文件用 LFS(不改历史,立刻见效):
$ git lfs install $ git lfs track "*.psd" "*.mp4" "*.sketch" $ git add .gitattributes && git commit -m "大文件走 LFS"
三、日常 clone 用部分克隆(不用改仓库,各人自便):
$ git clone --filter=blob:none <url>
四、万不得已才改写历史(filter-repo + LFS 迁移)——所有提交号变化,全员重新 clone。
顺带一个能立刻减轻痛苦的:给二进制文件关掉 delta 尝试,打包会快很多(反正也压不动):
# .gitattributes *.psd -delta *.mp4 -delta *.zip -delta
这不省空间,但能大幅缩短 git gc 和 push 的时间——Git 不用再对着一堆随机字节徒劳地找匹配。
这一章的一句话
packfile 里 Git 确实用了差量——copy/insert 指令流,划算才用。但它在存储层,对上面完全透明;模型层依然是「提交指向完整的树」。这个顺序决定了一切:取任何版本都是 O(1)、delta 基准可以是任何长得像的对象、存储方式可以随便演进而对象哈希永远不变。而二进制文件之所以是天敌,是因为它同时打败了 delta(压过的数据没有长匹配)和三路合并(没有行)。
卷 V 结束,技术部分到此为止。
最后一卷回到地面:把那些「玄学」现象逐条拆开,你会发现没有一条需要新知识;然后交给你一张出事时先看的决策表。