Git 底层原理

在阅读本篇之前, 确保你已经安装好了 Git 并且配置了.gitconfig (邮箱及用户等).

为什么要学习 Git?

动机: 怎么多人协作?

  • 不是像几个人做英语的小组作业那样灾难

    • 一个文件被传几遍. 改了什么内容?

    • 这段文案是谁改的...? 为啥要改?

    • ...

没有版本控制的灾难

有了版本管理系统(VCS)就能解决大部分上面的问题.

  • 别人都干了些啥?

  • 同步项目

  • 解决并行开发引起的冲突

我不和别人协作!

  • 项目快照

  • 记录改动目的

  • 多分支并行开发

如果愿意, 你也可以用 Git 打造专属自己的一套个人笔记系统!

XKCD 漫画, 自嵌
  • Git 这么强大, 那要怎么实现 Git?

  • 这是我该问的问题吗?

大部分的 Git 教程通常都是自上而下的: 从命令行接口开始, git add/commit/push/fetch/pull...

看起来你学到了很多, 但事实上, 你还是一个接口侠(这也是我们后端最不愿意看到的). 或者, 一旦学深入, 就不得不涉及到一些底层的原理. Git 被人诟病 "leaky abstraction", 就是因为它的接口太过丑陋 (就连Linus 本人也吐槽这一点).

虽然说现代 IDEs (例如Jetbrain, vscode) 已经提供了 Git 图形化的界面, 但是这不妨碍很多人依然不会.

本次的介绍会从 Git 的底层设计和模型开始讲起.

  • Git 的后端模型很简洁.

  • 理解一定的原理, 至少能让你在复制命令的时候不会感到一脸茫然.

Git 的底层原理

存储结构

  • 文件内容: Blob 对象 (Binary Large OBject)

  • 对象: 对象按照 SHA1-Hash 寻址

  • Git 的一切都是对象

不同于传统的文件存储系统: key-value

  • 文件的内容是 value

  • 文件或目录有一个唯一的名称/路径作为 key

你不是修改了文件, 而是在另一个位置新建了文件

  • 文件的内容是 value

  • 根据内容计算一个 hash key, 这个哈希值相当于路径

  • 这就是 Git 的后端模型: 内容寻址文件系统(IPFS) (听起来很吓人)

什么是哈希函数? strlen() 就是一个哈希函数, 你可以知道字符串的长度, 但永远不知道原字符串是长什么样的. 你可以想象哈希函数就是数据的身份证, 它唯一, 并且不可逆. 利用这些特点, SHA-1哈希值就是一个对象的唯一标识符.

示例1: git hash-object/cat-file

这个示例中我们会演示两个"底层" (plumbing) 的指令: git hash-objectgit cat-file. 即便这两个命令在我们日常使用中几乎不会见到, 但是对于我们了解 Git 的底层机制来说是很有用的.

在这个示例中, 一个文本被 hash 化, 同时作为一个新的数据对象被存储进 Git 数据库中(.git/objects/). 然后我们又用 cat-file 取回了原数据.

你可能会好奇 hash-object 是怎么做到的. 实际上这并不黑科技, 我们可以用 Python 简单地去实现:

然后是数据的存储. 这条文本消息会用 zlib 进行压缩, 并写入指定的路径. 用 Python 代码表示就是:

让我们再用实际的一个例子来演示:

这就已经是一个简单的版本控制系统了! 并且我们看到了, 在Git版本控制系统中, 文件都以对象存储. 但是这种方法有一个问题: 文件名, 以及文件所在的路径的信息并没有被保存. 为了解决这个问题, 于是引入了树对象 (tree object):

  • 一棵树可以包含其它树, 以及文件. (树是递归定义的!)

  • 树将文件名和 hash 值进行了关联. 正如下图所示:

树模型, 让Blob对象和文件名称关联, 同时它还可以包含其它树, 图来自 Pro Git

历史记录

接下来介绍的提交(Commit), 可能是Git中最重要的一个概念. 但有了上面的基础, 它很好理解:

  • 实际上就是一棵树, 附带一些其它信息, 例如提交者, 提交时间, 它的父提交等.

  • 一次提交, 就是当前项目的一次快照(Snapshot).

示例 2: Object Walkthrough

我们用一个现有的 Git 仓库来演示这些对象的概念. 看看 Linus 的 first commit: Git 的源码在 Github (代码托管网站) 有存档, 你可以将其克隆下来(虽然可能这会比较慢):

提交有着属于它的 SHA-1 值, 它也是一个对象, 存储在 /.git/objects 目录下(你可以去找找看). 如果你去查看这条 commit 记录(运用我们上面提到的 cat-file 命令), 它的结果

