以太坊区块头里有一个 32 字节的字段 stateRoot。它不含任何账户数据,却声称能把全网所有账户的余额、nonce、合约代码与合约存储一起固定住:只要这个哈希对得上,你就知道这份状态是哪一个版本。这个字段是 Merkle Patricia Trie(MPT,默克尔帕特里夏树)的根哈希,也是执行层对世界状态给出的唯一承诺。
要理解这份承诺有多重,需要拆开三件事:树的结构、根哈希为什么能绑定全部状态、以及维护这棵树要付出什么代价。第三件事是后面讨论状态分片与状态树换代的起点。
一个区块头里装着三棵 trie
以太坊区块头带有三个描述执行结果的 trie 根:stateRoot(世界状态 trie)、transactionsRoot(交易 trie)与 receiptsRoot(收据 trie)。黄皮书把它们记作 H_r、H_t、H_e。上海升级之后,区块头还多了描述提款列表的 withdrawalsRoot(H_w),它的结构与交易 trie 类似,只是每条提款的数据量小、每块条数少。黄皮书把“H_r 等于按顺序执行完区块内全部交易、再执行完全部提款之后得到的状态根”列为区块头的有效性条件之一。stateRoot 描述的是累计状态,另外几个根只描述本区块。
| trie | 键 | 值 | 生命周期 |
|---|---|---|---|
| 世界状态 trie | keccak256(账户地址) | RLP 编码的账户四元组 | 跨区块持续更新 |
| 交易 trie | rlp(交易在区块内的序号) | rlp(交易);类型化交易为类型前缀拼接编码后的交易 | 每块一棵,之后不再改动 |
| 收据 trie | rlp(交易在区块内的序号) | 类型化收据,或 rlp([status, cumulativeGasUsed, logsBloom, logs]) | 每块一棵,之后不再改动 |
交易 trie 与收据 trie 的叶子数与本区块的交易数成正比,因此验证某笔交易是否被打包,成本与链的长度无关,只与本区块规模有关。世界状态 trie 全局唯一,规模随全网账户数与存储槽数增长。区块头里的 logsBloom 由收据中的日志地址与 topic 聚合而成,供查询做概率过滤,它是索引加速器,不是承诺。
地址经过哈希才进 trie,账户是四元组
世界状态 trie 的键是 keccak256(账户地址),值是 RLP 编码的账户四元组 [nonce, balance, storageRoot, codeHash]。地址本身不进 trie,进去的是它的 256 位哈希:32 字节对应 64 个 nibble(半字节),也就是从根到叶最多 64 层。
四个字段各有明确语义。nonce 是账户发出的交易数,对合约账户还包括它创建过的合约数。balance 是以 wei 计的余额。codeHash 是账户代码的 keccak256,代码本体按哈希存放在状态数据库里,因此字节码相同的合约共享同一份数据。storageRoot 是另一棵 trie 的根,那棵树只属于这个账户。
结构于是呈现为树中树:世界状态 trie 的叶子节点里装着某个账户的 storageRoot,这个根指向该账户自己的存储 trie。存储 trie 的键是 keccak256(32 字节槽号),值是该槽内容(256 位)的 RLP 编码。槽值为零在规范里等同于该槽不存在,把某个槽写回 0 在状态层的效果是删除。
键先取哈希让树的形状由哈希分布决定,攻击者无法通过挑选键来塑造它。若键直接用地址或槽号,攻击者可以挑出共享长前缀的一批键,把某棵子树压成极深的链,放大访问与证明成本;keccak256 把键均匀打散后,这类构造不再可行。EIP-8297 草案在论证新树结构时,也把由哈希决定位置、从而保持平衡这一点列为设计依据。代价是槽号不可逆,合约无法从存储 trie 反推自己写过哪些槽,外部也只能按已知槽号逐个查询。
两个空值常量值得记下来,后面的证明与校验都会用到:空 trie 的根,以及空账户代码的哈希。
# pip install eth-utils rlp
import rlp
from eth_utils import keccak
# 空 trie 的根:对 RLP 编码的空字节串取 keccak
print(keccak(rlp.encode(b"")).hex())
# 56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421
# 空账户代码的哈希
print(keccak(b"").hex())
# c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470
三类节点与两比特标志:64 层路径如何被压短
MPT 是 16 叉(hexary)树,不是二叉树。黄皮书附录 D 定义了三类节点与一个空节点。branch 节点有 17 项,前 16 项对应下一个 nibble 的 16 种取值,第 17 项留给键在此结束的情形。extension 节点是 [encodedPath, key] 两项,跳过至少两个 nibble 的公共前缀。leaf 节点是 [encodedPath, value] 两项,承载键的剩余部分与终值。空节点用空字节串表示。规范里还有一条容易忽略的不变量:不允许存在只有一个非零项的 branch 节点。正因如此,同一组键值对只有一种编码方式,根哈希才有确定取值。
路径按 nibble 而不是字节组织,于是需要一个紧凑编码,把路径长度的奇偶与节点类型这两件事塞进首 nibble:
| 首 nibble | 二进制 | 节点类型 | 路径长度 |
|---|---|---|---|
| 0 | 0000 | extension | 偶数 |
| 1 | 0001 | extension | 奇数 |
| 2 | 0010 | leaf | 偶数 |
| 3 | 0011 | leaf | 奇数 |
偶数长度时首 nibble 之后再补一个 0 nibble,保证整个编码的 nibble 数为偶数,可以落成字节串。ethereum.org 给出的参考实现如下(返回值在此整理为 bytes):
def compact_encode(hexarray):
# 叶节点的路径以 16 结尾,检测到就剥掉并置 leaf 标志
term = 1 if hexarray[-1] == 16 else 0
if term:
hexarray = hexarray[:-1]
oddlen = len(hexarray) % 2
flags = 2 * term + oddlen # 首 nibble 同时编码类型与奇偶
if oddlen:
hexarray = [flags] + hexarray
else:
hexarray = [flags] + [0] + hexarray # 偶数长度补一个 0 nibble
return bytes(16 * hexarray[i] + hexarray[i + 1] for i in range(0, len(hexarray), 2))
路径压缩解释了为什么 64 nibble 的理论深度在实际主网上远未用满:EIP-8297 草案在动机部分估计,账户 trie 当前的最大深度约为 12 层。这是草案的自述估算,不是主网实测数据集;层数越浅,证明越短。
节点之间如何引用,由 32 字节规则决定。子节点 RLP 编码后不足 32 字节,内容直接内联进父节点;达到或超过 32 字节,父节点只写入 keccak(RLP(子节点)) 作为引用。这条规则省掉大量一次性的小节点读取,代价是验证证明时必须按实际编码形态重组节点,而不能假定每一层都是一次独立的数据库查询。
stateRoot 是对全部状态的一个承诺
把整个结构折叠成一个哈希的动作只有一行:对根节点的 RLP 编码取 keccak256。黄皮书在描述世界状态时给出的理由很直接,根节点在密码学上依赖其内部全部数据,因此这个哈希可以作为整个系统状态的安全标识。
承诺的强度来自引用方式。父节点引用子节点用的是子节点的哈希,任何一个叶子的任何一位变化,都会改变它所在的节点,再逐层改变到根。建立在 keccak256 抗碰撞性之上,找到两份不同状态却给出同一个根,等同于找到哈希碰撞。
由此得到三个性质。根哈希唯一确定一份键值集合。旧状态可以按根取回,因为节点按内容寻址、结构不可变,只要节点还在数据库里,知道旧根就能还原当时的状态。验证者也能沿路径重建承诺,路径上的兄弟哈希足以让他从某个叶子重算到根,黄皮书附录 D 把证明的空间需求记为 O(log N)。
区块头里的定义还限定了时点:stateRoot 是“执行完区块内全部交易与提款、并应用完最终处理之后”的状态根。它承诺的是这一刻的键值集合,不承诺这份状态如何变成现在这样,不同的历史可以收敛到同一个状态,同一个根也可能在连续多个区块里重复出现。它也不承诺状态的可用性。根哈希本身不含任何节点数据,要校验某个账户,必须另外取得路径上的节点。
从根到一个账户:Merkle 证明怎么用
账户证明是一个节点列表,从根节点开始,沿 keccak256(地址) 的 nibble 逐层向下:每一层要么命中 branch 的对应项,要么命中 extension 或 leaf 的路径前缀。验证者的动作很机械,对第一个节点重算 keccak(RLP(节点)),确认等于已知的 stateRoot;解码后按 nibble 找到下一个引用,重复到叶子;叶子里的值就是 RLP 编码的账户。证明不存在的账户同样可行,关键是把路径上最后一个匹配节点交出来:若它是 branch,对应分支为空;若它是 leaf,它与目标路径在某个 nibble 上分叉。存储证明要走第二遍,起点从 stateRoot 换成账户里的 storageRoot。
EIP-1186 把这个过程标准化成 eth_getProof,下面是一个请求示例(地址与槽号可替换):
{
"jsonrpc": "2.0",
"id": 1,
"method": "eth_getProof",
"params": [
"0x7f0d15c7faae65896648c8273b6d7e43f58fa842",
["0x0000000000000000000000000000000000000000000000000000000000000000"],
"latest"
]
}
返回值里的 accountProof 是从 stateRoot 出发的节点数组,storageProof 里每条记录则从该账户的 storageRoot 出发。两者都只是数据,可信与否由验证者自己重算决定。
Helios 一类轻客户端就按这个思路工作:共识层轻客户端先从信标链校验区块头,取得经过认证的执行层 payload 与 stateRoot,再向任意一个不信任的 RPC 索要 eth_getProof,最后在本地完成 MPT 校验。EIP-1186 的动机一节也提到,这类证明让物联网设备与移动应用只凭一个可信区块哈希,就能校验来自不可信来源的账户与存储数据。
承诺的边界:证明、旧根与轻客户端
证明的能力有清晰上限。它只覆盖被证明的那条路径,不能推出状态的其他部分;证明体积随路径深度与分支宽度增长。EIP-8297 草案用一组示例估算说明这个量级:按账户 trie 最大深度约 12 层计算,一条账户分支需要 12 层、每层 15 个兄弟哈希,合计 15 × 32 × 12 = 5760 字节;若把 60M Gas 全部用于触碰大量不同合约代码的单个字节,且代码不分块,草案估出的证明量约为 1.8 GB。这些都是草案的自述估算,未经主网实测复核,方向性结论可以采信,具体数值不宜当作基准。这也是 MPT 被认为对有效性证明不友好的原因:RLP 编码、Keccak 哈希、树中树结构,以及代码不能按段证明。
旧根的证明不是永久可得。承诺留在区块头里,能不能兑现取决于客户端如何存放状态。Geth 自 v1.16 起的路径式归档保存扁平状态的历史差分,读得到历史状态,但 v1.16.x 上无法为旧根生成 Merkle 证明,v1.17 起才可以通过显式保留 trie 历史来支持历史证明;传统的哈希式归档保留历史 trie 节点,可以为任意旧根出证明。磁盘代价见下一节。承诺的语义属于共识,履行承诺的方式属于实现选择。
执行层轻客户端也经历过一次收缩。主网的 LES 协议在合并之后长期无法可靠工作,Geth 在 2023 年底移除了相关代码。今天的链下校验更多以“共识层认证根、执行层提供证明”的组合出现,而共识层轻客户端校验的是信标链区块头,本身不会重新执行交易。
状态膨胀:承诺的账单落在磁盘上
stateRoot 的表达能力与状态规模无关,维护它的成本与状态规模正相关。每次状态更新都要重写从叶子到根的整条路径,状态越深越宽,单位交易需要的读写越多;新节点要追上链,也必须把这份状态完整拉下来。
以太坊研究论坛 2025 年 11 月的一篇分析给出了一组量级。该文称,2025 年 5 月一个只承载状态的 Geth 节点,未压缩数据库约 340 GiB;Gas 上限由 30M 上调至 36M 后,每日新增状态的中位数从约 102 MiB 翻到约 205 MiB。bloatnet 项目页面把 650 GB 标为临界规模,称接近这一规模时状态访问时间约增加 40%、内存占用与同步时间明显变差;该页面没有给出可复现的基准数据,这里的表述按项目自述口径转述,且它用的是 GB 而非 GiB。该论坛分析随后按保守、基准、激进三条 Gas 上限路径外推(2027 年年中分别升到 200M、400M、700M),得出 2027 年年中总状态规模落在 686 GiB 到 1.08 TiB。这是情景外推,不是既成事实:取值取决于客户端实现、压缩与剪枝策略,以及 Gas 上限的实际走势。
落到节点运营上,ethereum.org 汇总的 Geth 全节点(snap 同步)磁盘需求为 500 GB 以上;归档节点按 Geth 官方口径是路径式约 2 TB、保留历史 trie 数据约 6.5 TB、哈希式超过 20 TB,而 ethereum.org 汇总的跨客户端归档需求为 3 TB 到 12 TB 以上。这些数字依赖客户端实现、压缩方式、剪枝策略与 Gas 上限,跨实现直接比较没有意义;状态大小也不等于链上历史大小,历史交易与收据是另一笔账。
以太坊主网的状态只增不减,账户与存储槽一旦写入就永久占用空间,没有状态到期或状态租金的机制,压力因此单向累积。缓解方向大致有两类:更换树结构,例如长期讨论的 Verkle 树,以及仍处于草案阶段的分区二进制树(Partitioned Binary Tree,EIP-8297 草案);或者把状态切开,让单个节点只维护其中一部分。后者是后续文章的主题,这里只交代压力的来源。槽位在合约层如何排布,见 0.19《Storage Layout》。
资料来源
- Ethereum Yellow Paper:第 2 节的状态转移函数、第 4 章的区块头字段与整体有效性(stateRoot 的定义与校验条件)、附录 D 的 MPT 节点定义、hex-prefix 编码、32 字节内联规则与 D.1 的 O(log N) 证明空间。
- ethereum.org: Merkle Patricia Trie:节点类型、紧凑编码参考实现、三棵 trie 的键值定义、交易与收据的值编码。
- ethereum.org: Ethereum accounts:storageRoot 与 codeHash 的官方表述。
- EIP-1186: RPC-Method to get Merkle Proofs:eth_getProof 的字段、不存在的证明方式与使用场景;官方示例中的空 storageHash(0x56e81f…)与空 codeHash(0xc5d246…)也用于核对正文代码块里的两个空值常量。
- EIP-8297: Partitioned Binary Tree(Draft,创建于 2026-06-11,哈希函数尚未定稿):账户 trie 最大深度约 12 层、分支证明 5760 字节、最坏情况约 1.8 GB 的证明量,以及 MPT 对有效性证明不友好的原因。正文中这几项均标注为该草案的自述估算。
- Ethereum Research: State growth scenarios and the impact of repricings(2025-11-19):340 GiB 状态规模、102 MiB 到 205 MiB 的每日新增,以及按三条 Gas 上限路径做的 2027 年情景外推。该文属情景分析,非既成事实。
- Bloatnet Initiative:650 GB 临界规模与状态访问时间约 40% 增幅的项目自述口径,页面未附可复现的基准数据。
- go-ethereum: Archive mode:路径式归档的 2 TB 与 6.5 TB 口径、哈希式归档可超 20 TB,以及 v1.16.x 与 v1.17 在历史证明支持上的差异。
- ethereum.org: Ethereum archive node 与 Spin up your own Ethereum node:跨客户端的归档与全节点磁盘需求区间(归档 3 TB 到 12 TB 以上;Geth snap 同步 500 GB 以上)。
- go-ethereum PR #28586:移除 LES 及相关轻客户端代码。
- a16z crypto: Building Helios:共识层认证 stateRoot、执行层用 eth_getProof 做本地校验的轻客户端路径。