【楊鎮】【中譯修訂版】以太坊的分片技術官方介紹
楊鎮,資深軟件架構師,資深開發工程師。以太坊技術愛好者與布道者。
是Solidity官方文檔中譯項目的重要貢獻者,以太坊Homestead官方文檔中文版譯者,並對以太坊黃皮書中文版、Thunder共識白皮書中文版進行了獨立校訂。目前致力於以太坊技術推廣及智能合約開發、安全審計方向。
原文鏈接: https://github.com/ethereum/sharding/blob/develop/docs/doc.md
作者: Vitalik
序言
本文的目的是為那些希望理解分片建議詳情,乃至去實現它的朋友提供一份相對完整的細節說明和介紹。本文僅作為二次方分片(quadratic sharding)的第一階段的描述;第二、三、四階段目前不在討論範圍,同樣,超級二次方分片(super-quadratic sharding)(“Ethereum 3.0”) 也不在討論範圍。
假設用變量 c
來表示一個節點的有效計算能力,那麽在一個普通的區塊鏈裏,交易容量就被限定為 O(c),因為每個節點都必須處理所有的交易。二次方分片的目的,就是通過一種雙層的設計來增加交易容量。第一層不需要硬分叉,主鏈就保持原樣。不過,一個叫做 校驗器管理和約 (validator manager contract,VMC)的合約需要被發布到主鏈上,它用來維持分片系統。這個合約中會存在 O(c) 個 分片 (目前為 100),每個分片都像是個獨立的“銀河”:它具有自己的賬戶空間,交易需要指定它們自己應該被發布到哪個分片中,並且分片間的通信是受限的(事實上,在第一階段,不存在這種通信能力)。
分片運行在一個普通的符合最長鏈規則的權益證明系統中,權益數據將保存在主鏈上(具體來說,是在 VMC 中)。所有分片共享一個通用驗證器池,這也意味著:任何通過 VMC 註冊的驗證器,理論上都可以在任意時間被授權來在任意分片上創建區塊。每個分片會有一個 O(c) 的區塊大小 / gas 上限(block size/gas limit),這樣,系統的整體容量就變成了 O(c^2) 。
分片系統中的大多數用戶都會運行兩部分程序。(i) 一個在主鏈上的全節點(需要 O(c) 資源)或輕量節點(需要 O(log(c)) 資源)。 (ii) 一個通過 RPC 與主鏈交互的“分片客戶端”(由於這個客戶端同樣運行在當前用戶的計算機中,所以它被認為是可信的);它也可以作為任意分片的輕客戶端、作為特定分片的全客戶端(用戶需要指定他們正在“監視”某個特定的分片),或者作為一個驗證器節點。在這些情況下,一個分片客戶端的存儲和計算需求也將不會超過 O(c) (除非用戶指定他們正在監視 每個 分片;區塊瀏覽器和大型的交易所可能會這麽做)。
在本文中,術語 Collation
被用來與 Block
(區塊)相區別,因為: (i) 它們是不同的 RLP(Recursive Length Prefix)對象:交易是第 0 層的對象,collation 是用來打包交易的第一層的對象,而 block 則是用來打包 collation(header)的第二層的對象; (ii) 在分片的情景中這更加清晰。通常,Collation
CollationHeader
和 TransactionList
(交易列表)組成;Collation
的詳細格式和 Witness
(見證人)會在 無狀態客戶端 那節定義。Collator
(即用來打包 transaction 生成 collation 的某個地址,譯者註)是由主鏈上 驗證器管理合約 的 getEligibleProposer
函數所生成的示例。算法會在隨後的章節中介紹。Main Chain | Shard Chain |
---|---|
Block | Collation |
BlockHeader | CollationHeader |
Block Proposer (or Miner in PoW chain) | Collator |
二次方分片(Quadratic Sharding)
常量
LOOKAHEAD_PERIODS
: 4PERIOD_LENGTH
: 5COLLATION_GASLIMIT
: 10,000,000 gasSHARD_COUNT
: 100SIG_GASLIMIT
: 40000 gasCOLLATOR_REWARD
: 0.001 ETH
驗證器管理合約(Validator Manager Contract,VMC)
我們假定 VMC 存在於地址 VALIDATOR_MANAGER_ADDRESS
上(在已有的“主分片”上),它支持下列函數:
deposit() returns uint256
:添加一個驗證器到驗證器集合中,驗證器的大小就是函數調用時的msg.value
(也就是存入的以太幣數量)。這個函數會返回驗證器的索引序號。withdraw(uint256 validator_index) returns bool
:驗證msg.sender == validators[validator_index].addr
,如果相等,它會將驗證器從驗證器集合中移除,並退還存入的以太幣。get_eligible_proposer(uint256 shard_id, uint256 period) returns address
:使用一個區塊哈希(block hash)作為種子,基於預設的算法從驗證器集合中選擇一個簽名者(signer)。驗證器被選中幾率應該與其存款數量成正比。這個函數應該可以返回一個當前周期內的值或者以LOOKAHEAD_PERIODS
為上限的任意未來周期內的值。add_header(uint256 shard_id, uint256 expected_period_number, bytes32 period_start_prevhash, bytes32 parent_hash, bytes32 transaction_root, address coinbase, bytes32 state_root, bytes32 receipt_root, uint256 number) returns bool
:嘗試處理一個 collation header,成功時返回 true,失敗時返回 false。get_shard_head(uint256 shard_id) returns bytes32
:返回驗證器管理合約內由參數所指定的分片的 header 哈希。
這裏也有一個日誌類型:
CollationAdded(indexed uint256 shard_id, bytes collation_header_bytes, bool is_new_head, uint256 score)
其中的 collation_header_bytes
可以用 vyper 語言來構造:
collation_header_bytes = concat( as_bytes32(shard_id), as_bytes32(expected_period_number), period_start_prevhash, parent_hash, transaction_root, as_bytes32(collation_coinbase), state_root, receipt_root, as_bytes32(collation_number), )
註意:因為 coinbase
和 number
在 vyper 語言中是保留字,所以它們被重命名為 collation_coinbase
和collation_number
。
Collation Header
我們首先以一個有下列內容的 RLP 列表來定義一個“collation header”:
[ shard_id: uint256, expected_period_number: uint256, period_start_prevhash: bytes32, parent_hash: bytes32, transaction_root: bytes32, coinbase: address, state_root: bytes32, receipt_root: bytes32, number: uint256, ]
這裏:
shard_id
分片的ID;expected_period_number
是 collation 希望被包含進的周期序號,這是由period_number = floor(block.number / PERIOD_LENGTH)
計算出來的;period_start_prevhash
前一區塊,即區塊PERIOD_LENGTH * expected_period_number - 1
的區塊哈希(這其實就是希望被包含進的周期起始區塊之前的最後一個區塊的哈希)。分片中使用區塊數據的操作碼(例如 NUMBER 和 DIFFICULTY)會使用這個區塊的數據;只有 COINBASE 操作碼會使用分片的 coinbase;parent_hash
是父 collation 的哈希;transaction_root
是包含了當前 collation 中的所有交易數據的樹(trie)的根哈希;state_root
是分片中當前 collation 之後的新狀態根;(也就是當前 collation 中包含的所有交易執行之後,且記賬收益分配之後得到的狀態樹根節點哈希,譯者註)receipt_root
是收據樹(receipt trie)根哈希;number
是 collation 編號,現在也是分叉選擇規則中的分值;且
當 addHeader(header)
的調用返回 true 時, collation header 有效。驗證器管理合約會在滿足下列條件時這麽做:
shard_id
是 0 到SHARD_COUNT
之間的數值;expected_period_number
與當前周期號相等(即floor(block.number / PERIOD_LENGTH)
)在相同的分片中,一個具有
parent_hash
的 collation 已經被接受;(即當前 collation 的父 collation 已經被接受,譯者註)在相同分片中,當前周期內還沒有一個同樣的 collation 被提交;(也就是檢查要添加的 collation 是否已經添加過了,譯者註)
add_header
函數調用者的地址與get_eligible_proposer(shard_id, expected_period_number)
所返回的地址相同。(即判斷要添加這個 collation 的 proposer 是否是給定分片、給定周期的合法記賬人,譯者註)
當滿足以下條件時, collation 有效: (i) 它的“collation header”有效; (ii) 在 parent_hash
的 state_root
上執行 collation 的結果為給定的 state_root
和 receipt_root
;並且 (iii) 總共使用的 gas 小於等於 COLLATION_GASLIMIT
。
Collation 狀態轉換函數
執行一個 collation 時的狀態轉換處理如下:
按順序執行由
transaction_root
所指定的樹上的每個交易;並且將
COLLATOR_REWARD
的獎勵分配給 coinbase。
getEligibleProposer
的細節
這裏是用Viper寫的一個簡單實現:
def getEligibleProposer(shardId: num, period: num) -> address: assert period >= LOOKAHEAD_PERIODS assert (period - LOOKAHEAD_PERIODS) * PERIOD_LENGTH < block.number assert self.num_validators > 0 h = as_num256( sha3( concat( blockhash((period - LOOKAHEAD_PERIODS) * PERIOD_LENGTH), as_bytes32(shardId) ) ) ) return self.validators[ as_num128( num256_mod( h, as_num256(self.num_validators) ) ) ].addr
無狀態客戶端(Stateless Clients)
當驗證器被要求在一個給定的分片上創建區塊時,一個驗證器僅會被給予數分鐘的通知(準確地說,就是持續LOOKAHEAD_PERIODS * PERIOD_LENGTH
個區塊的通知)。在 Ethereum 1.0 中,創建一個區塊需要為驗證交易而訪問全部的狀態。這裏,我們的目標是避免需要驗證器保留整個系統的狀態(因為這樣就將使運算資源需求變為 O(c^2) 了)。取而代之,我們允許驗證器在僅知曉根狀態(state root)的情況下創建 collation,而將其他責任交給交易發送者,由他們提供“見證數據”(witness data)(也就是 Merkle 分支),以此來驗證交易對賬戶產生影響的“前狀態”(pre-state),並提供足夠的信息來計算交易執行後的“後狀態根”(post-state root)。
(應該註意到,使用非無狀態範式(non-stateless paradigm)來實現分片,理論上是可能的;然而,這需要: (i) 租用存儲空間來保持存儲的有界性;並且 (ii) 驗證器需要使用 O(c) 的時間在一個分片中創建區塊。上述方案避免了對這些犧牲的需求。)
數據格式
我們修改了交易的格式,以使交易必須指定一個 訪問列表 來列舉出它可以訪問的狀態(後邊我們會更精確的描述這點,這裏不妨把它想象為是一個地址列表)。任何在 VM 執行過程中試圖讀寫交易所指定的訪問列表以外的狀態,都會返回一個錯誤。這可以防止這樣的×××:某人發送了一個消耗 500 萬 gas 的隨機執行,然後試圖訪問一個交易發送者和 collator 都沒有見證人的隨機賬戶;也可以防止 collator 包含進像這樣浪費 collator 時間的交易。
交易發送者必須指定“見證人”(witness),這在被簽名的交易體 之外 ,但也被打包進交易。這裏的見證人是一個 Merkle 樹節點的 RLP 編碼的列表(RLP-encoded list),它是由交易在其訪問列表中所指定的狀態的組成部分。這使 collator 僅使用狀態根就可以處理交易。在發布 collation 的時候,collator 也會發送整個 collation 的見證人。
交易打包格式
[ [nonce, acct, data....], # transaction body (see below for specification) [node1, node2, node3....] # witness ]
Collation格式
[ [shard_id, ... , sig], # header [tx1, tx2 ...], # transaction list [node1, node2, node3...] # witness ]
也請參考 ethresearch 上的帖子 無狀態客戶端的概念 。
無狀態客戶端狀態轉換函數
通常,我們可以將傳統的“有狀態”客戶端執行狀態轉換的函數描述為: stf(state, tx) -> state'
(或 stf(state, block) -> state'
)。在無狀態客戶端模型中,節點不保存狀態,所以 apply_transaction
和 apply_block
可以寫為:
apply_block(state_obj, witness, block) -> state_obj', reads, writes
這裏,state_obj
是一個數據元組,包含了狀態根和其他 O(1) 大小的狀態數據(已使用的 gas、receipts、bloom filter 等等);witness
就是見證人;block
就是區塊的余下部分。其返回的輸出是:
一個新的
state_obj
包含了新的狀態根和其他變量;從見證人那裏讀取的對象集合(用於區塊創建);和
為了組成新的狀態樹(state trie)而被創建的一組新的狀態對象。
這使得函數是“單純性的”(pure),僅處理小尺寸對象(small-sized objects)(相反的例子就是現行的以太坊狀態數據,現在已經 數百G字節 ),從而使他們可以方便地在分片中使用。
客戶端邏輯
一個客戶端應該有一個如下格式的配置:
{ validator_address: "0x..." OR null, watching: [list of shard IDs], ...}
如果指定了 validator 地址,那麽客戶端會在主鏈上檢查這個地址是否是有效的 validator。如果是,那麽在每次在主鏈上開始一個新周期時(例如,當 floor(block.number / PERIOD_LENGTH)
變化的時候),客戶端將為所有分片的周期floor(block.number / PERIOD_LENGTH) + LOOKAHEAD_PERIODS
調用 getEligibleProposer
。如果這個調用返回了某個分片 i
的驗證器地址,客戶端會運行算法 CREATE_COLLATION(i)
(參考下文)。
對於 watching
列表中的每個分片 i
,每當一個新 collation header 出現在主鏈上,它就會從分片網絡中下載完整的 collation,並對其進行校驗。它將內部保持追蹤所有有效的 header(這裏的有效性是回溯的,也就是說,一個 header 如果是有效的,那麽他的父 header 也應該是有效的),並且將 head 具有最高得分的分片鏈接受為主分片鏈,同時從創世(genesis)collation 到 head 的所有 collation 都是有效的和可用的。註意,這表示主鏈的重組 和 分片鏈的重組都將影響分片的 head。
逆向匹配候選 head
為了實現監視分片的算法和創建 collation,我們要做的第一件事就是使用下面的算法來按由高到低的順序匹配候選 head。首先,假設存在一個(非單純的、有狀態的)方法 getNextLog()
,它可以取得某個還沒有被匹配的給定分片的最新的CollationAdded
日誌。這可以通過逆向匹配最新的區塊的所有日誌來達成,即從 head 開始,反方向掃描 receipt 中的每個區塊。我們定義一個有狀態的方法 fetch_candidate_head
:
unchecked_logs = [] current_checking_score = Nonedef fetch_candidate_head(): # Try to return a log that has the score that we are checking for, # checking in order of oldest to most recent. for i in range(len(unchecked_logs)-1, -1, -1): if unchecked_logs[i].score == current_checking_score: return unchecked_logs.pop(i) # If no further recorded but unchecked logs exist, go to the next # isNewHead = true log while 1: unchecked_logs.append(getNextLog()) if unchecked_logs[-1].isNewHead is True: break o = unchecked_logs.pop() current_checking_score = o.score return o
用普通的語言重新表述,這裏就是反向掃描 CollationAdded
日誌(對正確的分片),直到獲得一個 isNewHead = True
的日誌。首先返回那個日誌,然後用從老到新的順序返回所有與那個日誌分值相同的且 isNewHead = False
的所有最新日誌。隨後到前一個 isNewHead = True
的日誌(即確保分值會比前一個 NewHead 低,但比其他人高),再到這個日誌之後的所有具有該分值的最新 collation,而後到第四個。
這就是說這個算法確保了首先按照分值的由高到低、然後按照從老到新的順序檢查潛在的候選 head。
例如,假定 CollationAdded
日誌具有以下哈希和分值:
... 10 11 12 11 13 14 15 11 12 13 14 12 13 14 15 16 17 18 19 16
那麽,isNewHead
將被按如下賦值:
... T T T F T T T F F F F F F F F T T T T F
如果我們將 collation 命名為 A1..A5、 B1..B5、 C1..C5 和 D1..D5 ,那麽精確的返回順序將是:
D4 D3 D2 D1 D5 B2 C5 B1 C1 C4 A5 B5 C3 A3 B4 C2 A2 A4 B3 A1
監視一個分片
如果一個客戶端在監視一個分片,它應該去嘗試下載和校驗那個分片中的所有 collation(檢查任何給定的 collation,僅當其父 collation 已經被校驗過)。要取得 head,需要持續調用 fetch_candidate_head()
,直到它返回一個被校驗過的 collation,也就是 head。通常情況下它會立即返回一個有效的 collation,或者最多因為網絡延遲或小規模的×××導致生成過幾個無效或者不可用的 collation,而需要稍微嘗試幾次。只有在遭遇一個真正長時間運行的 51% ×××時,這個算法會惡化到 O(N) 的時間。
CREATE_COLLATION
這個處理由三部分組成,第一部分可以被叫做 GUESS_HEAD(shard_id)
,其示意代碼如下:
# Download a single collation and check if it is valid or invalid (memoized)validity_cache = {}def memoized_fetch_and_verify_collation(c): if c.hash not in validity_cache: validity_cache[c.hash] = fetch_and_verify_collation(c) return validity_cache[c.hash]def main(shard_id): head = None while 1: head = fetch_candidate_head(shard_id) c = head while 1: if not memoized_fetch_and_verify_collation(c): break c = get_parent(c)
fetch_and_verify_collation(c)
包含了從分片網絡取得 c
的所有數據(包括見證人信息)並校驗它們的處理。上述算法等價於“選取最長有效鏈,盡可能的檢查有效性,如果其數據無效,則轉而處理已知的次長鏈”。這個算法應該僅當校驗器執行超時時才會停止,這就是該創建 collation 的時候了。每個 fetch_and_verify_collation
的執行都應該返回一個“寫集合”(參考上文的“無狀態客戶端”那節)。保存所有這些“寫集合”,把它們組合在一起,就構成了 recent_trie_nodes_db
。
我們現在可以來定義 UPDATE_WITNESS(tx, recent_trie_nodes_db)
了。在運行 GUESS_HEAD
的過程中,某節點會接收到一些交易。當它要把交易(嘗試)包含進 collation 的時候,這個算法需要先運行交易。假定交易有一個訪問列表 [A1 ... An]
和一個見證人 W
,對於每個 Ai
使用當前狀態樹的根取得 Ai
的 Merkle 分支,使用 recent_trie_nodes_db
和 W
一起作為數據庫。如果原始的 W
正確,並且交易不是在客戶端做這些檢查之前就已經發出的話,那麽這個取得 Merkle 分支的操作總是會成功的。在將交易包含進 collation 之後,狀態變動的“寫集合”也應該被添加到 recent_trie_nodes_db
中。
下面我們就要來 CREATE_COLLATION
了。作為例證,這裏是這個方法中可能的、收集交易信息處理的完整示意代碼。
# Sort by descending order of gaspricetxpool = sorted(copy(available_transactions), key=-tx.gasprice) collation = new Collation(...)while len(txpool) > 0: # Remove txs that ask for too much gas i = 0 while i < len(txpool): if txpool[i].startgas > GASLIMIT - collation.gasused: txpool.pop(i) else: i += 1 tx = copy.deepcopy(txpool[0]) tx.witness = UPDATE_WITNESS(tx.witness, recent_trie_nodes_db) # Try to add the transaction, discard if it fails success, reads, writes = add_transaction(collation, tx) recent_trie_nodes_db = union(recent_trie_nodes_db, writes) txpool.pop(0)
最後,有一個額外的步驟,最終確定collation(給 collator 發放獎勵,也就是 COLLATOR_REWARD
的 ETH)。這需要詢問網絡以獲得 collator 賬戶的 Merkle 分支。當得到網絡對此的回應之後,發放獎勵之後的“後狀態根”(post-state root)就可以被計算出來了。Collator 就可以用 (header, txs, witness) 的形式打包這個 collation 了。這裏,見證人(witness)就是由所有交易的見證和 collator 賬戶的 Merkle 分支組成的。
協議變動
交易的格式
交易的格式現在將變為(註意這裏包含了 賬戶抽象 和 讀/寫列表 ):
[ chain_id, # 1 on mainnet shard_id, # the shard the transaction goes onto target, # account the tx goes to data, # transaction data start_gas, # starting gas gasprice, # gasprice access_list, # access list (see below for specification) code # initcode of the target (for account creation) ]
完成交易的處理過程也將變為:
校驗
chain_id
和shard_id
是正確的;從
target
賬戶中減去start_gas * gasprice
wei;檢查目標
account
是否有代碼,如果沒有,校驗sha3(code)[12:] == target
;如果目標賬戶為空,使用
code
作為初始代碼,在target
中執行一個合約的創建;否則,跳過這個步驟;執行一個消息,使用:剩余的氣作為 startgas,
target
作為地址,0xff...ff 作為發送者,0 作為 value,以及當前交易的data
作為 data;如果上述任何一個執行失敗,並且消耗了 <= 200000 的 gas(即
start_gas - remaining_gas <= 200000
),那麽這個交易是無效的;否則,
remaining_gas * gasprice
將被退還,已支付的交易費將被添加到一個交易費計數(註意:交易費 不會 被直接加入 coinbase 余額,而是在區塊最終確認時立即添加)。
雙層樹(two-layer trie)重新設計
現存的賬戶模型將被替換為:在一個單層樹中收錄進所有賬戶的余額、代碼和存儲。具體來講,這個映射為:
賬戶 X 的余額:
sha3(X) ++ 0x00
賬戶 X 的代碼:
sha3(X) ++ 0x01
賬戶 X 的存儲鍵值 K:
sha3(X) ++ 0x02 ++ K
請參考 ethresearch 上的帖子 單層樹中的雙層賬戶樹 。
此外,這個樹現在有了一個新的二進制設計: https://github.com/ethereum/research/tree/master/trie_research 。
訪問列表
一個賬號的訪問列表看起來大概像這樣:
[[address, prefix1, prefix2...], [address, prefix1, prefix2...], ...]
從根本上說,這意味著:“這個交易可以訪問這裏給定的所有賬戶的余額和代碼,並且賬戶列表中給出的每個賬戶的前綴中至少有一個是該賬戶存儲的一個鍵的前綴”。我們可以將其轉換為“前綴列表格式”,基本上就是一個賬戶的內部存儲樹(storage trie)的前綴列表(參考前面的章節):
def to_prefix_list_form(access_list): o = [] for obj in access_list: addr, storage_prefixes = obj[0], obj[1:] o.append(sha3(addr) + b'\x00') o.append(sha3(addr) + b'\x01') for prefix in storage_prefixes: o.append(sha3(addr) + b'\x02' + prefix) return o
我們可以通過取得交易的訪問列表,將其變換為前綴列表格式,然後對前綴列表中的每個前綴執行 get_witness_for_prefix
,並將這些調用結果組成一個集合;以此來計算某個交易見證人。
get_witness_for_prefix
會返回樹節點中可以訪問以指定前綴開始的所有鍵值的一個最小集合。參考這裏的實現:https://github.com/ethereum/research/blob/b0de8d352f6236c9fa2244fed871546fabb016d1/trie_research/new_bintrie.py#L250 。
在 EVM 中,任何嘗試對訪問列表以外的賬戶的訪問(直接調用、SLOAD 或者通過類似 BALANCE
或 EXTCODECOPY
的 opcode 的操作)都會導致運行這種代碼的 EVM 實例拋出異常。
請參考 ethresearch 上的帖子 賬戶讀/寫列表 。
Gas 的消耗
待定。
後續的階段
通過分離區塊 proposer 和 collator,我們實現了二次方擴展,這是一種快速、不徹底的中等安全權益證明分片,以此在不對協議或軟件架構做太多更改的情況下增加了大約 100 倍的吞吐量。這也被用來作為一個完整的二次方分片多階段計劃的第一階段,後續階段大致如下:
第二階段(two-way pegging,即雙向限定) :參考
USED_RECEIPT_STORE
章節,仍在撰寫;第三階段,選項a :將 collation header 作為 uncle 加入,而不是交易;
第三階段,選項b :將 collation header 加入一個數組,數組中的元素
i
必須為分片i
的 collation header 或者空字符串,並且額外的數據必須為這個數組的哈希(軟分叉);第四階段(tight coupling,即緊耦合) :如果區塊指向無效或不可用的 collation,那麽區塊也將變為無效;增加數據可用性證明。
翻譯後記
本文最初是我應以太坊中文社區(Ethfans.org)之邀做的翻譯稿,譯文於 2018/1/14 首發於【以太坊愛好者】公眾號:幹貨 | V神·以太坊上的分片 。此版本對應的原文 github commit 版本:0d0c74d41dec9ca55d1ff077400229ad524ce10a,更新時間 2018/1/5。
以上正文是我根據最新版原文修訂之後的版本,對應的原文 github commit 版本:8a8fbe298e0490e3acbe20f496fb2aeba59b8a41,更新時間 2018/5/16。
【楊鎮】【中譯修訂版】以太坊的分片技術官方介紹