以太坊狀態規模日益惡化,Vitalik 系統性梳理了可能的解決方案

狀態規模是一個日益惡化的問題,而狀態規模的解決方案也能為大幅提高區塊 Gas 上限鋪平道路。我們應該對某種形式的狀態過期方案達成共識並加以實現。

撰文:Vitalik Buterin | 翻譯:阿劍,來自 以太坊愛好者

以太坊協議所面臨的一個最為長久且尚未解決的挑戰,就是由於狀態數據規模不斷增長而帶來的問題。以太坊區塊鏈上的許多操作(創建賬戶、寫入一個合約存儲槽、發送ETH 到一個新的賬戶……)都會給以太坊添加狀態內容(也即是給狀態數據增加數據對象),而所有全節點都必須存儲全量的狀態數據,這樣才能驗證新區塊以及製造新區塊。這些操作只需事務的發送者一次性繳交按 Gas 用量來計量的手續費,但會給整個網絡造成永久的持續性成本,因為節點需要存儲這些新數據(而未來加入的節點也需要在同步過程中下載這些數據)。

這是系統設計中的一個顯著的失衡,可能會讓以太坊系統變得越來越難用,因為狀態中充斥著不再有用處的「垃圾數據」。本文的目的是詳細解釋問題產生的根源,以及一些解決該問題的方法。如果我們能實現某個解決方案,這將為安全地大幅提高區塊 Gas 上限 鋪平道路。

本文所論述的研究領域仍在推進中,隨時有可能出現更新、更好的想法和更優雅的權衡。

引言:問題出在哪?

「狀態」指的是節點若想處理新產生的區塊和事務就必須存有的信息。狀態與「歷史」完全不同,後者是關於過去時間的信息,節點可以保存這些信息以便日後重新廣播或歸檔,但並不是處理區塊鏈所必需的。

在以太坊協議中,狀態信息包括:

  • 賬戶的 ETH 餘額 和 nonce (流水號)
  • 智能合約的代碼
  • 智能合約的存儲項(storage)
  • 與共識機制相關的數據(近期的區塊哈希值,叔塊;權益證明的共識數據還包括驗證者的公鑰以及及其記錄在信標鏈上的活動,等等)

歷史信息則由舊的區塊和收據組成。 EVM 中沒有操作碼可以讓你訪問舊區塊、舊事務和內容和收據輸出,所以節點丟棄這些數據也仍然能驗證新區塊,所以這些是歷史信息。

上述狀態信息列表中的最後一項 —— 共識機制相關數據 —— 在設計上已經精心限制了其規模,因此我們不太需要為此困擾。但前面三項,就令人頭大了。這三類狀態信息的規模會隨著時間推移而不斷增大,因為不斷會有新用戶加入網絡,他們會創建新的賬戶、新的合約,還會加入合約、收到 token 什麼的。

難辦的是,許多狀態用過之後就會靜靜地躺在那裡(不會再被觸及);一旦某個用戶停用某個應用之後,就會產生一些「垃圾狀態」—— 不會再派上用場,但會永遠存在那裡。

理論上,用戶可以做到「垃圾不落地」。用戶可以僅發布帶有 SELFDESTRUCT 條件的合約,等他們再也用不上這個合約的時候,就調用這個操作碼移除這個合約、清空其 token 餘額;他們還可以使用智能合約錢包,通過一個已有的外部持有賬戶(EOA)來發送交易,而無需生成一個新的 EOA (EOA 狀態是沒法刪除的)。

但是在實踐中,這樣的激勵非常少,而適當的狀態清理的技術複雜性又太大了。在許多合約中,給任何人賦予這樣調用 SELFDESTRUCT 的權限都是不合適的(人們想要的就是「無法終止」的應用!),而且,也會給用戶體驗和代碼上也會增加很多複雜性。實際上,由於 SELFDESTRUCT 用處極其有限而副作用極大,我更傾向於永遠移除這個操作碼。如果我們真想控制狀態數據的規模,我們需要的是一個網絡中的節點可以 默認 丟棄不再被使用的「垃圾狀態」的方法。

無狀態客戶端

