内容即地址:blob 与那四十个字符
上一章说「对象的名字就是它内容的哈希」。这一章把这句话拆到字节。你会亲手算出一个哈希,然后拿到终端里核对——一个字符都不会差。中间会撞见一个几乎所有人都会猜错的细节。
先猜一下
一个文件里写着 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 -
对上了。就是这么一行公式,没有任何别的花样。
hashObject(类型, 内容) = sha1("<类型> <字节数>\0" + 内容)
四种对象——BLOB、TREE、COMMIT、TAG——全都用这一个公式命名,区别只在于头部写的类型不同、内容的格式不同。
接下来的三章,我们就是拿着这一行公式,把另外三种对象一个个建出来。
下面这台引擎跑的就是这行公式。SHA-1 是纯 JavaScript 写的,同步执行,没有任何依赖——它算出来的东西和你终端里的 git hash-object 一模一样。
随便改改内容框,看哈希怎么变。底下会显示喂给 SHA-1 的完整字节,红色那个就是 NUL 分隔符。
务必点一下「一个中文字」——那里藏着这一章第二个反直觉的地方。
两个容易猜错的地方
一、头部是内容的一部分
这就是为什么 shasum 和 git 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这类配置就是在文件进出对象库时做字节转换来打补丁。
对象在磁盘上是怎么放的?拿哈希 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 12 和 hello 之间那个看不见的东西就是 NUL 字节)。
注意顺序:先算哈希,再压缩。哈希算的是未压缩的字节。所以哈希跟压缩算法、压缩级别完全无关——换个 zlib 版本,同样的内容照样是同一个哈希。这个解耦很重要,否则 Git 每次升级压缩库,全世界的对象名都要变。
为什么这个设计叫「内容寻址」
一般的存储系统是这样的:你给一个名字(路径、主键、URL),系统给你内容。名字和内容是两回事,名字是人起的。
内容寻址反过来:内容决定名字。你没法给一份内容起名字,它的名字是算出来的。
这个反转带来三个性质,Git 的一切都建在上面:
| 性质 | 为什么 | Git 里的体现 |
|---|---|---|
| 天然去重 | 相同内容 → 相同哈希 → 同一个地址 | 一个文件在一千个提交里没变,对象库里只有一份(第 5 章会数给你看) |
| 天然防篡改 | 改了内容,名字就对不上了 | 历史一旦提交就无法被悄悄修改;git fsck 能查出任何损坏 |
| 天然可分布 | 同一份内容在任何机器上算出的名字都一样 | 不需要中心服务器分配 ID。这就是 Git「分布式」的技术根基 |
第三条值得多说一句,因为它常被误解。
Git 之所以能分布式,不是因为「每个人都有完整副本」——那只是结果。真正的原因是:你和我在各自的机器上,离线状态下,给同一份内容算出的名字必然相同。所以我们的对象库可以随便合并,不会撞车,也不需要谁来协调。
对比一下 SVN 那种中心化的版本号(1、2、3、4……):那个号必须由服务器分配,因为只有服务器知道下一个该是几。这就是为什么 SVN 离线提交不了——不是懒得做,是模型上做不到。
是的。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.txt 和 cat 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 对不上。