区块链节点离不开数据库。每次 EVM 需要读取账户余额、加载合约代码或检查存储槽时,实际上都是在从一个名为状态 trie 的数据结构中读取数据——这是一个 Merkle-Patricia Trie,它将键(如账户地址、存储槽)映射到值(如余额、nonce、字节码)。每次交易执行后,trie 都会更新,并计算出一个新的状态根哈希,以密码学方式提交整个状态。
这个数据库的性能直接决定了节点执行交易的速度。试想,如果每次状态访问都需要通过多层间接寻址进行多次磁盘读取,那么执行速度就会完全被存储延迟所限制,CPU 再快也无济于事。
MonadDb 就是 Monad 为此定制的状态数据库。它没有使用现成的通用键值存储,而是将 Merkle-Patricia Trie 直接实现为面向磁盘的一流数据结构,并且结合了异步 I/O 和 SSD 感知的存储布局。下面我们来深入探讨这背后的原理与实现。
通用键值存储的瓶颈
目前,大多数以太坊客户端将状态 trie 存储在 LevelDB、RocksDB 或 LMDB 这类通用键值存储中。它们成熟且经过充分测试,但都围绕着自身内部的数据结构构建,例如 B 树(LMDB)或日志结构合并树(LevelDB, RocksDB)。
客户端通过将 trie 节点的哈希作为键,序列化的节点数据作为值,将 trie “嵌入”到 KV 存储中。查找一个账户时,需要从根开始遍历 trie,在每一层都发起一次 KV 查询(例如一次 B 树遍历),以获取下一个节点的哈希。
这就产生了一个数据结构嵌套的问题:客户端遍历的是一棵树(Merkle-Patricia Trie),而这棵树又存储在另一棵树(B 树或 LSM 树)里。一次账户查询可能涉及 6-8 层 trie,每一层都需要一次独立的 KV 查找,而每次 KV 查找本身又可能在其内部结构中引发多次磁盘读取。间接寻址的开销被放大了。
除了读取路径,写入也会受到影响。LSM 树会在内存中缓冲写入,并定期压缩到磁盘,这个过程会重写大量数据。这种“写放大”——单个逻辑写入导致多次物理写入——是基于 LSM 树的存储的固有代价,它会与执行所需的读取操作竞争宝贵的 SSD 带宽。
MonadDb:将 Trie 本身作为数据库
MonadDb 的设计思路是消除这个额外的间接层,直接将 Merkle-Patricia Trie 实现为磁盘上的数据结构。Trie 节点不再需要通过通用索引按哈希查找,而是通过直接的物理指针定位。
Trie 中的每个节点都会为其每个子节点存储一个 chunk_offset 值,该值编码了子节点的精确磁盘位置。当从父节点遍历到子节点时,没有索引查找,没有哈希表探测,也没有 B 树下降过程。父节点“知道”子节点在磁盘上的确切位置,I/O 子系统可以直接发出读取请求来获取这些字节。
Trie 节点结构
查看代码:category/mpt/node.hpp
MonadDb 使用了一种广义的 trie,它扩展了标准的以太坊 Merkle-Patricia Trie。节点最多可以拥有 16 个子节点(对应 16 个十六进制半字节),一个可选的键路径和一个可选的值。这种泛化允许单个节点扮演双重角色——例如,一个节点可以同时是扩展节点(有路径)和分支节点(有多个子节点),或者是一个带有叶子值的分支节点。
节点的磁盘表示按顺序打包了以下数据:
- 头部字段:一个 16 位的子节点掩码(指示 16 个可能的子节点中哪些存在)、一个位打包字节(包含“是否有值”标志、路径半字节索引、中间哈希缓存大小)、值长度和一个版本号(此节点上次更新的区块号)。
- 子节点偏移量数组:对于每个存在的子节点(由掩码指示),存储一个 8 字节的
chunk_offset,指向子节点的磁盘位置。
- 压缩元数据:每个子节点的最小偏移量和最小版本,代表以该子节点为根的子 trie。这些字段使压缩器能够判断哪些存储区域仅包含过期数据。
- 子节点数据偏移量数组:每个子节点的 2 字节偏移量,指向子节点数据部分。这允许在不扫描的情况下定位任何子节点的数据。
- 路径:此节点的半字节路径(可变长度),用于 trie 中的前缀压缩。
- 值:面向用户的叶子数据(可变长度)——例如,账户的 nonce、余额和代码哈希的 RLP 编码。
- 中间哈希缓存:对于那些既是一个子 trie 的叶子,又是另一个子 trie 的根的节点(例如,指向存储 trie 根哈希的账户节点),这里会缓存一个中间哈希。
- 子节点数据:所有子节点的哈希数据,连接在一起。这是高效哈希计算的关键。
- 内存中的子节点指针(仅内存):当节点被加载到内存中时,会附加空间用于存放指向已在内存中的子节点的
shared_ptr 引用。这些指针不会持久化到磁盘。
内联子节点数据:避免读取哈希
一个核心设计选择是将每个子节点的哈希数据内联存储在父节点中。在标准实现中,计算一个节点的 Merkle 哈希需要读取其所有子节点以获取它们的哈希。而在 MonadDb 中,这些哈希已经嵌入在父节点的磁盘表示里。
这意味着,当 trie 更新并需要沿着从被修改的叶子到根的路径重新计算哈希时,该路径上的每个节点已经包含了它所有子节点所需的哈希数据——它只需要重新读取那个已更改的子节点。哈希重新计算所需的磁盘读取次数与修改的深度成正比,而不是与分支因子(子节点数量)成正比。
chunk_offset:在 64 位中编码位置与大小
查看代码:category/async/config.hpp
Trie 中的每个子指针都是一个 64 位的 chunk_offset,被编码为位字段。MonadDb 使用 15 个备用位来编码子节点的磁盘大小,表示为带移位的页数。
这意味着单个 64 位数值就能告诉 MonadDb 节点的位置和需要读取的字节数。当 I/O 子系统收到读取子节点的请求时,它可以发出精确大小的读取操作,而无需先进行一次 I/O 来确定节点大小。编码是近似的(向上取整),因此读取可能会获取比必要略多的字节,但绝不会更少。
基于 io_uring 的异步 I/O
查看代码:category/async/io.hpp
并行事务执行意味着多个事务正在并发执行,每个都可能读取状态 trie 的不同部分。如果磁盘读取阻塞调用线程,那么并行性就受限于线程数量,并且大多数线程时间都花在等待 I/O 上,而不是执行有效工作。
MonadDb 使用 Linux 的 io_uring 接口进行完全异步的磁盘 I/O。当 trie 遍历到达一个不在内存中的节点时,它会向 io_uring 的提交队列提交一个读取请求,然后让出控制权(通过轻量级的 Boost Fiber 协程),以便其他工作可以继续。当内核完成读取时,会从 io_uring 的完成队列中获取完成通知,并恢复等待的纤程。
这允许数百甚至数千个并发的状态读取同时处于活跃状态,所有这些读取都通过少量的操作系统线程进行多路复用。并发读取的数量是可配置的(对于读写数据库默认为 1024,对于只读数据库默认为 600),这提供了背压机制,避免压垮存储设备。
读取缓冲区分为两层管理:
- 短读取(最多 4 KB):从预分配的固定大小缓冲区池中提取,避免每次读取都分配内存。
- 长读取(超过 4 KB):使用符合直接 I/O 要求的对齐方式动态分配。
存储布局:块与区域
查看代码:category/async/storage_pool.hpp
MonadDb 将其存储划分为 256 MB 的“块”,其设计灵感来源于 NVMe 分区命名空间(ZNS)存储。即使在传统 SSD 上运行,MonadDb 也会模拟分区存储语义,将原始块设备或文件划分为两种类型的块:
- 传统块 (cnv):用于需要随机访问的元数据,例如根偏移量环形缓冲区和数据库元数据。通常这类块的数量很少(默认值:每个设备 3 个)。
- 顺序块 (seq):用于存储 trie 节点数据。这些块被视为仅追加(append-only):新节点按顺序写入当前块的末尾,一旦块写满,写入就移动到下一个块。一旦旧块中的数据不再需要,就可以批量回收整个块。
这种顺序写入模式对 SSD 性能有显著好处。SSD 在内部将 NAND 闪存组织成大的擦除块。对任意位置的随机写入会导致碎片,迫使 SSD 的垃圾回收器在回收部分使用的擦除块之前,必须先将活动数据复制出来——这个过程会消耗带宽并不可预测地增加延迟。通过顺序写入并一次性回收整个块,MonadDb 避免了这种内部碎片。回收块后,MonadDb 会发出 TRIM 命令,允许 SSD 高效地回收底层闪存块。
在支持 NVMe 分区命名空间的设备上,顺序块可以直接映射到设备的仅追加区域,完全绕过 SSD 内部的块设备模拟层。这可以将读取延迟从典型的约 70 微秒降低到 15-30 微秒,因为从顺序区域的读取可以直接访问 NAND 闪存,而无需经过 SSD 的闪存转换层。
绕过文件系统
MonadDb 可以直接在原始块设备上运行,完全绕过文件系统。这消除了文件系统开销,包括块分配、目录元数据管理和日志记录。MonadDb 通过其块系统管理自己的块分配,这比通用文件系统更简单、更可预测,因为访问模式是预先已知的。
持久化 Trie:无锁版本控制
查看代码:category/mpt/db.hpp
MonadDb 使用了函数式编程意义上的持久化(persistent)trie 结构。当一个区块更新某些账户时,MonadDb 不会原地修改现有节点。相反,它会创建被修改节点的新版本,并将它们写入磁盘上的新位置。旧节点保持完好,并且仍然可以从其原始位置被读取。
这意味着状态 trie 的多个版本可以同时存在于磁盘上,每个版本都可以通过其根偏移量访问。传统块中的一个环形缓冲区存储着每个区块号的根偏移量,允许通过区块号查找任何历史版本(在保留窗口内)。
这种持久化模型具有重要的并发优势。访问特定区块号状态的读取器,通过 trie 的一个不可变快照来追踪指针——它们永远不会观察到部分写入的更新,并且不需要任何锁或与写入器的协调。这正是区块链节点所需要的:执行引擎在写入新状态的同时,RPC 服务器和共识层正在并发读取不同区块高度的状态。
版本追踪
每个 trie 节点都记录了其上次更新的区块号(版本字段)。对于叶子节点,这是值最后一次被修改的区块。对于内部节点,该版本至少与其子 trie 中任何叶子的最大版本一样大。
这种按节点的版本控制支持高效的历史数据过期。压缩器可以检查子 trie 的最小版本,以确定其任何数据是否仍在保留窗口内,并跳过已知仅包含过期数据的整个子 trie。
压缩
随着新区块生成新的节点版本,旧版本会在磁盘上累积。MonadDb 通过压缩来回收空间:它遍历活动 trie,将可达的节点前向复制到新的顺序块中,并回收旧块。
块被组织成两个链表——一个快速列表和一个慢速列表——它们支持分层存储。热数据(最近写入或频繁访问的节点)位于快速列表块中,而冷数据则迁移到慢速列表。完全压缩后的块被移到空闲列表以供重用。
压缩过程与正常更新内联运行,并受到限制,以避免干扰执行吞吐量。数据库根据可用磁盘空间动态调整其历史记录保留长度。
崩溃安全
查看代码:category/mpt/detail/db_metadata.hpp
MonadDb 维护其数据库元数据的两个副本,存储在不同的传统块中。更新在两个副本之间交替进行,因此如果在元数据写入期间进程崩溃,至少有一个副本保持有效。启动时,MonadDb 会读取两个副本并使用一致的那个。
基于半字节分区的统一 Trie
查看代码:monad-triedb README
MonadDb 将所有区块链数据——账户状态、合约代码、交易收据、区块头和二级索引——存储在同一个统一的 trie 中。数据类型由前导半字节(nibble)前缀分隔:

