LabHub

博客

Git 到底是怎么存储数据的

한국어English日本語中文

引言 — 天天在用,却没人看过里面

Git 是开发者每天都要用的工具,但意外的是,真正知道它内部长什么样的人并不多。大多数人把 git addgit commitgit push 当咒语一样背下来用,一旦出了岔子,就靠从 Stack Overflow 上抄来的命令勉强脱身。

然而 Git 的内部结构其实简单又优雅得惊人。事实上,Git 的核心数据模型小到只需几个小时就能完全理解。而一旦理解了这个模型,那些曾经看起来像魔法的 Git 行为就全都能用逻辑解释清楚了 — 为什么创建分支那么快、git checkout 到底做了什么、提交哈希为什么长那个样子,全都能装进同一幅图景里。

本文不把 Git 当作"命令的集合",而是当作一个"数据存储系统"来看。我们从底层出发,挖掘 Git 究竟用什么结构来存储你的文件,以及这个结构为什么要这样设计。读完之后,Git 应该会让你觉得不那么可怕了。

核心洞察 — Git 本质上是一个内容寻址存储

如果说理解 Git 只有一把钥匙,那就是这句话:Git 本质上是一个内容寻址存储(content-addressable store)。

所谓内容寻址存储,指的是给数据寻址时,用的不是"名字"或"位置",而是内容本身。具体来说,Git 在存储某段内容时,会对这段内容计算一个 SHA 哈希,再把这个哈希值当作这段内容的地址(也就是名字)。

这个想法带来的后果非常强大。

在这套内容寻址方式之上,Git 堆叠了四种对象(object)。这四种对象就是 Git 数据模型的全部。

四种对象 — blob、tree、commit、tag

Git 存储的一切,归根到底都是这四种对象之一。我们逐个来看。

blob — 文件的内容

blob 存放的是单个文件的内容。这里重要的一点是,blob 只存内容。文件名、路径、权限都不在 blob 里面。它只是一坨字节。所以名字不同但内容相同的两个文件,指向的是同一个 blob。

tree — 目录结构

tree 对应目录。tree 存放的是名字和对象引用的列表。每一项要么指向"这个名字对应的是那个 blob(文件)",要么指向"这个名字对应的是那个 tree(子目录)"。换句话说,tree 给 blob 赋予了名字和结构。如果说 blob 是文件内容,那 tree 就是把这些内容像文件系统一样组织起来。

commit — 快照与历史

commit 就是我们通常所说的"一次提交"。一个 commit 对象包含以下内容:

这里的关键在于,commit 指向的是一个快照。一个常见的误解是"Git 存的是差异(diff)",但事实上每次提交指向的是那一刻的完整整棵树。diff 是在需要时对比两个快照才计算出来的东西,并不是存储的基本单位。(后面会看到的打包文件为了存储效率会用到差量,但那是存储层面的优化,不是逻辑模型。)

tag — 名牌

tag 对象用来给某个特定对象(通常是提交)贴上一个永久的名字。像 v1.0.0 这样的发布标签就是典型例子。标签里可以包含打标签的人、日期、消息,以及签名。

把这四种对象的关系画成图,是这样的:

  commit  ──parent──▶  commit  ──parent──▶  commit
    │                    │                    │
    ▼ (tree)             ▼                    ▼
  tree ──────────────▶ blob  (README.md 的内容)
    ├──────────────▶ blob  (main.py 的内容)
    └──────────────▶ tree ──────▶ blob (src/util.py 的内容)
                    (子目录)

commit 指向 tree,tree 指向 blob 和其他 tree,commit 指向父 commit。这个简单的引用结构,就是 Git 的全部。

SHA 哈希 — 为什么是那么长的一串字符

用 Git 的时候,你会不断看到像 a1b2c3d4... 这样 40 位(或者省略后的 7 位)的十六进制字符串。这正是对象的地址,也就是 SHA 哈希。

Git 在存储每个对象时,会把对象的类型和内容合在一起计算出一个 SHA 哈希。这个哈希就成了对象唯一的名字。提交哈希长成这个样子的原因就在这里 — 它不是随意分配的编号,而是从这次提交的全部内容(它指向的 tree、父提交、消息、作者等)计算出来的指纹。

