newsence

Snap v2:以區塊級訪問列表取代 Trie 修復機制

ethresear.ch·26 天前

這篇文章提出 snap/2 協議升級方案,旨在透過順序應用區塊級訪問列表來完全取代以太坊同步中存在問題的 Trie 修復階段,從而消除同步瓶頸。

特別感謝 Gary 的反饋與審閱!
Snap 同步 (snap/1) 在 Geth v1.10.0 推出時,極大地改善了以太坊節點的同步速度。但它有一個眾所周知的阿基里斯之踵:Trie Healing(狀態樹修復)階段。這是一個迭代過程,同步中的節點會逐個發現並修復狀態樹節點的不一致。這個階段曾導致節點卡在修復過程中長達數天甚至數週,並被社群確定為想要消除的問題

隨著 EIP-7928(區塊級訪問列表,Block-Level Access Lists)的提出,一種新的方法成為可能:完全以順序應用 BAL 來取代 Trie Healing。本文將解釋目前的 snap 同步如何運作、為何 Trie Healing 存在問題,以及提議中的 snap/2 協議升級將如何解決它。


第一部分:目前的 Snap 同步如何運作

Snap 同步解決的問題

一個新的以太坊節點需要當前狀態:每個帳戶餘額、儲存插槽(storage slot)和合約字節碼。這些狀態存在於一個 Merkle Patricia Trie 中,帳戶樹在深度約 7 層時達到飽和(EF 博客:快照加速),包含數億個節點。

舊的方法(「快速同步」,eth/63–66)是從根節點開始逐個節點下載這棵樹。在區塊高度約 11,177,000 時,狀態包含 6.17 億個 Trie 節點,同步這些節點需要下載 43.8 GB 的數據,分佈在 16.07 億個數據包中,總同步時間約為 10 小時 50 分鐘

Snap 同步的核心洞察是:完全跳過中間的 Trie 節點,將葉子節點(帳戶、儲存)作為連續範圍下載,然後在本地重建 Trie。這需要提供服務的節點維護一個動態快照,這是一個扁平的鍵值存儲,迭代帳戶僅需約 7 分鐘,而原始 Trie 迭代則需約 9.5 小時(參見 snap.md)。

比較快速同步與 Snap 同步,我們得到了以下改進:

指標快速同步Snap 同步改進幅度
下載量43.8 GB20.44 GB-53%
上傳量20.38 GB0.15 GB-99.3%
數據包1,607M0.099M-99.99%
服務端磁碟讀取15.68 TB0.096 TB-99.4%
時間10h 50m2h 6m-80.6%

注意: 這些基準測試來自區塊高度約 11.2M(2020 年底)。雖然狀態此後有所增長,但相對改進仍具代表性。現代 Snap 同步在良好硬體上通常需要 2–3 小時

三個階段

Snap 同步分為三個階段:

第一階段 - 區塊頭下載: 使用 eth 協議下載所有區塊頭,建立一條經過驗證的鏈。共識層(CL)驅動執行層(EL),這意味著第一個 HEAD 是從 CL 接收的,然後 EL 從最新的區塊頭開始向後下載所有父區塊頭。

第二階段 - 狀態下載: 節點選擇一個基準區塊 P(通常是 HEAD−64)並下載 P 時刻的完整狀態:

  • GetAccountRange (0x00):下載連續雜湊範圍內的帳戶,每個響應在邊界處進行 Merkle 證明以防止空隙攻擊。
  • GetStorageRanges (0x02):下載合約的儲存插槽,多個小合約可以合併到一個請求中。
  • GetByteCodes (0x04):下載合約代碼,通過代碼雜湊(codehash)對比進行驗證。

每個響應都受到字節大小(而非數量)的限制,以實現可預測的頻寬,且不同的對等節點可以同時服務不同的範圍。提供服務的節點會保留最近 128 個區塊的快照(以每時隙 12 秒計算,約 25.6 分鐘)。

