














2005 年,Linux 内核是全球最大的开源协作项目
全球 1000+ 贡献者当时用的是一个叫 BitKeeper 的商业版本管理工具
2005 年 4 月,一个周末。一个叫 Andrew Tridgell 的澳大利亚程序员(就是写 Samba 的那个人)对 BitKeeper 做了逆向工程
BitKeeper 的公司 Larry McVoy 发现了,宣布停止向整个 Linux 社区提供免费授权
Linus 一夜之间失去了工具
Linus 没有慌,他先花了两周时间研究了市面上所有的版本管理工具,比如 CVS、SVN、Monotone、Darcs……
然后他得出一个结论:
这些东西全都烂透了。
在他的邮件里原话是:
没有任何一个现有的系统满足我的需求,所以我决定自己写
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 发布
从零到托管全球最大开源项目,不到三个月
版本管理的本质不是记录「文件怎么变化的」,而是记录「项目在每个时刻的完整状态是什么」
CVS 和 SVN 记录的是 diff,也就是 这次改了哪些行
Linus 认为这是错的
他要记录的是快照,每次提交就是整个项目的一张完整 snapshot
这一个决策,推导出了 Git 几乎所有的设计
Linus 坐在那里,想的不是「我要怎么设计一个版本管理工具」
他想的是一个更根本的问题:
什么叫「同一份代码」?
1000 个开发者,每个人机器上都有一份 Linux 内核的代码。我怎么知道你这份和我这份是不是同一个状态?我怎么知道你发给我的补丁,是基于哪个状态写的?
SVN 当时给出的答案是看版本号,r1042 就是 r1042
Linus 觉得这是 Bullshit。版本号是中央服务器分配的,你得信任那台服务器。而且版本号只是一个计数器,它本身不包含任何关于内容的信息
Linus 的想法是:
不要用外部分配的 ID 来标识一份代码,用代码内容本身的哈希值
具体来说,对一个文件的全部内容做 SHA-1 哈希,得到一个 40 位的字符串
PRTCL // PLAINTEXT
SHA-1("Chongxi") = f5c3bc7e6fcd7b7b8d04b99f5d7e607302d3a911
这个字符串有一个数学性质:
内容变了,哈希一定变。哈希没变,内容一定没变
这不是协议,不是信任,是数学层面的 proof
所以两个人拿着同一个哈希值,就可以确认自己手里的文件是完全一样的,不需要任何第三方
一个文件可以哈希,那整个项目呢?
Linux 内核有几万个文件,分布在几百个目录里。Linus 需要一个方法,用一个哈希值来代表整个项目的状态
他的解法是递归地哈希:
PRTCL // PLAINTEXT
main.c 的内容 → 哈希 A
util.c 的内容 → 哈希 B
src/ 目录 → 哈希 (A + B + 文件名 ) = 哈希 C
README 的内容 → 哈希 D
根目录 → 哈希 (C + D + 文件名 ) = 哈希 E
根目录的哈希 E,是由所有子节点的哈希算出来的
这意味着:
任何一个文件改了一个字节,由此向上,所有父节点的哈希都会变,根哈希也会变
反过来:根哈希没变,整个项目的每一个字节都没变
这就是 Git 的 tree 对象,也是 Merkle Tree 的核心思想
有了 tree 对象,Linus 可以在任意时刻,用一个哈希值锁定整个项目的状态
然后他在外面再包一层,加上元信息:
PRTCL // PLAINTEXT
commit {
tree: e5f8c2... ← 这一刻整个项目的根哈希
parent: a3d9f1... ← 上一个 commit 的哈希
author: Linus
time: 2005-04-07
message: "Initial commit"
}
然后对这整个结构再做一次哈希,得到这个 commit 的哈希值
这里有一个非常关键的地方:
commit 的哈希,包含了 parent 的哈希
这意味着你没办法在不改变哈希的情况下,悄悄修改历史上某个 commit 的内容。改了那个 commit,它的哈希变了,下一个 commit 记录的 parent 哈希就对不上了,再下一个也对不上,整条链都断了
历史是用密码学锁住的,不是靠权限控制,不是靠信任,是靠数学
SVN 存的是:
PRTCL // PLAINTEXT
r1: 原始文件
r2: 第 3 行改成了这个
r3: 第 17 行删了,第 20 行加了这个
想知道 r3 的状态,你得从头把所有 diff 叠加起来算
Git 存的是:
PRTCL // PLAINTEXT
commit1: 整个项目的哈希 E1
commit2: 整个项目的哈希 E2
commit3: 整个项目的哈希 E3
想知道任意时刻的状态,直接拿那个哈希,找到对应的 tree,展开就是了。不需要计算,不需要回放
代价是存储空间?Git 用了对象去重来解决,两个 commit 如果某个文件没有改动,它们的 blob 哈希一样,Git 只存一份
到这里,Linus 还没有发明分支,没有发明merge,没有发明任何高级概念
他只做了一件事:
用哈希把每一个时刻的项目状态锁定成一个不可篡改的对象,用 parent 指针把这些对象串成链
剩下所有的东西——分支、合并、rebase,都是在这个基础上自然生长出来的
Git 并不是一个复杂的版本管理系统
Linus 最开始的定义非常朴素:
Git 就是一个内容寻址的文件系统。你把东西存进去,它给你一个哈希。你拿着哈希,随时可以把东西取出来
仅此而已
你可以打开任何一个 Git 仓库,看 .git/objects/ 目录,里面存的就是所有对象,按哈希值的前两位分文件夹:
PRTCL // PLAINTEXT
.git/objects/
a3/f8c2d9b1e4... ← 这就是一个对象文件
d9/f3a1b2c4e5...
e5/c2f8a3d9b1...
每个文件的内容就是压缩过的对象数据。整个 Git 仓库,本质上就是这个文件夹
Git 里最简单的对象叫 blob,存的就是文件的原始内容,不包含文件名,不包含任何元信息
PRTCL // PLAINTEXT
blob "CEPATO"
↓ SHA-1
d1fcd7b25f99b9f9d1a577557f45c10b6c1f9642
blob 不知道自己叫什么,也不知道自己在哪个目录里
文件名是上层的事,blob 只管内容
这个设计有一个直接好处:
两个不同路径下内容完全一样的文件,Git 只存一份 blob
文件名和目录结构,交给 tree 对象来管
一个 tree 对象就是一张表:
PRTCL // PLAINTEXT
tree d9f3a1... {
100644 blob 8c7e5a... main.c
100644 blob f7d8e9... util.c
040000 tree b2c4e5... src/
}
每一行记录:文件权限、对象类型、哈希值、名字
tree 可以嵌套 tree,这样就构成了完整的目录结构
然后对这张表做哈希,得到这个 tree 的哈希值
因为 tree 的哈希是由子节点哈希算出来的,所以:
PRTCL // PLAINTEXT
改了 main.c 的内容
→ main.c 的 blob 哈希变了
→ 包含它的 tree 哈希变了
→ 上层 tree 哈希变了
→ 根 tree 哈希变了
任何一个叶子的变动,都会反馈到到根节点
这里我们来区分一下,有个朋友单独找我提问了,私以为问题很好于是单独放出来讲下
Git 的 tree 不是哈希表,它更像一个有序列表,存的是文件名 → 子节点哈希的映射,但查找方式是线性遍历,不是哈希表的 O(1) 寻址
和 MPT 里的 Patricia Trie 部分相比
PRTCL // PLAINTEXT
Git tree: 文件名 → 子节点哈希 // 简单映射,不优化查找)
MPT: 路径 → 叶子值 // 按前缀压缩,O(log n) 查找
两者表面很像,都是某种 key 指向哈希,但设计目标不同:
所以 Git 用简单结构够了,以太坊才需要 Patricia Trie 来优化
也就是说Git 的 tree 是为了描述状态,MPT 是为了高效查询状态
有了 tree,我们能表示项目在某一刻的状态
但还缺两件事:这个状态是谁在什么时候创建的,以及它的上一个状态是什么
这就是 commit 对象:
PRTCL // PLAINTEXT
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 哈希,就能验证从第一天到今天,每一行代码的每一次变更,没有任何人动过
现在把三层放在一起看:
PRTCL // PLAINTEXT
commit a3f8c2
└── tree d9f3a1 (根目录)
├── blob 8c7e5a main.c
├── blob f7d8e9 util.c
└── tree b2c4e5 src/
└── blob 3a9f1c helper.c
每个节点都是一个独立的对象,存在 .git/objects/ 里,用哈希寻址
它们之间没有包含关系,只有引用关系。每个对象只记录子节点的哈希,不复制子节点的内容
假设你做了第二次提交,只改了 main.c:
PRTCL // PLAINTEXT
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
鱼和熊掌可以兼得
推导到这里,Git 的存储层已经完整了:一个哈希对象数据库,里面装着 blob、tree、commit,用引用关系连接成一棵棵树,再用 parent 指针串成时间轴
但有一个问题:
commit 的哈希是 a3f8c2d9b1e4f7... 这样一串东西,人类没法记
你怎么找到最新的那个 commit?
对象数据库建好了,里面存着几千个对象,每个都有一个 40 位的哈希地址
Linus 面临一个很实际的问题:
我怎么知道最新的提交在哪?
用每次都把最新的 commit 哈希记在纸上?那确实很直接了
git用一个文本文件把这个哈希存起来,给这个文件起一个人类能看懂的名字
这就是分支。对,就这么简单
Git 把这些文本文件统一放在 .git/refs/ 目录下:
PRTCL // PLAINTEXT
.git/refs/
heads/
main ← 内容:a3f8c2d9...
feature ← 内容:d9f3a1b2...
remotes/
origin/
main ← 内容:e5c2f8a3...
tags/
v1.0 ← 内容:f7d8e9b1...
你可以直接打开这些文件看,里面就是一行哈希值,没有任何其他东西
PRTCL // BASH
cat .git/refs/heads/main
# a3f8c2d9b1e4f7a8b2c3d4e5f6a7b8c9d0e1f2a3
所以「创建一个新分支」,在文件系统层面发生了什么?
PRTCL // BASH
git branch feature
发生了什么:
在
.git/refs/heads/下新建一个叫feature的文件,把当前 commit 的哈希写进去
就这一件事。没有复制代码,没有复制历史,没有任何你认为的企业级操作,就这么简单
这就是为什么 Git 的分支创建是瞬间完成的,而 SVN 创建分支要复制整个目录
你在 feature 分支上提交了新代码,生成了新的 commit b2c4e5...
发生了什么:
把
.git/refs/heads/feature文件里的哈希,更新成b2c4e5...
就是改了一个文件里的一行字符串
PRTCL // PLAINTEXT
提交前:feature 文件内容 = a3f8c2...
提交后:feature 文件内容 = b2c4e5...
分支跟着你走,本质就是这个文件在自动更新
现在你有了 main、feature 两个分支,Git 怎么知道你当前在哪个分支上?
再加一个文件,叫 HEAD,放在 .git/HEAD:
PRTCL // BASH
cat .git/HEAD
它存的不是 commit 哈希,而是指向某个分支的引用
所以:
PRTCL // PLAINTEXT
HEAD → refs/heads/feature → b2c4e5...(commit)
你提交新代码时:
feature 文件更新成新的哈希featurePRTCL // BASH
git checkout main
发生了什么:
.git/HEAD 改成:PRTCL // PLAINTEXT
ref: refs/heads/main
main 指向的 commit,把对应的文件树展开到你的工作目录你看到文件变了,本质是 Git 用对象数据库里的内容,重新写了你的工作目录
现在我们理解了 HEAD 是指向某个分支的指针
但如果你直接 checkout 一个 commit 哈希:
PRTCL // BASH
git checkout a3f8c2
这时候没有分支可以指,HEAD 只能直接存哈希值:
PRTCL // BASH
cat .git/HEAD
# a3f8c2d9b1e4f7...
这就是 detached HEAD,头指针脱离
为什么都说脱离状态危险?
因为你在这个状态下做了新的提交,生成了新的 commit。但没有任何分支文件记录这个新 commit 的哈希
一旦你切换到别的分支,HEAD 更新了,那个新 commit 的哈希就没有任何东西指向它了
它还躺在 .git/objects/ 里,但你再也找不到它
Git 会定期运行垃圾回收,把没有任何引用指向的对象清理掉。你的提交就这样消失了
最后还有一类指针,放在 .git/refs/remotes/ 下:
PRTCL // PLAINTEXT
origin/main ← 内容:上次 fetch 时,远端 main 的哈希
它的作用是:记录你上次同步时,远端仓库的状态
它是只读的,你不能直接在上面提交。它只在你 git fetch 的时候更新
git push 和 git pull 的本质,是让本地和远端的这些指针对齐
PRTCL // PLAINTEXT
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 操作,本质上都是在移动这些指针,或者在对象数据库里新增对象
我们先来建立一个场景,后面所有操作都基于此:
PRTCL // PLAINTEXT
C1 ← C2 ← C3 main
↑
└── C4 ← C5 feature
main 指向 C3,feature 指向 C5,C4 和 C5 是从 C3 分叉出去的。
你站在 main 上,执行:
PRTCL // BASH
git merge feature
git 操作如下
main 在 C3,feature 在 C5,顺着 parent 往上找,父节点是 C3
PRTCL // PLAINTEXT
C3 的文件状态
C3 → C3 之间 main 改了什么
C3 → C5 之间 feature 改了什么
改了不同地方,自动合并。改了同一个地方,冲突,让你手动解决
PRTCL // PLAINTEXT
commit C6 {
tree: 合并后的文件状态
parent: C3 ← main 这边
parent: C5 ← feature 这边
}
C6 有两个 parent,这在 Git 对象模型里是完全合法的
然后 main 指针移动到 C6:
PRTCL // PLAINTEXT
C1 ← C2 ← C3 ← C6 main
↑ ↑
└── C4 ← C5 feature
merge 的本质是在对象数据库里新建一个节点,把两条线接在一起,移动当前分支指针
历史是真实的,分叉和汇合都保留着
PRTCL // PLAINTEXT
C1 ← C2 ← C3 main
↑
└── C4 ← C5 feature
同样的场景,你站在 feature 上,执行:
PRTCL // BASH
git rebase main
git 则是把C4、C5 这两个 commit,在 C3 之后重放一遍
PRTCL // PLAINTEXT
C4 → C4'(parent 从 C2 变成了 C3)
C5 → C5'(parent 从 C4 变成了 C4')
C4’ 和 C4 的文件内容可能一样,但哈希值完全不同,因为 parent 变了
结果:
PRTCL // PLAINTEXT
C1 ← C2 ← C3 ← C4' ← C5' feature
↑
main
历史变成了一条直线,看起来好像 feature 一直是在 C3 之后顺序开发的
rebase 的本质是把一段 commit 历史,复制到另一个位置重建,原来的对象还在数据库里,但没有指针指向它们了
注意,C4 和 C4’ 同时存在于 .git/objects/ 里
rebase 之后,feature 指针移动到了 C5’,没有任何指针指向原来的 C4 和 C5 了
它们会在 Git 垃圾回收时被清理掉
这就是为什么 rebase 之后我们说「历史被改写了」,原来的 commit 被抛弃了,fork 了一批平行的对象
PRTCL // BASH
git reset C3
这个操作最简单,也最容易被误解
它做的事情只有一件:
把当前分支的指针,强行移动到指定的 commit
PRTCL // PLAINTEXT
执行前:main → C5
执行后:main → C3
C4、C5 还在对象数据库里,但 main 不再指向它们了
reset 有三种模式,区别在于工作目录和暂存区怎么处理:
PRTCL // PLAINTEXT
--soft 只移动指针,工作目录和暂存区不动
--mixed 移动指针,暂存区清空,工作目录不动(默认)
--hard 移动指针,暂存区清空,工作目录也回退
从指针视角看,三种模式做的是同一件事。区别只是你工作目录里的文件怎么变
PRTCL // BASH
git cherry-pick C4
你在 main 上,想把 feature 上的某一个 commit 单独拿过来
操作:
提取 C4 相对于它的 parent 的变更内容,在当前位置重新应用一遍,生成新的 commit C4’
PRTCL // PLAINTEXT
C1 ← C2 ← C3 ← C4' main
↑
└── C4 ← C5 feature
C4’ 和 C4 内容相同,哈希不同
和 rebase 一样,都是「复制 commit 到新位置重建」,只不过 rebase 复制一段,cherry-pick 复制一个
简单列个表:
| 操作 | 新建对象? | 移动指针? | 改写历史? |
|---|---|---|---|
| merge | 是,新建 merge commit | 是 | 否 |
| rebase | 是,重建 commit | 是 | 是 |
| reset | 否 | 是 | 看模式 |
| cherry-pick | 是,复制 commit | 是 | 否 |
所有操作都在做两件事的组合:在对象数据库里新增东西,和移动指针
我们现在可以理解任何 Git 操作:
这就是为什么 Git 几乎不会丢数据——只要对象还在数据库里,你总能通过 git reflog 找回指针曾经指向过的位置,把东西捞回来
接下来我们讲协作以及为什么不要在多人时用 rebase
先建立实际场景
你和 Chongxi,各自 clone 了同一个远端仓库。clone 的那一刻,你们三个人的状态完全一样:
PRTCL // PLAINTEXT
远端 origin: C1 ← C2 ← C3 main
你的本地: C1 ← C2 ← C3 main
Chongxi 的本地: C1 ← C2 ← C3 main
然后我们开始各自工作