你会发现这里的 parent e83c5163316f89bfbde7d9ab23ca2e25604af290 其实就是第一条提交的 hash. 这表明, 树本身会包含一些指向其他内容的指针. 也就是说, Git 对象引用其它对象时, 并不会真正储存对象, 只保存了他们的哈希值作为引用.

  • 一个大型项目(例如一个kernel), 各个版本都要进行备份, 这难道不会导致内存被占满吗?

  • 提交历史就是这么智能. 它会比较各个 commit 之间的不同, 然后仅储存这些不同的对象, 其余的引用历史版本即可.

事实上, Git内部储存还有包文件 (packfiles) 的机制, 它类似于一个压缩打包的过程, git gc 或者上传远程服务器时, Git 都会这么做. 如果你仔细阅读 git push 的 log 的话

Delta 就是差值, Git 只完整保存其中一个对象, 再保存另一个对象与之前版本的差异内容.

  • 一个 commit ID 不仅和文件内容相关, 还和整个提交历史和仓库相关.

历史记录的可视化. 图来自 Pro Git

所以历史记录(log)就是这些快照组成的有向无环图(DAG), 对于单分支而言, 它就是一个链表, 对于多分支的仓库来说, 你可以按照下面的方式可视化历史记录:

这个示例也显示一点: Git 的提交(git commit)其实就是:

  • 将被改写的文件保存为数据对象(object-hash)

  • 更新暂存区(update index) (你可以暂且不管)

  • 记录树对象(write-tree)

  • 最后创建一个指明了顶层树对象和父提交的提交对象(commit-tree)

引用和分支

假设你正在开发一款游戏, 按照进度, 你目前开发到第二个关卡, 但突然发现第一关出 BUG 了. 为了解决这个问题, 你打算对第一关之前的代码进行审阅, 也就是说, 你需要查看某一提交前的历史.

首先你需要定位到这次的提交, 根据 commit 信息, 你找到了这次的提交, 假如说它是cac56c61a49613280ec3eff9752c12612864b572, 但显然长度为 40 的 Hash 实在不适合人类记忆, 虽然说 Git 支持短 hash (cac56c), 你也只需要 git log cac56c 就可以完成这项任务, 但这并没有改变事情的本质. 假设你需要反复查看, 那必然的, 你可能需要反复的复制这个 Hash 值.

有没有一种更优雅的内部操作来解决这个问题呢? Git 采用的是引用 (reference).

  • 也就是用一个文件作为这个SHA-1值的指针.

你可以用 git update-ref 来实现这一点:

如果你还记得, 在上面演示的仓库中, 我们看到它的历史记录是错综复杂的好几条线, 当时我们提到了分支(branch), 现在就可以回答分支的本质:

  • 它就是一个特殊的引用.

  • ...只不过以当前最新提交的SHA-1值作引用.

假设你的游戏开发团队来了两名新人, alice 和 bob, 他们现在同时开发第三关, 要怎么才能让他们的工作互不影响, 并且最终能将成果合并到一起呢?

我们可以新建两个分支, git branch alice 以及 git branch bob, 然后让他们俩人分别在各自的分支里干活.

如果你查看了 /refs 这个目录的话, 你还会发现两个 heads 和 tags 的文件夹. 这时候你会发现我们创建的分支就在这个 heads 里头:

Git 用一个特殊的HEAD文件保存最新提交的引用. 也就是说它又是一个指向另一个分支的指针.

这里 master 分支就是 git init 默认创建的分支. 当你创建第一个 commit, 就创建了一个 refs/heads/master 的一个引用, 并指向这个提交. 当你第二次提交的时候, 它会读取 HEAD 引用指向的 SHA-1 值去设置它的父提交, 并更新当前引用:

切换分支 git checkout 实际上就是更新 HEAD 文件:

当我们的仓库为空时, 我们能不能创建一个新的分支? 答案为否. 想想看, 这是为什么? 你可以试试看到了什么报错.

额外的一个问题是, 为什么我们又可以创建一个空分支? git branch --orphan

分支就是 git update-ref refs/heads/<branch> HEAD. 用图表示, 那么就是一条主线出现了一条分叉:

剩下我们再来讨论下 refs/ 里剩下的一类引用: 标签(tag). 假如我们的游戏已经可以发布demo了, 我们想为其附上一个别名, 就叫它 demo1.0, 那么我们可以使用 git tag 命令(这里创建了一个附注标签)

我们之前提到过 tag 也是一个对象. 用 cat-file 可以看到这个对象的内容

补充和总结

  • Git 的一切都是对象

  • 引用就是一个指针

  • Git 仓库: 对象和引用

  • 提交对象之前要先把对象存储至暂存区

补充学习

MIT CS教育缺失的一课: 版本控制(Git): 整个 Missing-semester 课程都推荐大家学习, 非常好的一门课, 学习到很多好用的工具, 除了拓宽视野更能帮助你在未来的职业生涯中节省时间.

Git crash course: 看完就会用 Git. 需要科学工具.

最后更新于

这有帮助吗?