Read OSS

状态存储:从 StateDB 到磁盘

高级

前置知识

  • 第 1–3 篇文章
  • 了解默克尔帕特里夏树
  • 基本的键值数据库概念

状态存储:从 StateDB 到磁盘

在第 3 篇中,我们追踪了区块在执行管道中的流转过程,了解到 StateDB 在处理期间如何将世界状态保存在内存中。然而内存是易失性的——以太坊的状态需要在节点重启后依然存在,并且必须能够通过默克尔证明进行验证。本文将完整追踪从内存中的 StateDB 到磁盘字节的全链路,揭示一套精心设计的分层存储架构,在性能、可验证性与磁盘空间之间取得了良好平衡。

四层存储架构

Geth 的状态存储由四层构成,每一层解决一类特定问题:

flowchart TD
    subgraph "Layer 1 — Execution Cache"
        SDB["StateDB<br/>In-memory account objects<br/>Journal-based snapshots"]
    end
    subgraph "Layer 2 — Cryptographic Structure"
        MPT["Merkle Patricia Trie<br/>Authenticated data structure<br/>Account trie + storage tries"]
    end
    subgraph "Layer 3 — Trie Node Management"
        TDB["TrieDB<br/>Hash-based scheme (hashdb)<br/>OR Path-based scheme (pathdb)"]
    end
    subgraph "Layer 4 — On-Disk Storage"
        KV["Key-Value Store<br/>(LevelDB or Pebble)"]
        FREEZER["Ancient Freezer<br/>Append-only flat files"]
    end
    SDB --> MPT
    MPT --> TDB
    TDB --> KV
    KV --- FREEZER

第 1 层(StateDB) 是第 3 篇中介绍的写透缓存,负责保存已修改的账户对象,并为交易执行提供快照与回滚能力。

第 2 层(Trie) 是默克尔帕特里夏树——以太坊用于生成可验证状态根的密码学数据结构。每个区块头都包含一个状态根哈希,对整个世界状态做出承诺。

第 3 层(TrieDB) 管理 trie 节点的存储与检索,这里是在哈希方案和路径方案之间做出关键抉择的地方。

第 4 层(ethdb) 是真正的磁盘数据库——活跃数据使用 LevelDB 或 Pebble,历史区块和收据则交由"ancient"冷存储(freezer)处理。

ethdb:组合式数据库接口

正如第 1 篇所述,ethdb 包通过接口组合来定义存储接口。整个设计从职责单一的小接口出发,逐步向上组合:

classDiagram
    class KeyValueReader {
        <<interface>>
        +Has(key) bool, error
        +Get(key) bytes, error
    }
    class KeyValueWriter {
        <<interface>>
        +Put(key, value) error
        +Delete(key) error
    }
    class KeyValueStore {
        <<interface>>
        KeyValueReader
        KeyValueWriter
        KeyValueStater
        KeyValueSyncer
        KeyValueRangeDeleter
        Batcher
        Iteratee
        Compacter
        io.Closer
    }
    class AncientStore {
        <<interface>>
        AncientReader
        AncientWriter
        AncientStater
        io.Closer
    }
    class Database {
        <<interface>>
        KeyValueStore
        AncientStore
    }
    KeyValueReader <|-- KeyValueStore
    KeyValueWriter <|-- KeyValueStore
    KeyValueStore <|-- Database
    AncientStore <|-- Database

Database 接口本质上就是 KeyValueStoreAncientStore 的组合。这种设计带来了很强的灵活性:只需要读取时传入 KeyValueReader,需要完整功能时传入 Database。LevelDB 和 Pebble 均实现了 KeyValueStore,冷存储 freezer 则实现了 AncientStore

提示: memorydb 包提供了 KeyValueStore 的完整内存实现,在测试场景中非常实用。当节点在没有数据目录的情况下启动时,会自动使用 memorydb,从而支持开发阶段的全临时节点模式。

默克尔帕特里夏树的实现

trie.Trie 结构体实现了以太坊的改进型默克尔帕特里夏树——正是这个密码学数据结构使状态根成为可能:

type Trie struct {
    root  node
    owner common.Hash
    committed   bool
    unhashed    int
    uncommitted int
    reader      *Reader
}

Trie 内部使用四种节点类型:fullNode(分支节点,含 16 个子节点和一个值)、shortNode(扩展节点或叶节点,含一段路径前缀)、hashNode(指向数据库中某个节点的引用)以及 valueNode(原始叶数据)。对 trie 的 GetUpdateDelete 操作均通过遍历节点树完成,hashNode 引用会在需要时从数据库懒加载解析。

以太坊维护两类 trie:账户 trie(将地址映射到账户数据)和存储 trie(每个合约一棵,将存储槽映射到对应的值)。StateDB 协调这两类 trie:每个 stateObject 都持有其合约存储 trie 的引用。

