Read OSS

ステート・ストレージ:StateDB からディスクまで

上級

前提知識

  • 第1〜3回の記事
  • Merkle Patricia Trie の理解
  • キーバリューデータベースの基礎知識

ステート・ストレージ:StateDB からディスクまで

第3回では、ブロックが実行パイプラインを通過する流れを追い、処理中の WorldState がどのように StateDB としてメモリ上に保持されるかを見てきました。しかしメモリは揮発性です。Ethereum のステートはノードの再起動を越えて永続化される必要があり、Merkle プルーフによる検証にも耐えられなければなりません。本記事では、インメモリの StateDB からディスク上のバイト列までの全経路を追い、パフォーマンス・検証可能性・ディスク使用量を巧みに両立させた階層型ストレージアーキテクチャの全貌を明らかにします。

4層のストレージスタック

Geth のステートストレージは4層のスタック構造になっており、各層がそれぞれ固有の役割を担っています。

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

Layer 1 (StateDB) は第3回で詳しく扱った書き込みキャッシュです。変更されたアカウントオブジェクトを保持し、トランザクション実行のためのスナップショット/リバートを提供します。

Layer 2 (Trie) は Merkle Patricia Trie です。Ethereum がステートルートを生成するために使用する暗号学的データ構造であり、すべてのブロックヘッダにはワールドステート全体を確約するステートルートハッシュが含まれています。

Layer 3 (TrieDB) は Trie ノードの保存と取得を管理します。ハッシュ方式とパス方式という2つのストレージスキームの選択がここで行われます。

Layer 4 (ethdb) は実際のオンディスクデータベース層です。アクティブなデータには LevelDB または Pebble を使用し、過去のブロックやレシートは「ancient」フリーザーで管理します。

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 を実装し、ancient フリーザーは AncientStore を実装しています。

ヒント: memorydb パッケージは KeyValueStore の完全なインメモリ実装を提供しており、テスト時に非常に重宝します。Node がデータディレクトリなしで起動された場合、自動的に memorydb が使用され、開発用の完全エフェメラルノードとして動作します。

Merkle Patricia Trie の実装

trie.Trie 構造体は Ethereum の Modified Merkle Patricia Trie を実装しています。ステートルートを実現する、この暗号学的データ構造の定義は次のとおりです。

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

Trie は内部的に4種類のノード型を使用しています。fullNode(16個の子ノードと値を持つブランチノード)、shortNode(キーの断片を持つ拡張ノード/リーフノード)、hashNode(データベースに格納されたノードへの参照)、valueNode(生のリーフデータ)です。GetUpdateDelete といった操作はこのノードツリーを走査することで行われ、hashNode の参照はデータベースから遅延解決されます。

Ethereum が管理する Trie には2種類あります。アカウント Trie(アドレスからアカウントデータへのマッピング)とストレージ Trie(コントラクトごとに1つ存在し、ストレージスロットから値へのマッピング)です。StateDB がこの両方を調整し、各 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 による永続化のためにノードセットへと収集されます。

ハッシュ方式とパス方式の Trie ストレージ

triedb.Databasebackend インターフェースをラップしており、根本的に異なる2つのストレージ戦略を抽象化しています。

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

backend インターフェースは NodeReaderStateReaderSizeCommitClose の実装を要求します。その実装は2つあります。

ハッシュ方式(hashdb:Trie ノードはそのハッシュをキーとして格納されます。シンプルで実績のある方式ですが、プルーニングのコストが高くなりがちです。ノードがどこからも参照されなくなったタイミングを知るために参照カウントが必要で、ガベージコレクションにはステート全体の走査を伴います。

パス方式(pathdb:Trie ノードは Trie 内のパスをキーとして格納されます。バージョン間の差分を保持して古いステートをレイヤー削除だけでプルーニングできるため、ステート履歴の管理が効率的です。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)
}

パス方式は、Ethereum のステートサイズ増大に伴うプルーニングコストの問題を解決するために導入された新しい設計です。フラット化されたディスク層の上に差分レイヤーをスタックとして積み重ねることで、効率的なルックアップと境界付きの履歴ステートアクセスを両立しています。

方式の選択は Config 構造体で明示的に行われます。

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

HashDB を設定するとハッシュ方式が、PathDB を設定するとパス方式が有効になります。両方を同時に設定した場合は fatal エラーになります。

ステートスナップショットと Ancient フリーザー

Trie そのものに加えて、Geth はパフォーマンスを大幅に向上させる2つの補助的なストレージ機構を持っています。

ステートスナップショットは、アカウントアドレスやストレージスロットをその値に直接マッピングするフラットなキーバリュー層です。読み取り操作において Trie の走査を完全にバイパスできます。たとえば eth_getBalance を呼び出した場合、Trie を辿ることなくスナップショット層が O(1) で応答できます。スナップショットはブロック処理と並行してインクリメンタルに構築され、Trie データと同じキーバリューデータベース内に格納されます。

Ancient フリーザーは、古くなった履歴データを別の仕組みで扱います。設定可能なしきい値を超えた古いブロックは、そのヘッダ・ボディ・レシート・ハッシュがキーバリューストアから追記専用のフラットファイル群へと移動されます。これが重要な理由は次のとおりです。

  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 といった型安全なアクセサ関数を提供し、スキーマのバージョン管理も担います。この層があることで、コードベースの他の部分がデータベースキーを手動で組み立てる必要がなくなり、キーのレイアウトに関する知識が rawdb に一元化されます。

ヒント: ストレージの問題を調査するなら、geth db inspect コマンド(cmd/geth/dbcmd.go に実装)が便利です。データベース全体を走査して、Trie ノード・ヘッダ・ボディ・レシート・スナップショットなどのカテゴリ別にディスク使用量を報告してくれます。何がディスクを圧迫しているかを把握する最短ルートです。

以上でストレージアーキテクチャの全体像が明らかになり、ブロックの受信からステートのコミット、そしてディスクへの書き込みまでの流れをすべてカバーしました。次回の記事では逆方向の流れ、すなわちトランザクションが P2P ネットワークからシステムに入り、トランザクションプールを経てブロックビルダーに取り込まれるまでの道のりを追います。