Git:内容寻址数据库与四种对象

十个分支里各有一份 README.md,内容都是同一个版本——.git/objects/ 目录里这个文件只有一份存储。不是因为 Git 在运行时压缩,是它从一开始就没打算存十份。

这不是优化,是 Git 底层设计的起点。

Git 存的是”快照”,不是”差异”

SVN 和 CVS 用差异链(delta chain)存历史:把每次变更记录为”相对上一个版本改了什么”,重建某个版本就是把差异从头回放一遍。这个模型直觉上很节省空间——毕竟大多数文件每次只改了几行。

但它有几个根本性的问题。回放越老的版本越慢,因为要经过的差异越多。文件重命名很难追踪,因为差异是基于路径记录的,换了路径就断了历史。更关键的是,任何一个中间版本都不能独立验证自身的完整性——它的正确性依赖于从它到初始版本之间每一个差异的正确性。

Git 做了一个不同的选择:每次提交都是一张完整快照,而不是与上一版本的差异。任意一个历史版本都可以直接取出来,不需要回放任何东西。

这个选择带来一个新问题:如果每次提交都存整份代码,而大多数文件在两次提交之间根本没有改变,岂不是在大量重复存储?

内容寻址:Key 是内容本身的哈希

Git 的解法是内容寻址存储(Content-Addressed Storage):不用文件路径作为 Key,改用文件内容的 SHA 哈希作为 Key。

普通文件系统是路径寻址的——同一内容存在 src/utils.jslib/utils.js 两个路径,就是两份存储,彼此没有关联。Git 的 objects/ 目录不存路径,只存内容。任何一段字节,不管叫什么名字、在哪个目录、被哪个分支引用,只要内容相同,在 objects/ 里就只有一个条目,对应一个 SHA 哈希值。

这个设计推导出三件事,不是三个”特性”,是三个数学推论:

完整性自证。SHA 哈希是内容的确定性指纹——相同内容永远得到相同的哈希,不同内容几乎不可能得到相同的哈希。这意味着只要 Key(哈希)没变,内容就没被篡改,不需要额外的校验机制。Git 对象库的完整性不依赖任何外部信任,哈希本身就是证明。

自动去重。相同内容只存一份,由哈希保证。十个分支里未被修改的那份 README.md,不管被多少个 commit 引用,在 objects/ 里永远只有一个条目。去重不是一种优化策略,是内容寻址模型的直接结果。

分布式身份。哈希值与机器无关,与时间无关,与仓库的来源无关。你的机器上某个 commit 的 SHA 和世界上任何一台机器上同一 commit 的 SHA 完全一致。git clone 本质上是把一个 KV 库复制到另一台机器,双方用哈希对齐内容——不需要中央服务器裁决谁才是”权威版本”。

但内容寻址有一个代价,值得直接说清楚:改写历史的成本很高。因为 commit 的哈希是由它的完整内容(包括父 commit 的哈希)计算出来的,改变任意一个祖先 commit 的内容,后代所有 commit 的哈希全部随之改变。git rebase 不是”把提交挪过去”,是在新的位置上重新创建一批对象。旧的对象还在 objects/ 里,只是没有引用指向它们了。

四种对象:KV 库里装的是什么

Git 的 objects/ 目录只存四种类型的对象,整个仓库的历史、所有版本的所有文件、所有的元数据——全部由这四种对象组成。

Blob 是文件内容的存储单元。只有字节内容,没有文件名,没有路径,没有权限。一个 blob 的 SHA 完全由其字节内容决定,和它叫什么名字、在哪个目录里没有任何关系。这意味着两个不同文件,只要内容相同,就共享同一个 blob。

Tree 对应目录。它是一张映射表,每一项记录”名字 + 权限 + 对象哈希”。名字可以映射到 blob(文件),也可以映射到另一个 tree(子目录)。文件名存在 tree 里,不存在 blob 里——和 Linux 里文件名属于目录项而不属于 inode 是同一种设计。

Commit 不是”改动了什么”,是一张快照的元数据。它指向项目根目录的 tree(代表这一时刻整个项目的完整状态),记录父 commit 的哈希(确定历史顺序),以及作者、提交者、时间、提交信息。一次 commit 的完整对象图大致如下:

commit a1b2c3
 ├── tree 9f8e7d           ← 项目根目录
 │    ├── blob 3c4e9c  README.md
 │    ├── blob 7a1f2b  package.json
 │    └── tree 5d8a3f  src/
 │          ├── blob c9d821  index.js
 │          └── blob 0b4f1a  utils.js
 └── parent 4d5e6f         ← 上一次 commit

Tag(annotated tag)是带元数据的指针对象,记录打标签的人、时间、消息,以及可选的 GPG 签名。它指向一个 commit(通常),是发布版本时的身份锚点。轻量标签(git tag v1.0)不产生 tag 对象,只是一个指向 commit 的引用文件,用法不同,概念上不是同一种东西。

对象图意味着什么

理解了四种对象和内容寻址,Git 里很多”奇怪的行为”就有了直接的解释。

分支不是一段历史,是一个指针文件。 .git/refs/heads/main 里只有一行内容:某个 commit 的 40 位 SHA 哈希。“切换分支”就是把 HEAD 指向不同的引用文件。删除分支只是删掉这个文件,commit 对象本身不受影响,还在 objects/ 里,只是暂时没有引用指向它。

合并是创建一个有两个父节点的新 commit。 merge 不会改动任何现有对象,只是新建一个 commit,它的 parent 字段里有两个哈希,分别指向被合并的两条历史线。历史图从线性变成了 DAG(有向无环图),但所有旧 commit 原封不动。

Rebase 是创建新对象,不是搬运旧对象。 把 feature 分支 rebase 到 main 上,实际上是把 feature 上的每个 commit 按顺序重新创建一遍,以 main 的最新 commit 作为新的父节点。旧的 commit 对象还在,哈希不同,对外表现为一段新历史。这也是为什么已经 push 出去的 commit 不应该 rebase:远程的旧 commit 和本地的新 commit 是两批不同的对象,强推会让其他人的历史断掉。

以上三件事的共同前提是:对象一旦写入 objects/,永远不变。 所有 Git 操作,不管看起来多么”修改”或”删除”,实际上做的只有两件事:创建新对象,或者移动指针。

走进 objects/

运行 git init 之后,.git/objects/ 是空的。做一次 commit,目录里会出现几个子目录,每个子目录名是两位十六进制,子目录里的文件名是 38 位十六进制——合在一起就是对象的 40 位 SHA。

git cat-file 是读取对象内容的直接方式:

git cat-file -t <sha>    # 查看对象类型(blob / tree / commit / tag)
git cat-file -p <sha>    # 查看对象内容(可读格式)

追踪一次 commit 的完整对象链时,从 git log --oneline 拿到 commit SHA,用 -p 看它的内容,能看到它指向的 tree SHA;再对那个 tree SHA 运行 -p,能看到它的每一个条目;继续往下,能一直追到某个具体文件的 blob 内容。整个历史在 objects/ 里以这种树状结构存放,任何一个版本的任何一个文件,都可以这样找到。

随着仓库变大,objects/ 里会积累大量松散对象(loose objects)。git gc 会把它们打包成 .pack 文件,同时计算对象之间的差异并压缩存储——这才是 Git 做增量压缩的地方,是事后的存储优化,而不是基本数据模型。pack 文件的存在不改变内容寻址的逻辑,只改变物理存储方式。对象的 SHA 不变,cat-file 依然有效,Git 会自动在 .git/objects/pack/ 里查找。

关联