flowchart TD
    STATE_ROOT["State Root Hash"] --> ACCOUNT_TRIE["Account Trie"]
    ACCOUNT_TRIE --> ACCT_A["Account A<br/>nonce, balance,<br/>storageRoot, codeHash"]
    ACCOUNT_TRIE --> ACCT_B["Account B<br/>nonce, balance,<br/>storageRoot, codeHash"]
    ACCT_A --> STORAGE_A["Storage Trie A<br/>slot → value"]
    ACCT_B --> STORAGE_B["Storage Trie B<br/>slot → value"]

区块处理结束时调用 StateDB.Commit(),所有被修改的状态对象会将变更刷入各自的 trie,trie 随后计算新的根哈希,生成的 trie 节点集合最终交由 TrieDB 持久化。

哈希方案 vs. 路径方案

triedb.Database 内部包装了一个 backend 接口,将两种本质上不同的存储策略抽象统一:

type Database struct {
    disk      ethdb.Database
    config    *Config
    preimages *preimageStore
    backend   backend
}

backend 接口要求实现 NodeReaderStateReaderSizeCommitClose,对应两种实现方案:

哈希方案(hashdb:以节点的哈希值为键存储 trie 节点。方案简单、经过充分验证,但裁剪代价高昂——需要引用计数来判断节点是否还可达,垃圾回收则需要遍历整个状态。

路径方案(pathdb:以节点在 trie 中的路径为键存储节点。这使得高效的状态历史管理成为可能——可以保存不同版本之间的差异(diff),只需删除对应层级即可裁剪旧状态,同时也让快照同步(snap sync)期间的状态修复更加高效。

flowchart LR
    subgraph "Hash-Based (hashdb)"
        direction TB
        H1["Node hash → node data"]
        H2["Reference counting for GC"]
        H3["Full state required for pruning"]
    end
    subgraph "Path-Based (pathdb)"
        direction TB
        P1["Trie path → node data"]
        P2["Layered diffs (disk + memory)"]
        P3["Efficient pruning by layer removal"]
    end

具体的选择发生在 NewDatabase() 中:

if config.PathDB != nil {
    db.backend = pathdb.New(diskdb, config.PathDB, config.IsVerkle)
} else {
    db.backend = hashdb.New(diskdb, config.HashDB)
}

路径方案是较新的设计,正是为了解决随以太坊状态规模增长而日益高昂的状态裁剪成本而引入的。它将状态历史以差异层的形式叠加在扁平化的磁盘层之上,兼顾了高效查找和有界历史状态访问。

Config 结构体将这一选择表达得清晰明了:

type Config struct {
    Preimages bool
    IsVerkle  bool
    HashDB    *hashdb.Config
    PathDB    *pathdb.Config
}

设置 HashDB 启用哈希方案,设置 PathDB 启用路径方案,同时设置两者则会触发致命错误。

状态快照与冷存储

除了 trie 本身,Geth 还维护了另外两种存储机制,能够显著提升性能。

状态快照是一个扁平的键值层,将账户地址和存储槽直接映射到对应的值,读取操作完全绕过 trie。当你调用 eth_getBalance 时,快照层可以以 O(1) 的复杂度直接返回结果,而无需遍历 trie。快照随着区块处理逐步构建,与 trie 数据一同存储在键值数据库中。

冷存储 freezer 对历史数据采用了不同的处理方式。当区块的"年龄"超过可配置的阈值后,其区块头、区块体、收据和哈希会从键值存储迁移到追加写入的扁平文件中。这样做的原因如下:

  1. 历史数据是追加写入的——一旦最终确认就不会再变
  2. 对于顺序数据,扁平文件比键值存储更节省空间
  3. 键值存储保持较小体积,有助于提升活跃状态的访问性能
flowchart TD
    subgraph "Active Data (LevelDB/Pebble)"
        RECENT["Recent blocks<br/>State trie nodes<br/>Transaction indices<br/>Snapshots"]
    end
    subgraph "Ancient Data (Freezer)"
        OLD_H["headers.0000"]
        OLD_B["bodies.0000"]
        OLD_R["receipts.0000"]
        OLD_HA["hashes.0000"]
    end
    subgraph "rawdb — Key Encoding Bridge"
        RAW["Prefixed key encoding<br/>Type-safe read/write<br/>Schema versioning"]
    end
    RECENT <--> RAW
    OLD_H <--> RAW
    OLD_B <--> RAW
    OLD_R <--> RAW

core/rawdb/ 包充当上层代码与原始数据库之间的键编码桥梁。它定义了键前缀,提供了类型安全的访问函数(如 ReadBodyWriteReceipts),并负责管理 schema 版本。这一层确保了代码库其他部分永远不需要手动拼接数据库键——所有键的布局知识都集中在 rawdb 中统一维护。

提示: 在排查存储问题时,geth db inspect 命令(实现于 cmd/geth/dbcmd.go)会遍历整个数据库,并按类别汇报空间占用情况——包括 trie 节点、区块头、区块体、收据、快照等。这是了解磁盘空间分布的最快捷方式。

至此,我们已经完整梳理了从区块到达、状态提交直至写入磁盘的全部链路。下一篇文章将转换视角,追踪交易从 P2P 网络进入系统、经过交易池处理,最终被区块构建器打包的完整过程。