基準區塊選擇在鏈尖端之前足夠遠的位置,是為了確保我們不會下載到隨後被重組(reorg)的區塊所產生的狀態。64 個區塊深的重組在實踐中幾乎是不可能的。即使發生了這種重組,已下載的狀態也不需要丟棄,可以通過迭代獲取所需的 Trie 節點來修復。

至關重要的是,當接收到每個狀態範圍時,節點會在本地為該段重建並持久化中間 Trie 節點,而不是通過網路獲取。到第二階段結束時,大部分 Trie 已經正確構建,顯著減少了修復階段的工作量,使其僅需修復在下載窗口期間因狀態變化而導致不一致的節點。

第三階段 - 修復(Healing): 在第二階段運行的同時,鏈從 P 增長到 P+K,導致下載的狀態變得過時。修復階段負責修正這一點,但這也是問題開始的地方。

Trie Healing 如何運作

修復階段使用 GetTrieNodes (0x06) / TrieNodes (0x07) 來迭代地發現並獲取變動的 Trie 節點:

為什麼 Trie Healing 是瓶頸

  • 迭代發現。 同步節點在查看之前不知道發生了什麼變化。每一輪 GetTrieNodes 都會揭示下一組差異,需要另一次往返通訊。這本質上是順序執行的。
  • 載荷小,往返多。 單個 Trie 節點僅 100–500 字節。即使是批量處理,每次往返的數據量相對於網路延遲來說也非常微小。
  • 移動目標。12 秒一個時隙的情況下,每個區塊大約會刪除 1,000 個並增加 2,000 個 Trie 節點。修復速度必須超過這個速度,否則永遠無法收斂。
  • 隨機磁碟訪問。 響應 GetTrieNodes 需要隨機資料庫讀取。與 GetAccountRange 使用的順序讀取相比,這非常昂貴。
  • 進度不可知。 正如 Geth 文檔指出的:「無法監控狀態修復的進度,因為在當前狀態重新生成之前,無法知道錯誤的程度。」

現實世界的影響可能非常嚴重。例子包括節點在修復時卡住 2 週以上(下載了 4300 萬個 Trie 節點,11.7 GiB;吞吐量降至約 2 個節點/秒),或在修復期間卡住 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 的狀態修復

與其迭代地發現和獲取 Trie 節點,snap/2 反轉了 snap/1 的模式。在 snap/1 中,Trie 是在下載過程中增量構建的,而扁平狀態是從中推導出來的。在 snap/2 中,僅同步扁平狀態(葉子節點),直接對其應用 BAL 差異,然後從完整狀態中一次性重建 Trie,消除了增量 Trie 構建及其所需的複雜修復過程。

具體而言,節點不再迭代發現和獲取 Trie 節點,而是下載同步期間推進的每個區塊的 BAL,並順序應用狀態差異。區塊集合是預先知道的。每個 BAL 都會根據其區塊頭的承諾進行驗證。這消除了迭代發現的需求。

snap/2 移除了 Trie 修復消息,並用 BAL 取而代之,重複使用相同的消息 ID:

IDsnap/1snap/2
0x00–0x05帳戶/儲存/字節碼下載不變
0x06GetTrieNodesGetBlockAccessLists
0x07TrieNodesBlockAccessLists

請注意,重複使用消息 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/71snap/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 重建 + 根對比keccak256(rlp(bal)) == header.block_access_list_hash
移動目標每一輪修復 + 鏈增長 → 更多修復BAL 應用是本地且快速的;尾部極小
收斂保證弱:修復必須超過鏈增長速度強:確定性、有界的工作量

比較 snap/1 與 snap/2 的完整流程如下圖所示:

失敗模式

失敗情況snap/1snap/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 次往返,幾秒鐘內完成。

延伸閱讀

https://ethresear.ch/t/snap-v2-replacing-trie-healing-with-bals/24331