---
id: 25
title: "世界状态与 Merkle Patricia Trie：stateRoot 如何承诺一切"
slug: 0-8-world-state-mpt
date: 2026/09/27
summary: 区块头里的 stateRoot 只有 32 字节，却绑定了全网账户、余额与合约存储。下面拆开 Merkle Patricia Trie 的节点结构与账户下挂存储 trie 的设计，说明 Merkle 证明如何支撑链下校验，以及状态膨胀的成本落到谁身上。
keywords: Merkle Patricia Trie,stateRoot,世界状态,状态膨胀,Merkle证明
heroImage: /images/articles/photos/0-8-world-state-mpt.jpg
---

以太坊区块头里有一个 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 的根，以及空账户代码的哈希。

```python
# 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）：

```python
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，下面是一个请求示例（地址与槽号可替换）：

```json
{
  "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](https://ethereum.github.io/yellowpaper/paper.pdf)：第 2 节的状态转移函数、第 4 章的区块头字段与整体有效性（stateRoot 的定义与校验条件）、附录 D 的 MPT 节点定义、hex-prefix 编码、32 字节内联规则与 D.1 的 O(log N) 证明空间。
- [ethereum.org: Merkle Patricia Trie](https://ethereum.org/developers/docs/data-structures-and-encoding/patricia-merkle-trie/)：节点类型、紧凑编码参考实现、三棵 trie 的键值定义、交易与收据的值编码。
- [ethereum.org: Ethereum accounts](https://ethereum.org/developers/docs/accounts/)：storageRoot 与 codeHash 的官方表述。
- [EIP-1186: RPC-Method to get Merkle Proofs](https://eips.ethereum.org/EIPS/eip-1186)：eth_getProof 的字段、不存在的证明方式与使用场景；官方示例中的空 storageHash（0x56e81f…）与空 codeHash（0xc5d246…）也用于核对正文代码块里的两个空值常量。
- [EIP-8297: Partitioned Binary Tree](https://eips.ethereum.org/EIPS/eip-8297)（Draft，创建于 2026-06-11，哈希函数尚未定稿）：账户 trie 最大深度约 12 层、分支证明 5760 字节、最坏情况约 1.8 GB 的证明量，以及 MPT 对有效性证明不友好的原因。正文中这几项均标注为该草案的自述估算。
- [Ethereum Research: State growth scenarios and the impact of repricings](https://ethresear.ch/t/state-growth-scenarios-and-the-impact-of-repricings/23476)（2025-11-19）：340 GiB 状态规模、102 MiB 到 205 MiB 的每日新增，以及按三条 Gas 上限路径做的 2027 年情景外推。该文属情景分析，非既成事实。
- [Bloatnet Initiative](https://cperezz.github.io/bloatnet-website/)：650 GB 临界规模与状态访问时间约 40% 增幅的项目自述口径，页面未附可复现的基准数据。
- [go-ethereum: Archive mode](https://geth.ethereum.org/docs/fundamentals/archive)：路径式归档的 2 TB 与 6.5 TB 口径、哈希式归档可超 20 TB，以及 v1.16.x 与 v1.17 在历史证明支持上的差异。
- [ethereum.org: Ethereum archive node](https://ethereum.org/developers/docs/nodes-and-clients/archive-nodes/) 与 [Spin up your own Ethereum node](https://ethereum.org/developers/docs/nodes-and-clients/run-a-node/)：跨客户端的归档与全节点磁盘需求区间（归档 3 TB 到 12 TB 以上；Geth snap 同步 500 GB 以上）。
- [go-ethereum PR #28586](https://github.com/ethereum/go-ethereum/pull/28586)：移除 LES 及相关轻客户端代码。
- [a16z crypto: Building Helios](https://a16zcrypto.com/posts/article/building-helios-ethereum-light-client/)：共识层认证 stateRoot、执行层用 eth_getProof 做本地校验的轻客户端路径。

## 延伸阅读

- 相关阅读：[《去中心化与性能的张力：验证者门槛、硬件与地理分布》](/zh/blog/decentralization-performance-tradeoff)
- 相关阅读：[《Bitroot 多引擎并行执行设计：调度、分片与冲突面》](/zh/blog/bitroot-evm)
- 后续阅读：[《为什么单线程 EVM 会卡住 TPS：从历史拥堵到执行模型》](/zh/blog/evm-single-thread-bottleneck)
