复制成功
请遵守本站 许可

git到底是什么?一文讲通git第一性原理和诞生的历史

7519 字
38 分钟
Chongxi
Chongxi Author
2026-03-31 21:07:18

0. 引#

2005 年,Linux 内核是全球最大的开源协作项目

全球 1000+ 贡献者当时用的是一个叫 BitKeeper 的商业版本管理工具

2005 年 4 月,一个周末。一个叫 Andrew Tridgell 的澳大利亚程序员(就是写 Samba 的那个人)对 BitKeeper 做了逆向工程

BitKeeper 的公司 Larry McVoy 发现了,宣布停止向整个 Linux 社区提供免费授权

Linus 一夜之间失去了工具

Linus 没有慌,他先花了两周时间研究了市面上所有的版本管理工具,比如 CVS、SVN、Monotone、Darcs……

然后他得出一个结论:

这些东西全都烂透了。

在他的邮件里原话是:

没有任何一个现有的系统满足我的需求,所以我决定自己写

0.1 Why?#

Linus 的需求不是普通开发者的需求,他管理的是全球最复杂的协作项目

他每天的工作是这样的:

世界各地的开发者把自己的补丁发给各个子系统维护者,子系统维护者整理好再发给 Linus,Linus 最终决定哪些东西进内核

这是一个树状的信任网络,不是一个中心化的提交系统

所以他需要的工具必须满足:

1. 能够离线工作 开发者在没有网络的情况下也要能提交、能看历史、能做分支

2. 速度一定要快 Linux 内核有几百万行代码,操作必须在秒级甚至毫秒级完成。他当时嘲笑 CVS 说:“如果一个操作需要超过 3 秒,这个设计就是错的。”

3. 历史不可篡改 他必须能验证,某个开发者发过来的代码,从他写下第一行到发到 Linus 手里,中间没有被任何人动过。不能靠信任,要靠数学来保证。

4. 分支合并要够用 Linux 开发模式天然是并行的,几十条开发线同时推进,合并必须可靠、必须快。SVN 的合并是噩梦,对他来说不可接受

2005 年 4 月 3 日,Linus 开始写 Git

2005 年 4 月 7 日,Git 可以托管自己的源代码了

2005 年 4 月 18 日,Linux 内核开发者开始用 Git 协作

2005 年 6 月,Linux 2.6.12 正式用 Git 发布

从零到托管全球最大开源项目,不到三个月

0.2 diff vs snapshot#

版本管理的本质不是记录「文件怎么变化的」,而是记录「项目在每个时刻的完整状态是什么」

CVS 和 SVN 记录的是 diff,也就是 这次改了哪些行

Linus 认为这是错的

他要记录的是快照,每次提交就是整个项目的一张完整 snapshot

这一个决策,推导出了 Git 几乎所有的设计

1. 一个决策推导出了所有设计#

Linus 坐在那里,想的不是「我要怎么设计一个版本管理工具」

他想的是一个更根本的问题:

什么叫「同一份代码」?

1000 个开发者,每个人机器上都有一份 Linux 内核的代码。我怎么知道你这份和我这份是不是同一个状态?我怎么知道你发给我的补丁,是基于哪个状态写的?

SVN 当时给出的答案是看版本号,r1042 就是 r1042

Linus 觉得这是 Bullshit。版本号是中央服务器分配的,你得信任那台服务器。而且版本号只是一个计数器,它本身不包含任何关于内容的信息

1.1 内容本身就是身份#

Linus 的想法是:

不要用外部分配的 ID 来标识一份代码,用代码内容本身的哈希值

具体来说,对一个文件的全部内容做 SHA-1 哈希,得到一个 40 位的字符串

SHA-1("Chongxi") = f5c3bc7e6fcd7b7b8d04b99f5d7e607302d3a911

这个字符串有一个数学性质:

内容变了,哈希一定变。哈希没变,内容一定没变

这不是协议,不是信任,是数学层面的 proof

所以两个人拿着同一个哈希值,就可以确认自己手里的文件是完全一样的,不需要任何第三方

1.2 但单个文件不够,他需要整个项目#

一个文件可以哈希,那整个项目呢?

Linux 内核有几万个文件,分布在几百个目录里。Linus 需要一个方法,用一个哈希值来代表整个项目的状态

他的解法是递归地哈希:

main.c 的内容 → 哈希 A
util.c 的内容 → 哈希 B
src/ 目录 → 哈希 (A + B + 文件名 ) = 哈希 C
README 的内容 → 哈希 D
根目录 → 哈希 (C + D + 文件名 ) = 哈希 E

根目录的哈希 E,是由所有子节点的哈希算出来的

这意味着:

任何一个文件改了一个字节,由此向上,所有父节点的哈希都会变,根哈希也会变

反过来:根哈希没变,整个项目的每一个字节都没变

这就是 Git 的 tree 对象,也是 Merkle Tree 的核心思想

1.3 Snapshot#

有了 tree 对象,Linus 可以在任意时刻,用一个哈希值锁定整个项目的状态

然后他在外面再包一层,加上元信息:

commit {
tree: e5f8c2... ← 这一刻整个项目的根哈希
parent: a3d9f1... ← 上一个 commit 的哈希
author: Linus
time: 2005-04-07
message: "Initial commit"
}

然后对这整个结构再做一次哈希,得到这个 commit 的哈希值

这里有一个非常关键的地方:

commit 的哈希,包含了 parent 的哈希

这意味着你没办法在不改变哈希的情况下,悄悄修改历史上某个 commit 的内容。改了那个 commit,它的哈希变了,下一个 commit 记录的 parent 哈希就对不上了,再下一个也对不上,整条链都断了

历史是用密码学锁住的,不是靠权限控制,不是靠信任,是靠数学

1.4 snapshot vs diff who will win#

SVN 存的是:

r1: 原始文件
r2: 第 3 行改成了这个
r3: 第 17 行删了,第 20 行加了这个

想知道 r3 的状态,你得从头把所有 diff 叠加起来算

Git 存的是:

commit1: 整个项目的哈希 E1
commit2: 整个项目的哈希 E2
commit3: 整个项目的哈希 E3

想知道任意时刻的状态,直接拿那个哈希,找到对应的 tree,展开就是了。不需要计算,不需要回放

代价是存储空间?Git 用了对象去重来解决,两个 commit 如果某个文件没有改动,它们的 blob 哈希一样,Git 只存一份

1.5 我们可以推导出什么#

到这里,Linus 还没有发明分支,没有发明merge,没有发明任何高级概念

他只做了一件事:

用哈希把每一个时刻的项目状态锁定成一个不可篡改的对象,用 parent 指针把这些对象串成链

剩下所有的东西——分支、合并、rebase,都是在这个基础上自然生长出来的

2. 对象模型#

Git 并不是一个复杂的版本管理系统

Linus 最开始的定义非常朴素:

Git 就是一个内容寻址的文件系统。你把东西存进去,它给你一个哈希。你拿着哈希,随时可以把东西取出来

仅此而已

你可以打开任何一个 Git 仓库,看 .git/objects/ 目录,里面存的就是所有对象,按哈希值的前两位分文件夹:

.git/objects/
a3/f8c2d9b1e4... ← 这就是一个对象文件
d9/f3a1b2c4e5...
e5/c2f8a3d9b1...

每个文件的内容就是压缩过的对象数据。整个 Git 仓库,本质上就是这个文件夹

2.1 blob#

Git 里最简单的对象叫 blob,存的就是文件的原始内容,不包含文件名,不包含任何元信息

blob "CEPATO"
↓ SHA-1
d1fcd7b25f99b9f9d1a577557f45c10b6c1f9642

blob 不知道自己叫什么,也不知道自己在哪个目录里

文件名是上层的事,blob 只管内容

这个设计有一个直接好处:

两个不同路径下内容完全一样的文件,Git 只存一份 blob

2.2 tree#

文件名和目录结构,交给 tree 对象来管

一个 tree 对象就是一张表:

tree d9f3a1... {
100644 blob 8c7e5a... main.c
100644 blob f7d8e9... util.c
040000 tree b2c4e5... src/
}

每一行记录:文件权限、对象类型、哈希值、名字

tree 可以嵌套 tree,这样就构成了完整的目录结构

然后对这张表做哈希,得到这个 tree 的哈希值

因为 tree 的哈希是由子节点哈希算出来的,所以:

改了 main.c 的内容
→ main.c 的 blob 哈希变了
→ 包含它的 tree 哈希变了
→ 上层 tree 哈希变了
→ 根 tree 哈希变了

任何一个叶子的变动,都会反馈到到根节点

这里我们来区分一下,有个朋友单独找我提问了,私以为问题很好于是单独放出来讲下

