掃碼下載
BTC $68,039.50 +0.10%
ETH $1,975.64 +0.43%
BNB $625.47 -0.12%
XRP $1.42 -4.56%
SOL $81.67 -4.53%
TRX $0.2795 -0.47%
DOGE $0.0974 -3.83%
ADA $0.2735 -4.22%
BCH $564.75 +0.57%
LINK $8.64 -2.97%
HYPE $28.98 -1.81%
AAVE $122.61 -3.42%
SUI $0.9138 -6.63%
XLM $0.1605 -4.62%
ZEC $260.31 -8.86%
BTC $68,039.50 +0.10%
ETH $1,975.64 +0.43%
BNB $625.47 -0.12%
XRP $1.42 -4.56%
SOL $81.67 -4.53%
TRX $0.2795 -0.47%
DOGE $0.0974 -3.83%
ADA $0.2735 -4.22%
BCH $564.75 +0.57%
LINK $8.64 -2.97%
HYPE $28.98 -1.81%
AAVE $122.61 -3.42%
SUI $0.9138 -6.63%
XLM $0.1605 -4.62%
ZEC $260.31 -8.86%

Geth 源碼系列:存儲設計及實現

Summary: 本系列共有六篇文章,在第二篇中將系統講解 Geth 的存儲結構設計與相關源碼,介紹其數據庫層次劃分並詳細分析各個層次中相應模塊的核心功能。
LXDAO
2025-08-30 22:06:52
收藏
本系列共有六篇文章,在第二篇中將系統講解 Geth 的存儲結構設計與相關源碼,介紹其數據庫層次劃分並詳細分析各個層次中相應模塊的核心功能。

作者:po, LXDAO

以太坊作為全球最大的區塊鏈平台,其主流客戶端 Geth(Go-Ethereum)承擔了絕大部分節點運行與狀態管理的職責。Geth 的狀態存儲系統,是理解以太坊運行機制、優化節點性能、以及推動未來客戶端創新的基礎。

1. Geth 底層資料庫總覽

自 Geth v1.9.0 版本起,Geth 將其資料庫分為兩部分:快速訪問存儲 (KV資料庫,用於最近的區塊和狀態數據)和稱為 freezer 的存儲(用於較舊的區塊和收據數據,即"ancients")。

這樣劃分的目的是減少對昂貴、易損的 SSD 的依賴,將訪問頻率較低的數據遷移到成本更低、耐用性更高的磁碟上。與此同時,這種拆分也能減輕 LevelDB/PebbleDB 的壓力,提高其整理和讀取性能,使得在給定的快取大小下,更多狀態樹節點能夠常駐內存,從而提升整體系統效率。

  • 快速訪問存儲: Geth 用戶可能對底層資料庫的選項較為熟悉------可以通過 --db.engine 參數進行配置。目前的默認選項是 pebbledb,也可以選擇 leveldb。這兩個都是 Geth 所依賴的第三方鍵值資料庫,負責存儲路徑為 datadir/geth/chaindata(所有的區塊和狀態數據)和 datadir/geth/nodes(資料庫元數據文件,體積很小)的文件。通過 --history.state value 設置快速訪問保存的最近歷史狀態區塊數,默認為90,000個區塊。

  • freezerancients存儲(歷史數據) ,其目錄路徑通常為 datadir/geth/chaindata/ancients。由於歷史數據基本是靜態的,不需要高性能 I/O,因此可以節省寶貴的 SSD 空間,用於存儲更活躍的數據。

本文的重點是狀態數據,它存儲在 KV 資料庫中。因此,文中提到的底層資料庫默認是指這個 KV 存儲,而非 freezer。

Geth 存儲結構:五個邏輯資料庫

Geth 的底層使用 LevelDB/PebbleDB 存儲所有以 RLP 編碼後的數據,但其邏輯上劃分出五種用途不同的資料庫:

| 名稱 | 描述 | |----------------|----------------| | State Trie | 世界狀態,包括帳戶、合約存儲 | | Contract Codes | 合約代碼 | | State snapshot | 世界狀態快照 | | Receipts | 交易收據 | | Headers/Blocks | 區塊數據 |

每種數據通過 key 前綴(core/rawdb/schema.go)區分,邏輯上實現職責分離。通過geth db inspect可以查看 Geth 存儲的所有以太坊數據(塊高22,347,000),可以看到磁碟空間佔用最大的是區塊、收據和狀態數據。

