状态树不该塞进通用键值库:MonadDb 的原生帕特里夏树与异步 I/O 图 1
状态树不该塞进通用键值库:MonadDb 的原生帕特里夏树与异步 I/O · 图 1

把以太坊虚拟机搬到很高的出块节奏上,最先撑不住的不一定是计算,而是取状态。Monad 把这块单独拎出来做了一个专用数据库 MonadDb,官方架构文档直接称它是 Monad 里为了「既保持以太坊完全兼容、又给出高性能」而自建的关键组件。本文按官方文档拆解它的设计选择,以及这些选择在磁盘上换来了什么。

一个尴尬:状态树被塞进通用 KV 里

官方文档先描述行业惯例。绝大多数以太坊客户端用通用的键值数据库来存状态,实现要么是 B 树(文档举例 LMDB),要么是 LSM 树(举例 LevelDB、RocksDB)。问题在于以太坊本身用默克尔帕特里夏树(MPT)来表示状态、收据和交易这些需要认证的数据结构。于是出现「一个数据结构被嵌进另一个数据结构」:客户端每写一次状态,其实是把已经算好哈希的树节点当作字符串键,再塞进一套为通用场景设计的通用存储引擎里,读写放大、序列化开销、以及树结构与 B 树/LSM 分层之间的错配都由此产生。

MonadDb 的应对是不再转译,而是原生实现。文档说明它在磁盘和内存里都直接实现帕特里夏树(radix 树的一个特定变体),专为高效存放 MPT 节点而设计。同时它强调这个设计虽然立场鲜明,接口上仍然是灵活的键值存储:Monad 自己也用它来存区块头和区块负载。换句话说,这不是只会长树的专用件,而是把树作为一等公民的通用件。

MIP-8:让承诺的粒度和磁盘读取的粒度一致

示意:MonadDb机制示意

状态树之外,Monad 在 2025 年前后引入的一项改动把「存什么」也改了。官方文档写明:在 MIP-8 之下,存储树不再对单个槽位做承诺,而是对页做承诺。一页是连续的 128 个存储槽、共 4096 字节,每个存储叶子保存的是对这一页内容的 32 字节承诺,于是树里存的是「页号—页承诺」这样的配对;树的其他部分不变,键仍然用 keccak 哈希,分支、扩展、叶子节点仍按标准 MPT 规则。

文档给出的理由很实在:读一个 32 字节的槽,本来就会把一整页从 SSD 上取回来,那不如让承诺覆盖的范围就等于一次磁盘读取的范围。页承诺本身是对页内被占用的槽建 BLAKE3 默克尔根,所以计算它的工作量、以及针对它的证明大小,都随页的占用程度增长,而不是随 4096 字节的固定页大小增长。文档同时指向配套的 gas 表和已有数据库的迁移说明,说明这是一个需要数据搬迁的改动,而不是纯粹的读写路径优化。

异步 I/O 与绕过文件系统

Monad 要并行执行多笔交易,官方文档指出这要求读取不能阻塞后续工作,这正是给数据库配异步 I/O 的动机。文档顺带批评了上面提到的那些通用键值库:它们缺乏 proper 的异步 I/O 支持,为了异步而不得不派生大量内核线程去挂起中的 I/O 请求。MonadDb 的写法是充分利用 Linux 内核对异步 I/O 的最新支持,文档链接指向 io_uring 的入门资料,好处就是不必为等待中的请求铺一堆线程。

另一处更激进的选择是绕过文件系统。文档承认现代文件系统给应用提供了方便的抽象,但做高吞吐 I/O 时这些抽象自带隐藏成本:块分配、碎片化、读写放大、元数据管理。文件抽象让应用以为数据是连续存放的,实际磁盘上可能被切成多段不连续片段。MonadDb 因此自行管理盘上的物理位置,用官方的说法是把这些被抽象掩盖的成本纳入自己的设计里。

怎么读这套取舍

MonadDb 的价值主张不是「更快的数据库」这句口号,而是一条具体的工程判断:既然链要认证的状态本来就是树,就不该把它压扁进通用引擎;既然一次磁盘读取天然带回一页,承诺就不该只覆盖一个槽。这两条判断都以牺牲通用性换取匹配度,代价是可维护性、可移植性与生态工具链的兼容成本——这些不在机制文档的范围里,需要看节点运维与迁移材料。对只想理解性能来源的读者,值得记住的是文档明确的三个词:树结构原生实现、异步 I/O 与绕过文件系统,以及 MIP-8 之后承诺与页对齐。具体参数与实现细节会随版本演进,本文按官方架构文档描述机制,不构成对性能数字的主张,也不构成投资建议。