Git 的 tree 不是哈希表,它更像一个有序列表,存的是文件名 → 子节点哈希的映射,但查找方式是线性遍历,不是哈希表的 O(1) 寻址

和 MPT 里的 Patricia Trie 部分相比

Git tree: 文件名 → 子节点哈希 // 简单映射,不优化查找)
MPT: 路径 → 叶子值 // 按前缀压缩,O(log n) 查找

两者表面很像,都是某种 key 指向哈希,但设计目标不同:

  • Git 的 tree 不需要高效查找,你 checkout 一个 commit,Git 直接把整棵树展开就完了,不需要频繁查单个节点
  • 以太坊的 MPT 需要高效查找,因为每笔交易都要单独查某个账户的状态,O(n) 不可接受

所以 Git 用简单结构够了,以太坊才需要 Patricia Trie 来优化

也就是说Git 的 tree 是为了描述状态,MPT 是为了高效查询状态

2.3 commit#

有了 tree,我们能表示项目在某一刻的状态

但还缺两件事:这个状态是谁在什么时候创建的,以及它的上一个状态是什么

这就是 commit 对象:

commit a3f8c2... {
tree: d9f3a1... ← 根 tree,代表这一刻的全部文件
parent: prev_hash ← 上一个 commit
author: Linus Torvalds
time: 2005-04-07 15:16:22
message: "Initial commit"
}

然后对这整个结构做哈希,得到这个 commit 的哈希

因为 commit 哈希包含了 parent 哈希,parent 哈希又包含了它的 parent,一直追溯到第一个 commit

整条 history chain,被一个哈希值锁住了

你拿着最新的 commit 哈希,就能验证从第一天到今天,每一行代码的每一次变更,没有任何人动过

2.4#

现在把三层放在一起看:

commit a3f8c2
└── tree d9f3a1 (根目录)
├── blob 8c7e5a main.c
├── blob f7d8e9 util.c
└── tree b2c4e5 src/
└── blob 3a9f1c helper.c

每个节点都是一个独立的对象,存在 .git/objects/ 里,用哈希寻址

它们之间没有包含关系,只有引用关系。每个对象只记录子节点的哈希,不复制子节点的内容


假设你做了第二次提交,只改了 main.c

commit e5c2f8
└── tree 7a1b3d (新的根 tree)
├── blob 9f2e4a main.c ← 新的 blob
├── blob f7d8e9 util.c ← 和上次一样
└── tree b2c4e5 src/ ← 和上次一样

util.csrc/ 目录没有变,Git 直接复用它们的对象,不复制,不重新存储

两次 commit,Git 只新建了三个对象:新的 blob、新的根 tree、新的 commit

Git 存的是快照,但因为对象复用,实际占用的空间接近于存 diff

鱼和熊掌可以兼得

2.5 分支#

推导到这里,Git 的存储层已经完整了:一个哈希对象数据库,里面装着 blob、tree、commit,用引用关系连接成一棵棵树,再用 parent 指针串成时间轴

但有一个问题:

commit 的哈希是 a3f8c2d9b1e4f7... 这样一串东西,人类没法记

你怎么找到最新的那个 commit?

3. 指针#

对象数据库建好了,里面存着几千个对象,每个都有一个 40 位的哈希地址

Linus 面临一个很实际的问题:

我怎么知道最新的提交在哪?

用每次都把最新的 commit 哈希记在纸上?那确实很直接了

git用一个文本文件把这个哈希存起来,给这个文件起一个人类能看懂的名字

这就是分支。对,就这么简单

3.1 refs#

Git 把这些文本文件统一放在 .git/refs/ 目录下:

.git/refs/
heads/
main ← 内容:a3f8c2d9...
feature ← 内容:d9f3a1b2...
remotes/
origin/
main ← 内容:e5c2f8a3...
tags/
v1.0 ← 内容:f7d8e9b1...

你可以直接打开这些文件看,里面就是一行哈希值,没有任何其他东西

Terminal window
cat .git/refs/heads/main
# a3f8c2d9b1e4f7a8b2c3d4e5f6a7b8c9d0e1f2a3

3.2 分支的本质#

所以「创建一个新分支」,在文件系统层面发生了什么?

Terminal window
git branch feature

发生了什么:

.git/refs/heads/ 下新建一个叫 feature 的文件,把当前 commit 的哈希写进去

就这一件事。没有复制代码,没有复制历史,没有任何你认为的企业级操作,就这么简单

