哈希函数与默克尔树:区块链的指纹术(师生对话实录)
Web3 区块链系列 · 阶段 0 · 地基与密码学 · 第 2/57 篇
上一篇:《Web3 全景:区块链到底在解决什么问题》 · 下一篇:《公私钥与数字签名:从私钥到地址》
学习大纲:《Web3 区块链学习总纲》
写在前面
上一篇老师说「区块链让改账在数学上不可行」,我问凭什么,他说两个字:哈希。还说接下来整篇就干一件事——亲手把「改一行、全网指纹全断」造出来。
继续对话老办法:AI 当老师,我当学生,每课一个概念,有问题就打断。本篇实验全程 Python 标准库(hashlib)+ 一个 keccak 库,40 行代码跑完区块链的一半地基。
课程路线图:
① 指纹三性质 → ② 雪崩效应 → ③ 哈希指针:串链 → ④ 默克尔树:建树 → ⑤ 默克尔证明:SPV → ⑥ commit-reveal 初见
环境:WSL2 Ubuntu-22.04 + Python 3.10(pip3 install pycryptodome 提供 keccak)。官方背景:ethereum.org — 哈希函数、比特币开发者文档 — 默克尔树。
第 1 课:指纹——同一个人,永远同一个指纹
🧑🏫 老师:
密码学哈希函数,一句话:把任意长度的输入,压缩成固定长度的「指纹」,而且同一个输入永远得到同一个指纹。先摸一下:
import hashlib
a = hashlib.sha256(b"hello blockchain").hexdigest()
b = hashlib.sha256("hello blockchain".encode()).hexdigest()
print(a)
print("两次计算相同:", a == b)
print("长度:", len(a), "个十六进制字符 =", len(a) * 4, "bit")cf55026ba78c889dbdaf0c32701cdb4d662f3d3ea4460110d3ed2edd0d753e72
两次计算相同: True
长度: 64 个十六进制字符 = 256 bit不管输入是一句话、一首歌还是 4GB 的电影,sha256 吐出来的永远是 64 个十六进制字符(256 位)。确定性是第一个性质——这一点看似平平无奇,却是它后面一切用法的底座:全网几万个节点对同一段数据各自计算指纹,得到的必须一模一样,否则「对账」无从谈起。
正式列一下密码学哈希的三大性质,后面全是它们在打工:
| 性质 | 白话 | 在区块链里买到的能力 |
|---|---|---|
| 确定性 | 同输入必同输出 | 几万节点能对同一份账算出同一串指纹 |
| 抗碰撞性 | 找不到两个不同输入有相同输出 | 没人能「造一份假数据冒充真数据」 |
| 单向性 | 从指纹推不回原文 | 指纹可以随便公开,不泄露数据本身 |
一句话收口:哈希 = 任意数据 → 固定 256 位指纹;确定性让全网对账,抗碰撞让冒充无门,单向性让公开无害。
第 2 课:雪崩效应——改一个字符,指纹面目全非
🧑🎓 学生: 「改没改过」要能看出来,光确定性不够吧?如果我只改一个字符,指纹只变一点点,是不是也能看出来?
🧑🏫 老师:
正好相反——指纹变得面目全非,这个性质叫雪崩效应。实测,末尾只加一个字符:
h1 = hashlib.sha256(b"hello blockchain").hexdigest()
h2 = hashlib.sha256(b"hello blockchainX").hexdigest()
print("原 :", h1)
print("改 :", h2)
diff = sum(x != y for x, y in zip(h1, h2))
print(f"256 位里不同的十六进制位: {diff}/64")原 : cf55026ba78c889dbdaf0c32701cdb4d662f3d3ea4460110d3ed2edd0d753e72
改 : 3bf135c4ce6c57df7b67a32e68cf7f7e17b45f7c4879fc1cf660bbb385f9200f
256 位里不同的十六进制位: 62/6462/64 的位置都变了,而且变化毫无规律——新旧指纹的关系,跟两个随机数没有区别。这正是「篡改无处遁形」的来源:改多少都会引发雪崩,没有「小改小变」的侥幸。把「老王给小李 20」偷偷改成「200」?指纹当场变成另一个毫不相干的值,和账本上记的那个对不上。
顺带认一下两条链的选型——函数不同,用途相同:
from Crypto.Hash import keccak
k = keccak.new(digest_bits=256); k.update(b"hello blockchain")
print("sha256 :", h1)
print("keccak256:", k.hexdigest())sha256 : cf55026ba78c889dbdaf0c32701cdb4d662f3d3ea4460110d3ed2edd0d753e72
keccak256: 4bbf65195f5698ff1e68597c5b8fa9920190a330df81d13ff7811da563d853f1比特币用 SHA-256,以太坊用 keccak256。为什么两个?插问 3 讲这段小历史——现在只需要记住:看到 0x 开头的 64 位十六进制,多半是以太坊的 keccak256;比特币的地址和块哈希背后是 SHA-256。
一句话收口:雪崩效应 = 输入微变、输出全变;这让「偷偷改一点」在指纹层面等于「全部重写」。
插问 1:哈希是加密吗?我能用它加密消息吗?
🧑🎓 学生: 我一直以为哈希就是某种加密——把东西「加密」成一串乱码。那能解密回去吗?
🧑🏫 老师:
不能,而且这正是不小的误解:哈希不是加密。区别在「可逆性」:
- 加密(可逆):密文 + 钥匙 → 还原出原文。有锁就有钥匙,设计目标就是「合法的人能解回去」;
- 哈希(不可逆):只有单向的「进去」,没有「出来」。原文的信息在压缩中被真正丢弃了——256 位指纹装不下任意大的原文,数学上不存在还原函数。
一个实验感受「不可逆」的分量。我拿 cat 的指纹前 8 位,暴力枚举所有 1~3 位小写字母组合去找它:
import itertools, string
target = hashlib.sha256(b"cat").hexdigest()
print("目标('cat' 的 sha256 前 8 位):", target[:8])
found = None
for cand in ("".join(p) for n in (1, 2, 3) for p in itertools.product(string.ascii_lowercase, repeat=n)):
if hashlib.sha256(cand.encode()).hexdigest()[:8] == target[:8]:
found = cand; break
print("暴力枚举找到的原文:", found, "(枚举了 26+676+17576=18278 个词)")目标('cat' 的 sha256 前 8 位): 77af778b
暴力枚举找到的原文: cat (枚举了 26+676+17576=18278 个词)「破解」了?——不是。我只匹配了前 8 位(32 位),而且限定了「原文是 3 位以内小写字母」。真实场景是 256 位全匹配、原文任意长:枚举空间 2²⁵⁶ ≈ 10⁷⁷,全宇宙的原子数才约 10⁸⁰。「找不回去」不是工程上难,是宇宙尺度上难。
所以哈希的用法从来不是「保密」,而是「对暗号」:存密码时只存指纹(登录时对比指纹,数据库被拖走也推不出密码);对账时比对指纹;下面要讲的「链」更是拿指纹当焊条。想保密,那是加密(对称/非对称)的事——非对称加密下一篇就到。
一句话收口:哈希 ≠ 加密:加密可逆、哈希彻底不可逆;它的用法是「对暗号」不是「藏内容」。
第 3 课:哈希指针——把区块焊成一条链
🧑🏫 老师:
现在把上一篇的「账本页」正式化。一个区块除了记交易,还带一个特殊字段:前一块的指纹。动手造一条三块的链:
def H(s): return hashlib.sha256(s.encode()).hexdigest()
blocks = ["创世: 老张给老王 50", "第2块: 老王给小李 20", "第3块: 小李给老赵 5"]
prev = "0" * 64 # 创世块前面没有块,全 0 占位
for data in blocks:
cur = H(prev + data) # 本块哈希 = H(前块哈希 + 本块数据)
print(f"块数据=「{data}」 前块哈希={prev[:16]}… 本块哈希={cur[:16]}…")
prev = cur块 0: 数据=「创世: 老张给老王 50」
前块哈希=0000000000000000…
本块哈希 =aecd0e74a6ef4a86…(下一块的『前块哈希』就是它)
块 1: 数据=「第2块: 老王给小李 20」
前块哈希=aecd0e74a6ef4a86…
本块哈希 =2809156b12e13ebe…
块 2: 数据=「第3块: 小李给老赵 5」
前块哈希=2809156b12e13ebe…
本块哈希 =f3aab66aeec94856…注意每块的哈希把「前块哈希 + 数据」一起当输入——所以每块的身份里烙着前一块的身份。这个结构叫哈希指针:普通指针只说「上一块在哪」,哈希指针还说「上一块长什么样」。
现在做上一篇吹过的那个实验:偷改第 2 块(20 → 200),重算:
prev = "0" * 64
for data in [blocks[0], "第2块: 老王给小李 200", blocks[2]]:
prev = H(prev + data)
print("原第3块哈希: f3aab66aeec94856 …")
print("篡改后第3块哈希:", prev[:16], "…")
print("对得上吗:", prev == "f3aab66aeec94856…")原第3块哈希: f3aab66aeec94856 …
篡改后第3块哈希: 9e4dff40fab60994 …
对得上吗: False第 2 块的雪崩 → 第 3 块的「前块哈希」对不上 → 第 3 块身份重算 → 第 3 块也变了 → 第 4 块又对不上……改中间一块,它后面所有块全部作废。想伪造一本「历史」让全网认账,就得把改动的块连同后面所有块全部重造——而「重造被全网接受的历史」要过共识那关(1.2 篇)。哈希负责让篡改「藏不住」,共识负责让重造「过不了」,两把锁各管一段。
(对照真链:第 1 篇拉过的比特币创世块 JSON 里,previousblockhash: null、最新块的 previousblockhash 指向前一块——结构就是这三行代码的样子,只是输入换成序列化后的完整区块头。)
一句话收口:哈希指针 = 每块身份里烙着前块身份;改一块,后面全断——篡改的「可见性」由本课保证,「不可行性」由共识保证。
第 4 课:默克尔树——一个块里几百笔交易的指纹怎么算
🧑🎓 学生: 一个区块里有几千笔交易(第 1 篇看到的最新块有 3700 笔),区块头里那个 merkle_root 是怎么把几千笔压缩成一个哈希的?直接 H(全部交易拼起来) 不行吗?
🧑🏫 老师:
拼起来哈希一下当然「行」,但会丢掉一个关键能力:证明某笔交易在这个块里,不用下载整个块。默克尔树(Merkle tree)就是为了这个能力设计的:
根(merkle_root,进区块头)
/ \
N12 N34
/ \ / \
L1 L2 L3 L4
tx1 tx2 tx3 tx4- 叶子:每笔交易各自哈希一次(L1~L4);
- 内部节点:两个子哈希拼接后再哈希(N12 = H(L1+L2));
- 根:一路归并到顶,得到一个哈希——就是区块头里的
merkle_root。
用 4 笔交易手搓一棵:
txs = ["tx1:A->B:5", "tx2:B->C:3", "tx3:C->D:2", "tx4:D->A:1"]
leaves = [H(t) for t in txs]
def pair(a, b): return H(a + b)
n12, n34 = pair(leaves[0], leaves[1]), pair(leaves[2], leaves[3])
root = pair(n12, n34)
print("叶子:", [l[:8] + "…" for l in leaves])
print("L1+L2 →", n12[:8] + "…")
print("L3+L4 →", n34[:8] + "…")
print("根 merkle root:", root[:8] + "…")叶子: ['c6f20467…', '58f8b737…', '9f0fed69…', '4c75d869…']
L1+L2 → b2ae184e…
L3+L4 → 2ced419a…
根 merkle root: e6f823ff…树形归并的好处马上兑现——篡改任何一笔,根都会变(雪崩逐级放大):
bad = [H(t) for t in ["tx1:A->B:5", "tx2:B->C:3", "tx3:C->D:999", "tx4:D->A:1"]]
badroot = pair(pair(bad[0], bad[1]), pair(bad[2], bad[3]))
print("篡改 tx3 后的根:", badroot[:8] + "…")
print("与原根相等:", badroot == root)篡改 tx3 后的根: 70aa98de…
与原根相等: Falsemerkle_root 只有 32 字节,却「代表」了块里全部 3700 笔交易的总指纹——和第 3 课的哈希指针一结合:区块头里有前块头的指纹 + 本块全部交易的指纹,头与头相连、交易全被封印。
一句话收口:默克尔树 = 两两归并到顶的总指纹;根变了 = 交易被动过,逐级雪崩让 3700 笔的篡改也瞒不过 32 字节的根。
插问 2:如果交易数是奇数(5 笔、7 笔)怎么办?两两配对会落单啊
🧑🎓 学生: 4 笔正好配满。真实区块的交易数是随机的,落单的那个叶子跟谁配?
🧑🎓→🧑🏫 老师:
比特币的做法简单粗暴:落单的节点和自己配——把它复制一份凑成一对。5 笔的树:
L1 L2 L3 L4 L5 L5(复制)
...归并逻辑永远「取第 i 和第 i+1 个,没有 i+1 就再取一次 i」。这个细节不影响任何性质:根依然唯一确定、篡改依然逐级雪崩、证明路径依然成立。
以太坊更讲究一些——它的状态树(MPT,默克尔帕特里夏树)在 2.5 篇会讲到,节点可以分叉成 16 叉,专门为「键值对状态」优化。树的具体形状是工程选择,「叶子哈希逐级归并出根」的思想是共同的地基——认树先认思想,形状到对应篇再细究。
另外一个常见追问顺手答了:树高是 log₂(n),所以证明路径长度也是 log₂(n)——4 笔交易证明只要 2 个哈希,100 万笔也只要 20 个。这就是下一课 SPV 的本钱。
一句话收口:奇数叶子复制凑对(比特币做法);树形不影响性质,log₂(n) 的树高才是证明效率的来源。
第 5 课:默克尔证明——不下载整个块,也能证明「这笔交易在块里」
🧑🏫 老师:
默克尔树埋的宝藏现在挖出来。问题:手机钱包想确认「我收的这笔 tx3 真的在区块里」——难道要下载 1.5GB 的整个区块?
不用。只需要证明路径上的兄弟节点。看树:要证 tx3(在右子树的左边),需要的是——
- tx3 自己的哈希(L3,这你知道,因为交易就在你手上);
- 兄弟叶子 L4(同层的另一半);
- 兄弟节点 N12(上一层的另一半)。
拿到这两个「兄弟」就能独立重算出根:
v = leaves[2] # L3:自己这笔交易的哈希
v = H(v + leaves[3]) # 和右兄弟 L4 合并 → N34
v = H(n12 + v) # 和左兄弟 N12 合并 → 根
print("验证计算出的根:", v[:8] + "…")
print("与 merkle root 相等:", v == root)验证计算出的根: e6f823ff…
与 merkle root 相等: True算出的根和区块头里的 merkle_root 一致——证明成立,交易确实在这个块里。全程只用了 2 个哈希(64 字节),没有碰 tx1、tx2、tx4 的内容。
这就是比特币白皮书里的 SPV(Simplified Payment Verification,简化支付验证):轻钱包只下载区块头(每块 80 字节,17 年全部头加起来才几十 MB),要验证一笔交易时向全节点要一条默克尔证明,自己算根对暗号。对照数据量:全节点验证 ≈ 下载整个块(MB 级/块);SPV 验证 = 80 字节/块的头部 + log₂(n) 个哈希的证明。「证明某笔交易在某个区块里不需要下载整个区块」——占位大纲里那句话的机器实现,就是你刚才跑的这五行代码。
(SPV 的信任前提也交代一句:它信的是「区块头链」= 信最长链的算力,而不是亲自验证每笔交易——够用,但和全节点不是同一种「信法」,8 篇讲 L2 时这个区别还会回来。)
一句话收口:默克尔证明 = 交易哈希 + 一路兄弟节点 → 重算出根对上区块头;SPV 靠它用 80 字节/块的代价验证任意一笔交易。
第 6 课:哈希承诺——commit-reveal 初见
🧑🎓 学生: 我在 DApp 里见过「先提交一个哈希、之后再揭晓」的玩法,跟这套指纹是一回事吗?
🧑🏫 老师:
是同一个地基的又一栋楼,名字叫承诺(commitment)。场景:两人玩「石头剪刀布」上链,如果明文直接上链,后出手的人能看到先出手的人出什么——必胜。解法分两步:
第一步 commit(承诺):各自公布 H(我的选择 + 一个随机数)
第二步 reveal(揭晓):公布「我的选择 + 那个随机数」
验证:大家算一遍 H(选择+随机数),和第一步公布的承诺对得上 → 没换牌为什么加随机数?不然「石头」的哈希是固定的,对方查表就破解了。为什么能防赖账?reveal 之后所有人都能算哈希对承诺——换过牌立即穿帮。哈希在这里的角色是把「我已经决定了」变成公开事实,但又不泄露决定本身——又是三大性质在打工:确定性(可验证)、单向性(提前推不出)、抗碰撞(伪造不了另一个能对上承诺的值)。
这个模式在后面到处都是:白名单抽奖(4 篇)、链上随机数(7.5 VRF)、盲拍、公平排序。现在记住形状就好:先交指纹,后亮原文。
一句话收口:commit-reveal = 用哈希把「已决定」公开、把「决定了什么」藏住;先交指纹后亮牌,换牌必穿帮。
插问 3:比特币用 SHA-256、以太坊用 keccak256——为什么不统一?keccak 和 SHA-3 又是什么关系?
🧑🎓 学生: 两边各选一个哈希,仅仅是历史巧合吗?我还见过「SHA-3」这个词,和 keccak 是一个东西吗?
🧑🏫 老师:
三分 historía,一次说清:
- 比特币(2009):诞生时 SHA-2 家族(SHA-256)是 NIST 的最新标准,选它理所当然;
- keccak(以太坊,2015):SHA-3 竞赛(2007-2012)的胜出算法叫 Keccak(由 Daemen 等人设计,海绵结构)。以太坊选了它——但注意时间线:以太坊上线时 NIST 的 SHA-3 标准还没正式发布(2015-08 发布),标准定稿时 NIST 对 Keccak 做了一处填充参数的修改;
- 于是世界上有了两个「几乎一样但哈希值不同」的函数:以太坊用的 keccak256(原始参数)和标准 SHA3-256(NIST 参数)。同一个输入,两者输出不同——在以太坊工具里选错成 SHA3-256,地址、签名哈希全对不上,是新手经典坑。
工程提醒就一条:在以太坊侧说「哈希」默认指 keccak256(Python 里 pycryptodome 的 Crypto.Hash.keccak,而不是 hashlib.sha3_256——后者是 NIST 版)。不统一是历史,认准函数名是纪律。
一句话收口:比特币选当年的新标准 SHA-256,以太坊选了 SHA-3 竞赛冠军 Keccak 的原始版 keccak256——和后来的标准 SHA3-256 差一个参数、哈希值互不相通,工具里选错是经典坑。
小结
本篇 40 行 Python,把「指纹术」全部跑通:
- 三性质:确定性(全网对账)、抗碰撞(冒充无门)、单向性(公开无害)。
- 雪崩效应:改一个字符,62/64 位指纹变化——「小改小变」的侥幸不存在。
- 哈希不是加密:不可逆是宇宙级难度(2²⁵⁶),用法是「对暗号」不是「藏内容」。
- 哈希指针:每块身份烙着前块身份,改一块后面全断(第 3 课实测)。
- 默克尔树:叶子两两归并出根,32 字节的根封印全部交易。
- 默克尔证明 / SPV:交易哈希 + 兄弟节点重算出根,64 字节证明「这笔交易在块里」。
- commit-reveal:先交指纹后亮原文——「已决定」公开、「决定了什么」藏住。
- 选型:比特币 SHA-256、以太坊 keccak256(≠ 标准 SHA3-256,工具里选错是坑)。
验收清单(做完再进下一篇):
思考题:SPV 钱包为什么必须下载区块头链,而不能只下载最新一个区块头?(提示:第 3 课的哈希指针——头链本身也要能验证「从头连到今天没有断过」。)
下一篇:《公私钥与数字签名:从私钥到地址》——指纹解决了「改没改过」,还没解决「这是谁的」;下一篇用一对钥匙回答它。
本篇实验脚本(可照抄)
# merkle.py —— 手写默克尔树 + 证明 + 篡改检测
import hashlib
def H(s): return hashlib.sha256(s.encode()).hexdigest()
def pair(a, b): return H(a + b)
txs = ["tx1:A->B:5", "tx2:B->C:3", "tx3:C->D:2", "tx4:D->A:1"]
leaves = [H(t) for t in txs]
n12, n34 = pair(leaves[0], leaves[1]), pair(leaves[2], leaves[3])
root = pair(n12, n34)
print("merkle root:", root[:16], "…")
# 第 3 笔(tx3)的证明:兄弟 L4 + 兄弟 N12
v = H(leaves[2] + leaves[3])
v = H(n12 + v)
print("验证:", "通过" if v == root else "失败")
# 篡改 tx3 金额
bad = [H(t) for t in ["tx1:A->B:5", "tx2:B->C:3", "tx3:C->D:999", "tx4:D->A:1"]]
badroot = pair(pair(bad[0], bad[1]), pair(bad[2], bad[3]))
print("篡改后根:", badroot[:16], "…,检测:", "发现" if badroot != root else "漏了")参考资料
- ethereum.org — Hashing(keccak256 口径)
- 比特币开发者文档 — Merkle Trees
- NIST — SHA-3 标准与 Keccak 的参数差异(插问 3 出处)
- 本机:WSL2 Ubuntu-22.04 + Python 3.10(hashlib + pycryptodome keccak)