上一篇主要講解了MPT的基本原理,這篇分析一下以太坊數據存儲相關的流程。
首先介紹一下MPT的存儲流程,然後依次分析StateDB、Transactions、Receipts的存儲,這3棵樹的Merkle Root最終會存儲到區塊Header中的Root、TxHash、ReceiptHash字段。
1.MPT存儲流程
從圖中可以看出,MPT的存儲涉及3種編碼方式:
- KeyBytes編碼
- Hex編碼
- Compact編碼
在完成Compact編碼後,會通過摺疊操作把子結點替換成子結點的hash值,然後以鍵值對的形式將所有結點存儲到數據庫中。下面詳細介紹上面3中編碼方式。
1.1 KeyBytes編碼
即原始關鍵字,比如圖中的0x811344、0x879337等。每個字節中包含2個nibble(半字節,4 bits),每個nibble的數值範圍時0x0~0xF。
1.2 Hex編碼
由於我們需要以nibble爲單位進行編碼並插入MPT,因此需要把一個字節拆分成兩個,轉換爲Hex編碼。
編碼轉換是在Trie.TryUpdate()中觸發的,具體轉換代碼參見trie/encoding.go:
func keybytesToHex(str []byte) []byte {
l := len(str)*2 + 1
var nibbles = make([]byte, l)
for i, b := range str {
nibbles[i*2] = b / 16
nibbles[i*2+1] = b % 16
}
nibbles[l-1] = 16
return nibbles
}
1.3 Compact編碼
當我們需要把內存中MPT存儲到數據庫中時,還需要再把兩個字節合併爲一個字節進行存儲,這時候會碰到2個問題:
- 關鍵字長度爲奇數,有一個字節無法合併
- 需要區分結點是擴展結點還是葉子結點
爲了解決這個問題,以太坊設計了一種Compact編碼方式,具體規則如下:
- 擴展結點,關鍵字長度爲偶數,前面加00前綴
- 擴展結點,關鍵字長度爲奇數,前面加1前綴(前綴和第1個字節合併爲一個字節)
- 葉子結點,關鍵字長度爲偶數,前面加20前綴(因爲是Big Endian)
- 葉子結點,關鍵字長度爲奇數,前面加3前綴(前綴和第1個字節合併爲一個字節)
編碼轉換是在Trie.Commit()時觸發的,具體調用在hasher.hashChildren()函數中,轉換代碼參見trie/encoding.go:
func hexToCompact(hex []byte) []byte {
terminator := byte(0)
if hasTerm(hex) {
terminator = 1
hex = hex[:len(hex)-1]
}
buf := make([]byte, len(hex)/2+1)
buf[0] = terminator << 5 // the flag byte
if len(hex)&1 == 1 {
buf[0] |= 1 << 4 // odd flag
buf[0] |= hex[0] // first nibble is contained in the first byte
hex = hex[1:]
}
decodeNibbles(hex, buf[1:])
return buf
}
2. StateDB的存儲
StateDB中存儲了很多stateObject,而每一個stateObject則代表了一個以太坊賬戶,包含了賬戶的地址、餘額、nonce、合約代碼hash等狀態信息。所有賬戶的當前狀態在以太坊中被稱爲“世界狀態”,在每次挖出或者接收到新區塊時需要更新世界狀態。
爲了能夠快速檢索和更新賬戶狀態,StateDB採用了兩級緩存機制,參見下圖:
- 第一級緩存以map的形式存儲stateObject
- 第二級緩存以MPT的形式存儲
- 第三級就是LevelDB上的持久化存儲
當上一級緩存中沒有所需的數據時,會從下一級緩存或者數據庫中進行加載。
我們可以看一下StateDB具體實現的UML圖:
可以看到,一共封裝了3個包:state,trie,ethdb。如果按接口類型來分,主要分爲Trie和Database兩種接口。Trie接口主要用於操作內存中的MPT,而Database接口主要用於操作LevelDB,做持久化存儲。StateDB中同時包含了這兩種接口。
查看StateDB和stateObject的定義可以發現,這兩種類型內部各有一個Trie,那麼這兩個Trie裏存儲的什麼內容呢?請看下圖:
StateDB裏的Trie以賬戶地址爲key,存儲經過RLP編碼後的stateObject。
stateObject裏的Trie也被稱爲storage trie,存儲的是智能合約執行後修改的變量值,細節可以參見之前的一篇文章:以太坊stateObject中Storage存儲內容的探究
這兩個Trie是怎麼關聯起來的呢?實際上stateObject內部有一個Account類型的字段,我們看一下它的類型定義:
type Account struct {
Nonce uint64
Balance *big.Int
Root common.Hash // merkle root of the storage trie
CodeHash []byte
}
看到了吧,Account類型內部有一個Root字段,記錄的正是對應的storage trie的merkle root。
3. Transactions的存儲
從圖中可以看出,MPT中是以交易在區塊中的索引的RLP編碼作爲key,存儲交易數據的RLP編碼。
事實上交易在LeveDB中並不是單獨存儲的,而是存儲在區塊的Body中。在往LeveDB中存儲不同類型的鍵值對時,會在關鍵字中添加不同的前綴予以區分,這些前綴的定義在core/rawdb/schema.go中:
// Data item prefixes (use single byte to avoid mixing data types, avoid `i`, used for indexes).
headerPrefix = []byte("h") // headerPrefix + num (uint64 big endian) + hash -> header
headerTDSuffix = []byte("t") // headerPrefix + num (uint64 big endian) + hash + headerTDSuffix -> td
headerHashSuffix = []byte("n") // headerPrefix + num (uint64 big endian) + headerHashSuffix -> hash
headerNumberPrefix = []byte("H") // headerNumberPrefix + hash -> num (uint64 big endian)
blockBodyPrefix = []byte("b") // blockBodyPrefix + num (uint64 big endian) + hash -> block body
blockReceiptsPrefix = []byte("r") // blockReceiptsPrefix + num (uint64 big endian) + hash -> block receipts
txLookupPrefix = []byte("l") // txLookupPrefix + hash -> transaction/receipt lookup metadata
bloomBitsPrefix = []byte("B") // bloomBitsPrefix + bit (uint16 big endian) + section (uint64 big endian) + hash -> bloom bits
preimagePrefix = []byte("secure-key-") // preimagePrefix + hash -> preimage
configPrefix = []byte("ethereum-config-") // config prefix for the db
因此,以b + block index + block hash作爲關鍵字就可以唯一確定某個區塊的Body所在的位置。
另外,爲了能夠快速查詢某筆交易的數據,在數據庫中還存儲了每筆交易的索引信息,稱爲TxLookupEntry。TxLookupEntry中包含了block index和block hash用於定位區塊Body,同時還包含了該筆交易在區塊Body中的索引位置。
4. Receipts的存儲
交易回執的存儲和交易類似,區別是交易回執是單獨存儲到LevelDB中的,以r爲前綴。
另外,由於交易回執和交易是一一對應的,因此也可以通過TxLookupEntry快速定位交易回執所在的位置,加速交易回執的查找。
更多文章歡迎關注“鑫鑫點燈”專欄:https://blog.csdn.net/turkeycock/article/category/7669858