这就是为什么 Git 的分支创建是瞬间完成的,而 SVN 创建分支要复制整个目录

3.3 分支怎么移动#

你在 feature 分支上提交了新代码,生成了新的 commit b2c4e5...

发生了什么:

.git/refs/heads/feature 文件里的哈希,更新成 b2c4e5...

就是改了一个文件里的一行字符串

提交前:feature 文件内容 = a3f8c2...
提交后:feature 文件内容 = b2c4e5...

分支跟着你走,本质就是这个文件在自动更新

3.4 HEAD#

现在你有了 mainfeature 两个分支,Git 怎么知道你当前在哪个分支上?

再加一个文件,叫 HEAD,放在 .git/HEAD

refs/heads/feature
cat .git/HEAD

它存的不是 commit 哈希,而是指向某个分支的引用

所以:

HEAD → refs/heads/feature → b2c4e5...(commit)

你提交新代码时:

  1. 生成新的 commit 对象
  2. feature 文件更新成新的哈希
  3. HEAD 不动,还是指向 feature

3.5 切换分支呢#

Terminal window
git checkout main

发生了什么:

  1. .git/HEAD 改成:
ref: refs/heads/main
  1. 根据 main 指向的 commit,把对应的文件树展开到你的工作目录

你看到文件变了,本质是 Git 用对象数据库里的内容,重新写了你的工作目录

3.6 detached HEAD#

现在我们理解了 HEAD 是指向某个分支的指针

但如果你直接 checkout 一个 commit 哈希:

Terminal window
git checkout a3f8c2

这时候没有分支可以指,HEAD 只能直接存哈希值:

Terminal window
cat .git/HEAD
# a3f8c2d9b1e4f7...

这就是 detached HEAD,头指针脱离

为什么都说脱离状态危险?

因为你在这个状态下做了新的提交,生成了新的 commit。但没有任何分支文件记录这个新 commit 的哈希

一旦你切换到别的分支,HEAD 更新了,那个新 commit 的哈希就没有任何东西指向它了

它还躺在 .git/objects/ 里,但你再也找不到它

Git 会定期运行垃圾回收,把没有任何引用指向的对象清理掉。你的提交就这样消失了

3.7 远端指针#

最后还有一类指针,放在 .git/refs/remotes/ 下:

origin/main ← 内容:上次 fetch 时,远端 main 的哈希

它的作用是:记录你上次同步时,远端仓库的状态

它是只读的,你不能直接在上面提交。它只在你 git fetch 的时候更新

git pushgit pull 的本质,是让本地和远端的这些指针对齐

3.8 小结#

HEAD
└── refs/heads/main → commit C4
└── refs/heads/feature → commit C5
refs/remotes/origin/main → commit C3(上次 fetch 的状态)
refs/tags/v1.0 → commit C1(不移动)

对象数据库是不可变的,指针系统是可变的
所有的 Git 操作,本质上都是在移动这些指针,或者在对象数据库里新增对象

4. 平时做的核心操作本质#

我们先来建立一个场景,后面所有操作都基于此:

C1 ← C2 ← C3 main
└── C4 ← C5 feature

main 指向 C3,feature 指向 C5,C4 和 C5 是从 C3 分叉出去的。

4.1 merge:新建一个节点#

你站在 main 上,执行:

Terminal window
git merge feature

git 操作如下

  1. 找到分支父节点

main 在 C3,feature 在 C5,顺着 parent 往上找,父节点是 C3

  1. diff
C3 的文件状态
C3 → C3 之间 main 改了什么
C3 → C5 之间 feature 改了什么

改了不同地方,自动合并。改了同一个地方,冲突,让你手动解决

  1. 生成一个新的 commit C6
commit C6 {
tree: 合并后的文件状态
parent: C3 ← main 这边
parent: C5 ← feature 这边
}

C6 有两个 parent,这在 Git 对象模型里是完全合法的

然后 main 指针移动到 C6:

C1 ← C2 ← C3 ← C6 main
↑ ↑
└── C4 ← C5 feature

merge 的本质是在对象数据库里新建一个节点,把两条线接在一起,移动当前分支指针

历史是真实的,分叉和汇合都保留着

4.2 rebase:重建节点,换个爹#

C1 ← C2 ← C3 main
└── C4 ← C5 feature

同样的场景,你站在 feature 上,执行:

Terminal window
git rebase main

