Snap v2:以區塊級訪問列表 BALs 取代 Trie 修復機制
本文提出 Snap v2 協議升級,利用 EIP-7928 區塊級訪問列表來完全取代問題重重的 Trie 修復階段,從而大幅提升以太坊節點的同步效率。
特別感謝 Gary 的回饋與審閱!
Snap 同步(snap/1)在 Geth v1.10.0 發布時,大幅提升了以太坊節點的同步效率。但它有一個眾所周知的阿基里斯之踵:Trie 修復階段(trie healing phase)。這是一個迭代過程,同步中的節點會逐個發現並修復 Trie 節點中的狀態不一致。這個階段曾導致節點卡在修復中長達數天甚至數週,並被社群列為希望消除的問題。
藉由 EIP-7928(區塊級存取列表,Block-Level Access Lists),一種新的方法成為可能:完全以順序應用 BAL 來取代 Trie 修復。本文將解釋目前的 snap 同步如何運作、為何 Trie 修復會產生問題,以及提議中的 snap/2 協議升級將如何解決它。
第一部分:目前的 Snap 同步如何運作
Snap 同步解決的問題
一個新的以太坊節點需要當前狀態:每個帳戶餘額、儲存插槽(storage slot)和合約字節碼(bytecode)。這些狀態存在於 Merkle Patricia Trie 中,帳戶 Trie 的深度大約飽和在 7 層(EF 部落格:快照加速),包含數億個節點。
舊的方法(「快速同步」,eth/63–66)是從根節點開始逐個節點下載這個 Trie。在區塊高度約 11,177,000 時,狀態包含 6.17 億個 Trie 節點,同步這些節點需要下載 43.8 GB 的數據,分布在 16.07 億個數據包中,總同步時間約為 10 小時 50 分鐘。
Snap 同步的核心洞察是:完全跳過中間的 Trie 節點,將葉子節點(帳戶、儲存)作為連續範圍下載,然後在本地重建 Trie。這要求提供服務的節點維護一個動態快照,這是一個扁平的鍵值存儲(flat key-value store),迭代帳戶僅需約 7 分鐘,而原始 Trie 迭代則需約 9.5 小時(參見 snap.md)。
比較快速同步與 snap 同步,我們得到以下改進:
| 指標 | 快速同步 | Snap 同步 | 改進幅度 |
|---|---|---|---|
| 下載量 | 43.8 GB | 20.44 GB | -53% |
| 上傳量 | 20.38 GB | 0.15 GB | -99.3% |
| 數據包數量 | 1,607M | 0.099M | -99.99% |
| 服務端磁碟讀取 | 15.68 TB | 0.096 TB | -99.4% |
| 時間 | 10h 50m | 2h 6m | -80.6% |
注意: 這些基準測試來自區塊高度約 11.2M(2020 年底)。雖然狀態量已增長,但相對改進仍具代表性。現代 snap 同步在良好硬體上通常總共需要 2–3 小時。
三個階段
Snap 同步分為三個階段進行:
第一階段 - 區塊頭下載: 使用 eth 協議下載所有區塊頭,建立一個經過驗證的鏈。共識層(CL)驅動執行層(EL),這意味著第一個 HEAD 是從 CL 接收的,然後 EL 從最新的區塊頭開始向後下載所有父區塊頭。
第二階段 - 狀態下載: 節點選擇一個基準區塊 P(通常是 HEAD−64)並下載 P 點的完整狀態:
-
GetAccountRange (0x00):下載連續雜湊範圍內的帳戶,每個響應在邊界處都有 Merkle 證明以防止間隙攻擊(gap attacks)。
-
GetStorageRanges (0x02):下載合約的儲存插槽,多個小合約可以批次處理到一個請求中。
-
GetByteCodes (0x04):下載合約代碼,通過 codehash 對比進行驗證。
每個響應都以字節大小(而非數量)為上限,以確保頻寬可預測,且不同的節點可以同時服務不同的範圍。提供服務的節點會保留最近 128 個區塊的快照(在每時段 12 秒的情況下約為 25.6 分鐘)。
基準區塊選擇在距離鏈尖足夠遠的過去,以確保我們下載的狀態不會因為隨後的區塊重組(reorg)而失效。64 個區塊深的重組在實踐中幾乎是不可能的。即使發生此類重組,已下載的狀態也不需要丟棄,可以通過迭代獲取所需的 Trie 節點來修復。
至關重要的是,當接收到每個狀態範圍時,節點會在本地重建並持久化該段的中間 Trie 節點,而不是通過網路獲取。到第二階段結束時,大部分 Trie 已經正確構建,顯著減少了修復工作量,僅需修復在下載窗口期間發生的狀態變化所導致的不一致節點。
第三階段 - 修復: 在第二階段運行的同時,鏈從 P 增長到 P+K,導致下載的狀態變得過時。修復階段會解決這個問題,但這也是問題開始的地方。
Trie 修復如何運作
修復階段使用 GetTrieNodes (0x06) / TrieNodes (0x07) 來迭代發現並獲取已更改的 Trie 節點:
為什麼 Trie 修復是瓶頸
-
迭代發現。 同步節點在查看之前不知道發生了什麼變化。每一輪 GetTrieNodes 都會揭示下一組差異,需要另一次往返(round trip)。這本質上是順序執行的。
-
小負載,多次往返。 單個 Trie 節點大小為 100–500 字節。即使是批次處理,每次往返的數據量相對於網路延遲來說也非常小。
-
移動的目標。 在 12 秒一個時段的情況下,每個區塊大約會刪除 1,000 個 Trie 節點並增加 2,000 個。修復的速度必須超過這個增長速度,否則永遠無法收斂。
-
隨機磁碟存取。 服務 GetTrieNodes 需要隨機數據庫讀取。與 GetAccountRange 使用的順序讀取相比,這非常昂貴。
-
進度不可知。 正如 Geth 文檔所指出的:「無法監控狀態修復的進度,因為在當前狀態重新生成之前,無法知道錯誤的程度。」
現實世界的影響可能非常嚴重。例子包括節點在修復時卡住超過 2 週(下載了 4300 萬個 Trie 節點,11.7 GiB;吞吐量降至約 2 個 Trie 節點/秒),或在修復期間卡住 4 天或 6 天。
發布時的基準測試顯示,在區塊高度約 11.2M 時,修復增加了 ~541,260 個 Trie 節點 (~160 MiB),但隨著今天更大的狀態和更高的區塊 Gas 上限,修復負擔已經大幅加重,並且會隨著 Gas 上限的進一步提高而惡化。
第二部分:區塊級存取列表 (BALs)
EIP-7928 引入了區塊級存取列表 (BALs):這是一種記錄區塊執行期間訪問的每個帳戶和儲存位置,以及執行後數值的數據結構。每個區塊頭通過放置在區塊頭中的新欄位 block_access_list_hash 來承諾其 BAL:
block_access_list_hash = keccak256(rlp.encode(block_access_list))
對於每個被訪問的帳戶,BAL 包含:
-
儲存變更:每個插槽的執行後數值,按導致變更的交易索引。
-
儲存讀取:已讀取但未修改的插槽。
-
餘額/Nonce/代碼變更:交易後的數值。
BAL 採用 RLP 編碼,具有確定性的順序(帳戶按地址字典序排列,變更按交易索引排列),且內容完整。狀態差異(State diffs)是 BAL 的子集,因此可以用於輔助同步。
BAL 大小
對 60M 區塊 Gas 上限下的 1,000 個主網區塊進行的實證分析顯示,BAL 平均大小約為 72.4 KiB。
節點必須至少保留 BAL 直到弱主觀性時期結束(最多 3,533 個時段,按今天的驗證者集規模計算約為 15.7 天)。
第三部分:snap/2:基於 BAL 的狀態修復
snap/2 不再迭代發現並獲取 Trie 節點,而是反轉了 snap/1 的模式。在 snap/1 中,Trie 是在下載過程中增量構建的,扁平狀態是從中推導出來的。在 snap/2 中,僅同步扁平狀態(葉子節點),直接將 BAL 差異應用於其上,然後從完整狀態中一次性重建 Trie,消除了增量 Trie 構建及其所需的複雜修復過程。
具體而言,節點不再迭代發現並獲取 Trie 節點,而是下載同步期間推進的每個區塊的 BAL,並順序應用狀態差異。區塊集合是預先知道的。每個 BAL 都會根據其區塊頭承諾進行驗證。這消除了迭代發現的需求。
snap/2 移除了 Trie 修復消息,並以 BAL 取而代之,重複使用相同的消息 ID:
| ID | snap/1 | snap/2 |
|---|---|---|
| 0x00–0x05 | 帳戶/儲存/字節碼下載 | 不變 |
| 0x06 | GetTrieNodes | GetBlockAccessLists |
| 0x07 | TrieNodes | BlockAccessLists |
請注意,重複使用消息 ID 是安全的,因為 snap/2 是在 RLPx 握手期間協商的新協議版本。snap/1 節點永遠不會看到 snap/2 消息。
新消息
GetBlockAccessLists (0x06):
[request-id: P, [blockhash₁: B_32, blockhash₂: B_32, ...]]
BlockAccessLists (0x07):
[request-id: P, [block-access-list₁, block-access-list₂, ...]]
-
節點必須始終響應
-
對於不可用的 BAL,使用空條目(零長度字節)
-
響應保留請求順序,並可能從尾部截斷
-
建議的軟限制設置為每個響應 2 MiB,這與現有消息(如區塊、區塊頭或收據)一致。
新同步演算法
值得注意的是,由於 BAL 通過共識保證正確(針對規範區塊進行 BAL 雜湊檢查),狀態根保證匹配;因此,客戶端甚至可以跳過最後的狀態根比較步驟。
為什麼這有效
使用 snap/2,修復窗口是有界且已知的。對於 HEAD−64 的基準點:
-
64 個區塊 × ~72.4 KiB(預計 60M Gas)≈ 4.5 MiB 總 BAL 數據
-
在 2 MiB 軟限制下,只需 2–3 個響應即可容納
-
服務 BAL 僅需幾次磁碟查找,而不是為每個更改的 Trie 節點進行查找
-
總共 1–3 次往返(包括應用期間到達的任何「尾部」區塊)
-
從 BAL 中提取狀態差異是純本地計算。不需要 Trie 遍歷。
與 snap/1 相比,snap/2 的修復效率更高,需要的磁碟讀取和往返次數更少。使用 snap/2,至少在理論上,鏈的增長速度將不可能超過同步速度。
與 eth/71 的關係
EIP-8159 將 BAL 交換作為消息 0x12/0x13 添加到 eth 協議中。兩者存在的原因不同:
| 特性 | eth/71 | snap/2 |
|---|---|---|
| 用途 | 用於並行執行、重組處理的近期 BAL | 同步:修復期間的大量 BAL 下載 |
| 容量 | 一次 1–3 個 BAL | 一次多個 BAL |
| 協議 | 所有節點強制執行 | 可選的衛星協議 |
消息在 eth/71 和 snap/2 中重複存在,是為了確保 snap 保持為一個自包含的衛星協議,並允許 snap 獨立演進(例如,在未來版本中僅提供狀態差異而非完整 BAL),而無需更改 eth 協議。
第四部分:比較
修復階段:snap/1 vs snap/2
| 屬性 | snap/1 (Trie 修復) | snap/2 (BAL 修復) |
|---|---|---|
| 發現方式 | 迭代:節點在查看前不知道變化 | 確定性:區塊 P+1..P+K 是預先知道的 |
| 往返次數 | 數百次以上(報告顯示有數百萬個 Trie 節點) | 最終數量待定,但估計僅需幾次 |
| 驗證方式 | 複雜的 Trie 重建 + 根對比 | keccak256(rlp(bal)) == header.block_access_list_hash |
| 移動目標 | 每輪修復 + 鏈推進 → 更多修復 | BAL 應用是本地且快速的;尾部極小 |
| 收斂保證 | 弱:修復必須快於鏈增長 | 強:確定性、有界的工作量 |
比較 snap/1 與 snap/2 的完整流程如下:
失敗模式
| 失敗情況 | snap/1 | snap/2 |
|---|---|---|
| 修復無法收斂 | 真實風險:Trie 修復慢到鏈增長超過它 | 幾乎消除:僅 BAL 下載需要網路 |
| 數據不可用 | 無:snap/1 僅要求修復快於鏈增長 | 弱主觀性時期(~15.7 天)非常寬裕 |
| 錯誤數據 | Merkle 證明捕捉錯誤的 Trie 節點 | 雜湊對比捕捉錯誤的 BAL |
| 重組超過基準點 | 可恢復:Trie 修復針對新規範鏈解析狀態 | 如果保留孤立 BAL 則可恢復;否則需重啟同步 |
具體範例
假設基準點在區塊 22,000,000,當狀態下載完成時,鏈已經領先了 200 個區塊:
snap/1: 從區塊 22,000,200 的狀態根開始 Trie 遍歷。每一輪都會發現更多差異,並向深處延伸。與此同時,修復期間又有新的區塊到達。在最佳情況下,這需要幾分鐘;在病態情況下(磁碟慢、網路慢),曾耗時數天。
snap/2: 請求多個區塊的 BAL。在 60M 區塊 Gas 上限下,這大約是 4–5 MiB,只需幾次響應即可容納。在本地應用 BAL,可選擇性驗證狀態根是否匹配。應用期間又有幾個新區塊到達?獲取 2–3 個更多的 BAL。總計:2–3 次往返,幾秒鐘即可完成。
延伸閱讀
-
1 則貼文 - 1 位參與者 [閱讀完整主題](https://ethresear.ch/t/snap-v2-replacing-trie-healing-with-bals/24333)