git到底是什么?一文讲通git第一性原理和诞生的历史
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 的内容 → 哈希 Autil.c 的内容 → 哈希 Bsrc/ 目录 → 哈希 (A + B + 文件名 ) = 哈希 CREADME 的内容 → 哈希 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: 整个项目的哈希 E1commit2: 整个项目的哈希 E2commit3: 整个项目的哈希 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-1d1fcd7b25f99b9f9d1a577557f45c10b6c1f9642blob 不知道自己叫什么,也不知道自己在哪个目录里
文件名是上层的事,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.c 和 src/ 目录没有变,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...你可以直接打开这些文件看,里面就是一行哈希值,没有任何其他东西
cat .git/refs/heads/main# a3f8c2d9b1e4f7a8b2c3d4e5f6a7b8c9d0e1f2a33.2 分支的本质
所以「创建一个新分支」,在文件系统层面发生了什么?
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
现在你有了 main、feature 两个分支,Git 怎么知道你当前在哪个分支上?
再加一个文件,叫 HEAD,放在 .git/HEAD:
cat .git/HEAD它存的不是 commit 哈希,而是指向某个分支的引用
所以:
HEAD → refs/heads/feature → b2c4e5...(commit)你提交新代码时:
- 生成新的 commit 对象
feature文件更新成新的哈希- HEAD 不动,还是指向
feature
3.5 切换分支呢
git checkout main发生了什么:
- 把
.git/HEAD改成:
ref: refs/heads/main- 根据
main指向的 commit,把对应的文件树展开到你的工作目录
你看到文件变了,本质是 Git 用对象数据库里的内容,重新写了你的工作目录
3.6 detached HEAD
现在我们理解了 HEAD 是指向某个分支的指针
但如果你直接 checkout 一个 commit 哈希:
git checkout a3f8c2这时候没有分支可以指,HEAD 只能直接存哈希值:
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 push 和 git 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 featuremain 指向 C3,feature 指向 C5,C4 和 C5 是从 C3 分叉出去的。
4.1 merge:新建一个节点
你站在 main 上,执行:
git merge featuregit 操作如下
- 找到分支父节点
main 在 C3,feature 在 C5,顺着 parent 往上找,父节点是 C3
- diff
C3 的文件状态C3 → C3 之间 main 改了什么C3 → C5 之间 feature 改了什么改了不同地方,自动合并。改了同一个地方,冲突,让你手动解决
- 生成一个新的 commit C6
commit C6 { tree: 合并后的文件状态 parent: C3 ← main 这边 parent: C5 ← feature 这边}C6 有两个 parent,这在 Git 对象模型里是完全合法的
然后 main 指针移动到 C6:
C1 ← C2 ← C3 ← C6 main ↑ ↑ └── C4 ← C5 featuremerge 的本质是在对象数据库里新建一个节点,把两条线接在一起,移动当前分支指针
历史是真实的,分叉和汇合都保留着
4.2 rebase:重建节点,换个爹
C1 ← C2 ← C3 main ↑ └── C4 ← C5 feature同样的场景,你站在 feature 上,执行:
git rebase maingit 则是把C4、C5 这两个 commit,在 C3 之后重放一遍
- 找到共同父节点 C3
- 把 C4、C5 的变更内容提取出来
- 从 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:移动指针,不新建节点
git reset C3这个操作最简单,也最容易被误解
它做的事情只有一件:
把当前分支的指针,强行移动到指定的 commit
执行前:main → C5执行后:main → C3C4、C5 还在对象数据库里,但 main 不再指向它们了
reset 有三种模式,区别在于工作目录和暂存区怎么处理:
--soft 只移动指针,工作目录和暂存区不动--mixed 移动指针,暂存区清空,工作目录不动(默认)--hard 移动指针,暂存区清空,工作目录也回退从指针视角看,三种模式做的是同一件事。区别只是你工作目录里的文件怎么变
4.4 cherry-pick:复制单个节点
git cherry-pick C4你在 main 上,想把 feature 上的某一个 commit 单独拿过来
操作:
提取 C4 相对于它的 parent 的变更内容,在当前位置重新应用一遍,生成新的 commit C4’
C1 ← C2 ← C3 ← C4' main ↑ └── C4 ← C5 featureC4’ 和 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 mainChongxi 的本地: C1 ← C2 ← C3 main然后我们开始各自工作
5.1 clone 发生了什么