這個問題的一類解決方案基於「無狀態客戶端」的觀念(此文是論述這個觀念的出處 ,此處是演講視頻)。基本原理是,讓區塊驗證不再以持有全局狀態為前提。相反,區塊會自帶證據(或者叫「見證數據(witness)」),證明其所訪問狀態的值。就跟現在的設計一樣,區塊內會包含一個「狀態根(state root)」,所訪問的值可以對應著狀態根得到證明(譯者註:默克爾證明即是一種常見的證明技術)。以太坊現在的狀態樹方案(默克爾帕特里夏樹)支持這樣的證明技術,像二進制樹或者 Verkle Trie 這樣更高效的方案也可以。見證數據也會證明處理完該塊後新狀態根的正確性。

無狀態性有兩種形式:

  • 弱無狀態性:出塊者仍然需要完整的狀態,以為(自己製造的)區塊生成見證數據;但驗證區塊的階段可以是無狀態的;
  • 強無狀態性:沒有任何節點需要完整的轉台。反過來,是交易發送者需要提供見證數據,而出塊者可以聚合這些數據。交易發送者自己負責存儲為所關切的賬戶生成見證數據所需的部分狀態樹。

強無狀態性是一個非常「優雅」的解決方案,因為它把責任完全轉移給了用戶,雖然為了保證實踐中的良好用戶體驗,我們需要創造某些類型的協議來幫助不運行個人節點的用戶維護狀態、並處理用戶需要與意料之外的賬戶交互的情形。打造這樣的協議非常難。

此外,所有類型的無狀態性都提高了網絡所需的數據帶寬;而強無狀態性還需要交易聲明其所交互的賬戶及存儲項的鍵(概念上這個叫做「訪問列表」)。

一個更溫和的解決方案:狀態過期

更溫和的解決方案可以歸結為不同形式的「狀態過期」方案。必須持續得到訪問的狀態才能保持「激活狀態」;而長期無人問津的狀態會變成「失活」(或者叫「過期的」)。具體用什麼機制來更新狀態,有很多選擇(例如預付「租金」,或者只需訪問那個狀態),但一般原則是,除非某個狀態對像被顯式地更新,否則就以某種形式處於失活狀態。因此,任何創建新狀態對象(以及更新已有狀態對象)的活動,都只能成為節點在一段時間內的負擔,而不像現在這樣變成永久負擔。

失活狀態,故名思義,就不是「狀態」的一部分;想要處理區塊或創建區塊的節點無需存儲失活狀態。不過,失活狀態不是被完全刪除了!在所有類型的狀態過期提案中,都預設了某種方法可以「復活」已經失活的狀態。

一般原則是,激活狀態的使用與當前相同,而失活狀態則需通過上述無狀態客戶端的機制來使用。復活一個過期狀態對象的事務需要提供一個證據(見證數據),來證明該對像是失活狀態的一部分。為了能夠生成這樣的證據,用戶自己需要存儲和維護至少一部分失活狀態(對應於其所關切的失活狀態對象的那部分)。

何時過期

決定過期條件的設計也有很多種。最常見的幾種是:

  • 直接租金:逐塊逐塊收取「租金」,直接以每個賬戶(或其他狀態對象)的餘額來支付;狀態對象的餘額降到了零,該賬戶就過期了。
  • 剩餘存活時間值:每個狀態對像都存儲一個「剩餘存活時間」值,這個值可以通過支付費用來增加
  • 觸達即刷新:每個狀態對像都存儲一個「剩餘存活時間」值,並且每逢讀取或寫入該賬戶都會增加該值
  • 所有狀態對象定期過期(例如每 6 個月一次):也就是 ReGenesis 提案

我自己越來越喜歡「觸達即刷新」方案,因為(1)它避免了應用需要創造複雜的經濟模型來讓用戶承擔狀態租金;以及(2)它保證了激活狀態的規模有一個清晰的上限(區塊 Gas 上限/ 觸達狀態對象的 Gas 消耗量× 狀態存活的時長)。讓大量狀態按照規律的時間間隔過期的方案(也就是 ReGenesis )也有同樣的好處,但也有一些有趣的權衡:關鍵好處是,過期方案更簡單(無需遍歷整棵狀態樹而逐個逐個地滅活狀態對象),但關鍵不足是,跨過一個過期時點後,你再激活自己的狀態對象時,需要多少見證數據會跟你觸達狀態對象的時間點有關。

賬戶層面的過期 vs. 存儲槽層面的過期