clone 并不是单纯的「下载代码」
准确说法是:
把远端的整个对象数据库复制到本地,然后把远端的所有指针,在本地存一份只读的副本
PRTCL // PLAINTEXT
.git/refs/remotes/origin/main = C3 的哈希
这个 origin/main 就是你本地对远端状态的快照,记录的是上次我跟远端同步时,它在哪
你工作了一段时间,远端有了新的提交
Git 操作如下:
从远端下载所有新的对象,更新
origin/main指针。你的本地main不动
PRTCL // PLAINTEXT
远端: C1 ← C2 ← C3 ← C4 ← C5 main
你的本地 main: C1 ← C2 ← C3 main(没动)
origin/main: C1 ← C2 ← C3 ← C4 ← C5 (更新了)
fetch 只做一件事:同步对象,更新远端指针。你的工作完全不受影响
你在本地提交了 C4’,想推到远端:
Git 操作如下
main 指针移动到 C4’但远端会检查一件事:
你的 C4’ 的 parent,是不是远端当前 main 指向的 commit?
如果是,叫做 fast-forward,远端直接把指针往前移,没有任何问题
如果不是,远端拒绝,返回错误:
PRTCL // PLAINTEXT
! [rejected] main -> main (non-fast-forward)
为什么会被拒绝
你和 Chongxi 同时从 C3 开始工作:
PRTCL // PLAINTEXT
你: C1 ← C2 ← C3 ← C4' 本地 main
Chongxi: C1 ← C2 ← C3 ← C4 推到了远端
Chongxi 比你先推,远端 main 现在在 C4
你要推 C4’,但 C4’ 的 parent 是 C3,不是 C4
远端:你的历史和我的历史分叉了,我不知道该怎么合并,你自己看着办吧
本质是两步:
PRTCL // PLAINTEXT
git fetch ← 先同步远端对象
git merge ← 再把 origin/main 合并到本地 main
合并之后生成 merge commit C5,然后你再 push,这次 C5 的 parent 包含了 C4,远端会接受
PRTCL // PLAINTEXT
C1 ← C2 ← C3 ← C4 ← C5 远端 main(C5 是 merge commit)
↑ ↑
Chongxi 你
你和 Chongxi 改了同一个文件的同一个地方
Git 做三方对比:
PRTCL // PLAINTEXT
共同父节点 C3:第 5 行是 "喵"
Chongxi 的 C4:第 5 行改成了 "qwq"
你的 C4': 第 5 行改成了 "uwu"
Git 不知道该用哪个,只能停下来让你决定:
PRTCL // PLAINTEXT
<<<<<<< HEAD
uwu
=======
qwq
>>>>>>> origin/main
这不是 Git 的缺陷,是真实冲突,机器没办法替你决定业务逻辑
现在回到之前说的那个问题,我们来用对象模型解释
你和 Chongxi 都基于 C3 创建了 feature 分支,Chongxi 已经把他的 C4、C5 推到了远端:
PRTCL // PLAINTEXT
远端 feature: C1 ← C2 ← C3 ← C4 ← C5
你的 feature: C1 ← C2 ← C3 ← C4 ← C5
你在本地对 feature 做了 rebase,生成了 C4’、C5’:
PRTCL // PLAINTEXT
你的 feature: C1 ← C2 ← C3 ← C4' ← C5'
然后你强行推到远端:
PRTCL // BASH
git push --force // 瞎 push --force 会被打死
远端 feature 变成了:
PRTCL // PLAINTEXT
远端 feature: C1 ← C2 ← C3 ← C4' ← C5'
原来的 C4、C5 在远端消失了
但是 Chongxi 本地还有:
PRTCL // PLAINTEXT
Chongxi 的 feature: C1 ← C2 ← C3 ← C4 ← C5
Chongxi 再去 fetch,发现远端的历史和自己本地的历史完全对不上,因为 C4 和 C4’ 哈希不同,Git 认为这是两个完全不相关的 commit
我的本地仓库陷入混乱
根本原因:rebase 改写了 commit 的哈希,而哈希是 Git 世界里唯一的身份证。你改了别人依赖的身份证,别人的世界就崩了
已经推到远端、别人可能基于它工作的 commit,永远不要 rebase
自己本地没推出去的,随便 rebase,没有任何问题
每个人本地都有一份完整的对象数据库和指针系统。协作的本质,就是在不同机器之间同步对象、对齐指针。冲突是指针对不齐时的自然结果,push 被拒绝是远端在保护历史
回到最开始的问题:
在没有中央权威的情况下,怎么让分布在全球的人,对同一份数据达成共识?
这是一个几十年来反复出现的问题。不同领域的人,用不同的方式,给出了同一个答案的变体
所有这些系统,都建立在两个原语上:
不用「这个文件在第 3 个服务器的第 5 个目录里」来定位数据,而是用数据内容本身的哈希值来定位
哈希值就是地址,地址就是内容的指纹
每个节点包含上一个节点的哈希,形成一条链
改动链上任何一个节点,后续所有节点的哈希都会变。链的完整性可以被任何人独立验证,不需要权威机构背书
这两个原语组合在一起,解决的是同一个问题:
让数据自证清白
是不是很熟悉?web3?不不不,这个思想我们可以追溯到上个世纪七十年代
Ralph Merkle 在他的博士论文里提出了这个数据结构
最初的目的很朴素:怎么高效验证一大批数据里,某一条数据没有被篡改?
解法:
把所有数据放在叶子节点 每个父节点存左右子节点哈希的组合哈希 一直算到根节点
根哈希代表整棵树的状态。想验证 C 没被篡改,只需要提供 D、哈希 AB、根哈希,不需要暴露 A 和 B 的内容
这叫 Merkle Proof,验证效率是 O(log n)
这是一个纯密码学概念,当时没有具体应用
Linus 独立地想到了同样的结构,用来解决代码协作问题
Git 的对象模型就是一棵 Merkle Tree:
PRTCL // PLAINTEXT
commit
└── tree(根哈希)
├── blob
├── blob
└── tree
└── blob
然后用 parent 指针把多棵树串成时间轴
Git 的贡献是把 Merkle Tree 从静态数据结构,变成了带时间维度的版本历史
中本聪面临的问题和 Linus 本质上一样:
在没有银行这个中央权威的情况下,怎么让全球的人对谁拥有多少钱达成共识?
他的解法借鉴了同样的思想:
每个区块包含:
PRTCL // PLAINTEXT
block {
transactions_root: 所有交易的 Merkle Tree 根哈希
prev_block_hash: 上一个区块的哈希
nonce: 工作量证明
}
prev_block_hash 就是 Git 的 parent,把区块串成链
改动任何一个历史区块,后续所有区块的哈希都会变,全网立刻发现
比特币的贡献是在哈希链的基础上加了工作量证明(也就是挖矿),解决了谁有权写入新区块的问题
Git 不需要解决这个问题,因为 Linus 自己就是最终权威(笑
以太坊要存的状态比比特币复杂得多——不只是交易记录,还有每个账户的余额、智能合约的状态、代码……
普通的 Merkle Tree 查找效率不够高,以太坊把它和 Patricia Trie(压缩前缀树)结合,发明了 Merkle Patricia Trie
PRTCL // PLAINTEXT
MPT = Merkle Tree(防篡改)
+ Patricia Trie(高效查找)
Patricia Trie 的思路是按路径寻址,比如账户地址 0x1a2b3c... 就是树上的一条路径,顺着走就能找到这个账户的状态
以太坊每个区块有三棵 MPT:
PRTCL // PLAINTEXT
state_root ← 全局账户状态
transactions_root ← 这个区块的所有交易
receipts_root ← 所有交易的执行结果
和 Git 相比:
PRTCL // PLAINTEXT
Git 的 tree 对象 ≈ MPT 的中间节点(按路径寻址)
Git 的 blob 对象 ≈ MPT 的叶子节点(实际数据)
Git 的 commit ≈ 以太坊的区块(状态快照 + 指向上一个)
IPFS 要解决的问题是:
HTTP 是按位置寻址的,服务器挂了,数据就消失了。能不能让数据按内容寻址,只要有人存着,任何人都能取到?
IPFS 的每个文件被切成块,每块算哈希,目录结构也用 Merkle DAG(有向无环图)来组织:
PRTCL // PLAINTEXT
目录哈希
├── 文件 A 的哈希
│ ├── 块 1 的哈希
│ └── 块 2 的哈希
└── 文件 B 的哈希
你请求一个文件,给出根哈希,IPFS 从网络上任何存着这个数据的节点取回来,用哈希验证完整性
和 Git 比几乎一一对应:
PRTCL // PLAINTEXT
Git blob ≈ IPFS 数据块
Git tree ≈ IPFS 目录节点
Git remote ≈ IPFS 网络节点
IPFS 的作者直接说过:IPFS 就是把 Git 的对象模型推广到整个互联网
他们的核心思想完全一样,差别只在于:
各自要解决的额外问题不同,导致在哈希链的基础上做了不同的扩展
最后我们回到 2005 年,Linus 用十天时间写出了 Git。他没有预见到区块链,没有预见到 IPFS
他只是想清楚了一个问题:
信任不应该依赖权威,应该依赖数学
这一个洞察,在接下来的二十年里,被不同领域的人反复重新发现,长出了完全不同的东西
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。