+-----------------------+-----------------------------+------------+------------+
| DATABASE | CATEGORY | SIZE | ITEMS |
+-----------------------+-----------------------------+------------+------------+
| Key-Value store | Headers | 576.00 B | 1 |
| Key-Value store | Bodies | 44.00 B | 1 |
| Key-Value store | Receipt lists | 42.00 B | 1 |
| Key-Value store | Difficulties (deprecated) | 0.00 B | 0 |
| Key-Value store | Block number->hash | 42.00 B | 1 |
| Key-Value store | Block hash->number | 873.78 MiB | 22347001 |
| Key-Value store | Transaction index | 13.48 GiB | 391277094 |
| Key-Value store | Log index filter-map rows | 12.98 GiB | 132798523 |
| Key-Value store | Log index last-block-of-map | 2.73 MiB | 59529 |
| Key-Value store | Log index block-lv | 45.05 MiB | 2362175 |
| Key-Value store | Log bloombits (deprecated) | 0.00 B | 0 |
| Key-Value store | Contract codes | 9.81 GiB | 1587159 |
| Key-Value store | Hash trie nodes | 0.00 B | 0 |
| Key-Value store | Path trie state lookups | 19.62 KiB | 490 |
| Key-Value store | Path trie account nodes | 45.88 GiB | 397626541 |
| Key-Value store | Path trie storage nodes | 176.23 GiB | 1753966511 |
| Key-Value store | Verkle trie nodes | 0.00 B | 0 |
| Key-Value store | Verkle trie state lookups | 0.00 B | 0 |
| Key-Value store | Trie preimages | 0.00 B | 0 |
| Key-Value store | Account snapshot | 13.34 GiB | 290797237 |
| Key-Value store | Storage snapshot | 93.42 GiB | 1295163402 |
| Key-Value store | Beacon sync headers | 622.00 B | 1 |
| Key-Value store | Clique snapshots | 0.00 B | 0 |
| Key-Value store | Singleton metadata | 1.36 MiB | 20 |
| Ancient store (Chain) | Hashes | 809.85 MiB | 22347001 |
| Ancient store (Chain) | Bodies | 639.98 GiB | 22347001 |
| Ancient store (Chain) | Receipts | 244.19 GiB | 22347001 |
| Ancient store (Chain) | Headers | 10.69 GiB | 22347001 |
| Ancient store (State) | History.Meta | 37.58 KiB | 487 |
| Ancient store (State) | Account.Index | 5.80 MiB | 487 |
| Ancient store (State) | Storage.Index | 7.47 MiB | 487 |
| Ancient store (State) | Account.Data | 6.46 MiB | 487 |
| Ancient store (State) | Storage.Data | 2.70 MiB | 487 |
+-----------------------+-----------------------------+------------+------------+
| TOTAL | 1.23 TIB | |
+-----------------------+-----------------------------+------------+------------+

2. 源碼視角下的存儲分層: 6種DB

總體而言,Geth 中包含 StateDBstate.Databasetrie.TrieTrieDBrawdbethdb 六個資料庫模組,它們如同一棵"狀態生命樹"的各個層級。最頂層的 StateDB 是 EVM 執行階段的狀態介面,負責處理帳戶與存儲的讀寫請求,並將這些請求逐層下傳,最終由最底層負責物理持久化的 ethdb 讀/寫物理資料庫。

接下來,我們將依次介紹這六個資料庫模組的職責及它們之間的協作關係。

2.1 StateDB

在 Geth 中,StateDBEVM 與底層狀態存儲之間的唯一橋樑 ,負責抽象和管理合約帳戶、餘額、nonce、存儲槽等信息的讀寫,對所有其他資料庫(TrieDB, EthDB)的狀態相關讀寫都由 StateDB 中的相關介面觸發,可以說 StateDB 是所有狀態資料庫的大腦 。它並不直接操作底層的 Trie 或底層資料庫(ethdb),而是提供一個簡化的內存視圖,讓 EVM 能以熟悉的帳戶模型進行互動。因此,多數依賴 Geth 的專案其實也不會關心底層的 EthDBTrieDB 是怎麼實現的------它們能正常工作就夠了,沒必要動。大多數基於 Geth 的分叉專案都會修改 StateDB 結構,以適應自己的業務邏輯。例如,Arbitrum 修改了 StateDB 以管理它們的 Stylus 程序;EVMOS 修改了 StateDB 來追蹤對其有狀態預編譯合約(stateful precompile)的調用。

源碼中,StateDB 的主要定義位於 core/state/statedb.go。它的核心結構維護了一系列內存狀態對象(stateObject),每個stateObject對應一個帳戶(包含合約存儲)。它還包含一個 journal(事務日誌)用於支持回滾,以及用於追蹤狀態更改的快取機制。在交易處理和區塊打包過程中,StateDB 提供臨時狀態變更的記錄,只有在最終確認後才會寫入底層資料庫。

StateDB 的核心讀寫介面如下,基本都是帳戶模型相關的API:

// 讀相關
func (s *StateDB) GetBalance(addr common.Address) *uint256.Int
func (s *StateDB) GetStorageRoot(addr common.Address) common.Hash
// 寫入dirty狀態數據
func (s *StateDB) SetStorage(addr common.Address, storage map[common.Hash]common.Hash)
// 將EVM執行過程中發生的狀態變更(dirty數據) commit到後端資料庫中
func (s *StateDB) commitAndFlush(block uint64, deleteEmptyObjects bool, noStorageWiping bool) (*stateUpdate, error)

生命週期

