卷 I · 物CH 02深度 2/24

内容即地址:blob 与那四十个字符

上一章说「对象的名字就是它内容的哈希」。这一章把这句话拆到字节。你会亲手算出一个哈希,然后拿到终端里核对——一个字符都不会差。中间会撞见一个几乎所有人都会猜错的细节。

内容寻址SHA-1对象头zlib

先猜一下

一个文件里写着 hello world(末尾一个换行)。Git 给它算出来的哈希是 3b18e51…

问题:这个哈希是什么东西的 SHA-1

几乎所有人的第一反应是「文件内容的 SHA-1 呗」。我们验一下:

$ printf 'hello world\n' | shasum
22596363b3de40b06f981fb85d82312e8c0ed511  -

$ printf 'hello world\n' | git hash-object --stdin
3b18e512dba79e4c8300dd08aeb37f8e728b8dad

完全不一样。

所以 Git 算的不是文件内容本身。它在内容前面加了点东西

那点东西是什么

Git 在算哈希之前,会先给内容套一个头部

sha1( "<类型> <内容的字节数>\0" + 内容 )

对我们这个例子来说,就是:

"blob 12" + 00 + "hello world\n"
 ↑     ↑     ↑
 类型  字节数  一个真正的 NUL 字节(0x00),不是字符串 "0"

hello world\n 是 12 个字节,所以头部写 blob 12,然后一个 NUL 字节,然后才是内容。把这一整串喂给 SHA-1,就得到 3b18e51…

验证一下这个公式对不对——直接手工拼出来算:

$ printf 'blob 12\0hello world\n' | shasum
3b18e512dba79e4c8300dd08aeb37f8e728b8dad  -

对上了。就是这么一行公式,没有任何别的花样。

◆ 整个 Git 的地基,就是这一行

hashObject(类型, 内容) = sha1("<类型> <字节数>\0" + 内容)

四种对象——BLOBTREECOMMITTAG——全都用这一个公式命名,区别只在于头部写的类型不同、内容的格式不同。

接下来的三章,我们就是拿着这一行公式,把另外三种对象一个个建出来。

下面这台引擎跑的就是这行公式。SHA-1 是纯 JavaScript 写的,同步执行,没有任何依赖——它算出来的东西和你终端里的 git hash-object 一模一样

▶ 动手 · git hash-object,真的算给你看

随便改改内容框,看哈希怎么变。底下会显示喂给 SHA-1 的完整字节,红色那个就是 NUL 分隔符。

务必点一下「一个中文字」——那里藏着这一章第二个反直觉的地方。

两个容易猜错的地方

一、头部是内容的一部分

这就是为什么 shasumgit hash-object 结果不同。头部参与了哈希计算

为什么要加这个头?两个理由。

第一,区分类型。同样的字节串,当成 blob 和当成 commit,应该是两个不同的对象。加上类型前缀,它们的哈希天然就不同了。

第二,长度前置。读对象的时候,Git 先读到 NUL 之前的部分,就已经知道「这是什么类型、有多少字节」,可以直接分配好缓冲区再读内容。这是个很老派、很务实的格式设计。

二、按字节算,不按字符

点过 demo 里那个「一个中文字」的话,你已经看到了:内容是 快\n,头部写的是 blob 4,不是 blob 2

因为「快」这个字在 UTF-8 里是 3 个字节,加上换行是 4 个。

这个细节平时不影响你,但它揭示了一件重要的事:Git 眼里没有「字符」,也没有「文本」,只有字节。它不知道你的文件是 UTF-8 还是 GBK,不知道是代码还是图片。

这解释了 Git 的几个脾气:

  • 为什么 Git 对二进制文件「支持不好」:不是不支持,是它对所有文件一视同仁地当字节处理。只是按行做差分这件事对二进制没意义,所以 diff 出来是一句「Binary files differ」。
  • 为什么换行符会惹事\n\r\n不同的字节,所以是不同的 blob,所以是「文件改了」。Windows 和 Linux 协作时那些莫名其妙的「整个文件都变了」,根源在此。core.autocrlf 这类配置就是在文件进出对象库时做字节转换来打补丁。
⌗ 掀开 .git 看一眼

对象在磁盘上是怎么放的?拿哈希 3b18e512dba79e… 举例:

$ ls .git/objects/3b/
18e512dba79e4c8300dd08aeb37f8e728b8dad

前 2 位当目录名,后 38 位当文件名。纯粹是为了避免一个目录里塞几十万个文件——很多文件系统在那种情况下会变慢。

那个文件里是什么?直接 cat 会看到一堆乱码,因为它是 zlib 压缩过的。解开看看:

$ python3 -c "import zlib,sys; sys.stdout.buffer.write(
    zlib.decompress(open('.git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad','rb').read()))"
blob 12hello world

头部和内容原原本本躺在里面blob 12hello 之间那个看不见的东西就是 NUL 字节)。

注意顺序:先算哈希,再压缩。哈希算的是未压缩的字节。所以哈希跟压缩算法、压缩级别完全无关——换个 zlib 版本,同样的内容照样是同一个哈希。这个解耦很重要,否则 Git 每次升级压缩库,全世界的对象名都要变。