收据、交易和区块头是按区块号进行版本控制的,因此相同的键结构会根据查询的版本(区块号)检索到不同的数据。
二级索引(交易哈希和区块哈希)支持标准的以太坊 RPC 查询,例如 eth_getTransactionByHash——给定一个交易哈希,索引返回区块号和交易索引,然后可以使用这些信息来获取完整的交易数据。
这种统一的方法意味着只需要优化一个存储引擎、运行一个压缩过程、维护一个缓存层,而不是为不同数据类型维护多个独立的系统。
缓存
查看代码:category/mpt/node_cache.hpp , category/core/lru/lru_cache.hpp
MonadDb 维护了一个最近访问的 trie 节点的内存 LRU 缓存,以虚拟块偏移量为键。缓存受内存容量限制(对于只读数据库默认为 50 MB),而不是条目数量限制,并且它会追踪每个缓存节点的实际大小。
平均节点大小约为 104 字节,50 MB 的缓存可以容纳大约 500,000 个节点。由于 trie 的上层节点比叶子节点访问频率高得多,缓存自然会保留热门的内部节点,从而为典型工作负载提供高命中率。
在 Rust 方面,共识层和 RPC 层添加了额外的缓存。每个区块缓存会保留最近最终确定的区块的账户数据和区块头,并且 RPC 服务器会预加载最新区块的交易和收据,以便在不查询 trie 的情况下处理常见请求。
整合
查看代码:monad-triedb/src/lib.rs
MonadDb 的核心(trie、I/O 层和存储池)是用 C++ 实现的,作为执行引擎的一部分。用 Rust 编写的共识客户端和 RPC 服务器通过 C 语言外部函数接口(FFI)边界访问数据库。
FFI 接口暴露了一小组操作:同步读取、带回调的异步读取、前缀遍历和范围查询。在 Rust 端,一个专用的轮询线程驱动 io_uring 完成循环,将 C++ 回调转换为 Rust 通道消息。这使得异步 I/O 机制被封装在 C++ 层内,同时为 Rust 代码提供了一个用于并发状态访问的简洁接口。
Rust 端对 trie 是只读的。状态写入完全发生在 C++ 执行引擎中,该引擎在执行区块时写入新的节点版本。Rust 共识层查询数据库以检查最终确定的状态、读取验证器集以及服务 RPC 请求,但它从不直接修改 trie。这种分离确保了单一写入者和多个读取者的模式,这与持久化 trie 的并发模型自然吻合。
原文链接:https://x.com/keonehd/status/2020388066725179801?s=46