狀態過期的邏輯既可以運營到賬戶層面,也可以運用到單個存儲槽層面。當前,我強烈偏向於在存儲槽層面實現狀態過期方案。因為很多合約賬戶的存儲槽數量是不受限制的,任意用戶都能加入合約並增加合約名下的存儲槽的數量(例如,空投就是一個已經出現過的案例)。不管使用什麼樣的賬戶層過期方案,想要實際限制狀態的規模,租金的數量都必須與合約內存儲槽的數量成比例(或者存活時間與之成反比)。結果是,用戶還是能夠僅支付一次性的費用就給合約及其用戶施加 永久的持續性成本。

要解決這個問題,合約要么加入複雜的內部邏輯,將存儲操的租金「轉嫁」給用戶,要么重新設計自己合約的模式,轉向使用 CREATE2 操作碼創建新的合約並使用這些合約來充當存儲槽。不管是哪種辦法,最後都會變成等價於存儲槽層面的過期方案。因此,我個人認為,我們應該僅在合約存儲槽層面實現狀態過期方案。

但是,存儲槽層面的過期方案也有自己的缺點:每個存儲槽都要增加一個元數據,指明它何時過期(或者說是否已經失活),這也意味著「復活衝突問題」(詳見下文)不僅會影響賬戶,也會影響存儲槽。

從狀態樹上移除 vs. 給狀態樹安排一個「退休」部分

另一個區分不同狀態過期提議的技術角度是「一樹流」和「二樹流」。也就是說,我們到底是像現在這樣,只有一棵狀態樹,只不過把某些狀態標記為過期;還是直接把失活的狀態從主狀態樹上移除,轉移到另一棵專門的(只包含過期狀態的)樹(或者其他數據)上?

一樹流

观点 | 以太坊状态规模管理诸提议(下)
激活節點以白色標記,失活節點以灰色標記

注意,即使是樹上的中間節點,也會被標記為激活或者失火(或者,更現實一點的方案,每個節點都會帶有失活日期的標記,所以能夠容易檢查其活性);標記工作可以在狀態樹上的每個節點(葉子節點和中間節點)處完成。

二樹流

观点 | 以太坊状态规模管理诸提议(下)
白色的樹包含激活狀態;灰色的樹存儲失活狀態

一樹流的好處是,最起碼,其工作方式看起來會跟當前的狀態樹相似,失活和復活的流程也比較簡單:復活流程只需刷新樹上相關節點的「過期日期」參數,而失活則是自動化的。但它的缺點在於:它需要一種能夠在節點中以此種方式存儲過渡信息(intermediate information)的樹結構,而且不能很好地擴展到 Verkle 樹。此外,它還需要額外的默克爾證明元件,不僅要能夠下沉到葉子節點,還要能夠(在需要證明某部分狀態已經過期時)停在中間節點處。

二樹流的好處是:當前的、形式純粹的狀態累加器就能支持這類方案,而無需為每個節點增加元數據。缺點是,它需要對整個協議做一些更深層次的變更,而且需要一個顯式的流程來滅活狀態(所以過期不再是自動化的了)。另外,它也沒有為複活衝突兩難(見下一節)提供內置的解決方案,所以需要在兩種辦法中作出選擇。

注意,在二樹流中,存儲失活狀態的數據結構不是非樹不可。事實上,完全有可能出現這樣一種設計:需要復活一個狀態對象時,只需提供一個指向該對象失活時候收據的默克爾樹,再附上一些密碼學證據,證明此前該對象未被復活過(或者最近又重新過期),即可。

復活衝突

然後我們就到了狀態過期方案的一個關鍵難題上:「復活衝突」。復活衝突的概念如下。假設某個賬戶由地址A 生成;這個賬戶過期了;然後,地址A 又創建了一個新的賬戶(例如,使用CREATE2 操作碼保證兩次生成的賬戶的地址是同一個);最後,地址A 再嘗試復活那個最開始的賬戶。這時候會出現什麼情況?