由此产生了一个很美的性质。提交哈希里包含了它所指向的 tree,tree 的哈希里包含了它所指向的那些 blob,而提交里又包含了父提交的哈希。也就是说,每个对象的哈希都依赖于它能到达的一切。

结果就是,哪怕在历史深处某个久远的提交里,一个文件只改了一个字节,那个提交的哈希也会变,而以它为父提交的所有后代提交的哈希都会连锁地跟着变。这正是 Git 保证完整性的方式。悄悄篡改历史是不可能的,因为所有哈希都会对不上。这种结构被称为默克尔树(Merkle tree),或者哈希 DAG。

(历史上 Git 一直使用 SHA-1,出于对哈希碰撞的担忧,向 SHA-256 的迁移一直在推进。但无论用哪种哈希函数,数据模型的原理都是一样的。)

提交 DAG — 历史是一张图

很多人把 Git 的历史想象成"一排排排好队的提交"。但准确地说,它是一个 DAG(有向无环图,Directed Acyclic Graph)。

当分支分叉又合并时,这张图就不再是一条直线,而是一张真正的图了。

                  o---o---o   (feature 分支)
                 /         \
  o---o---o---o-------------o---o   (main 分支)
      │           │              │
    过去 ─────────────────────▶ 现在

合并提交(上图中两条线交汇的地方)有两个父提交:一个是 main 上的前一次提交,另一个是 feature 上的最后一次提交。正因为提交可以拥有多个父提交,历史才成为一张图。

这种 DAG 视角能清楚地解释 Git 的许多行为。git log 就是从当前位置出发,沿着这张图向父提交方向回溯遍历;git merge 是找到两个位置的共同祖先,再把从那里开始的变更合并起来;rebase 则是把一批提交摘下来,重新接到另一个位置之上。这一切都是对图的操作。

分支只是一个指针 — Git 最令人释然的事实

学 Git 时最大的顿悟时刻,通常是这一个:分支不是什么又重又复杂的东西,它只是一个指向某个提交的指针。

准确地说,分支是一个只装了一个提交哈希的小文本文件。main 分支不过是一个 41 字节的文件,里面写着"main 指向的最新提交的哈希就是这个"。创建一个新分支,做的事情不过是再造一个指向同一个提交的新指针文件。

这个事实解释了很多现象。

把这个关系画成图,是这样的:

  HEAD ──▶ main ──▶ commit(f9a2...) ──▶ commit(3c1d...) ──▶ ...
                        (最新)              (再往前)

  执行 git commit 之后:
  HEAD ──▶ main ──▶ commit(新的!) ──▶ commit(f9a2...) ──▶ ...
                     指针往前移动了

一旦把"分支就是指针"这件事内化于心,Git 就不再那么可怕了。删除一个分支,提交对象本身(只要还被别处引用)并不会消失;不小心挪动了分支,也只需把指针指回原来的提交即可。标签也是一种类似分支的指针,区别在于分支每次提交都会往前移动,而标签则固定在一个提交上,不会移动。

看看 .git 目录里面到底有什么

到目前为止的所有概念,都真实存在于 .git 目录之中。打开项目根目录下的 .git 文件夹,你就能看到之前一直抽象讨论的那些东西的实物。它大致长这样:

  .git/
  ├── HEAD              # 指向当前检出的分支 (例如: ref: refs/heads/main)
  ├── config            # 仓库配置
  ├── objects/          # 所有对象(blob、tree、commit、tag)都存在这里
  │   ├── 3c/
  │   │   └── 1d8f...   # 哈希前 2 位作文件夹名,剩下的是文件名
  │   ├── f9/
  │   │   └── a2b7...
  │   └── pack/         # 打包文件 (后面会解释)
  ├── refs/
  │   ├── heads/        # 本地分支们 — 每个文件里装着一个提交哈希
  │   │   ├── main
  │   │   └── feature
  │   └── tags/         # 标签们
  └── logs/             # reflog — 记录引用是怎么移动的

来梳理一下要点。

如果你想亲手触摸 Git 的内部来学习它,可以在 Git 实验场里堆叠提交、创建分支,亲眼看看这套结构是如何变化的。只读文字理解概念,和亲眼看着这张图真的长大,是两种不同深度的理解。