StateDB的生命週期只持續一個區塊。在一個區塊被處理並提交之後,這個 StateDB 就會被廢棄,不再具有作用

  • EVM 第一次讀取某個地址時,StateDB 會從Trie→TrieDB→EthDB資料庫中加載它的值,並將其快取一個新的狀態對象(stateObject.originalStorage)中。這一階段被視為"乾淨的對象"(clean object)。

  • 當交易與該帳戶發生互動並改變其狀態時,對象就變成"髒的"(dirty)。stateObject會同時追蹤該帳戶的原始狀態和所有修改後的數據,包括其存儲槽及其乾淨/髒狀態。

  • 如果整個交易最終成功被打包到區塊,StateDB.Finalise() 會被調用。這個函數負責清理已 selfdestruct 的合約,並重置 journal(事務日誌)以及 gas refund 計數器。

  • 當所有交易都執行完畢後,StateDB.Commit() 被調用。在這之前,狀態樹Trie實際上還未被更改。直到這一步,StateDB 才會將內存中的狀態變更寫入存儲 Trie,計算出每個帳戶的最終 storage root,從而生成帳戶的最終狀態。接下來,所有"髒"的狀態對象會被寫入 Trie中,更新其結構並計算新的 stateRoot

  • 最後,這些更新後的節點會被傳遞給 TrieDB,它會根據不同的後端(PathDB/HashDB)快取這些節點,並最終將它們持久化到磁碟(LevelDB/PebbleDB)------前提是這些數據沒有因為鏈重組被丟棄。

2.2 State.Database

state.Database 是 Geth 中連接 StateDB 與底層資料庫(EthDBTrieDB)的重要中間層,它為狀態訪問提供了一組簡潔的介面和實用方法。雖然它的介面比較薄,但在源碼中,它扮演了多個關鍵角色,尤其是在狀態樹訪問與優化方面。

在 Geth 源碼中(core/state/database.go),state.Database 介面由 state.cachingDB 這一具體數據結構實現。它的主要作用包括:


  • 提供統一的狀態訪問介面

state.Database 是構建 StateDB 的必要依賴,它封裝了打開帳戶 Trie 和存儲 Trie 的邏輯,例如:

func (db *cachingDB) OpenTrie(root common.Hash) (Trie, error)
func (db *cachingDB) OpenStorageTrie(stateRoot common.Hash, address common.Address, root common.Hash, trie Trie) (Trie, error)

這些方法隱藏了底層 TrieDB 的複雜性,開發者在構建某個區塊的狀態時,只需調用這些方法獲取正確的 Trie 實例,而不必直接操作 hash 路徑、trie 編碼或底層資料庫。

  • 暫存和重用合約代碼(code cache)

合約代碼的訪問代價較高,且往往在多個塊中重複使用。為此,state.Database 中實現了代碼快取邏輯,避免重複從磁碟加載合約字節碼。這一優化對提高區塊執行效率至關重要:

func (db *CachingDB) ContractCodeWithPrefix(address common.Address, codeHash common.Hash) []byte

這個介面允許按地址和代碼哈希快速命中快取,若未命中才回退到底層資料庫加載。

  • 長生命週期,跨多個區塊重用

StateDB 的生命週期僅限於單個區塊不同,state.Database 的生命週期和整個鏈(core.Blockchain)保持一致。它在節點啟動時構造,並貫穿整個運行週期,作為 StateDB 的"忠實夥伴",為其在每個區塊處理時提供支持。


  • 為未來的 Verkle Tree 遷移做準備

雖然當前 state.Database 看似只是"代碼快取+trie訪問封裝",但它在 Geth 架構中的定位非常前瞻性。一旦未來的狀態結構切換至 Verkle Trie,它將成為遷移過程的核心組件:處理新舊結構之間的橋接狀態。

2.3 Trie

在 Geth 中,狀態樹TrieMerkle Patricia Trie)本身並不存儲數據,但 Trie 承擔計算狀態根哈希和收集修改節點的核心職責,並起到銜接 StateDB與底層存儲之間的橋樑作用,是以太坊狀態系統的中樞結構。

當 EVM 執行交易或調用合約時,並不會直接操作底層的資料庫,而是通過 StateDB 間接與 Trie交互。Trie接收帳戶地址和存儲槽位的查詢與更新請求,並在內存中構建狀態變化路徑。這些路徑最終通過遞歸哈希運算,自底向上生成新的根哈希(state root),這個根哈希是當前世界狀態的唯一標識,並被寫入區塊頭中,確保狀態的完整性和可驗證性。

在一個區塊執行完畢並進入提交階段(StateDB.Commit),Trie會將所有修改過的節點"塌縮"為一個必要的子集,並傳遞給 TrieDB,由其進一步交由後端的節點資料庫(如 HashDB 或 PathDB)持久化。由於 Trie節點以結構化形式編碼,它既支持高效讀取,也使得狀態在不同節點之間可以安全地同步和驗證。因此,Trie 不只是個狀態容器,更是連接上層 EVM 與底層存儲引擎的紐帶,使得以太坊狀態具備一致性、安全性和模組化可擴展性。

源碼中,Trie主要定位於 trie/trie.go中, 它提供了如下核心介面:

type Trie interface {
GetKey([]byte) []byte
GetAccount(address common.Address) (*types.StateAccount, error)
GetStorage(addr common.Address, key []byte) ([]byte, error)
UpdateAccount(address common.Address, account *types.StateAccount, codeLen int) error
UpdateStorage(addr common.Address, key, value []byte) error
DeleteAccount(address common.Address) error
DeleteStorage(addr common.Address, key []byte) error
UpdateContractCode(address common.Address, codeHash common.Hash, code []byte) error
Hash() common.Hash
Commit(collectLeaf bool) (common.Hash, *trienode.NodeSet)
Witness() map[string]struct{}
NodeIterator(startKey []byte) (trie.NodeIterator, error)
Prove(key []byte, proofDb ethdb.KeyValueWriter) error
IsVerkle() bool
}

以節點查詢trie.get為例,它會根據節點類型遞歸地查找帳戶或合約存儲對應的節點,查找時間複雜度是log(n),n為路徑深度。

func (t *Trie) get(origNode node, key []byte, pos int) (value []byte, newnode node, didResolve bool, err error) {
switch n := (origNode).(type) {
case nil:
return nil, nil, false, nil
case valueNode:
return n, n, false, nil
case *shortNode:
if !bytes.HasPrefix(key[pos:], n.Key) {
// key not found in trie
return nil, n, false, nil
}
value, newnode, didResolve, err = t.get(n.Val, key, pos+len(n.Key))
if err == nil \&\& didResolve {
n.Val = newnode
}
return value, n, didResolve, err
case *fullNode:
value, newnode, didResolve, err = t.get(n.Children[key[pos]], key, pos+1)
if err == nil \&\& didResolve {
n.Children[key[pos]] = newnode
}
return value, n, didResolve, err
case hashNode:
child, err := t.resolveAndTrack(n, key[:pos])
if err != nil {
return nil, n, true, err
}
value, newnode, _, err := t.get(child, key, pos)
return value, newnode, true, err
default:
panic(fmt.Sprintf("%T: invalid node: %v", origNode, origNode))
}
}

2.4 TrieDB

TrieDBTrie與磁碟存儲之間的中間層,專注於 Trie 節點的存取與持久化 。每一個 Trie 節點(無論是帳戶信息還是合約的存儲槽)最終都會通過 TrieDB進行讀寫。

程序啟動時會創建一個 TrieDB 實例,在節點關閉時會被銷毀。它在初始化時需要傳入一個 EthDB 實例,EthDB實例負責具體的數據持久化操作。

目前,Geth 支持兩種 TrieDB 後端實現:

  • HashDB:傳統方式,以哈希為鍵。

  • PathDB:新引入的 Path-based 模型(Geth 1.14.0版本後默認配置),以路徑信息作為鍵,優化了更新與修剪性能。

源碼中,TrieDB 主要位於triedb/database.go

Trie 節點的讀取邏輯

我們先來看節點的讀取流程,因為它相對簡單。

所有的 TrieDB 後端都必須實現一個 database.Reader 介面,其定義如下:

type Reader interface {
Node(owner common.Hash, path []byte, hash common.Hash) ([]byte, error)
}

這個介面提供了基本的節點查詢功能,它會根據路徑(path)和節點哈希(hash)從 trie 樹中定位並返回該節點。注意,返回的是原始字節數組 ------ TrieDB 對節點的內容並不關心,也不知道這是不是帳戶節點、葉子節點還是分支節點(這由上層的Trie來解析)。

介面中的 owner 參數用於區分不同的 trie:

  • 如果是帳戶 trie,則 owner 留空。

  • 如果是合約的存儲 trie,則 owner 是該合約的地址,因為每個合約都有自己獨立的存儲 trie。

換句話說,TrieDB 是底層節點的讀寫總線,為上層的 Trie提供統一的介面,不涉及語義,只關心路徑和哈希。它讓 Trie 與物理存儲系統之間解耦,使得不同存儲模型可以靈活替換而不影響上層邏輯。

TrieDB 之 HashDB

TrieDB歷史上採用的節點持久化方式是:

將每個 Trie 節點的哈希(Keccak256)作為鍵,將該節點的 RLP 編碼作為值 ,並寫入底層的 key-value 存儲中。這種方式現在被稱為HashDB

這種設計方式非常直接,但有幾個顯著的優點:

  • 支持多棵 Trie 並存:只需知道根哈希,就能遍歷恢復整個 Trie。每個帳戶的存儲、帳戶 Trie、不同歷史狀態的根哈希都可以分別管理。

  • 子樹去重(Subtrie Deduplication) :由於相同的子樹具有相同的結構和節點哈希,它們在 HashDB 中會自然共享,不需要重複存儲。這對於以太坊的大狀態樹尤為重要,因為大部分狀態在區塊之間是保持不變的。

要注意的是,普通的 Geth 節點並不會在每個區塊之後將 Trie 完整寫入磁碟 ,這種完整持久化只發生在 "歸檔模式"(--gcmode archive)下,而大多數主網節點並不使用歸檔模式。

那普通模式下,狀態是怎麼寫入磁碟的呢?實際上,狀態更新會先快取在內存中,延遲寫入磁碟。這個機制叫做"延遲刷盤"(delayed flush),觸發條件包括:

  • ⏱️ 定時刷盤:默認每隔 5 分鐘(等價於處理完約 5 分鐘內的區塊)會自動寫入一次。

  • 💾 快取容量達到上限:當狀態快取填滿,必須刷盤釋放內存。

  • 節點關閉時:為了數據完整性,所有快取都會刷盤。