這裡有幾種可能的解決方案:

  1. 顯式的「賬戶合併」流程:類似於規定「除了兩個賬戶的ETH 餘額相累加以外,以舊賬戶的狀態為準」或者「除了累加ETH 之外,以新賬戶的狀態為準」;甚至於,可以由舊賬戶的合約代碼來規定特殊的合併流程
  2. 通過消除同一地址重複部署的功能來確保復活衝突不會發生:也就是調整CREATE2 的功能,比如在最終哈希成地址的數據原像中包含當前時間,因此即使未來使用同樣的數據來生成,也無法得到同樣的地址
  3. 向狀態對象增加一個「存根」,以防止在同一位置生成新賬戶(上述一樹流方法自動實現了這一功能)
  4. 要求生成新賬戶時都必須附帶該賬戶此前未過期的證明:某種意義上等價於存根方案,只不過這種辦法是把存根放在狀態的一個單獨部分中,所以任何想要創建合約賬戶的用戶都必須跟踪這部分狀態

(注意,如果我們使用存儲槽過期方案,則上述任一解決方案都必須延伸到單個存儲槽層面,而不能止步於賬戶層)

主要的擔憂有:(1)會給應用增加很多複雜性,他們需要加入合併的邏輯;(2)這樣做了之後,除非在鏈上「註冊」一個地址,否則用戶就沒法再輕易獲得可以與之交互、可以積累資產(例如 ERC20 token)的地址了。未註冊的地址是很重要的:任何第一次收到 ETH 的用戶都是在使用一個尚未註冊的地址。這第(2) 的擔憂的根源是:未註冊的地址實際上有了時間限制,如果用戶生成了一個地址、收到了資金,但在接下來一年裡忘了發送交易(也就是忘了「註冊」),那他的資金就會被鎖住。

注意,EOA 也不能倖免。雖然看起來能夠,因為 EOA 的合併流程比較簡單(只需把舊的 ETH 餘額加到新的里,對 nonce 則有 EIP 169)這樣的方案。不過,這裡也有兩個問題。首先,賬戶抽象的目標是用合約來替代 EOA,而賬戶抽象化的合約的合併流程可能並不簡單。其次,會受過期和復活事件影響的不僅有 EOA 本身,還有該 EOA 所參與的應用中的相關存儲鍵(例如 ERC20 token 餘額),所以還是需要復雜的合併邏輯。

因此,從我的角度來看,破壞性最小的是某種形式的存根方案。不過,存根方案裡存在一個信息理論問題,會導致一些奇怪的結果。為了防止新的狀態對像在 N 個已經過期的狀態對象位置處創建,一個覆蓋(cover)了這 N 個地址(以及 / 或者 存儲鍵)的集合必須是狀態的一部分。如果這個集合是信息最小化的(即,只包含了這些地址),那麼這個集合的大小會是 O(N),因此其狀態規模也是 O(N);那麼,激活狀態的規模就將與失活狀態的規模成比例,所以實際上我們並沒有解決這個問題。

Tree rot

解決這個問題的唯一辦法就是覆蓋超過那 N 個賬戶的信息;實際上,我們將不得不讓整棵樹都變得不可訪問(再次提醒,這就是一樹流解決方案的實質:如果兩個賬戶過期了,它們之間的所有空間都會隱式過期( if two accounts get expired, all the space in between them also implicitly gets expired))。

而這裡還有一個問題:這產生了一種形式的「樹發霉(tree rot)」,隨著時間推移,對於新帳戶的創建來說,狀態樹的所有部分都是不可訪問的,至少對那些沒有跟踪該區域過期狀態的用戶來說是這樣的。

而樹發霉導致的次生問題也必須解決。舉個例子:如果一個合約要創建子合約,它必須能夠在要么未發霉,要么用戶具有見證數據的狀態區域創建合約(也許需要用戶提供的「提示」)。樹發霉問題的一個解決方案見此處:持續地開放狀態的新區域以供賬戶創建。另一種思路是每個用戶都選擇狀態的某些區域(例如狀態的 1/256),跟踪該區域的變化(包括過期狀態)以便能創建見證消息,並且只在該區域創建帳戶。

樹發霉的另一個問題是,它需要一個顯式的數據結構來存儲和檢查範圍。如果一棵樹有能夠放在節點中、指明該節點以下的哪些部分已經過期的數據(就像一樹流解決方案所用的那樣),那是最好的,但一個鍵值對存儲要做到這一點還是相當有難度的。

回頭再看強無狀態性