clone 并不是单纯的「下载代码」
准确说法是:
把远端的整个对象数据库复制到本地,然后把远端的所有指针,在本地存一份只读的副本
.git/refs/remotes/origin/main = C3 的哈希这个 origin/main 就是你本地对远端状态的快照,记录的是上次我跟远端同步时,它在哪
5.2 fetch
你工作了一段时间,远端有了新的提交
git fetchGit 操作如下:
从远端下载所有新的对象,更新
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’,想推到远端:
git pushGit 操作如下
- 把本地新的对象(C4’)传到远端的对象数据库
- 请求远端把
main指针移动到 C4’
但远端会检查一件事:
你的 C4’ 的 parent,是不是远端当前 main 指向的 commit?
如果是,叫做 fast-forward,远端直接把指针往前移,没有任何问题
如果不是,远端拒绝,返回错误:
! [rejected] main -> main (non-fast-forward)为什么会被拒绝
你和 Chongxi 同时从 C3 开始工作:
你: C1 ← C2 ← C3 ← C4' 本地 mainChongxi: C1 ← C2 ← C3 ← C4 推到了远端Chongxi 比你先推,远端 main 现在在 C4
你要推 C4’,但 C4’ 的 parent 是 C3,不是 C4
远端:你的历史和我的历史分叉了,我不知道该怎么合并,你自己看着办吧
5.3 pull:fetch + 合并
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 不知道该用哪个,只能停下来让你决定:
<<<<<<< HEADuwu=======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'然后你强行推到远端:
git push --force // 瞎 push --force 会被打死远端 feature 变成了:
远端 feature: C1 ← C2 ← C3 ← C4' ← C5'原来的 C4、C5 在远端消失了
但是 Chongxi 本地还有:
Chongxi 的 feature: C1 ← C2 ← C3 ← C4 ← C5Chongxi 再去 fetch,发现远端的历史和自己本地的历史完全对不上,因为 C4 和 C4’ 哈希不同,Git 认为这是两个完全不相关的 commit
我的本地仓库陷入混乱
根本原因:rebase 改写了 commit 的哈希,而哈希是 Git 世界里唯一的身份证。你改了别人依赖的身份证,别人的世界就崩了
- 所以多人协作的铁律有这么一条
已经推到远端、别人可能基于它工作的 commit,永远不要 rebase
自己本地没推出去的,随便 rebase,没有任何问题
5.6 解构一下整个协作模型的本质
每个人本地都有一份完整的对象数据库和指针系统。协作的本质,就是在不同机器之间同步对象、对齐指针。冲突是指针对不齐时的自然结果,push 被拒绝是远端在保护历史
6. 结:一个思想的四种实现
回到最开始的问题:
在没有中央权威的情况下,怎么让分布在全球的人,对同一份数据达成共识?
这是一个几十年来反复出现的问题。不同领域的人,用不同的方式,给出了同一个答案的变体
6.1 底层思想:内容寻址 + 哈希链
所有这些系统,都建立在两个原语上:
- 内容寻址
不用「这个文件在第 3 个服务器的第 5 个目录里」来定位数据,而是用数据内容本身的哈希值来定位
哈希值就是地址,地址就是内容的指纹
- 哈希链
每个节点包含上一个节点的哈希,形成一条链
改动链上任何一个节点,后续所有节点的哈希都会变。链的完整性可以被任何人独立验证,不需要权威机构背书
这两个原语组合在一起,解决的是同一个问题:
让数据自证清白
是不是很熟悉?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
他只是想清楚了一个问题:
信任不应该依赖权威,应该依赖数学
这一个洞察,在接下来的二十年里,被不同领域的人反复重新发现,长出了完全不同的东西