为什么这个设计叫「内容寻址」

一般的存储系统是这样的:你给一个名字(路径、主键、URL),系统给你内容。名字和内容是两回事,名字是人起的。

内容寻址反过来:内容决定名字。你没法给一份内容起名字,它的名字是算出来的。

这个反转带来三个性质,Git 的一切都建在上面:

性质为什么Git 里的体现
天然去重相同内容 → 相同哈希 → 同一个地址一个文件在一千个提交里没变,对象库里只有一份(第 5 章会数给你看)
天然防篡改改了内容,名字就对不上了历史一旦提交就无法被悄悄修改git fsck 能查出任何损坏
天然可分布同一份内容在任何机器上算出的名字都一样不需要中心服务器分配 ID。这就是 Git「分布式」的技术根基

第三条值得多说一句,因为它常被误解。

Git 之所以能分布式,不是因为「每个人都有完整副本」——那只是结果。真正的原因是:你和我在各自的机器上,离线状态下,给同一份内容算出的名字必然相同。所以我们的对象库可以随便合并,不会撞车,也不需要谁来协调。

对比一下 SVN 那种中心化的版本号(1、2、3、4……):那个号必须由服务器分配,因为只有服务器知道下一个该是几。这就是为什么 SVN 离线提交不了——不是懒得做,是模型上做不到

✎ SHA-1 不是早就被攻破了吗

是的。2017 年 Google 的 SHAttered 攻击造出了两个 SHA-1 相同的 PDF,2020 年之后成本已经降到几万美元量级。SHA-1 在密码学上确实死了

那 Git 为什么还在用?因为Git 主要用的不是它的抗碰撞性,而是它的确定性。

Git 需要的是:同一份内容,在任何时间任何机器上,算出同一个名字。这个性质跟安全性无关——CRC32 也有,只是碰撞概率高到不能用。SHA-1 在这里的角色更像是「一个碰撞概率低到可以忽略的 ID 生成器」。

那安全性完全不重要吗?也不是。如果攻击者能造出一个和你的正常文件哈希相同的恶意文件,理论上能做替换攻击。所以:

  • Git 从 2.13 起默认启用 SHA-1DC(碰撞检测版),会识破 SHAttered 那类构造出来的碰撞并直接报错;
  • Git 早已支持 SHA-256 仓库(git init --object-format=sha256),但因为生态迁移成本巨大,至今仍是少数派;
  • 真正的完整性保障其实在另一层签名的提交与标签(GPG/SSH 签名),那才是防篡改的正经手段。

顺带一提,这也解释了为什么短哈希是安全的3b18e51 只有 7 位,看起来很容易撞。但 Git 只需要它在这个仓库里唯一——不唯一的时候它会直接报错让你多写几位。Linux 内核那种百万提交的仓库通常要 12 位左右。

⚠ 一个真会咬人的坑

git hash-object 默认只算,不存。要真正写进对象库得加 -w

$ echo "test" | git hash-object --stdin        # 只告诉你哈希是多少
$ echo "test" | git hash-object --stdin -w      # 真的写进 .git/objects/

另外,git hash-object <文件> 会走一遍你配置的过滤器core.autocrlf.gitattributes 里的 clean filter、Git LFS 等等)。所以在开了 autocrlf 的 Windows 上,git hash-object a.txtcat a.txt | git hash-object --stdin 可能给出不同的结果——前者做了换行转换,后者没有。

调试哈希对不上的时候,第一个要怀疑的就是这个。

↩ 回到你的仓库

「两个同事各自复制粘贴了同一段 300 行的工具函数到不同文件里。仓库会存两份吗?」

会。因为 blob 的去重粒度是整个文件,不是文件里的片段。两个文件内容不同(哪怕只差一行),就是两个不同的 blob。

但故事没完——第 22 章的 packfile 会在存储层把它们做 delta。两个 90% 相同的 blob,打包后第二个可能只占几百字节。

这正好体现了 Git 的分层:对象模型层追求简单和正确(内容一样就是一个对象,就这么一条规则),存储层再去追求省地方。两件事分开做,各自都能做干净。

顺带一个实用推论:把一个大文件改名,Git 会存两份吗?不会——改名不改内容,blob 哈希不变,变的只是树里那一行的名字(下一章)。这也是为什么 Git 其实不记录重命名,而是在你要看 diff 的时候现场推断出来的。

这一章的一句话

Git 给每个对象起的名字是 sha1("<类型> <字节数>\0" + 内容)——头部参与计算,长度按字节算。名字由内容决定,于是去重、防篡改、可分布这三件事同时白拿到手,而这正是 Git 敢于「每次都存完整快照」的前提。

下一章:blob 只是一坨字节,它连自己叫什么名字都不知道。文件名存在哪儿?目录结构存在哪儿?答案是 tree 对象——那里有一个古怪的二进制格式,和一个排序规则,弄错了哈希就和真 git 对不上。