儘管 HashDB 的結構設計很簡單,但它在內存管理方面非常複雜,特別是 對失效節點的垃圾回收機制 :假設某個合約在一個區塊被創建,在下一個區塊就被銷毀 ------ 此時與該合約有關的狀態節點(包括合約帳戶和其獨立的存儲 Trie)都已經沒用了,如果不清理,它們會白白佔用內存。因此,HashDB 設計了引用計數和節點使用追蹤機制來判斷哪些節點不再使用並從快取中清除。

TrieDB 之 PathDB

PathDBTrieDB 的一種新後端實現。它改變了 Trie 節點在磁碟上持久化和內存中維護的方式。如前所述,HashDB 是通過節點的哈希進行索引存儲的。而這種方法讓清除(prune)狀態中不再使用的部分變得非常困難。為了解決這一長期問題,Geth 引入了 PathDB

PathDBHashDB 有幾個顯著區別:

  • Trie 節點在資料庫中是按照其路徑(path)作為鍵進行存儲的。某帳戶或storage key節點的路徑為該帳戶地址哈希或storage key在trie樹上與其他節點公共前綴部分;某個合約的 storage Trie 中的節點,其路徑前綴包含該帳戶地址哈希。

account trie node key = Prefix(1byte) || COMPACTED(nodepath) storage trie node key = Prefix(1byte) || account hash(32byte) || COMPACTed(nodepath)

  • HashDB 會定期把每個區塊的完整狀態刷盤。這意味著即使是你並不關心的舊區塊,也會殘留完整狀態。而 PathDB 始終只在磁碟上維護一棵 Trie。每個區塊只更新同一棵 Trie。因為使用路徑作為鍵,節點的修改僅需覆蓋舊節點即可;被清除的節點也可以安全刪除,因為沒有其他 Trie 會引用它們。

  • 被持久化的這棵 Trie 並非鏈的最新頭部,而是落後頭部至少 128 個區塊。最近 128 個區塊的 Trie 更改則分別存在內存中,用於應對短鏈重組(reorg);

  • 如果出現更大的 reorg,PathDB會利用 freezer 中預存的每個區塊的 state diff(狀態差異)進行逆應用(rollback),將磁碟狀態回滾至分叉點。

2.5 RawDB

在 Geth 中,rawdb 是一個底層資料庫讀寫模組,它直接封裝了對狀態、區塊鏈數據、Trie 節點等核心數據的存取邏輯,是整個存儲系統的基礎介面層 。它並不直接暴露給 EVM 或業務邏輯層,而是作為內部工具服務於如 TrieDBStateDBBlockChain 等模組的持久化操作。rawdbtrie 一樣,並不直接存儲數據本身,它們都是對底層資料庫的抽象封裝層 ,負責定義存取規則,而非執行最終的數據落盤或讀取。可以把 rawdb 看作是 Geth 的"硬碟驅動器",它定義了所有核心鏈上數據的鍵值格式和訪問介面,負責確保不同模組可以統一、可靠地讀寫數據。雖然在直接開發中很少會使用它,但它是整個 Geth 存儲層最基礎、最關鍵的一環。

核心功能

源碼中,rawdb 主要定位於core/rawdb/accessors_trie.gorawdb 提供了大量 ReadXxxWriteXxx 等讀寫方法,用於標準化地訪問不同類型的數據。例如:

  • 區塊數據(core/rawdb/accessors_chain.go):ReadBlock, WriteBlock, ReadHeader

  • 狀態數據(core/rawdb/accessors_trie.go):WriteLegacyTrieNode, ReadTrieNode

  • 總體元數據:如總難度、最新頭區塊哈希、創世信息等

這些方法通常以約定好的 key 前綴(如 h 表示 header, b 表示 block, a 表示 AccountTrieNode)組織數據在底層資料庫中(LevelDB 或 PebbleDB)。

與 TrieDB 的關係

TrieDB 本身並不直接操作硬碟,它把具體的讀寫委託給 rawdb。而 rawdb 又會調用更底層的 ethdb.KeyValueStore 介面,這可能是 LevelDB、PebbleDB 或內存資料庫。例如,寫入 Trie相關的數據(帳戶、存儲槽等)時:

  • 基於HashDB 的 Trie 節點採用rawdb.WriteLegacyTrieNode 等方法負責將以 (hash, rlp-encoded node) 的形式寫入資料庫。

  • 基於PathDB的 Trie 節點則採用WriteAccountTrieNode, WriteStorageTrieNode 等方法將以(path, rlp-encoded node)的形式寫入資料庫。

2.6 EthDB

在 Geth 中,ethdb 是整個存儲系統的核心抽象,它扮演著"生命之樹"的角色------深深扎根於磁碟,向上传遞支持至 EVM 與執行層各個組件。其主要目的是屏蔽底層資料庫實現的差異 ,為整個 Geth 提供統一的鍵值讀寫介面。正因如此,Geth 在任意地方都不直接使用具體的資料庫(如 LevelDB、PebbleDB、MemoryDB等),而是通過 ethdb 提供的介面進行數據訪問。

