2026-07-26 学习答疑记录
涉及模块:区块链是什么(notes/01,跨 02/03/04/06/07)
Q1
问:首先,现在需要你仔细科学且完善的介绍区块链是什么,不要空洞或物料的解释,从密码学、记账、交易、以及合约、链等。
答:
先给一个不依赖比喻、可以直接验证的定义,后面逐段展开:
区块链 = 一个由哈希指针链接、仅追加(append-only)的区块序列;每个区块里的交易由数字签名授权,经由确定性的状态转移函数(UTXO 规则或 EVM)更新一份可用 Merkle 根验证的全局状态;全网独立节点各自重放执行来验证,并通过某种带经济代价的共识规则(PoW 算力 / PoS 质押)决定唯一合法的下一区块——从而让互不信任、没有裁判的参与者对同一份状态达成一致,且事后篡改的代价随时间指数级上升。
这句话里每个词都对应一个可精确定义的机制,不是"信任的账本"这种口号。
1. 密码学:撑起全部安全性的三个原语
哈希函数(比特币用 SHA-256,以太坊用 Keccak-256)有四个可验证的数学性质:确定性(同输入必同输出)、固定长度输出(不管输入多大,输出都是 256 位)、单向性/原像抗性(已知 H(x) 在计算上无法反推 x)、抗碰撞性(找不到 x≠y 使 H(x)=H(y))、雪崩效应(改 1 比特输入,输出约一半比特翻转)。
区块链靠这一个原语做两件事:
- 链式锚定:每个区块头存着"上一个区块头的哈希"。改动第 N 块的任何一个字节 → 它的哈希变 → 第 N+1 块存的 parentHash 对不上 → 从那之后所有块全部失效。要伪造,攻击者必须重算 N 之后的全部区块。
- Merkle Tree / Merkle Patricia Trie:一个区块里几千笔交易两两哈希、层层归约成一个 32 字节的 Merkle Root 放进区块头。验证"某笔交易是否在这个区块里"只需要 O(log n) 条哈希(Merkle proof),不用下载整个区块——这是轻客户端能验证的数学基础,不是"信任全节点告诉你"。
非对称加密 + 数字签名(比特币/以太坊都用 secp256k1 椭圆曲线上的 ECDSA)是所有权和授权的根:私钥是一个 256 位随机数,通过椭圆曲线上的单向运算得到公钥,公钥再哈希编码得到地址——这个方向不可逆,是基于"椭圆曲线离散对数问题"在计算上的困难性,不是"没人试过"。
签名的作用:用私钥对"交易内容的哈希"做一次签名,任何人可以只用公钥+签名+原始消息,验证"这确实是这个私钥持有者签的、且内容一个字节都没被改"——不需要知道私钥,也不需要签名者在场。这才是 trustless 的准确含义:不是没人验证,是任何人都能独立数学验证,不必依赖某个可信第三方的口头担保。
这三层组合成一条"密码学承诺链":交易被签名 → 一批交易被 Merkle Root 锚定进区块 → 区块被父哈希锚定进链。任何一层改一个比特,都会让往上所有哈希对不上——这是数学暴露,不是审计员发现。
2. 记账模型:状态到底怎么表示
记账的本质是分布式系统里的"状态复制"问题:所有诚实节点独立执行同一套规则,必须算出同一个结果。两种主流模型:
UTXO 模型(比特币):账本不存"余额",存一堆"未花费的输出"(Unspent Transaction Output)。一笔交易消耗若干已存在的 UTXO 作输入、产生若干新 UTXO 作输出,输入总额必须 ≥ 输出总额。"你的余额"是查询时现算出来的(你能签名花费的所有 UTXO 之和),账本里根本没有一个"余额"字段。防双花很直接:同一个 UTXO 在两笔交易里都当输入,节点直接拒绝后一笔。代价:表达不了"一个变量的当前值",比特币脚本图灵不完备。
账户/状态机模型(以太坊):每个地址是一个账户对象 {nonce, balance, storageRoot, codeHash}。交易是一次显式状态转移:σ' = Υ(σ, T)——给定旧全局状态 σ 和交易 T,用统一规则 Υ(也就是 EVM)算出新状态 σ'。这里的"状态"不只是余额,是所有合约里所有 storage 变量的当前值。全局状态被组织成一棵 Merkle Patricia Trie,区块头里的 stateRoot 就是这棵树的根——任何一个账户任何一个存储槽变了,根哈希就变,"验证全局状态没被篡改"因此归约成"比较一个 32 字节数"。
nonce 是账户级严格递增计数器,作用是防止同一笔已发出的交易被重放——对应 UTXO 模型里"每个输出只能花一次"的角色,但实现方式不同(这是很多人搞混的一点:nonce 防的是重放,不是防双花本身,双花在账户模型里靠"余额不能为负"这条状态转移规则约束)。
3. 交易:从构造到成为不可逆历史的完整生命周期
一笔以太坊交易的原始字段:{nonce, gasPrice/maxFeePerGas, gasLimit, to, value, data, chainId, v, r, s}。to + data 决定"调用谁、调用什么",to 为空则是部署合约。gasLimit × gasPrice 是你愿意为这次计算付的资源上限——这是"图灵完备语言在分布式系统里怎么防止无限循环拖垮全网"的答案:不是靠语言禁止循环,是靠经济代价(gas 耗尽整个交易 revert,且已耗的 gas 不退)。v, r, s 是 ECDSA 签名的三个分量,任何节点可以从中恢复出发送者公钥(ecrecover),交易本身不需要显式带发送者字段。
生命周期六步:
- 钱包本地构造并用私钥签名(私钥不出本机)
- 广播进 mempool(全网节点的待打包池)——此刻公开可见但未生效,MEV 套利/抢跑就发生在这个窗口
- 出块者挑选一批交易并决定顺序(这个排序权正是 MEV 的根源),EVM 逐条执行字节码(opcode),每条指令消耗 gas,读写 storage(持久化)/memory(临时)/stack(运算)
- 打包成区块,算出新的 stateRoot/transactionsRoot/receiptsRoot,块头带上父哈希,广播全网
- 每个节点独立重放执行,算出的新状态哈希必须和区块头里的 stateRoot 一致才接受这个块——trustless 在执行层的落地:不是相信出块者算对了,是每个人自己重算一遍验证
- 后续区块不断叠加,回滚这笔交易的代价(重算 + 反超全网算力/质押权重)随确认数指数上升——"事实上不可逆",是算力/经济上不可行,不是数学上不可能
4. 智能合约:图灵完备但有界的确定性状态机
合约 = 部署在某地址下的 EVM 字节码 + 一块持久化 storage。核心约束是确定性:给定同样的输入和同样的链上前置状态,全世界任何一个诚实节点执行同一段字节码必须得到完全相同的结果——这是"全网独立验证"能成立的前提。所以合约代码里不能有非确定性来源:不能读真实系统时钟、不能要随机数、不能主动发 HTTP 请求。这正是为什么需要预言机(Oracle)——外部数据必须"以一笔交易的形式"被喂进链上,而不是合约自己伸手去外部世界拿。
EVM 指令集允许任意循环递归,是图灵完备的,但每条指令消耗 gas、单笔交易 gasLimit 是硬上限——用经济代价而不是语言限制绕开了"停机问题"在实际系统里的危害。这就是"图灵完备但有界"的准确含义。
5. 链结构:为什么必须是"链"
区块头关键字段:parentHash, stateRoot, transactionsRoot, receiptsRoot, timestamp, number + PoW 的 difficulty/nonce 或 PoS 的一组 validator 签名证明。parentHash 把每个块和唯一的前一个块密码学锚定——这是"链"的字面含义:一个由哈希指针组成的单向链表,不是任意图。
分叉会发生(两个出块者几乎同时都产出合法的下一块),协议需要一条确定性的选链规则:PoW 下是"累计工作量最大的链获胜"(Nakamoto Consensus);PoS 下是 fork-choice 规则(如 LMD-GHOST)加上 finality gadget(如以太坊的 Casper FFG,两个 epoch 后区块被"最终确定",是比"最长链"更强的不可逆保证)。
安全性来源:要篡改 N 个块之前的一笔交易,攻击者必须从那块开始重新计算它及其后所有区块的工作量证明(PoW),或拥有并愿意烧毁足够质押权重(PoS 的 slashing),且要在诚实节点持续出块的同时反超他们——这不是数学上不可能,是算力/资金上不划算,即概率性/经济性安全,而不是绝对确定性安全(所以说"N 个确认后基本不可逆",而不是"从第 0 秒起就绝对不可逆")。
拿掉任何一块,这套系统就塌
哈希链(防篡改)、数字签名(授权与所有权)、记账/状态模型(大家算的是同一件事)、共识规则(谁来写、写错会被拒绝)、独立重放验证(不信任出块者的结果,自己验)——这五块互相咬合,任何一块拿掉,"互不信任者能达成一致账本"这件事就不成立。这也是为什么学习顺序上密码学 → 区块结构 → 共识 → 账户模型 → EVM → 智能合约环环相扣、不能跳的原因。
这个解释整合了 notes/01-区块链是什么.md(本质与流程)、02-密码学三原语.md(哈希/签名细节)、03-区块与链式结构.md(Merkle/链结构)、04-共识机制.md(PoW/PoS 选链)、06-账户模型.md(UTXO vs 账户模型)、07-EVM与Gas.md(EVM 执行)几篇的精确内容,比这几篇里"村庄类比"的直觉版本更技术化。与现有正文未发现矛盾之处,未更正任何一篇正文。
同步状态:✅ 已作为技术性补充追加到 notes/01-区块链是什么.md 的 Q&A 区(不改动 01 正文,也未改动 02/03/04/06/07 正文,因为没有发现这些文件里有需要更正的错误——只是给 01 补一层更精确的整合视角)。
Q2
问:详细补充哈希函数的算法细节,包括其中 hash 过程处理求解得出前面几位是固定值或者求解过程是怎么样的。
答:
先澄清一个概念区分,这是理解整件事的关键:哈希函数本身的计算是一次性的、确定性的、不含任何"搜索/求解"步骤——给定输入,走完固定的几百次内部运算就直接吐出输出,没有循环尝试、没有"猜"。"求解前导零/挖矿"是外部协议加的一层游戏规则:反复换输入(nonce)、每次都完整跑一遍这个确定性哈希、看结果满不满足门槛。这层"搜索"根本不在哈希算法内部,是矿工在算法外面套了个暴力循环。
1. SHA-256 内部到底怎么算(比特币用它,双重 SHA-256)
预处理(Padding):输入消息要先补齐到 512 比特的整数倍:① 消息后面加一个比特 1;② 再加若干个 0,补到长度 ≡ 448 (mod 512);③ 最后 64 位写上原始消息的比特长度(大端)。这样保证任意长度输入都能切成整数个 512 位的块——这是为什么哈希函数能处理任意长度输入但输出永远定长的机制层原因。
初始值与常量:8 个初始哈希值 H0..H7 分别是前 8 个质数(2,3,5,7,11,13,17,19)平方根的小数部分取前 32 位;64 个轮常量 K0..K63 是前 64 个质数立方根的小数部分取前 32 位。这些数字被选成"看起来随机但可公开复现"的常量,称为 nothing-up-my-sleeve numbers——目的是让任何人都能验证算法里没有被设计者藏进后门/弱点。
每个 512 位块的处理(压缩函数):
- 消息扩展:把这 512 位切成 16 个 32 位字
W0..W15,再用一个递推公式把它扩展成 64 个字W0..W63(后面的字由前面的字经过循环右移+异或算出) - 8 个工作变量
a,b,c,d,e,f,g,h先赋值成当前的 H0..H7 - 跑 64 轮,每轮做(简化写法):
其中T1 = h + Σ1(e) + Ch(e,f,g) + K[i] + W[i] T2 = Σ0(a) + Maj(a,b,c) h=g; g=f; f=e; e=d+T1; d=c; c=b; b=a; a=T1+T2Ch(e,f,g) = (e AND f) XOR (NOT e AND g)(多数由 e 决定,e 是 1 就听 f、e 是 0 就听 g);Maj(a,b,c) = (a AND b) XOR (a AND c) XOR (b AND c)(三者少数服从多数);Σ0, Σ1是不同幅度的循环右移再异或(比如Σ0(a)=ROTR2(a) XOR ROTR13(a) XOR ROTR22(a)) - 64 轮跑完,把 a..h 分别加回 H0..H7(mod 2³²)——这个"加回"是雪崩效应真正的来源:一个比特的差异经过 64 轮的移位+异或+进位传播,几乎必然扩散到全部 256 位
- 下一个 512 位块重复用上一块算出的新 H0..H7 作为起点,直到所有块处理完,最终 H0‖H1‖...‖H7 拼起来就是 256 位摘要
这整个过程从头到尾只走一遍,没有任何"尝试不同值直到满足条件"的步骤——这是纯函数计算,输入固定、输出就固定。
比特币用的是双重哈希 SHA256(SHA256(x)),多套一层是为了防御一种叫 length-extension attack 的理论弱点(单层 SHA-256 的内部结构使得攻击者在不知道原文的情况下也能"续接"消息并算出续接后的合法哈希,双重哈希切断了这个可能性)。
2. Keccak-256 内部机制(以太坊用它算地址、Merkle Patricia Trie、几乎所有链上哈希)
结构完全不同于 SHA-256,用的是海绵结构(Sponge Construction):
- 内部维护一个 1600 位的状态(可以想象成 5×5×64 的三维比特数组)
- 状态分两部分:速率 r(Keccak-256 里 r=1088 位)负责跟外部消息交互,容量 c(这里 c=512 位)完全不对外暴露、只在内部混合——c 越大,抗碰撞/抗原像攻击的安全边界越高,这里 c=512 对应 256 位输出要求的安全等级
- 吸收阶段(Absorb):把消息切成 1088 位一块,跟状态的 r 部分异或,然后整个 1600 位状态过一次
Keccak-f[1600]置换,重复直到消息吸收完 - 挤出阶段(Squeeze):直接从状态的 r 部分读出 256 位作为输出(Keccak-256 一次挤出就够,不需要多轮)
Keccak-f[1600] 置换本身跑 24 轮,每轮由 5 个步骤映射组成(θ、ρ、π、χ、ι),全部是在这个三维比特数组上做异或/循环移位/查表替换——细节比 SHA-256 更"矩阵化",但性质是一样的:确定性、无搜索、雪崩效应来自 24 轮的持续扩散。
冷知识:以太坊说的"SHA3"其实是 Keccak,不是 NIST 标准化后的 SHA3——NIST 定稿 SHA3 时改了填充规则(多加了两位后缀区分不同用途),以太坊在 NIST 定稿前就已经用了原始 Keccak 版本的填充规则,两者输出不同。这是个常见误区,务必分清
keccak256≠ 严格意义的sha3_256。
3. "求解前导位" —— 挖矿到底在干什么(这才是真正的"搜索")
矿工要做的事,一句话:反复换一个叫 nonce 的字段,重新跑一遍上面的 SHA-256(比特币)或 Ethash(以太坊 PoW 时代,内部也用 Keccak),直到算出的哈希数值小于协议规定的目标值(target)。
"目标值/前导零"到底是什么:把 256 位的哈希输出当成一个巨大的二进制数来看。协议规定:"这个数必须小于某个目标值 T"。因为 T 通常远小于 2²⁵⁶,等价的直观说法就是"哈希值前面必须有至少 d 个比特是 0"——d 不是哈希算法算出来的,是协议/网络难度设定的一个门槛,跟哈希算法内部完全无关。哈希算法不知道也不关心有没有"目标"这回事,它只是老老实实按上面的步骤把输入变成 256 位数字。
为什么必须暴力试,没有捷径:因为哈希函数满足单向性(无法从想要的输出反推出能产生它的输入)和雪崩效应(输入变 1 比特,输出近乎完全无规律地变化)——这意味着输入和输出之间不存在任何代数关系可以让你"解方程"。你不能像解 x + 3 = 7 那样解出 x = hash⁻¹(目标值)。唯一已知的方法就是:
nonce = 0
while True:
h = SHA256(SHA256(区块头 with nonce))
if int(h, 16) < target:
break # 找到合法区块!
nonce += 1
每次尝试都是把 nonce 换个值、完整重新算一遍整套流程——这才是"求解"这个词的真正含义:穷举搜索,不是解方程。这正是"工作量证明"(Proof of Work)名字的由来:能拿出一个满足条件的 nonce,本身就是"确实算了很多次哈希"这件事的证明,因为不这么算根本找不到。
期望要试多少次——概率推导:哈希函数的输出可以视为在 [0, 2²⁵⁶) 上均匀分布。任意一次尝试命中"< target"的概率是 p = target / 2^256。如果难度要求 d 个前导零比特,target ≈ 2^(256-d),代入:p = 2^(256-d) / 2^256 = 2^-d。因为每次尝试相互独立,这是一个几何分布,期望尝试次数 E[尝试次数] = 1/p = 2^d。直观类比:这就像连续抛硬币要求连续 d 次都是正面——概率 2⁻ᵈ,平均要抛 2ᵈ 次。每多要求 1 个前导零比特,平均耗算力翻一倍——这就是"难度"的数学本质,也是比特币每 2016 个区块根据全网实际出块速度自动调整 target(从而调整 d)以维持平均 10 分钟一个块的原理。
一个可以自己跑的最小例子:
import hashlib
def leading_zero_bits(h: bytes) -> int:
n = int.from_bytes(h, "big")
return 256 - n.bit_length() if n else 256
target_zero_bits = 16 # 要求前 16 个比特是 0,期望约 2^16 = 65536 次
nonce = 0
while True:
data = f"block-header-{nonce}".encode()
h = hashlib.sha256(hashlib.sha256(data).digest()).digest()
if leading_zero_bits(h) >= target_zero_bits:
print(f"找到!nonce={nonce}, hash={h.hex()}")
break
nonce += 1
自己跑一遍会发现:命中所需的 nonce 次数在 2¹⁶ 附近波动(有时快有时慢,符合几何分布的方差),且完全没有规律可循去提前预测哪个 nonce 会中——这就是"暴力搜索、无捷径"最直接的实证。
4. 澄清"前面几位是固定值"
严格说,"前面几位是固定值"这句话本身有个概念上的小偏差需要注意:不是哈希算法在计算时刻意让输出前几位固定成某个值(哈希算法根本不知道有难度要求这回事),而是:① 协议外部设定一个门槛(target / 前导零位数 d);② 矿工不断改变输入的一个字段(nonce,有时还带 extraNonce / timestamp / coinbase 微调);③ 每次都完整重跑一遍确定性的哈希算法;④ 凭运气(本质是均匀分布的概率游戏)撞中一个恰好满足门槛的输出。
所以准确的说法应该是:"矿工暴力搜索,直到某次哈希结果恰好满足前导零位数要求",而不是"哈希算法求解出前几位固定值"——这个区分很关键,因为它解释了为什么 PoW 唯一能加速的方法就是堆算力(每秒多算几次哈希),而不存在什么"算法优化"能让你更快猜中(除非哈希函数本身被攻破,那是另一回事,也是为什么密码学哈希函数被攻破会是行业级灾难)。
这块内容补的是 notes/02-密码学三原语.md(哈希函数直觉层面已经讲了避雪崩/单向/抗碰撞,但没有展开 SHA-256/Keccak-256 的内部算法步骤)和 notes/04-共识机制.md(PoW 只有一句"不断尝试不同 nonce",没有展开概率推导和暴力搜索的本质)。
同步状态:✅ 已同步到 notes/02-密码学三原语.md(哈希小节,补 SHA-256/Keccak-256 内部算法层细节)和 notes/04-共识机制.md(PoW 小节,补 target/前导零/期望尝试次数的概率推导),均为补充说明,未更正原有正文错误。
Q3
问:详细讲一下 Merkle Patricia Trie 的具体结构和查找过程并记录在问答中。
答:
先说清楚一件事:03-区块与链式结构.md 已经讲的"Merkle 树"(交易树)和 MPT 是两种不同的数据结构,只是都用了"哈希上卷"这个思想。交易树是一棵无键、按顺序排列的二叉哈希树,只解决"证明第 i 笔交易在不在";MPT 是一棵按 key 检索、支持单点高效更新的树,解决的是"给定一个 key(地址/存储槽),怎么高效查到值、高效改值、还能证明某 key 不存在"——以太坊的全局状态(几亿个账户)每个块都要变几十到几千条记录,如果每次都重建一整棵普通 Merkle 树,是不可能实时算完的。MPT 就是为解决这个问题设计的。
1. 两种结构拼在一起:Trie + Merkle
- Trie(前缀树/基数树):key 被当成一串符号(以太坊里是 nibble,即 4 位一组的半字节,0-f 共 16 种取值),从根节点开始按 key 的每个 nibble 逐层往下走,公共前缀被合并成共享路径——这保证查找是
O(key 长度),与树里存了多少条数据无关。 - Merkle(哈希树):每个节点的"身份"是它内容的 Keccak256 哈希。改一个叶子节点的值,只有从根到这个叶子路径上的节点需要重算哈希,其余全部子树原封不动复用——这保证单点更新是
O(路径长度),不是O(全树)。
两者结合:既能按 key 快速定位,又能用一个 32 字节的根哈希(stateRoot)代表整棵树的完整性,改一个值根就变,改哪个值也能被 Merkle 证明精确指出。
2. 三种节点类型(Ethereum Yellow Paper Appendix D 的精确定义)
- 叶子节点 Leaf:
[encodedPath, value]—— 路径到此为止,encodedPath是剩余没走完的 nibble 序列,value是实际数据(比如一个账户的 RLP:[nonce, balance, storageRoot, codeHash])。 - 扩展节点 Extension:
[encodedPath, nextNodeHash]—— 代表一段"共享前缀但还没到分叉点"的路径压缩,encodedPath是这段共享 nibble,指向下一个节点的哈希。这是"前缀压缩"的关键:如果一长串 key 共享前 20 个 nibble 都一样,不需要建 20 层只有单个子节点的节点,压成一个 Extension 节点直接跳过去。 - 分支节点 Branch:一个 17 个槽位的数组
[v0, v1, ..., v15, value]。前 16 个槽位对应下一个 nibble 的 16 种可能取值(0-f),每个槽位存的是"走这个 nibble 该去哪"(子节点的哈希引用,或空);第 17 个槽位存"如果 key 恰好在这个分支节点这里就结束了"对应的 value。
节点之间的引用规则(这里体现"Merkle"性质):如果子节点 RLP 编码后 ≥32 字节,引用存的是它的 Keccak256 哈希(要去查它得先按哈希从底层 KV 存储里取出内容——所以整棵树的底层存储本质上是一个巨大的 hash → 节点内容 键值对数据库,go-ethereum 里就是直接拿 LevelDB/RocksDB 这么用的);如果编码 <32 字节,为了不浪费空间,直接把内容内联嵌进父节点里,不单独存、不哈希。
3. Hex-Prefix 编码:怎么把 nibble 路径塞进字节,还要区分节点类型
encodedPath 不能直接存 nibble 序列,因为要解决两个问题:① nibble 是 4 位,两个才凑一字节,长度可能是奇数;② 光看 [path, ref] 这个二元数组,没法区分这是 Leaf 还是 Extension。解法是在最前面塞一个前缀 nibble:
| 前缀值 | 含义 |
|---|---|
0 |
扩展节点,剩余 nibble 数为偶数 |
1 |
扩展节点,剩余 nibble 数为奇数(第一个 nibble 就编码在这个前缀字节里) |
2 |
叶子节点,剩余 nibble 数为偶数 |
3 |
叶子节点,剩余 nibble 数为奇数 |
奇数情况下第一个真实 nibble 会跟前缀共享同一字节的低 4 位,偶数情况下补一个全零的占位半字节凑齐整字节——这套编码叫 hex-prefix encoding (HP)。
4. 查找过程:给定一个 key,怎么一步步走到值
以太坊状态树(state trie)里,key 不是地址原文,而是先对地址做一次 Keccak256 得到的 32 字节哈希(这叫 Secure Trie)——目的是让所有 key 在 256 位空间里近似均匀分布,防止有人故意构造一串前缀相同的地址把树捅成一条长链(拒绝服务风险)。32 字节 = 64 个 nibble,这就是查找路径最多要走的步数上限。
具体过程(假设查地址 0xAB... 对应的账户):
- 算出 key:
h = Keccak256(address),展开成 64 个 nibble。 - 从根开始:区块头里的
stateRoot就是根节点的哈希,用它去底层 KV 存储取出根节点的 RLP 内容,解码后判断类型:17 元数组 → 分支节点;2 元数组 → 根据encodedPath首字节的 HP 前缀判断是叶子还是扩展节点。 - 按节点类型分支处理:
- 分支节点:取当前剩余 key 的第一个 nibble(比如
0xA),去分支节点第 10 号槽位,拿到下一个节点的哈希引用,消耗掉这 1 个 nibble,往下走。 - 扩展节点:比较它存的共享 nibble 段和你剩余 key 的对应前缀是否完全一致。一致 → 跳过这一段(一次消耗多个 nibble),沿着它的
nextNodeHash往下走;不一致 → 立刻可以断定这个 key 不存在于树里,不需要再往下走了(这是 trie 结构相对普通 Merkle 树的一个额外能力:不存在性可以在中途就被结构性地证明,不需要遍历到底、也不需要拥有全部数据)。 - 叶子节点:比较它存的剩余路径和你剩余 key 是否完全匹配。匹配 → 它的
value就是你要的账户 RLP(再解码出[nonce, balance, storageRoot, codeHash]);不匹配 → key 不存在。
- 分支节点:取当前剩余 key 的第一个 nibble(比如
- 重复步骤 3,每次消耗 1 个或多个 nibble,直到命中叶子节点或提前判定不存在。最多 64 步(64 个 nibble),但因为 Extension 节点压缩了共享前缀,实际步数通常远小于 64。
如果你查的是某个合约的存储值(比如 Uniswap 池子的 reserve0),过程完全一样,只是:先在 state trie 里查到这个合约账户,取出它的 storageRoot,再用同一套算法在这个账户自己独立的 storage trie 里查——每个合约账户都有一棵自己的存储子树,key 是 Keccak256(存储槽编号)(这正是之前提到的 keccak256(abi.encode(addr, 9)) 算 USDC 余额槽位的由来——那一步算的就是 storage trie 要用的 key)。
5. 更新过程:为什么改一条记录不用重算整棵树
更新一个值(比如某地址余额变了),走的路径和查找完全一样——先按上面步骤走到目标叶子节点,改掉它的 value,然后沿着来时的路径反向往上、逐层重新计算每个经过节点的哈希,一直到根。没有被经过的节点(也就是这次没变的所有其他账户/存储项所在的子树)哈希原封不动、完全复用,不需要重新计算。
这就是为什么"改一笔账户余额"的开销是 O(路径长度)(几十个哈希)而不是 O(全树大小)(上亿个账户)——这是 MPT 相对于普通 Merkle 树最核心的工程价值:普通 Merkle 树天生适合"一次性提交一批不再变的数据"(比如一个块里已经打包定型的交易列表),而 MPT 天生适合"频繁做单点增删改的活数据"(比如每个块都在变的全局状态)。
6. Merkle 证明:怎么不下载整个状态就验证某个值
因为查找过程本身就是"沿路径逐层解开哈希引用",所以查找路径上依次经过的所有节点原始内容,拼起来就是一份完整的 Merkle 证明:
- 请求方给出:
stateRoot(从可信的区块头拿到)+ 从根到目标叶子路径上所有节点的原始 RLP 字节。 - 验证方从叶子往根反向重算:先确认叶子内容对应的值是自己要的值;用叶子内容算出哈希,检查这个哈希是否等于它的父节点(分支/扩展节点)里对应槽位存的引用;再用父节点内容算哈希,检查是否等于它自己父节点里的引用……一路验到根;最后比较算出来的根哈希是否等于区块头里那个已经被全网共识确认过的
stateRoot。 - 全部匹配 ⇒ 这个值确实是该区块状态的一部分,且没被篡改——不需要拥有全部状态数据,只需要这一条路径(通常几十个哈希,几 KB)。
这正是 eth_getProof 这个 RPC 方法背后的原理——它返回的 accountProof/storageProof 数组就是这条路径上的节点列表,跨链桥的轻客户端验证、状态无信任读取(trustless state reads)都是靠这个机制,而不是信任某个 RPC 节点说的话。
7. 三个常见误区
- "MPT 就是普通 Merkle 树" —— 不对,普通 Merkle 树无 key、不支持按 key 查找/单点高效更新;MPT 是"按 key 路径检索的 trie"+"内容寻址的哈希树"的结合体。
- "改一个存储值要重算整个状态树" —— 不对,只有从根到该叶子路径上的节点需要重新哈希,其余子树完全复用。
- "证明某个 key 不存在需要遍历全部数据" —— 不对,走到某个分支/扩展节点发现路径对不上,当场就能证明不存在,不需要拿到全部数据。
这块内容补的是 notes/03-区块与链式结构.md §5 词典里"Merkle-Patricia Trie"那一条(此前只有一句"以太坊存状态用的 Merkle 变体,可高效改单条",没展开结构和查找过程)和 notes/06-账户模型.md 里提到 state root 的部分。
同步状态:✅ 已同步到 notes/03-区块与链式结构.md(§5 词典 "Merkle-Patricia Trie" 词条此前只有一句概括,现补充完整结构与查找/更新/证明流程)和 notes/06-账户模型.md(state root 相关段落),均为补充说明,未更正原有正文错误。
Q4
问:详细讲一下 RLP 编码的具体规则。
答:
RLP 是以太坊用来序列化数据的编码格式——上一轮讲 MPT 时提到"节点 RLP 编码后 ≥32 字节存哈希",这里把 RLP 本身的规则讲透。先说清楚它的定位:
RLP 只认识两种东西:一段字节串(byte string / item),和若干项组成的列表(list,列表里的每一项本身又可以是字节串或列表,可以无限嵌套)。它不知道也不关心"这段字节到底是整数、地址还是字符串"——类型的解释权完全在使用者手里,RLP 只负责"用一种没有歧义的方式,把嵌套结构变成一串字节,以及原样解回来"。
这跟 JSON 之类的格式很不一样:JSON 有整数/字符串/布尔/数组/对象好几种类型标记;RLP 故意只留两种,为的是规则极简、编码结果唯一(canonical)、解码不需要任何额外元信息就能确定边界——这对区块链系统很关键:同一份数据必须被所有节点编码成完全相同的字节序列,否则哈希就对不上(回想 MPT 里"节点哈希=Keccak256(RLP(节点))",如果 RLP 编码不是唯一确定的,同一个节点在不同实现里会算出不同哈希,整个 Merkle 体系就塌了)。
1. 四条规则(外加一个单字节的优化特例)
规则来自 Ethereum Yellow Paper Appendix B,本质是"看第一个字节的数值范围,就能唯一确定这是哪种情况、要读多长":
| 情况 | 前缀字节范围 | 编码方式 |
|---|---|---|
| 单字节,值 0x00-0x7f | 该字节本身 | 无前缀,字节自己就是编码结果 |
| 字节串,长度 0-55 | 0x80-0xb7 |
(0x80+长度) + 原始字节 |
| 字节串,长度 >55 | 0xb8-0xbf |
(0xb7+长度的字节数) + 长度(大端) + 原始字节 |
| 列表,payload 长度 0-55 | 0xc0-0xf7 |
(0xc0+payload长度) + 各子项编码依次拼接 |
| 列表,payload 长度 >55 | 0xf8-0xff |
(0xf7+长度的字节数) + 长度(大端) + 各子项编码依次拼接 |
逐条拆解:
规则 1:单字节 0x00-0x7f 自成编码。如果要编码的字节串恰好只有 1 个字节,且这个字节的值 <0x80,那它的 RLP 编码就是它自己,不加任何前缀。这是个纯粹的空间优化——绝大多数小整数/单字符编码后只占 1 字节,不用浪费 1 个字节去写"长度=1"。(这条规则也解释了为什么范围卡在 0x80:如果字节值 ≥0x80,就没法跟"这是一个长度前缀"区分开了,所以单字节自编码只能覆盖到 0x7f。)
规则 2:短字节串(0-55 字节)。前缀字节 = 0x80 + 实际长度,长度 0 对应前缀恰好是 0x80(空字符串编码就是单独一个 0x80 字节),长度 55 对应前缀 0x80+55=0xb7。之后原样跟上这段字节串本身。
规则 3:长字节串(>55 字节)。先用一个字节表示"接下来用几个字节来写长度"——即 0xb7 + len(length的字节数)。比如字节串长 300,300 用大端表示要 2 字节(0x01 0x2c),所以前缀是 0xb7+2=0xb9,完整编码是 0xb9 01 2c <300字节原始数据>。
规则 4/5:列表。列表的编码不直接看列表里"有几项",而是看把每一项分别 RLP 编码后拼起来的总字节数(payload 长度)。payload ≤55 字节用规则 4(前缀 0xc0+payload长度,空列表编码是单独一个 0xc0),payload >55 字节用规则 5(前缀 0xf7+长度的字节数,后面跟长度,再跟 payload)。关键点:列表内部每一项都是先各自完整 RLP 编码,再原样拼接——这就是"Recursive"的含义:列表的元素如果本身也是列表,就递归套用这四条规则,没有深度限制(只受实际字节长度限制)。
为什么边界选在 55:一个字节(8 位)最多能表示 0-255,前缀字节要同时承担"区分字节串/列表/单字节"和"编码长度"两个任务,55 是在"覆盖足够多常见小数据不用额外长度字节"和"给前缀字节留出区分空间"之间的工程权衡值。
2. 一步步手算三个例子
例 1:编码字符串 "dog"。"dog" 是 3 个 ASCII 字节:0x64 0x6f 0x67;长度 3,落在"短字节串"区间 → 前缀 = 0x80 + 3 = 0x83;完整编码:0x83 64 6f 67。
例 2:编码列表 ["cat", "dog"]。先各自编码子项:"cat" → 前缀 0x83,编码 83 63 61 74;"dog" → 前缀 0x83,编码 83 64 6f 67。拼接两项编码:83 63 61 74 83 64 6f 67,共 8 字节。这是列表,payload=8 字节,落在"短列表"区间 → 前缀 = 0xc0 + 8 = 0xc8。完整编码:c8 83 63 61 74 83 64 6f 67。
例 3:整数怎么编码(RLP 本身不认识"整数"这个类型!)。约定是:先把整数转成"最短的大端字节表示(不能有前导零字节)",当成一个普通字节串来编码。整数 1024(十六进制 0x0400)→ 大端最短表示是 2 字节 04 00 → 长度 2,前缀 0x80+2=0x82 → 完整编码 82 04 00。整数 0 的编码是空字节串,也就是单独一个 0x80——不是单字节 0x00。这是个容易踩的坑:0x00 单独一个字节会被规则 1 解释成"值为 0 的单字节",跟"空字符串(也常被约定表示数字 0/布尔 false)"是两种不同的字节序列。以太坊的规范做法(go-ethereum 的 rlp 包)是只允许 0x80 表示整数 0,如果看到用 0x00 表示 0 会被判定为"非规范编码(non-canonical)"而拒绝——目的正是前面说的"编码结果必须唯一":如果同一个数字能有两种合法字节表示,不同节点/不同实现可能编出不同字节,哈希就对不上。
3. 解码为什么无歧义、不需要额外信息
给定一串字节,解码器只看当前位置的第一个字节数值,就能唯一确定:是不是单字节自编码(0x00-0x7f);是不是短字符串、要读几字节长度、字符串从哪到哪;是不是长字符串、要先读几字节的"长度的长度",再读长度,再读字符串;是不是列表(短/长),payload 从哪到哪,再对 payload 递归解码出子项列表。
因为每种情况的前缀字节范围完全不重叠(0x00-0x7f / 0x80-0xb7 / 0xb8-0xbf / 0xc0-0xf7 / 0xf8-0xff 五段互斥),只看一个字节就能确定接下来怎么读,不需要分隔符、不需要类型标签、不需要提前知道数据结构——这也是为什么 RLP 能作为 MPT 节点、交易、区块头这些结构完全不同的数据的统一底层序列化格式:解码器不用预先知道"这是账户结构"还是"这是交易结构",只要按 RLP 的四条规则机械解开,就能还原出一棵"字节串+列表"组成的树,再由上层代码按业务含义去解释每个位置该是什么字段。
4. RLP 在链上实际用在哪几个地方
- MPT 节点:叶子
[encodedPath, value]、扩展[encodedPath, hash]、分支(17 元数组)——这些数组结构本身就是 RLP 列表,先 RLP 编码,再对编码结果取 Keccak256 作为节点引用("≥32 字节存哈希",那个"32 字节"指的正是 RLP 编码后的长度,不是原始数据长度)。 - 账户对象:
[nonce, balance, storageRoot, codeHash]是一个 4 项列表,RLP 编码后就是 MPT 里存的 value。 - legacy 交易:
RLP([nonce, gasPrice, gasLimit, to, value, data, v, r, s])——这也是为什么交易哈希是Keccak256(RLP(交易各字段)),改任何一个字段(哪怕只改 1 个 gasPrice 数字)都会让 RLP 字节流变、哈希变、签名验不过。 - 区块头:
[parentHash, ommersHash, stateRoot, transactionsRoot, receiptsRoot, ...]同样是一个大 RLP 列表。
一个后续会碰到的细节(先留个印象,不展开):EIP-2930/EIP-1559 之后的"类型化交易"(type 1/2)不是纯 RLP 列表了,而是在 RLP 编码前面加一个类型字节(
0x01/0x02),形成TransactionType || RLP(payload)的"信封"格式(EIP-2718)。这个类型字节本身不属于 RLP 规则的一部分,是以太坊在 RLP 之上叠的一层协议约定,用来支持交易格式的可扩展性(比如 EIP-1559 的maxFeePerGas/maxPriorityFeePerGas字段)。
这块内容延续上一轮 MPT 里"RLP 编码 ≥32 字节存哈希"那一句的展开,补的是 notes/03-区块与链式结构.md(MPT 词条里第一次提到 RLP 的地方)。
同步状态:✅ 已同步到 notes/03-区块与链式结构.md(延续 MPT 词条里"RLP 编码"的展开),未更正原有正文错误,是补充说明。