Git 底层原理
最后更新于
最后更新于
在阅读本篇之前, 确保你已经安装好了 Git 并且配置了.gitconfig
(邮箱及用户等).
动机: 怎么多人协作?
不是像几个人做英语的小组作业那样灾难
一个文件被传几遍. 改了什么内容?
这段文案是谁改的...? 为啥要改?
...
有了版本管理系统(VCS)就能解决大部分上面的问题.
别人都干了些啥?
同步项目
解决并行开发引起的冲突
我不和别人协作!
项目快照
记录改动目的
多分支并行开发
如果愿意, 你也可以用 Git 打造专属自己的一套个人笔记系统!
Git 这么强大, 那要怎么实现 Git?
这是我该问的问题吗?
为Git祛魅: Write Yourself a Git! 563 行 Python 代码实现了 Git 的底层机制.
大部分的 Git 教程通常都是自上而下的: 从命令行接口开始, git add/commit/push/fetch/pull
...
看起来你学到了很多, 但事实上, 你还是一个接口侠(这也是我们后端最不愿意看到的). 或者, 一旦学深入, 就不得不涉及到一些底层的原理. Git 被人诟病 "leaky abstraction", 就是因为它的接口太过丑陋 (就连Linus 本人也吐槽这一点).
虽然说现代 IDEs (例如Jetbrain, vscode) 已经提供了 Git 图形化的界面, 但是这不妨碍很多人依然不会.
什么是 leaky abstraction 泄露抽象?
简单来说, 抽象层次的不完全封装, 导致底层细节和实现细节对上层抽象产生意外影响或泄漏出来, 这就是泄露抽象.
举一个例子, matching_user(id:int)
是你写的一个匹配指定用户的函数, 你只需要传入用户 id
即可完成匹配, 但如果你写成 matching_user(pattern:SQL_query)
, 这个接口就是泄露抽象的.
作为一名合格的后端人员, 你应当避免泄露抽象的出现.
本次的介绍会从 Git 的底层设计和模型开始讲起.
Git 的后端模型很简洁.
理解一定的原理, 至少能让你在复制命令的时候不会感到一脸茫然.
文件内容: Blob 对象 (Binary Large OBject)
对象: 对象按照 SHA1-Hash 寻址
Git 的一切都是对象
不同于传统的文件存储系统: key-value
文件的内容是 value
文件或目录有一个唯一的名称/路径作为 key
你不是修改了文件, 而是在另一个位置新建了文件
文件的内容是 value
根据内容计算一个 hash key, 这个哈希值相当于路径
这就是 Git 的后端模型: 内容寻址文件系统(IPFS) (听起来很吓人)
什么是哈希函数? strlen()
就是一个哈希函数, 你可以知道字符串的长度, 但永远不知道原字符串是长什么样的. 你可以想象哈希函数就是数据的身份证, 它唯一, 并且不可逆. 利用这些特点, SHA-1哈希值就是一个对象的唯一标识符.
git hash-object/cat-file
这个示例中我们会演示两个"底层" (plumbing) 的指令: git hash-object
和 git cat-file
. 即便这两个命令在我们日常使用中几乎不会见到, 但是对于我们了解 Git 的底层机制来说是很有用的.
在这个示例中, 一个文本被 hash 化, 同时作为一个新的数据对象被存储进 Git 数据库中(.git/objects/
). 然后我们又用 cat-file
取回了原数据.
你可能会好奇 hash-object
是怎么做到的. 实际上这并不黑科技, 我们可以用 Python 简单地去实现:
然后是数据的存储. 这条文本消息会用 zlib 进行压缩, 并写入指定的路径. 用 Python 代码表示就是:
让我们再用实际的一个例子来演示:
这就已经是一个简单的版本控制系统了! 并且我们看到了, 在Git版本控制系统中, 文件都以对象存储. 但是这种方法有一个问题: 文件名, 以及文件所在的路径的信息并没有被保存. 为了解决这个问题, 于是引入了树对象 (tree object):
一棵树可以包含其它树, 以及文件. (树是递归定义的!)
树将文件名和 hash 值进行了关联. 正如下图所示:
接下来介绍的提交(Commit), 可能是Git中最重要的一个概念. 但有了上面的基础, 它很好理解:
实际上就是一棵树, 附带一些其它信息, 例如提交者, 提交时间, 它的父提交等.
一次提交, 就是当前项目的一次快照(Snapshot).
不用对看不懂示例中的指令感到担忧, 你很快就会上手并且变着花地去使用这些 Git 指令.
我们用一个现有的 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 不仅和文件内容相关, 还和整个提交历史和仓库相关.
所以历史记录(log)就是这些快照组成的有向无环图(DAG), 对于单分支而言, 它就是一个链表, 对于多分支的仓库来说, 你可以按照下面的方式可视化历史记录:
这个示例也显示一点: Git 的提交(git commit
)其实就是:
将被改写的文件保存为数据对象(object-hash
)
更新暂存区(update index
) (你可以暂且不管)
记录树对象(write-tree
)
最后创建一个指明了顶层树对象和父提交的提交对象(commit-tree
)
commit 是不可变的 (immutable), 因为一旦创建, 它就是一个冰冷冷的对象, 躺在仓库里了. 假设你想撤回刚才的提交, 其实你是新建了一个删除它的的提交. 这点对于了解 git reset
和 git rebase
是至关重要的.
假设你正在开发一款游戏, 按照进度, 你目前开发到第二个关卡, 但突然发现第一关出 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. 需要科学工具.