Skip to content
Published on

Git 到底是怎么存储数据的

分享
Authors

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

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

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

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

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

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

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

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

  • 相同的内容永远拥有相同的地址。 如果两个文件的内容完全一样,它们的哈希也一样,因此在 Git 仓库里只会被存一次。
  • 内容哪怕只变一点点,地址也会彻底改变。 由于哈希函数的性质,哪怕只改一个字节,得到的也会是完全不同的哈希。
  • 完整性是白送的。 因为地址就是内容的哈希,数据一旦损坏,哈希就会对不上,从而被立刻检测出来。

在这套内容寻址方式之上,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 对象包含以下内容:

  • 对一个最上层 tree 的引用 — 这次提交时刻整个项目的完整快照
  • 对一个或多个父提交的引用(第一次提交没有父提交,合并提交则有两个及以上的父提交)
  • 作者和提交者信息,以及时间戳
  • 提交信息

这里的关键在于,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)。

  • 有向(Directed):每个提交都指向它的父提交。箭头指向过去。
  • 无环(Acyclic):提交不能把自己的后代当作父提交。不存在环。

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

                  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 是什么。 HEAD 是另一个指针,指向"我现在在哪个分支上"。它通常指向一个分支,而那个分支又指向一个提交。

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

  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 — 记录引用是怎么移动的

来梳理一下要点。

  • objects/ 目录就是真正的数据存储。所有的 blob、tree、commit、tag 都以各自的哈希作为文件名存在这里。把哈希的前 2 位拆出来当文件夹名,是一个实用的手段,为的是避免一个文件夹里塞进太多文件。
  • refs/heads/ 里的每个文件就是一个分支。打开 main 文件,里面就是一行提交哈希。前面说的"分支就是指针",在这里得到了字面意义上的印证。
  • HEAD 文件通常存着类似 ref: refs/heads/main 的内容,用来指明你现在在哪个分支上。
  • logs/ 里的 reflog 记录了分支和 HEAD 随时间是怎么移动的。这也是为什么不小心弄丢一次提交后,还能靠 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 依然是基于快照的。 每次提交在概念上都指向一整棵完整的树。但在物理存储上,打包文件靠差量编码来节省空间。这两者并不矛盾。用户和命令看到的是快照模型,差量只是底下那层存储的优化而已。Git 读取以差量方式存储的对象时,会自动重建出完整的对象再返回给你。

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

全景图 — 把一切拼在一起

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

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

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

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

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

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

  • 命令看起来变得有逻辑了。 checkout 是把工作目录变成某个特定 tree 的内容并挪动 HEAD;merge 是找共同祖先并创建一个合并提交;reset 是挪动分支指针。这一切都可以用对象和指针的操作来解释。
  • 出错也不那么可怕了。 只要还被引用着,提交对象就不会消失,所以哪怕不小心挪错了分支,也可以靠 reflog 找到原来的提交哈希,把指针挪回去。那些"看起来丢了"的提交,通常都还在原地。
  • 性能变得可以理解了。 为什么分支是轻量的,为什么大仓库里某些操作依然很快,都能从数据结构上得到解释。
  • 协作变得清晰了。 pushfetch 归根到底就是在仓库之间交换对象和引用。一旦能在脑子里画出交换的是什么,冲突和同步的问题也就不那么让人困惑了。

结语

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

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

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

参考资料