介面抽象與職責劃分

源碼中,ethdb 主要定位於ethdb/database.goethdb 中最核心的介面是 KeyValueStore(),它定義了常見的鍵值操作方法:

type KeyValueStore interface {
Has(key []byte) (bool, error)
Get(key []byte) ([]byte, error)
Put(key []byte, value []byte) error
Delete(key []byte) error
}

這套介面非常簡潔,覆蓋了基礎讀寫操作。而擴展介面 ethdb.Database 則在此基礎上加入了對 freezer 冷存儲的讀寫支持(AncientStore,主要用於鏈數據(如歷史區塊、交易回執)的管理:新近區塊保存在 KV 存儲中,較老的則遷移至 freezer。

此外,ethdb 還提供了多種具體實現版本:

  • LevelDB:最早期的默認實現,穩定成熟。

  • PebbleDB:目前推薦使用的默認實現,更快、資源效率更高。

  • RemoteDB:用於遠程狀態訪問場景,在輕節點、驗證者或模組化執行環境中尤為重要。

  • MemoryDB:完全內存實現,常用於 dev 模式和單元測試。

這讓 Geth 能夠靈活地在不同場景間切換存儲後端,比如開發調試使用 MemoryDB,主網上線使用 PebbleDB

生命週期與模組貫通

每個 Geth 節點啟動時,都会創建唯一的 ethdb 實例,這個對象貫穿程序始終,直到節點關閉。在結構設計上,它被注入到 core.Blockchain 中,進而傳遞到 StateDBTrieDB 等模組,成為全局共享的數據訪問入口。

正因為 ethdb 抽象了底層資料庫細節,Geth 的其他組件才能專注於各自的業務邏輯,比如:

  • StateDB 只關心帳戶和存儲槽;

  • TrieDB 只關心如何存儲和查找 Trie 節點;

  • rawdb 只關心如何組織鏈數據的鍵值佈局;

這些上層組件都無需感知數據是存在哪個具體資料庫引擎裡。

3. 六種 DB 的創建順序和調用鏈

本節從 Geth 節點啟動開始,梳理這 6 種 DB 的啟動流程和調用關係。

3.1 創建順序:

整體創建順序為ethdb → rawdb/TrieDB → state.Database → stateDB → trie,源碼中具體調用鏈如下:

【節點初始化階段】
MakeChain
└── MakeChainDatabase
└── node.OpenDatabaseWithFreezer
└── node.openDatabase
└── node.openKeyValueDatabase
└── newPebbleDBDatabase / remotedb

ethdb.Database

rawdb.Database (封裝 ethdb)
└── rawdb.NewDatabaseWithFreezer(ethdb)

trie.Database (TrieDB)
└── trie.NewDatabase(ethdb)
└── backend: pathdb.New(ethdb) / hashdb.New(ethdb)

state.Database (cachingDB)
└── state.NewDatabase(trieDB)

【區塊處理階段】
chain.InsertChain
└── bc.insertChain
└── state.New(root, state.Database)

state.StateDB
└── stateDB.OpenTrie()
└── stateDB.OpenStorageTrie()

trie.Trie / SecureTrie

3.2 生命週期一覽

| DB模組 | 創建時機 | 生命週期 | 主要職責 | |--------------------|------------------------|------------|------------------------------------------| | ethdb.Database | 節點初始化 | 程序全程 | 抽象底層存儲,統一介面(LevelDB / PebbleDB / Memory) | | rawdb | 包裹 ethdb 調用 | 不存儲數據本身 | 提供區塊/receipt/總難度等鏈數據的讀寫介面 | | TrieDB | core.NewBlockChain() | 程序全程 | 快取+持久化 PathDB/HashDB 節點 | | state.Database | core.NewBlockChain() | 程序全程 | 封裝 TrieDB,合約代碼快取,後期支持 Verkle 遷移 | | state.StateDB | 每個區塊執行前創建一次 | 區塊執行期間 | 管理狀態讀寫,計算狀態根,記錄狀態變更 | | trie.Trie | 每次帳戶或slot訪問時創建 | 臨時,不存儲數據本身 | 負責 Trie 結構修改和根哈希計算 |

4. HashDB 和 PathDB 狀態提交和讀取機制詳細對比

區塊執行完畢後StateDB會調用func (s ***StateDB**) **Commit**(block uint64, deleteEmptyObjects bool, noStorageWiping bool),並觸發如下存儲狀態更新:

  • 通過ret, err := s.**commit**(deleteEmptyObjects, noStorageWiping)收集 Trie 狀態樹涉及到的所有更新

func (s *StateDB) commit(deleteEmptyObjects bool, noStorageWiping bool) (*stateUpdate, error) {

newroot, set := s.trie.Commit(true)
root = newroot

}

  • 其中調用到的trie.Commit方法會把所有的節點(不論是short節點還是full節點)塌縮為hash節點t.root = **newCommitter**(nodes, t.tracer, collectLeaf).**Commit**(t.root, t.uncommitted > 100),並收集所有髒節點返回給 StateDB

  • StateDB利用收集到的所有髒節點更新TrieDB快取層:

  • HashDB在內存中維護了dirties map[**common**.**Hash**]***cachedNode**這個對象來快取這些更新,並更新相應的trie節點引用,快取有大小限制

  • PathDB則在內存中維護了tree ***layerTree** 這個對象並增加一層diff來快取這些更新,最多可快取128層diff

func (s *StateDB) commitAndFlush(block uint64, deleteEmptyObjects bool, noStorageWiping bool) (*stateUpdate, error) {

// If trie database is enabled, commit the state update as a new layer
if db := s.db.TrieDB(); db != nil {
start := time.Now()
if err := db.Update(ret.root, ret.originRoot, block, ret.nodes, ret.stateSet()); err != nil {
return nil, err
}
s.TrieDBCommits += time.Since(start)
}

  • HashDBPathDB快取超限時,則會觸發flush,通過rawdb提供的相關介面將快取寫入ethdb的實際持久層:

  • 全節點HashDB模式下,由於key是hash,所以同一個帳戶如果被修改,由於底層資料庫通過key無法感知是否是同一個帳戶,不能輕易刪除該key及其對應的值,否則可能會影響其他帳戶狀態 ,所以只會把新修改的KV寫入DB,而無法刪除舊狀態,因此全節點狀態很難被修剪。例如兩個不同的合約地址A和B實際保存相同的合約代碼,他們在HashDB中共享同一個(key為hash,value為合約代碼)的存儲,若EVM執行後銷毀其中一個合約A,另外一個合約B代碼和合約A代碼在資料庫中的key一樣,所以不能隨意刪除資料庫中hash為key的值,否則會導致B合約後面讀取不到該合約代碼了。

  • 全節點PathDB模式下,由於key是path,所以同一個帳戶在底層DB對應的key是相同的,會把同一個帳戶對應的狀態覆蓋掉,因此更容易裁剪全節點的狀態。因此現在Geth全節點默認採用的是PathDB模式

  • 由於 歸檔(archive)節點需要存儲每一個區塊對應的狀態,此時HashDB則更具優勢,因為不同區塊下很多帳戶的數據實際上並未修改,基於hash作為key相當於自動具備裁剪的特性;而此時PathDB則需要保存每個區塊下所有帳戶的狀態,導致狀態會超級大,因此Geth的archive節點只支持HashDB模式

實例:全節點下 HashDB 和 PathDB 實際落盤對比

假設左邊的 Trie 是 MPT 的初始狀態,其中紅色的是將被修改的節點;右邊的則是 MPT 的新狀態,綠色表示之前的4個紅色節點被修改了。

在HashDB模式下,由於 C/D/E節點更改後hash必定會發生變化,因此儘管 C/D/E 節點對應的三個帳戶之前已經落盤了,這三個帳戶對應的新節點 C'/D'/E' 還是需要落盤,且一旦持久化之後就很難刪除這些舊節點了。磁碟更新之前(左圖)和之後(右圖)的狀態如下,其中collapsed Node可以簡單理解為節點存儲的值。

在PathDB模式下,雖然 C/D/E 節點對應的值發生了變化,但是由於底層存儲的 key(path) 不變,在持久化是可以直接替換這三個節點對應的值為 C'/D'/E' 就可以了,磁碟數據並不會有過多冗餘(雖然有些相同的合約可能會在不同的 path下都保存了一份,但是影響不大)。磁碟更新之前(左圖)和之後(右圖)的狀態如下。

實例: HashDB 和 PathDB 讀取帳戶對比

core/rawdb/accessors_trie.go中增加如下 debug 代碼,測試 stateDB 讀取0xB3329fcd12C175A236a02eC352044CE44d (account hash:0x**aea7c67d**a6a9bdb230dd07d0e96626e5e57c9cba04dc8039c923baefe55eacd1)涉及到的Trie節點資料庫讀取:

func ReadAccountTrieNode(db ethdb.KeyValueReader, path []byte) []byte {
fmt.Println("PathDB read:", hexutil.Encode(accountTrieNodeKey(path)))
data, _ := db.Get(accountTrieNodeKey(path))
return data
}
func ReadLegacyTrieNode(db ethdb.KeyValueReader, hash common.Hash) []byte {
fmt.Println("HashDB read:", hash)
data, err := db.Get(hash.Bytes())
if err != nil {
return nil
}
return data
}

PathDB 讀到的 Trie 節點如下,可看出讀取的是帳戶地址 hash 的前 8 位相應 path 的節點:

0x41為前綴,多加的0是nibbles(半字節) 的對齊需要

PathDB read: 0x410a
PathDB read: 0x410a0e
PathDB read: 0x410a0e0a
PathDB read: 0x410a0e0a07
PathDB read: 0x410a0e0a070c
PathDB read: 0x410a0e0a070c06
PathDB read: 0x410a0e0a070c0607
PathDB read: 0x410a0e0a070c06070d

HashDB 讀到的 Trie 節點如下,可以看出讀取是的 hash 為 key 對應的節點:

HashDB read: 0xb01e32b0c38555bb27f1a924b8408824f97dd8d70f096b218d397906a9095385
HashDB read: 0x99d38ce254e6c35a49504345a30e94b4ea08338279385bae33feaaa11c3a0a00
HashDB read: 0xfcc42d902aa9107b83ee7839a8bc61b370cc5eac9ee60db1af7165daf6c3f76b
HashDB read: 0x3232bc99a88337d2aea2e8c237eb5b4ebb9366ff5bdd94b965ac6f918bd6303f
HashDB read: 0x04ae6f0462f6c0c7e5827dc46fcd69329483d829c39f624744f7b55c09c2cc96
HashDB read: 0x22a16c466cc420e8ed97fd484cecc8f73160ee74a56cfc87ff941d1b56ff46f8
HashDB read: 0xae26238e219065458f314e456265cd9c935e829ba82aebe6d38bacdbb14582f3
HashDB read: 0xe9ce7770c224e563b0c407618b7b7d8614da3d5da89f3960a3bec97e78fc0ae0
HashDB read: 0x2c7d134997a5c3e0bf47ff347479ee9318826f1c58689b3d9caeac77287c3af8

總體來說,PathDBHashDB均是保持Trie數據結構來存儲狀態數據,只是PathDB以Trie節點的path作為key,而HashDB則是以Trie節點值對應的hash作為key,兩者均存儲值相同均為Trie節點的值。

5. DB 相關讀寫操作流程追蹤

1.交易執行階段

  • 所有帳戶和Storage值通過StateDB.GetState等方法經過Trie→TrieDB(pathdb/hashdb)→RawDB→Level/PebbleDB讀取到StateDB內存中

  • 隨後EVM執行狀態變更(如調用 Statedb.SetBalance() )也保留在 StateDB 的內存中

  • 包括:餘額變更、nonce 更新、storage 修改

2.單個區塊執行完畢更新快取

  • 調用 StateDB.Commit() → 收集髒節點轉化為修改的 Trie 節點組,並計算新 StateRoot

  • 內部調用 Trie.Commit() → 調用 TrieDB.Update()將更改保存在 TrieDB 快取層

  • PathDB 最多有 128 個塊的 diff 快取層限制

  • HashDB 的快取層有大小限制

  • 超過上述限制則進一步觸發TrieDB.Commit實際落盤到底層資料庫

3.單個區塊執行完畢 Header / Receipts 提交:

*

  • 除狀態以外,區塊頭、body、交易回執等數據通過 RawDB.Write*(ethdb) 等介面寫入 ethdb

4.多個區塊執行後快取超限觸發實際落盤TrieDB.Commit → batch → DB

*

  • 節點是歸檔節點node 或超過 flushInterval 或超過 TrieDB 的快取限制或節點關閉前,開始觸發commit,並最終落盤。如下是PathDB模式下落盤核心代碼:

func (db *Database) commit(hash common.Hash, batch ethdb.Batch, uncacher *cleaner) error {

rawdb.WriteLegacyTrieNode(batch, hash, node.node) // 多個修改的trie節點加入 batch(未落盤)
if batch.ValueSize() >= ethdb.IdealBatchSize { // 達到 IdealBatchSize 後觸發寫盤
batch.Write() // 落盤
batch.Replay(uncacher) // 通知 uncacher 清理內存
batch.Reset() // 重置 batch
}

6. 總結

Geth 中的這 6 個資料庫模組各自承擔不同層級的職責,形成了一條自底向上的數據訪問鏈。通過多層抽象與多級快取,上層模組無需關心底層的具體實現,從而實現了底層存儲引擎的可插拔性與較高的 I/O 性能。

最底層的 ethdb 抽象了物理存儲,屏蔽具體資料庫類型,支持如 LevelDB、Pebble、RemoteDB 等多種後端;其上一層是 rawdb,負責對區塊、區塊頭、交易等核心鏈上數據結構的編碼、解碼與封裝,簡化了鏈數據的讀寫操作。TrieDB 管理狀態樹節點的快取與持久化,支持 hashdbpathdb 兩種後端,用於實現不同的狀態修剪策略和存儲方式。

再往上,trie.Trie 是狀態變化的執行容器與根哈希的計算核心,承擔實際的狀態構建與遍歷操作;state.Database 封裝對帳戶和合約存儲 Trie 的統一訪問,並提供合約代碼快取;而最頂層的 state.StateDB 是在區塊執行過程中與 EVM 對接的介面,提供帳戶與存儲的讀快取和寫支持,使得 EVM 無需感知底層 Trie 的複雜結構。

這些模組通過職責分離與介面隔離,協同構建了一個既靈活又高效的狀態管理體系,使 Geth 能在複雜鏈狀態與交易執行中保持良好性能與可維護性。

References

[1]go-ethereum 源碼

[2]The Tale of 5 DBs

[3]Path-based storage \& Inline prune - NodeReal

[4]RLP 編碼規範

[5]Ethereum data structures and encoding

關聯標籤
warnning 風險提示
app_icon
ChainCatcher 與創新者共建Web3世界