以太坊區塊頭裡有一個 32 位元組的欄位 stateRoot。它不含任何帳戶資料,卻聲稱能把全網所有帳戶的餘額、nonce、合約程式碼與合約儲存一起固定住:只要這個雜湊對得上,你就知道這份狀態是哪一個版本。這個欄位是 Merkle Patricia Trie(MPT,字典樹)的根雜湊,也是執行層對世界狀態給出的唯一承諾。
要理解這份承諾有多重,需要拆開三件事:樹的結構、根雜湊為什麼能綁定全部狀態,以及維護這棵樹要付出什麼代價。第三件事是後面討論狀態分片與狀態樹換代的起點。
一個區塊頭裡裝著三棵字典樹
以太坊區塊頭帶有三個描述執行結果的字典樹根:stateRoot(世界狀態字典樹)、transactionsRoot(交易字典樹)與 receiptsRoot(收據字典樹)。黃皮書把它們記作 H_r、H_t、H_e。上海升級之後,區塊頭還多了描述提款列表的 withdrawalsRoot(H_w),它的結構與交易字典樹類似,只是每條提款的資料量小、每塊條數少。黃皮書把「H_r 等於按順序執行完區塊內全部交易、再執行完全部提款之後得到的狀態根」列為區塊頭的有效性條件之一。stateRoot 描述的是累計狀態,另外幾個根只描述本區塊。
| 字典樹 | 鍵 | 值 | 生命週期 |
|---|---|---|---|
| 世界狀態字典樹 | keccak256(帳戶位址) | RLP 編碼的帳戶四元組 | 跨區塊持續更新 |
| 交易字典樹 | rlp(交易在區塊內的序號) | rlp(交易);型別化交易為型別前綴拼接編碼後的交易 | 每塊一棵,之後不再改動 |
| 收據字典樹 | rlp(交易在區塊內的序號) | 型別化收據,或 rlp([status, cumulativeGasUsed, logsBloom, logs]) | 每塊一棵,之後不再改動 |
交易字典樹與收據字典樹的葉子數與本區塊的交易數成正比,因此驗證某筆交易是否被打包,成本與鏈的長度無關,只與本區塊規模有關。世界狀態字典樹全域唯一,規模隨全網帳戶數與儲存槽數增長。區塊頭裡的 logsBloom 由收據中的日誌位址與 topic 聚合而成,供查詢做機率過濾,它是索引加速器,不是承諾。
位址經過雜湊才進字典樹,帳戶是四元組
世界狀態字典樹的鍵是 keccak256(帳戶位址),值是 RLP 編碼的帳戶四元組 [nonce, balance, storageRoot, codeHash]。位址本身不進字典樹,進去的是它的 256 位雜湊:32 位元組對應 64 個 nibble(半位元組),也就是從根到葉最多 64 層。
四個欄位各有明確語意。nonce 是帳戶發出的交易數,對合約帳戶還包括它建立過的合約數。balance 是以 wei 計的餘額。codeHash 是帳戶程式碼的 keccak256,程式碼本體按雜湊存放在狀態資料庫裡,因此位元組碼相同的合約共享同一份資料。storageRoot 是另一棵字典樹的根,那棵樹只屬於這個帳戶。
結構於是呈現為樹中樹:世界狀態字典樹的葉節點裡裝著某個帳戶的 storageRoot,這個根指向該帳戶自己的儲存字典樹。儲存字典樹的鍵是 keccak256(32 位元組槽號),值是該槽內容(256 位)的 RLP 編碼。槽值為零在規範裡等同於該槽不存在,把某個槽寫回 0 在狀態層的效果是刪除。
鍵先取雜湊讓樹的形狀由雜湊分布決定,攻擊者無法透過挑選鍵來塑造它。若鍵直接用位址或槽號,攻擊者可以挑出共享長前綴的一批鍵,把某棵子樹壓成極深的鏈,放大存取與證明的成本;keccak256 把鍵均勻打散後,這類構造不再可行。EIP-8297 草案在論證新樹結構時,也把由雜湊決定位置、從而保持平衡這一點列為設計依據。代價是槽號不可逆,合約無法從儲存字典樹反推自己寫過哪些槽,外部也只能按已知槽號逐個查詢。
兩個空值常量值得記下來,後面的證明與驗證都會用到:空字典樹的根,以及空帳戶程式碼的雜湊。
# pip install eth-utils rlp
import rlp
from eth_utils import keccak
# 空字典樹的根:對 RLP 編碼的空位元組串取 keccak
print(keccak(rlp.encode(b"")).hex())
# 56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421
# 空帳戶程式碼的雜湊
print(keccak(b"").hex())
# c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470
三類節點與兩位元標誌:64 層路徑如何被壓短
MPT 是 16 叉(hexary)樹,不是二元樹。黃皮書附錄 D 定義了三類節點與一個空節點。branch 節點有 17 項,前 16 項對應下一個 nibble 的 16 種取值,第 17 項留給鍵在此結束的情形。extension 節點是 [encodedPath, key] 兩項,跳過至少兩個 nibble 的共同前綴。leaf 節點是 [encodedPath, value] 兩項,承載鍵的剩餘部分與終值。空節點用空位元組串表示。規範裡還有一條容易忽略的不變量:不允許存在只有一個非零項的 branch 節點。正因如此,同一組鍵值對只有一種編碼方式,根雜湊才有確定取值。
路徑按 nibble 而不是位元組組織,於是需要一個緊湊編碼,把路徑長度的奇偶與節點類型這兩件事塞進首 nibble:
| 首 nibble | 二進位 | 節點類型 | 路徑長度 |
|---|---|---|---|
| 0 | 0000 | extension | 偶數 |
| 1 | 0001 | extension | 奇數 |
| 2 | 0010 | leaf | 偶數 |
| 3 | 0011 | leaf | 奇數 |
偶數長度時首 nibble 之後再補一個 0 nibble,保證整個編碼的 nibble 數為偶數,可以落成位元組串。ethereum.org 給出的參考實作如下(回傳值在此整理為 bytes):
def compact_encode(hexarray):
# 葉節點的路徑以 16 結尾,偵測到就剝掉並置 leaf 標誌
term = 1 if hexarray[-1] == 16 else 0
if term:
hexarray = hexarray[:-1]
oddlen = len(hexarray) % 2
flags = 2 * term + oddlen # 首 nibble 同時編碼類型與奇偶
if oddlen:
hexarray = [flags] + hexarray
else:
hexarray = [flags] + [0] + hexarray # 偶數長度補一個 0 nibble
return bytes(16 * hexarray[i] + hexarray[i + 1] for i in range(0, len(hexarray), 2))
路徑壓縮解釋了為什麼 64 nibble 的理論深度在實際主網上遠未用滿:EIP-8297 草案在動機部分估計,帳戶字典樹當前的最大深度約為 12 層。這是草案的自述估算,不是主網實測資料集;層數越淺,證明越短。
節點之間如何引用,由 32 位元組規則決定。子節點 RLP 編碼後不足 32 位元組,內容直接內聯進父節點;達到或超過 32 位元組,父節點只寫入 keccak(RLP(子節點)) 作為引用。這條規則省掉大量一次性的小節點讀取,代價是驗證證明時必須按實際編碼形態重組節點,而不能假定每一層都是一次獨立的資料庫查詢。
stateRoot 是對全部狀態的一個承諾
把整個結構折疊成一個雜湊的動作只有一行:對根節點的 RLP 編碼取 keccak256。黃皮書在描述世界狀態時給出的理由很直接,根節點在密碼學上依賴其內部全部資料,因此這個雜湊可以作為整個系統狀態的安全識別。
承諾的強度來自引用方式。父節點引用子節點用的是子節點的雜湊,任何一個葉子的任何一位變化,都會改變它所在的節點,再逐層改變到根。建立在 keccak256 抗碰撞性之上,找到兩份不同狀態卻給出同一個根,等同於找到雜湊碰撞。
由此得到三個性質。根雜湊唯一確定一份鍵值集合。舊狀態可以按根取回,因為節點按內容定址、結構不可變,只要節點還在資料庫裡,知道舊根就能還原當時的狀態。驗證者也能沿路徑重建承諾,路徑上的兄弟雜湊足以讓他從某個葉子重算到根,黃皮書附錄 D 把證明的空間需求記為 O(log N)。
區塊頭裡的定義還限定了時點:stateRoot 是「執行完區塊內全部交易與提款、並套用完最終處理之後」的狀態根。它承諾的是這一刻的鍵值集合,不承諾這份狀態如何變成現在這樣,不同的歷史可以收斂到同一個狀態,同一個根也可能在連續多個區塊裡重複出現。它也不承諾狀態的可用性。根雜湊本身不含任何節點資料,要驗證某個帳戶,必須另外取得路徑上的節點。
從根到一個帳戶:Merkle 證明怎麼用
帳戶證明是一個節點列表,從根節點開始,沿 keccak256(位址) 的 nibble 逐層向下:每一層要麼命中 branch 的對應項,要麼命中 extension 或 leaf 的路徑前綴。驗證者的動作很機械,對第一個節點重算 keccak(RLP(節點)),確認等於已知的 stateRoot;解碼後按 nibble 找到下一個引用,重複到葉子;葉子裡的值就是 RLP 編碼的帳戶。證明不存在的帳戶同樣可行,關鍵是把路徑上最後一個匹配節點交出來:若它是 branch,對應分支為空;若它是 leaf,它與目標路徑在某個 nibble 上分叉。儲存證明要走第二遍,起點從 stateRoot 換成帳戶裡的 storageRoot。
EIP-1186 把這個過程標準化成 eth_getProof,下面是一個請求範例(位址與槽號可替換):
{
"jsonrpc": "2.0",
"id": 1,
"method": "eth_getProof",
"params": [
"0x7f0d15c7faae65896648c8273b6d7e43f58fa842",
["0x0000000000000000000000000000000000000000000000000000000000000000"],
"latest"
]
}
回傳值裡的 accountProof 是從 stateRoot 出發的節點陣列,storageProof 裡每條記錄則從該帳戶的 storageRoot 出發。兩者都只是資料,可信與否由驗證者自己重算決定。
Helios 一類輕客戶端就按這個思路工作:共識層輕客戶端先從信標鏈驗證區塊頭,取得經過認證的執行層 payload 與 stateRoot,再向任意一個不信任的 RPC 索要 eth_getProof,最後在本機完成 MPT 驗證。EIP-1186 的動機一節也提到,這類證明讓物聯網裝置與行動應用只憑一個可信區塊雜湊,就能驗證來自不可信來源的帳戶與儲存資料。
承諾的邊界:證明、舊根與輕客戶端
證明的能力有清晰上限。它只覆蓋被證明的那條路徑,不能推出狀態的其他部分;證明體積隨路徑深度與分支寬度增長。EIP-8297 草案用一組範例估算說明這個量級:按帳戶字典樹最大深度約 12 層計算,一條帳戶分支需要 12 層、每層 15 個兄弟雜湊,合計 15 × 32 × 12 = 5760 位元組;若把 60M Gas 全部用於觸碰大量不同合約程式碼的單個位元組,且程式碼不分塊,草案估出的證明量約為 1.8 GB。這些都是草案的自述估算,未經主網實測複核,方向性結論可以採信,具體數值不宜當作基準。這也是 MPT 被認為對有效性證明不友好的原因:RLP 編碼、Keccak 雜湊、樹中樹結構,以及程式碼不能按段證明。
舊根的證明不是永久可得。承諾留在區塊頭裡,能不能兌現取決於客戶端如何存放狀態。Geth 自 v1.16 起的路徑式歸檔保存扁平狀態的歷史差分,讀得到歷史狀態,但 v1.16.x 上無法為舊根生成 Merkle 證明,v1.17 起才可以透過顯式保留字典樹歷史來支援歷史證明;傳統的雜湊式歸檔保留歷史字典樹節點,可以為任意舊根出證明。磁碟代價見下一節。承諾的語意屬於共識,履行承諾的方式屬於實作選擇。
執行層輕客戶端也經歷過一次收縮。主網的 LES 協定在合併之後長期無法可靠工作,Geth 在 2023 年底移除了相關程式碼。今天的鏈下驗證更多以「共識層認證根、執行層提供證明」的組合出現,而共識層輕客戶端驗證的是信標鏈區塊頭,本身不會重新執行交易。
狀態膨脹:承諾的帳單落在磁碟上
stateRoot 的表達能力與狀態規模無關,維護它的成本與狀態規模正相關。每次狀態更新都要重寫從葉子到根的整條路徑,狀態越深越寬,單位交易需要的讀寫越多;新節點要追上鏈,也必須把這份狀態完整拉下來。
以太坊研究論壇 2025 年 11 月的一篇分析給出了一組量級。該文稱,2025 年 5 月一個只承載狀態的 Geth 節點,未壓縮資料庫約 340 GiB;Gas 上限由 30M 上調至 36M 後,每日新增狀態的中位數從約 102 MiB 翻到約 205 MiB。bloatnet 專案頁面把 650 GB 標為臨界規模,稱接近這一規模時狀態存取時間約增加 40%、記憶體佔用與同步時間明顯變差;該頁面沒有給出可重現的基準資料,這裡的表述按專案自述口徑轉述,且它用的是 GB 而非 GiB。該論壇分析隨後按保守、基準、激進三條 Gas 上限路徑外推(2027 年年中分別升到 200M、400M、700M),得出 2027 年年中總狀態規模落在 686 GiB 到 1.08 TiB。這是情境外推,不是既成事實:取值取決於客戶端實作、壓縮與剪枝策略,以及 Gas 上限的實際走勢。
落到節點營運上,ethereum.org 彙總的 Geth 全節點(snap 同步)磁碟需求為 500 GB 以上;歸檔節點按 Geth 官方口徑是路徑式約 2 TB、保留歷史字典樹資料約 6.5 TB、雜湊式超過 20 TB,而 ethereum.org 彙總的跨客戶端歸檔需求為 3 TB 到 12 TB 以上。這些數字依賴客戶端實作、壓縮方式、剪枝策略與 Gas 上限,跨實作直接比較沒有意義;狀態大小也不等於鏈上歷史大小,歷史交易與收據是另一筆帳。
以太坊主網的狀態只增不減,帳戶與儲存槽一旦寫入就永久佔用空間,沒有狀態到期或狀態租金的機制,壓力因此單向累積。緩解方向大致有兩類:更換樹結構,例如長期討論的 Verkle 樹,以及仍處於草案階段的分區二進位樹(Partitioned Binary Tree,EIP-8297 草案);或者把狀態切開,讓單個節點只維護其中一部分。後者是後續文章的主題,這裡只交代壓力的來源。槽位在合約層如何排布,見 0.19《Storage Layout》。
資料來源
- Ethereum Yellow Paper:第 2 節的狀態轉移函數、第 4 章的區塊頭欄位與整體有效性(stateRoot 的定義與驗證條件)、附錄 D 的 MPT 節點定義、hex-prefix 編碼、32 位元組內聯規則與 D.1 的 O(log N) 證明空間。
- ethereum.org: Merkle Patricia Trie:節點類型、緊湊編碼參考實作、三棵字典樹的鍵值定義、交易與收據的值編碼。
- ethereum.org: Ethereum accounts:storageRoot 與 codeHash 的官方表述。
- EIP-1186: RPC-Method to get Merkle Proofs:eth_getProof 的欄位、不存在的證明方式與使用情境;官方範例中的空 storageHash(0x56e81f…)與空 codeHash(0xc5d246…)也用於核對正文程式碼區塊裡的兩個空值常量。
- EIP-8297: Partitioned Binary Tree(Draft,建立於 2026-06-11,雜湊函式尚未定稿):帳戶字典樹最大深度約 12 層、分支證明 5760 位元組、最壞情況約 1.8 GB 的證明量,以及 MPT 對有效性證明不友好的原因。正文中這幾項均標註為該草案的自述估算。
- Ethereum Research: State growth scenarios and the impact of repricings(2025-11-19):340 GiB 狀態規模、102 MiB 到 205 MiB 的每日新增,以及按三條 Gas 上限路徑做的 2027 年情境外推。該文屬情境分析,非既成事實。
- Bloatnet Initiative:650 GB 臨界規模與狀態存取時間約 40% 增幅的專案自述口徑,頁面未附可重現的基準資料。
- go-ethereum: Archive mode:路徑式歸檔的 2 TB 與 6.5 TB 口徑、雜湊式歸檔可超 20 TB,以及 v1.16.x 與 v1.17 在歷史證明支援上的差異。
- ethereum.org: Ethereum archive node 與 Spin up your own Ethereum node:跨客戶端的歸檔與全節點磁碟需求區間(歸檔 3 TB 到 12 TB 以上;Geth snap 同步 500 GB 以上)。
- go-ethereum PR #28586:移除 LES 及相關輕客戶端程式碼。
- a16z crypto: Building Helios:共識層認證 stateRoot、執行層用 eth_getProof 做本機驗證的輕客戶端路徑。