在狀態過期方案中使用樹結構所產生的許多問題,都可以被追溯到這樣一個事實:我們需要對哪些狀態是活躍的、哪些狀態是失活的,達成共識。在二樹流模式中,這一點更加明顯;但即使是在一樹流模式中,狀態樹上也需要有顯式的標記,以便近期使用快速同步下載了狀態的以太坊節點能夠確定一筆嘗試訪問某個賬戶、但又沒有提供見證消息的交易,應該成功還是失敗。那我們能不能做到不需要明確這個區別呢?

如果我們實現了完全的無狀態性,然後能幫助交易發送者和區塊生產者可靠地獲得見證消息生成所需的狀態,不就解決這個問題了嗎?那什麼辦法能幫助交易發送者和區塊生產者做到這些呢?

一種自然而然的辦法是:網絡中的節點都僅保存狀態樹的一部分,例如,在過去一年中訪問到的那部分。只需在客戶端設定中加入一個自願的設定即可。如果我們想要更可靠一些,我們可以通過引入一種 proof of custody 方案,強制至少礦工(後面就是 PoS 的驗證者)存儲一些數據。

有一點需要注意:如果共識層不能感知哪些狀態是活躍的、哪些狀態是失活的,那訪問近期狀態和老舊狀態的 Gas 開銷就是一樣的。這會導致兩個結果:

  1. 訪問近期狀態的 Gas 開銷也需要進一步提高
  2. 包含了見證消息的區塊大小上限可能非常之大,如果一個區塊裡滿是訪問老舊狀態的事務的話(大概是800 bytes * 12.5 m gas / 2400 gas per access ~= 4.1 MB,已假設實行了EIP-2929,轉成了二進制樹)

如果我們想避免這些不利因素,就需要在共識中跟踪哪些狀態對象(包括尚未填滿的地址空間區域)是活躍狀態,這又會讓我們回到接近於狀態過期方案的屬性。這再一次地說明了,「無狀態性 vs. 狀態過期(狀態租金)」 是一條光譜,是一個複雜的權衡空間,而不是一個非此即彼的選擇。

Rollup 也需要,也可以,使用同樣的解決方案

以太坊的一種重要的中期可擴展性解決方案是 rollups。不過,rollup 本身並非不再需要擔憂狀態數據規模問題;實際上,rollup 系統的狀態規模問題,與以太坊鏈本身的,性質完全相同。

幸運的是,如果我們能推出一種解決方案,則至少 EVM rollup (嘗試最大程度複製以太坊運行環境的 rollup 方案)能夠使用同樣的解決方案,來解決其內部狀態的規模問題。因此,狀態規模管理方案,與 rollup 和 分片等可擴展性方案是互補的(state size management is complementary to rollups, sharding and other scaling strategies)。

(譯者註:個人認為此處的「互補」一詞有嚴重誤導性。)

結論

狀態規模是一個日益惡化的問題,而狀態規模的解決方案也能為大幅提高區塊 Gas 上限鋪平道路。我們應該對某種形式的狀態過期方案達成共識並加以實現。不過,不同的解決方案之間存在重大技術權衡,尤其如果我們還想要保持當前設計的一些重要屬性的話。

一些我們可能需要犧牲的屬性包括:

  • 用戶可以離線生成賬戶並以該地址接收資金、並​​且在使該地址在鏈上顯明之前可以靜默任意時長的屬性
  • 地址保持 20 字節的長度(rolling state expansion 方案需要更大的地址空間,雖然地址的長度可能本來就需要為抗碰撞的緣故很快改變)
  • 狀態可以被視為「純粹的」鍵值對存儲的屬性,以及無需在狀態樹上每個節點內存儲元數據的屬性
  • 現有的應用需要程度不等的重寫,以保證用戶無需存儲全部失活狀態就能生成見證數據
  • Gas 消耗量;或者創建新合約、寫入新存儲槽的難度

我們如果已經準備好作出犧牲,有些方案可以很快開始著手實現。另一方面,也許假以時日,我們能修補或者更好地匯總這些觀念,減少問題,尤其是使它們在技術上更容易實現(例如,允許使用「純粹的」鍵值對存儲)。我們應該更深入地理解我們 更願意 / 更不願意 接受哪些方面的犧牲,並繼續積極研究改進提案。

來源鏈接:hackmd.io

本文不構成投資建議,虛擬貨幣波動大請謹慎小心

掌握虛擬貨幣、區塊鏈大小事

發表迴響