多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

以太坊交易树与收据树:MPT结构、三根哈希与Bloom Filter实战解析

以太坊交易树与收据树:MPT结构、三根哈希与Bloom Filter实战解析 1. 先说结论以太坊节点到底在“记”什么提到区块链的存储结构大多数人的第一反应是比特币区块头里塞一个 Merkle 根把所有交易拧成一棵二叉默克尔树任何一个交易被篡改根哈希都会变。但以太坊没有照抄这套设计交易树Transaction Trie和收据树Receipt Trie这两棵“树”是同时存在的而且它们的技术内核不是简单的 Merkle Tree而是默克尔帕特里夏树Merkle Patricia Trie简称 MPT。这两棵树长在每一个区块里和状态树State Trie一起构成了以太坊区块头的三根哈希。它们解决的核心问题是**不仅要把“交易内容”锁定还要把“交易执行结果”锁定并且让轻节点可以在不重放全部交易的前提下验证某个交易确实被打包、某个事件确实发生过。**如果你做的是链上数据解析、钱包查询、区块浏览器、或者智能合约事件监听那么交易树和收据树就是你绕不开的两块基石。这篇文章我会从实际开发视角把这棵树的节点类型、键值编码、三根哈希的协作方式、以及收据树里 Bloom Filter 的作用一点点拆开。适合想搞懂以太坊数据结构的开发者也适合做链上数据服务的同学参考。我尽量用大白话讲但该给的原理和编码细节也不会省。2. 区块里的三棵树为什么是“三”而不是“一”2.1 比特币的“一树方案”为什么不够用先回到比特币看一眼。比特币区块头里只有一个 Merkle Root它是对区块体内所有交易两两哈希之后得到的根值。你要验证某笔交易是否在区块里只要提供从该交易一路到根节点的兄弟哈希Merkle Proof就能在不动整棵树的条件下完成验证。这看起来很完美但它只锁定了一件事交易内容。问题来了如果我想知道这笔交易执行之后到底消耗了多少 Gas、触发过哪些日志事件、交易是成功还是失败呢比特币的模型里没有“执行结果”这个概念因为它只有简单的脚本验证UTXO 花没花掉就是状态的全部。以太坊不一样一次转账背后要执行 EVM 字节码会改变账户余额、存储值还会产生一笔笔日志。这些执行结果如果不存下来节点之间对“同一笔交易执行后状态是否一致”就无法快速达成共识。这时候你可能会想那把交易和结果都塞进一棵树不就行了实践中真不行。交易树里的叶子节点是按交易在区块中的顺序排列的收据树里的叶子也按同样顺序排列但两者的“内容性质”完全不同。交易树里的叶子是交易数据本身收据树里的叶子是交易执行后的元数据。如果把它们混在一棵树里轻节点想“只校验收据”就必须把交易数据也一并拉下来节点同步的灵活性就没了。所以以太坊干脆拆成三棵树状态树管账户余额和合约存储交易树管交易内容收据树管执行结果。三棵树的根哈希都写进区块头互不干扰又互相印证。2.2 区块头里那三个根哈希的具体含义以太坊区块头里有一个字段叫ReceiptsRoot有一个叫TransactionsRoot还有一个叫StateRoot。每次新矿工打包区块时会先把区块内的所有交易按顺序组织成交易树把每笔交易对应的收据组织成收据树再结合之前的世界状态更新得到状态树。三棵树的根哈希最终都会写进Header一旦写进去整个区块的内容就被锁定了。需要特别注意的是三个根的用途完全不同StateRoot锁定的是“当前世界状态”也就是所有账户的余额、Nonce、合约代码哈希、存储根。只要有任何一笔交易执行结果不同状态根一定不同。TransactionsRoot锁定的是“本区块包含哪些交易”。它不关心交易执行成不成功只关心交易数据本身。ReceiptsRoot锁定的是“每笔交易执行后的结果摘要”。成功或失败、用了多少 Gas、产生了哪些日志都在这里。如果一个恶意节点伪造了某笔交易的结果比如把一次失败的内部调用说成成功那它必须同时伪造收据树中的对应收据否则收据根就对不上。但光改收据树还不够因为状态根会因为余额变化不一致而暴露。所以三棵树是互相制衡的这种设计让以太坊在“交易内容”和“交易结果”两个维度上都做到了防篡改。3. 交易树与收据树的“同与不同”3.1 两棵树的外形同样的 MPT不同的叶子交易树和收据树都使用 MPT 结构但两者的叶子内容完全不同。交易树中的键是对交易在区块中索引的 RLP 编码值则是对交易本身的 RLP 编码。这里有个容易误会的点树中的键不是交易的哈希而是交易在区块中的序号。第一个交易的键是0x00第二个是0x01以此类推。因为交易在打包时本来就有一个固定顺序直接用索引作为键最省事也方便按序重放。收据树的结构与之类似键仍是交易索引的 RLP 编码但值变成了收据的 RLP 编码。收据里封装了交易执行后的状态status累积 Gas 使用量cumulativeGasUsed日志集合logs日志布隆过滤器logsBloom其他辅助字段这里要注意区分“累积 Gas”和“交易 Gas”。收据里的cumulativeGasUsed是从区块第一笔交易开始一直累加到当前这笔交易的 Gas 总和。这个设计是为了方便节点快速推算某笔交易执行到某个时刻的区块 Gas 消耗不用一笔笔重新累加。3.2 为什么不用普通 Merkle Tree而用 MPT说到这就绕不开一个灵魂拷问比特币用二叉默克尔树用得好好的以太坊为什么非要自己搞一个十六叉的 MPT原因是 Merkle Tree 只能做“存在性证明”不能做“更新与回溯”。以太坊每打包一个区块账户状态可能被几十笔交易修改状态树要频繁插入、删除、更新节点。如果用普通 Merkle Tree每次更新一个叶子节点都要重新计算从该叶子到根的所有兄弟哈希更新成本和对历史状态的快照成本都很高。更关键的是普通 Merkle Tree 没有“前缀共享”的压缩能力存大量键值对时树会非常“胖”磁盘空间和网络传输的负担都很大。MPT 的英文全称是 Merkle Patricia Trie本质是“默克尔化”的基数树。它用 Hex-Prefix 编码把具有相同前缀的键合并到同一个节点里让树变得“扁而紧凑”。而它的另一个优势是支持“历史回溯”在 Patricia Trie 中每个节点的哈希都是自己内容的函数你只要保存某个历史版本的根引用就能通过 MPT 的节点哈希快速访问当时的世界状态。简单说MPT 既保留了默克尔树的防篡改和可验证性又获得了前缀树的高效检索和历史快照能力。从工程实现上看以太坊客户端对标准 MPT 还会做进一步压缩插入操作完成后立即将标准 MPT 转换成更紧凑的二进制表示有些实现称为 compact encoding。这个压缩不改变树逻辑只减少存储体积。所以你在阅读源码时看到的一些中间节点形态和教科书上的略有不同是完全正常的。4. 拆开 MPT 节点叶子、扩展、分支4.1 三种节点和它们的分工MPT 的核心就三类节点叶子节点Leaf Node包含一段完整的键路径和对应的值。叶子节点的哈希值 Keccak256(RLP(encodedPath, value))。注意这里encodedPath是一种带前缀的十六进制字符串编码用来区分“键到这里是否结束”。扩展节点Extension Node它表示一段“多个键共享的前缀”。比如很多键都以0xa7开头那0xa7就可以单独建立一个扩展节点避免在每个叶子中重复存这两个 nibble。扩展节点里保存的是共享前缀和下一层节点的引用哈希本身不存实际数据。分支节点Branch Node有 17 个元素前 16 个分别对应十六进制中的0x0到0xf十六个可能的分支最后一个元素是该节点自身可能存在的值。这个结构有点像一棵树的“十字路口”。从根开始键的每一个 nibble半字节4 bit就决定走向哪一个分支走到尽头就是叶子或分支里的最终值。因为一个字节可以被拆成两个 nibble所以这种树的空间复杂度较低查找效率也稳定在键长度的数量级上。4.2 键编码RLP、Hex-Prefix 和 Keccak256这一节看起来枯燥但如果不搞清楚编码规则看源码时很容易一头雾水。RLPRecursive Length Prefix以太坊最基础的序列化方式。它对字符串和嵌套列表做编码规则简单说就是单字节小于0x80的直接编码短字符串加一个长度前缀长字符串用更长字节表示长度。MPT 的节点在序列化、存储、哈希时都会用到 RLP。Hex-Prefix 编码专门用来表示 MPT 中的路径。它的规则是在路径最前面加一个 nibble半个字节用这个 nibble 的最高位标记“当前节点是否为叶子节点”用次高位标记“路径长度为奇数还是偶数”。因为路径是用 nibble 表示的如果路径长度是奇数就得补一个0x0占位否则会破坏字节对齐。实际实现里编码结果还要再做一次“反转 nibble 顺序”的变换这一点在阅读源码时非常容易踩坑。最简单的记忆方式是十六进制前缀编码 类型标记 长度标记 路径内容。Keccak256以太坊使用的哈希算法是 SHA-3 标准竞争中的原始方案和最终标准在 padding 上有细微差异。所有 MPT 节点的哈希值都用它计算。注意比特币用的 SHA-256 和以太坊的 Keccak256 不是同一个算法别混用。4.3 哈希寻址与节点存储为了不漏掉细节再补充一下节点哈希在存储中的作用。MPT 节点在数据库中实际存储时并不是以内联对象的方式存进父节点里的而是用这个节点的 RLP 编码后的哈希作为数据库的键Key。也就是说父节点里保存的是子节点的哈希引用而不是子节点的完整内容。这样做的好处是如果要验证某个分支节点下的数据轻节点只需要拿到这个哈希引用指向的节点数据重新计算哈希跟父节点里的引用比对即可。这也正是“默克尔化”的核心和普通 Merkle Tree 的逐层哈希验证逻辑完全一致。实际操作中在 Ethereum 的 LevelDB / RocksDB 实现里你会看到很多键值是Hash - RLP(Encoded Node)这种形式整棵 MPT 其实是“逻辑上成树、物理上打平存储”的。理解这一点对排查节点同步慢、数据库膨胀之类的问题很有帮助。5. 实操解析从打包到验证的一次完整流程5.1 打包阶段先交易树、再执行、后收据树矿工在构建区块时流程是固定的从交易池里按 Gas 价格、Nonce 顺序选出若干交易形成一个有序交易列表。把交易列表逐笔写入一个空白的 MPT得到 Transaction Trie计算根哈希。逐笔执行交易执行完一笔就生成一个收据按序号再写入另一个空白的 MPT得到 Receipt Trie计算根哈希。更新世界状态得到更新后的 State Trie 根哈希。把三个根哈希以及其他字段时间戳、GasLimit、矿工地址等一起组装成区块头。这里有一个实现细节交易树可以先于执行构建但收据树必须在交易执行后才能构建因为收据里有执行结果的摘要。有些 p2p 层的优化实现里为了省内存会先构建交易树然后边执行边追加收据节点最后统一计算收据根。5.2 验证阶段轻节点怎么用收据树轻节点Light Client只保存区块头不保存完整的 MPT 节点数据。当它需要验证某个地址是否收到了某笔转账时它向全节点请求一个“收据证明”。全节点会从收据树中取出对应叶子节点以及一路向上到根的所有兄弟哈希组成一个 MPT Proof。轻节点拿到 Proof 后按相同规则重新计算路径上的哈希如果能和区块头里的ReceiptsRoot对上就证明这个收据确实属于那个区块。这就是收据树非常重要的实战价值不需要同步全世界状态也不需要重放全部交易就可靠验证某笔交易的结果。这也正是各类轻钱包、跨链桥、索引服务能工件的原理基础。5.3 Bloom Filter快速检索日志的“索引小抄”收据树里藏着一个经常被忽略的设计logsBloom布隆过滤器。每产生一条日志时以太坊会把日志的地址和所有 topic 分别映射到布隆过滤器的若干个 bit 位置置为 1。收据里会记录这个区块内所有日志合并后的logsBloom。这样一来比如你想找出“某个区块中哪些交易触发了 USDT 的 Transfer 事件”不需要遍历每一笔收据里的日志只需要拿着 USDT 合约地址和 Transfer 事件的 topic对区块头里的logsBloom做一次快速检查。如果所有对应位都为 1才说明“可能存在”匹配日志这时再去拉具体收据进一步确认。注意 Bloom Filter 的特性是“一定不会漏报但可能有误报”。所以基于它做筛选时命中的交易仍然需要二次验证。这是做区块索引服务时必须知道的坑先用布隆过滤器粗筛再用收据树精确验证。5.4 实战命令在 geth 里查看根哈希如果你安装了 geth可以直接用 RPC 接口查看某个区块的三个根哈希。curl -X POST http://localhost:8545 -H Content-Type: application/json --data { jsonrpc: 2.0, method: eth_getBlockByNumber, params: [latest, false], id: 1 }返回结果中会包含transactionsRootreceiptsRootstateRoot如果你想深入验证某笔交易对应的收据可以调eth_getTransactionReceipt传交易哈希进去。返回的logsBloom、cumulativeGasUsed、status就是收据树里存的核心字段。我用这个方式做合约事件追踪时通常先拿到收据里的日志地址和 topic再和合约接口定义逐一对上效率比直接扫链高很多。6. 常见问题与排查技巧实录6.1 交易树里的键是索引不是哈希别搞混这是初学者最容易犯的错误。很多人看到“交易树”三个字默认树键是txHash。实际根本不是交易树的键是交易在区块中的序号RLP 编码。交易哈希是需要通过eth_getTransactionByHash查询时节点先定位到区块号再在交易树里按索引顺序找到交易然后重新计算哈希来匹配的。如果你自己在写代码去重放交易树千万别用哈希直接当键去 MPT 里查找否则必然扑空。6.2 收据树验证失败先从 RLP 编码查起MPT Proof 验证不通过大部分时候不是根算错了而是 RLP 编码细节错了。常见问题包括路径编码没有处理奇数长度路径导致节点哈希算出来不一致。叶子节点里的 value 没有按收据的标准 RLP 格式序列化。分支节点第 17 项是“值”不是“空字符串”空值要用空字符串但不能直接省略。我自己的排查习惯是先用 geth 自带的debug_getRawReceipts拿到某个区块的原始收据 RLP然后和自己代码里序列化的结果做逐字节对比。只要原始字节一致后续的哈希计算基本就能对上。6.3 全节点同步很慢可能和“修剪”机制有关以太坊客户端默认会对 MPT 做历史状态修剪Pruning只保留近期状态节点。如果你发现全节点越来越慢别急着加内存先确认是不是同步模式设置成了full而不是snap。在snap模式下节点会利用收据树和状态树的快照加速同步速度能快好几倍。对于只做数据服务的场景snap是更合理的选择。6.4 日志查询接口返回空先查 Bloom Filter有时候你监听某个合约的 Transfer 事件明明链上有交易但eth_getLogs返回空。这时候别怀疑节点有问题先检查你的 topic 参数是不是按“合约地址 event signature”的规则拼接。另外如果你把fromBlock设得过远节点会做 Bloom Filter 的过滤万一你的 topic 编码有一位写错过滤结果就为空集。正确的做法是先用eth_getBlockByNumber拉区块头的logsBloom手动匹配几个位确认后再发eth_getLogs。这样能把“检索逻辑错误”和“节点问题”快速分开。7. 从交易树和收据树里“捡”到的工程经验我个人做链上数据服务这几年和这两棵树打了无数次交道有几个体会值得分享。第一理解“交易内容”和“交易结果”的差异是设计链上索引的第一步。如果你只同步区块头不存交易树和收据树那么你只能知道有哪些交易无法知道交易执行是否成功、触发了哪些事件。但绝大多数业务场景都需要事件数据所以收据树的价值往往被低估了。第二MPT Proof 是跨链验证的老朋友。现在大家做跨链桥、资产互操作经常要用“交易是否存在”“事件是否发生”的证明。这两种证明分别来自交易树和收据树。如果你已经吃透了这两种树的 MPT 结构写一个通用证明校验器会非常顺手。第三区分“节点实现”和“协议规范”。黄皮书里写的是规范但 geth、Nethermind、Erigon 各自对 MPT 的实现细节有差异比如对空节点、内联节点的处理方式就不同。你测试环境跑通了不代表主网全节点行为一致。最稳妥的方法是直接在测试网跑一个 Erigon 或者 Nethermind 节点用debug接口拿 RLP 原始数据来验证自己的解析代码。最后再分享一个小技巧如果你只是想快速验证某个区块内所有交易的收据根不需要等全节点同步完。直接用公共 RPC 拉原始区块头拿到receiptsRoot再拉每笔交易的收据构造 MPT本地算出根哈希后对比即可。这不会消耗你多少带宽却能帮你把整棵收据树的逻辑跑通。我在本地写过一个基于trie库的 demo处理一个约 150 笔交易的区块从拉数据到验证完成基本在一秒以内。这个思路可以直接用于审计脚本、事件证明服务和链上数据分析工具。
返回列表