git 则是把C4、C5 这两个 commit,在 C3 之后重放一遍

  1. 找到共同父节点 C3
  2. 把 C4、C5 的变更内容提取出来
  3. 从 main 的最新位置 C3 开始,依次重新应用这些变更,生成新的 commit
C4 → C4'(parent 从 C2 变成了 C3)
C5 → C5'(parent 从 C4 变成了 C4')

C4’ 和 C4 的文件内容可能一样,但哈希值完全不同,因为 parent 变了

结果:

C1 ← C2 ← C3 ← C4' ← C5' feature
main

历史变成了一条直线,看起来好像 feature 一直是在 C3 之后顺序开发的

rebase 的本质是把一段 commit 历史,复制到另一个位置重建,原来的对象还在数据库里,但没有指针指向它们了


注意,C4 和 C4’ 同时存在于 .git/objects/

rebase 之后,feature 指针移动到了 C5’,没有任何指针指向原来的 C4 和 C5 了

它们会在 Git 垃圾回收时被清理掉

这就是为什么 rebase 之后我们说「历史被改写了」,原来的 commit 被抛弃了,fork 了一批平行的对象

4.3 reset:移动指针,不新建节点#

Terminal window
git reset C3

这个操作最简单,也最容易被误解

它做的事情只有一件:
把当前分支的指针,强行移动到指定的 commit

执行前:main → C5
执行后:main → C3

C4、C5 还在对象数据库里,但 main 不再指向它们了

reset 有三种模式,区别在于工作目录和暂存区怎么处理

--soft 只移动指针,工作目录和暂存区不动
--mixed 移动指针,暂存区清空,工作目录不动(默认)
--hard 移动指针,暂存区清空,工作目录也回退

从指针视角看,三种模式做的是同一件事。区别只是你工作目录里的文件怎么变

4.4 cherry-pick:复制单个节点#

Terminal window
git cherry-pick C4

你在 main 上,想把 feature 上的某一个 commit 单独拿过来

操作:

提取 C4 相对于它的 parent 的变更内容,在当前位置重新应用一遍,生成新的 commit C4’

C1 ← C2 ← C3 ← C4' main
└── C4 ← C5 feature

C4’ 和 C4 内容相同,哈希不同

和 rebase 一样,都是「复制 commit 到新位置重建」,只不过 rebase 复制一段,cherry-pick 复制一个

简单列个表:

操作新建对象?移动指针?改写历史?
merge是,新建 merge commit
rebase是,重建 commit
reset看模式
cherry-pick是,复制 commit

所有操作都在做两件事的组合:在对象数据库里新增东西,和移动指针

4.5 小结#

我们现在可以理解任何 Git 操作:

  • 对象数据库只进不出(垃圾回收除外),是 append-only 的
  • 指针可以随意移动
  • 你所见的历史,取决于指针指向哪里

这就是为什么 Git 几乎不会丢数据——只要对象还在数据库里,你总能通过 git reflog 找回指针曾经指向过的位置,把东西捞回来

接下来我们讲协作以及为什么不要在多人时用 rebase

5. 多人协作#

先建立实际场景

你和 Chongxi,各自 clone 了同一个远端仓库。clone 的那一刻,你们三个人的状态完全一样:

远端 origin: C1 ← C2 ← C3 main
你的本地: C1 ← C2 ← C3 main
Chongxi 的本地: C1 ← C2 ← C3 main

然后我们开始各自工作

5.1 clone 发生了什么#

图文无关

clone 并不是单纯的「下载代码」

准确说法是:

把远端的整个对象数据库复制到本地,然后把远端的所有指针,在本地存一份只读的副本

.git/refs/remotes/origin/main = C3 的哈希

这个 origin/main 就是你本地对远端状态的快照,记录的是上次我跟远端同步时,它在哪

5.2 fetch#

你工作了一段时间,远端有了新的提交

Terminal window
git fetch

Git 操作如下:

从远端下载所有新的对象,更新 origin/main 指针。你的本地 main 不动

远端: C1 ← C2 ← C3 ← C4 ← C5 main
你的本地 main: C1 ← C2 ← C3 main(没动)
origin/main: C1 ← C2 ← C3 ← C4 ← C5 (更新了)

fetch 只做一件事:同步对象,更新远端指针。你的工作完全不受影响

5.2 push:把本地指针推到远端#

你在本地提交了 C4’,想推到远端:

Terminal window
git push

Git 操作如下

  1. 把本地新的对象(C4’)传到远端的对象数据库
  2. 请求远端把 main 指针移动到 C4’

但远端会检查一件事:

你的 C4’ 的 parent,是不是远端当前 main 指向的 commit?

如果是,叫做 fast-forward,远端直接把指针往前移,没有任何问题

如果不是,远端拒绝,返回错误:

! [rejected] main -> main (non-fast-forward)

为什么会被拒绝

你和 Chongxi 同时从 C3 开始工作:

你: C1 ← C2 ← C3 ← C4' 本地 main
Chongxi: C1 ← C2 ← C3 ← C4 推到了远端

Chongxi 比你先推,远端 main 现在在 C4

你要推 C4’,但 C4’ 的 parent 是 C3,不是 C4

远端:你的历史和我的历史分叉了,我不知道该怎么合并,你自己看着办吧

5.3 pull:fetch + 合并#

Terminal window
git pull

本质是两步:

git fetch ← 先同步远端对象
git merge ← 再把 origin/main 合并到本地 main

合并之后生成 merge commit C5,然后你再 push,这次 C5 的 parent 包含了 C4,远端会接受

C1 ← C2 ← C3 ← C4 ← C5 远端 main(C5 是 merge commit)
↑ ↑
Chongxi 你

5.4 冲突怎么发生的#

你和 Chongxi 改了同一个文件的同一个地方

Git 做三方对比:

共同父节点 C3:第 5 行是 "喵"
Chongxi 的 C4:第 5 行改成了 "qwq"
你的 C4': 第 5 行改成了 "uwu"

Git 不知道该用哪个,只能停下来让你决定:

<<<<<<< HEAD
uwu
=======
qwq
>>>>>>> origin/main

这不是 Git 的缺陷,是真实冲突,机器没办法替你决定业务逻辑

5.5 rebase 在多人场景下为什么危险#

现在回到之前说的那个问题,我们来用对象模型解释

你和 Chongxi 都基于 C3 创建了 feature 分支,Chongxi 已经把他的 C4、C5 推到了远端:

远端 feature: C1 ← C2 ← C3 ← C4 ← C5
你的 feature: C1 ← C2 ← C3 ← C4 ← C5

你在本地对 feature 做了 rebase,生成了 C4’、C5’:

你的 feature: C1 ← C2 ← C3 ← C4' ← C5'

然后你强行推到远端:

Terminal window
git push --force // push --force 会被打死

远端 feature 变成了:

远端 feature: C1 ← C2 ← C3 ← C4' ← C5'

原来的 C4、C5 在远端消失了

但是 Chongxi 本地还有:

Chongxi 的 feature: C1 ← C2 ← C3 ← C4 ← C5

Chongxi 再去 fetch,发现远端的历史和自己本地的历史完全对不上,因为 C4 和 C4’ 哈希不同,Git 认为这是两个完全不相关的 commit

我的本地仓库陷入混乱

根本原因:rebase 改写了 commit 的哈希,而哈希是 Git 世界里唯一的身份证。你改了别人依赖的身份证,别人的世界就崩了

  • 所以多人协作的铁律有这么一条

已经推到远端、别人可能基于它工作的 commit,永远不要 rebase

自己本地没推出去的,随便 rebase,没有任何问题

5.6 解构一下整个协作模型的本质#

每个人本地都有一份完整的对象数据库和指针系统。协作的本质,就是在不同机器之间同步对象、对齐指针。冲突是指针对不齐时的自然结果,push 被拒绝是远端在保护历史

6. 结:一个思想的四种实现#

回到最开始的问题:

在没有中央权威的情况下,怎么让分布在全球的人,对同一份数据达成共识?

这是一个几十年来反复出现的问题。不同领域的人,用不同的方式,给出了同一个答案的变体

6.1 底层思想:内容寻址 + 哈希链#

所有这些系统,都建立在两个原语上:

  1. 内容寻址

不用「这个文件在第 3 个服务器的第 5 个目录里」来定位数据,而是用数据内容本身的哈希值来定位

哈希值就是地址,地址就是内容的指纹

  1. 哈希链

每个节点包含上一个节点的哈希,形成一条链

改动链上任何一个节点,后续所有节点的哈希都会变。链的完整性可以被任何人独立验证,不需要权威机构背书

这两个原语组合在一起,解决的是同一个问题:

让数据自证清白

