· 能快速的查詢 key 所對應的 value 資料。
在以太坊中,MPT被定義為四種不同型別的節點:fullNode, shortNode, valueNode, hashNode:
valueNode 儲存具體的 value 資料,它的 key 是從 root 到此節點的路徑上所有 key 的總和。
hashNode 儲存一個資料庫中其他節點的雜湊用作索引。
shortNode 是 MPT 的枝幹節點之一。"Key" 欄位儲存當前 shortNode 之後所有 node 共同的一段字首 key。"Val" 欄位儲存一個後續的節點。如果從根節點到當前節點所組成的 key 字首已經鍵值對結構中的"鍵"完全吻合,且沒有其它符合此字首的鍵值對存在,則後續節點為一個 valueNode。如果滿足此字首 key 的鍵值對組合多於一個,則後續儲存一個 fullNode。
fullNode 是 MPT 的枝幹節點之一。和 shortNode 不同的是,fullNode 沒有 Key 欄位,只有一個 node 陣列 "Children"。fullNode 主要作用是儲存有共同字首 key 但是在後續key值產生分歧的所有鍵值對。Children 陣列中的每一個元素都表示一個字首符號(具體可以參考下面的例子),用不同的字首符號來分隔不同的鍵值對。以太坊中 Children 陣列的長度為 17,因為涉及具體的編碼方式,所以不在這裡展開講為什麼這麼設定。可以簡單將 17 理解為 16 + 1,16 進位制加上本身的 value 值。
具體例子
光看字面比較抽象,我們看看具體的例子。假設現在我們有 6 個鍵值對:
他們在 MPT 中就會以如下的方式儲存:
可以看到,因為存在字首分歧,所以 Root 節點是 fullNode。後續節點分成兩派,key 以 c 為字首和以 d 為字首。我們關注以 d 為字首的三個鍵值對,它們的 key 分別是 "dog"、"doge" 和 "doggie"。除了開頭的 d 以外,它們還有個共同的字首og。因此 Root 節點往下引申一個 ShortNode 2。ShortNode 2 的 key 就是 "og", 又因為除了og 其它部分都存在分歧,所以 ShortNode 2 的 Val 欄位儲存的是一個FullNode(參考上面對 shortNode 的解釋)。
上面提到過,fullNode 存在17個元素,可以簡單理解為 16 進位制加上本身 value 值構成 17 個元素。在我們的例子中,此處的 FullNode 2 已經構成了 dog 這個字首 key(根節點的 d 加上後續 shortNode 的 og),已經完全吻合 dog - value4 這個鍵值對中的 key,所以 FullNode 2 的末尾便是一個 ValueNode,ValueNode 的值是 value4。
FullNode 2 的字首 key 再加上其內部的 e 能夠構成字首 key "doge",所以 e 位置下面直接引申一個 ValueNode,其值便是 doge - value5 這鍵值對內的 value5。
FullNode 2 的字首 key 加上其內部的 g 組成 key 字首 dogg。剩下的符合dogg 字首的只有doggie - value6。所以 g 下面引申一個 ShortNode 5,其節點的Key欄位為 ie,Val 欄位為一個值為 value6 的 ValueNode。另外三個字首為 c 的鍵值對則同理可以得到圖中的結構。
MPT 的編碼
上面的例子中所有的資料都是沒有經過編碼的,這會造成什麼問題呢?之前提過 FullNode 的每個 Children 元素都代表一個字首符號,如果不對key進行編碼則很難將這個字首符號規範化,使得字首的種類過多無法確定 Children 陣列的長度。
為了解決這個問題,以太坊使用 Hex 編碼對所有鍵值對的key進行編碼。編碼後所有的key就都由 [0,1,2,3,4,5,6,7,8,9,a,b,c,d,e,f] 這 16 個十六進位制符號組成。編碼後,FullNode 的 Children 就可以分割成 16 份,每份對應各自的字首符號,這也是為什麼以太坊 FullNode 的 Children 陣列長度是 17 ( 16 個子節點 + 本身節點 )。
舉個例子:假設鍵值對doggie - value6經過編碼後變為 0x646f67676965 - value6,那麼在 Root 節點中這個鍵值對就會被分到 6 這個子節點下面,也就是 Children 陣列的第七個元素中。
StateDB
StateDB 作為賬戶狀態的更新以及查詢入口,基於具體邏輯的方法呼叫。比如賬戶餘額的更新,nonce 的查詢等。同時,它還肩負著所有合約資料的儲存查詢。為了支援資料的快速查詢以及區塊的回滾操作,StateDB 使用 MPT 結構作為其下層的儲存方式。
先來看看 StateDB 的資料結構:
db - 用於連線下層 trie 資料庫的欄位。本身不儲存資料,為了調取 TrieDB 存在。
trie - 當前所有賬戶資訊構建的 MPT 結構。
stateObjects - 儲存快取的賬戶 state 資訊。
stateObjectsDirty - 標記被更改的state物件,用於後續的 commit 操作。
StateDB透過操作和查詢stateObjects中快取的state物件來完成業務邏輯的執行。如果stateObjects中找不到需要操作的物件,則透過createObject(addr common.Address) 方法從trie欄位的MPT中讀取對應的state物件並放入快取中。
stateObject
stateObject 是以太坊中用於儲存每個賬戶資訊的資料結構:
data 欄位儲存賬戶的餘額,Nonce等資訊。同時,在後續落盤 MPT 的過程中,主要儲存的內容就是經過編碼序列化後的 data 欄位。
這裡值得特別提一點的是 stateObject 的 trie 欄位和 data 中的 Root 欄位。和 StateDB 中的 trie 不同,此處的 trie 是用來儲存此 state 地址下的合約資料的。每個地址賬戶都會有屬於自己的一棵 trie 用來做合約儲存。data 中的 Root 則是這個 trie 的根節點的雜湊。在將 state 資料存入 StateDB 的 trie 的過程中會帶上這個 Root,這麼做的好處主要是能將合約資料和世界狀態資料繫結在一起,增加關聯性和安全性,同時,在後續的回滾操作中能透過世界狀態 trie 的 Root 還原出包括合約資料的所有狀態。
由於篇幅有限,上篇就先只介紹 MPT 和 StateDB 本身。在下篇,我們將重點結合上圖講解 StateDB 和 MPT 兩者的工作結構,以及不同型別 Transaction 執行過程中 StateDB 和 MPT 的邏輯流程。
整體結構
在以太坊中,資料的最上層就是上圖中的StateDB。StateDB負責將資料做最初步的記錄。往下一層是Trie層,Trie負責將所有資料結構化,方便後續的儲存查詢回滾等操作。Trie分兩種,State Trie和Storage Trie。前者是世界狀態樹,記錄了所有賬號的餘額nonce等基本資訊。後者用於記錄各種合約資料。世界狀態樹只有一棵,合約狀態樹有很多棵,因為每個合約都有棵屬於自己的合約狀態樹。
Trie再往後就是TrieDB,TrieDB將Trie中的節點序列化後儲存在記憶體中。TrieDB的主要作用是做為最終插入硬碟資料之前的快取層。整個結構中最後一環就是最終硬碟上的資料庫。
以太坊中,StateDB的主要資料結構是StateObject。每個StateObject代表一個地址的狀態。在StateObject中主要有兩個結構,data 和 dirtyStorage。data 用於儲存賬號的基本資訊如餘額nonce等,dirtyStorage用於儲存該賬號地址下的合約的資料。具體實現可以參考以太坊 core/state/ 目錄下的 state_object.go檔案。
狀態更更新流程
1. 當收到一個區塊時,以太坊會根據區塊內body的內容逐條執行裡面的交易。在執行過程中,會確認此交易是否呼叫合約。
2. 如果是合約相關的交易,則建立一個新的EVM物件並根據交易中的Data欄位的執行碼執行合約。
3. 執行期間所有的合約狀態更改都會記錄到合約的StateObject 的 dirtyStorage 中。
4. 執行完成後更新 From,To 倆地址的StateObject 的 data 欄位。主要更新 Balance 和 Nonce。
到第四步執行完,所有此交易相關的狀態更改就都記錄到 StateDB 中了。之後在所有的區塊交易都執行完後,StateDB就儲存了了此區塊造成的所有狀態更新。
執行完所有區塊交易後,程式會呼叫StateDB.Commit() 方法,將StateDB中的髒狀態更更新到Trie上:
需要注意的是,trie 更新並不會從0開始重新構建一個新的trie。因為trie的資料結構特點只需要將更改路徑上的節點更新即可。如下圖所示:
左邊是原來的trie,右邊是更新後的trie。假設新的資料不涉及節點2和節點4,那麼只需要在更節點中將其中一個葉子節點設定成節點2即可。同時原來的trie中不被繼續使用的節點不會被刪掉,而是繼續存在db中,方便後續回滾操作。
透過呼叫 trie.Commit() 將新產生的對映到 TrieDB 的過程中,首先會將新的節點轉化成cachedNode。
在我們的例子中,就是節點 1,3,5,6,7。cachedNode 儲存了節點的本體以及它的葉子節點
hash,用於最終落盤時的序列化。此處具體可以參考以太坊 trie 目錄下的 database.go 檔案。
StateDB.Commit() 完成後,程式會執行最後的落盤步驟,將 TrieDB 中的髒節點寫入磁碟資料庫中:
這一步就是呼叫 TrieDB.Commit() ,將所有的髒節點(1,3,5,6,7)序列列化成 []byte 結構,然後用hash作為 key,[]byte 結構做為 value,儲存到最終的磁碟資料中。
到這一步落盤步驟完成後,整個狀態資料的更新就算完成了。
回滾
我們前面提到過,更新trie結構時,舊trie中不需要的節點並不會被從db中刪除。所以在回滾時,我們只需要知道要回滾到的狀態樹的根節點,然後從磁碟資料庫中以此根節點為源頭,重新載入出這個根節點對應的整棵樹就可以還原出當時的狀態了。在以太坊中,這個根節點的hash都會存在每個區塊的header中,所以假設我們要回滾到區塊高度為 n 的狀態,那麼只需要讀取出區塊 n 的 header 中的root hash,然後用這個hash為源頭從資料庫還原整棵樹就 ok 了。