相同的内容只存一次 — 去重的优雅之处

内容寻址方式最实用的结果之一,就是自动去重(deduplication)。

前面说过,blob 的名字就是它内容的哈希。所以哪怕仓库里有好几个内容完全相同的文件,它们指向的也都是同一个 blob。物理上只存了一份。

这个原理在提交与提交之间同样强力地发挥作用。假设你提交了 100 次,其中某个文件一次都没变过。那么这个文件的 blob 在这 100 次提交里就只存在一份 — 每次提交的 tree 都只是指向同一个 blob 哈希而已。同理,如果某次提交只改了一个文件,那么只有改动过的文件的新 blob、以及包含它的那些 tree 会被新建出来,其余没变的 blob 和 tree 会原样复用上一次提交的。

这就是为什么 Git 即便装下了庞大的历史,仓库也不会像想象中那样膨胀 — 因为每次提交虽然"指向"的是一整个快照,但没变的部分是被物理共享的。你同时得到了快照模型概念上的简单性,以及去重带来的存储效率。

打包文件 — 再压缩一次存储

到目前为止我说的都是,每个对象作为一个独立文件存在 objects/ 下面(这种叫作松散对象,loose object)。光是这样,靠着去重已经相当高效了,但 Git 还要更进一步 — 打包文件(packfile)。

仓库变大之后,单独的对象文件可能会膨胀到几十万个。文件太多既会给文件系统带来负担,把各个对象分开压缩也不如放在一起压缩来得高效。于是 Git 会定期(或者在执行 git gc 时)把大量对象收拢进一个打包文件里。

打包文件的两项核心优化是这样的:

这里重要的是分清层次。在逻辑模型上,Git 依然是基于快照的。 每次提交在概念上都指向一整棵完整的树。但在物理存储上,打包文件靠差量编码来节省空间。这两者并不矛盾。用户和命令看到的是快照模型,差量只是底下那层存储的优化而已。Git 读取以差量方式存储的对象时,会自动重建出完整的对象再返回给你。

这个设计思路,和前面提到的 SQLite、ripgrep 的经验也是相通的 — 对用户展示干净、简单的逻辑模型,而把实用的优化藏在底下悄悄完成。

全景图 — 把一切拼在一起

把前面所有的碎片拼成一张图,就能看到 Git 数据模型的全貌。

  refs/heads/main  ─┐
                    │ (指针)
  HEAD ─────────────┘
                 commit  ──parent──▶  commit  ──▶ ...
                    ▼ (快照: 指向一个 tree)
                  tree ──────▶ blob   (文件内容, 以哈希寻址)
                    └────────▶ tree ──▶ blob
                              (子目录)

  * 每个对象都以其内容的 SHA 哈希寻址 (内容寻址)
  * 相同内容 = 相同哈希 = 只存一次 (去重)
  * 先以松散对象存在 objects/ 下,之后再压缩进打包文件

这一张图里装下了本文的全部概念。指针(分支、HEAD)指向提交,提交指向快照(tree),tree 指向文件内容(blob),一切都以内容的哈希来寻址,且不重复存储。

理解这个模型之后,会有什么不同

理解了 Git 的数据模型,实际工作中会有几件事变得不一样。

结语

Git 之所以让人觉得难,大多是因为人们只背命令,却不了解它的内部模型。但那个模型本身小而优雅得惊人。四种对象(blob、tree、commit、tag),以内容寻址的 SHA 哈希,提交们组成的 DAG,以及在其上轻巧地指来指去的指针 — 分支。这就是全部。

把这幅图景装进脑子之后,那些曾经看起来像魔法、或者让人害怕的 Git 行为,全都能在同一套逻辑上得到理解。创建分支、撤销提交、改写历史,说到底都不过是创建对象、挪动指针而已。

下次 Git 出乎意料地行动、让你摸不着头脑时,不妨先别去想命令,而是想想数据模型:"现在创建了什么对象?哪个指针指向了哪里?"这一个问题,往往就能解开大部分困惑。到 Git 实验场里亲手创建提交和分支,亲眼看着这张图长大,这个模型才会彻底变成你自己的东西。

参考资料

评论

还没有评论。

登录后即可发表评论