是不是很熟悉?web3?不不不,这个思想我们可以追溯到上个世纪七十年代

6.2 1979 年:Merkle Tree#

Ralph Merkle 在他的博士论文里提出了这个数据结构

最初的目的很朴素:怎么高效验证一大批数据里,某一条数据没有被篡改?

解法:

把所有数据放在叶子节点 每个父节点存左右子节点哈希的组合哈希 一直算到根节点

根哈希代表整棵树的状态。想验证 C 没被篡改,只需要提供 D、哈希 AB、根哈希,不需要暴露 A 和 B 的内容

这叫 Merkle Proof,验证效率是 O(log n)

这是一个纯密码学概念,当时没有具体应用

6.3 2005 年:Git#

Linus 独立地想到了同样的结构,用来解决代码协作问题

Git 的对象模型就是一棵 Merkle Tree:

commit
└── tree(根哈希)
├── blob
├── blob
└── tree
└── blob

然后用 parent 指针把多棵树串成时间轴

Git 的贡献是把 Merkle Tree 从静态数据结构,变成了带时间维度的版本历史

6.4 2008 年:比特币#

中本聪面临的问题和 Linus 本质上一样:

在没有银行这个中央权威的情况下,怎么让全球的人对谁拥有多少钱达成共识?

他的解法借鉴了同样的思想:

每个区块包含:

block {
transactions_root: 所有交易的 Merkle Tree 根哈希
prev_block_hash: 上一个区块的哈希
nonce: 工作量证明
}

prev_block_hash 就是 Git 的 parent,把区块串成链

改动任何一个历史区块,后续所有区块的哈希都会变,全网立刻发现

比特币的贡献是在哈希链的基础上加了工作量证明(也就是挖矿),解决了谁有权写入新区块的问题

Git 不需要解决这个问题,因为 Linus 自己就是最终权威(笑

6.5 2014 年:以太坊的 MPT#

以太坊要存的状态比比特币复杂得多——不只是交易记录,还有每个账户的余额、智能合约的状态、代码……

普通的 Merkle Tree 查找效率不够高,以太坊把它和 Patricia Trie(压缩前缀树)结合,发明了 Merkle Patricia Trie

MPT = Merkle Tree(防篡改)
+ Patricia Trie(高效查找)

Patricia Trie 的思路是按路径寻址,比如账户地址 0x1a2b3c... 就是树上的一条路径,顺着走就能找到这个账户的状态

以太坊每个区块有三棵 MPT:

state_root ← 全局账户状态
transactions_root ← 这个区块的所有交易
receipts_root ← 所有交易的执行结果

和 Git 相比:

Git 的 tree 对象 ≈ MPT 的中间节点(按路径寻址)
Git 的 blob 对象 ≈ MPT 的叶子节点(实际数据)
Git 的 commit ≈ 以太坊的区块(状态快照 + 指向上一个)

6.6 2015 年:IPFS#

IPFS 要解决的问题是:

HTTP 是按位置寻址的,服务器挂了,数据就消失了。能不能让数据按内容寻址,只要有人存着,任何人都能取到?

IPFS 的每个文件被切成块,每块算哈希,目录结构也用 Merkle DAG(有向无环图)来组织:

目录哈希
├── 文件 A 的哈希
│ ├── 块 1 的哈希
│ └── 块 2 的哈希
└── 文件 B 的哈希

你请求一个文件,给出根哈希,IPFS 从网络上任何存着这个数据的节点取回来,用哈希验证完整性

和 Git 比几乎一一对应:

Git blob ≈ IPFS 数据块
Git tree ≈ IPFS 目录节点
Git remote ≈ IPFS 网络节点

IPFS 的作者直接说过:IPFS 就是把 Git 的对象模型推广到整个互联网


他们的核心思想完全一样,差别只在于:

各自要解决的额外问题不同,导致在哈希链的基础上做了不同的扩展

最后我们回到 2005 年,Linus 用十天时间写出了 Git。他没有预见到区块链,没有预见到 IPFS

他只是想清楚了一个问题:

信任不应该依赖权威,应该依赖数学

这一个洞察,在接下来的二十年里,被不同领域的人反复重新发现,长出了完全不同的东西

git到底是什么?一文讲通git第一性原理和诞生的历史

作者: Chongxi
发布于: 2026-03-31
许可协议: CC BY-NC-SA 4.0
分享博文信息 (Copy All)
Contents