死磕以太坊源碼分析之MPT樹-上html
前綴樹(又稱字典樹),一般來講,一個前綴樹是用來存儲字符串
的。前綴樹的每個節點表明一個字符串
(前綴
)。每個節點會有多個子節點,通往不一樣子節點的路徑上有着不一樣的字符。子節點表明的字符串是由節點自己的原始字符串
,以及通往該子節點路徑上全部的字符
組成的。以下圖所示:git
Trie的結點看上去是這樣子的:github
[ [Ia, Ib, … I*], value]算法
其中 [Ia, Ib, ... I*]
在本文中咱們將其稱爲結點的 索引數組 ,它以 key 中的下一個字符爲索引,每一個元素I*
指向對應的子結點。 value
則表明從根節點到當前結點的路徑組成的key所對應的值。若是不存在這樣一個 key,則 value 的值爲空。數據庫
前綴樹的性質:數組
每一層節點上面的值都不相同;緩存
根節點不存儲值;除根節點外每個節點都只包含一個字符,表明的字符串是由節點自己的原始字符串
,以及通往該子節點路徑上全部的字符
。安全
前綴樹的查找效率是$O(m)$,$m$爲所查找節點的長度,而哈希表的查找效率爲$O(1)$。且一次查找會有 m 次 IO
開銷,相比於直接查找,不管是速率、仍是對磁盤的壓力都比較大。數據結構
當存在一個節點,其內容很長(如一串很長的字符串),當樹中沒有與他相同前綴的分支時,爲了存儲該節點,須要建立許多非葉子節點來構建根節點到該節點間的路徑,形成了存儲空間的浪費。app
基數樹(也叫基數特里樹或壓縮前綴樹)是一種數據結構,是一種更節省空間的前綴樹,其中做爲惟一子節點的每一個節點都與其父節點合併,邊既能夠表示爲元素序列又能夠表示爲單個元素。 所以每一個內部節點的子節點數最多爲基數樹的基數 r ,其中 r 爲正整數, x 爲 2 的冪, x≥1 ,這使得基數樹更適用於對於較小的集合(尤爲是字符串很長的狀況下)和有很長相同前綴的字符串集合。
圖中能夠很容易看出數中所存儲的鍵值對:
Merkle樹看起來很是像二叉樹,其葉子節點上的值一般爲數據塊的哈希值,而非葉子節點上的值,因此有時候Merkle tree也表示爲Hash tree,以下圖所示:
在構造Merkle
樹時,首先要計算數據塊的哈希值,一般,選用SHA-256
等哈希算法。但若是僅僅防止數據不是蓄意的損壞或篡改,能夠改用一些安全性低但效率高的校驗和算法,如CRC
。而後將數據塊計算的哈希值兩兩配對(若是是奇數個數,最後一個本身與本身配對),計算上一層哈希,再重複這個步驟,一直到計算出根哈希值。
因此咱們能夠簡單總結出merkle Tree 有如下幾個性質:
在以太坊的Trie模塊中,key和value都是[]byte類型。若是要使用其它類型,須要將其轉換成[]byte類型(好比使用rlp進行轉換)。
Nibble :是 key 的基本單元,是一個四元組(四個 bit 位的組合例如二進制表達的 0010 就是一個四元組)
在Trie模塊對外提供的接口中,key類型是[]byte。但在內部實現裏,將key中的每一個字節按高4位和低4位拆分紅了兩個字節。好比你傳入的key是:
[0x1a, 0x2b, 0x3c, 0x4d]
Trie內部將這個key拆分紅:
[0x1, 0xa, 0x2, 0xb, 0x3, 0xc, 0x4, 0xd]
Trie內部的編碼中將拆分後的每個字節稱爲 nibble
若是使用一個完整的 byte 做爲 key 的最小單位,那麼前文提到的索引數組的大小應該是 256(byte做爲數組的索引,最大值爲255,最小值爲0)。而索引數組的每一個元素都是一個 32 字節的哈希,這樣每一個結點要佔用大量的空間。而且索引數組中的元素多數狀況下是空的,不指向任何結點。所以這種實現方法佔用大量空間而不使用。以太坊的改進方法,能夠將索引數組的大小降爲 16(4個bit的最大值爲0xF,最小值爲 0),所以大大減小空間的浪費。
前綴樹和merkle樹存在明顯的侷限性,因此以太坊爲MPT樹新增了幾種不一樣類型的樹節點,經過針對不一樣節點不一樣操做來解決效率以及存儲上的問題。
此外,爲了將 MPT 樹存儲到數據庫中,同時還能夠把 MPT 樹從數據庫中恢復出來,對於 Extension 和 Leaf 的節點類型作了特殊的定義:若是是一個擴展節點,那麼前綴爲 0,這個 0 加在 key 前面。若是是一個葉子節點,那麼前綴就是 1。同時對key 的長度就奇偶類型也作了設定,若是是奇數長度則標示 1,若是是偶數長度則標示 0。
State Trie
區塊頭中的狀態樹
Receipts Trie
區塊頭中的收據樹
Storage Trie
存儲樹
這兩個區塊頭中,state root
、tx root
、 receipt root
分別存儲了這三棵樹的樹根,第二個區塊顯示了當帳號 17 5的數據變動(27 -> 45)的時候,只須要存儲跟這個帳號相關的部分數據,並且老的區塊中的數據仍是能夠正常訪問。
三種編碼方式分別爲:
Raw編碼
Raw編碼就是原生的key值,不作任何改變。這種編碼方式的key,是MPT對外提供接口的默認編碼方式。
例如一條key爲「cat」,value爲「dog」的數據項,其Raw編碼就是['c', 'a', 't'],換成ASCII表示方式就是[63, 61, 74]
Hex編碼
Hex編碼用於對內存中MPT樹節點key進行編碼.
爲了減小分支節點孩子的個數,將數據 key 進行半字節拆解而成。即依次將 key[0],key[1],…,key[n] 分別進行半字節拆分紅兩個數,再依次存放在長度爲 len(key)+1 的數組中。 並在數組末尾寫入終止符 16
。算法以下:
半字節,在計算機中,一般將8位二進制數稱爲字節,而把4位二進制數稱爲半字節。 高四位和低四位,這裏的「位」是針對二進制來講的。好比數字 250 的二進制數爲 11111010,則高四位是左邊的 1111,低四位是右邊的 1010。
從Raw編碼向Hex編碼的轉換規則是:
0x10
表示結束例如:字符串 「romane」 的 bytes 是 [114 111 109 97 110 101]
,在 HEX 編碼時將其依次處理:
i | key[i] | key[i]二進制 | nibbles[i*2]=高四位 | nibbles[i*2+1]=低四位 |
---|---|---|---|---|
0 | 114 | 01110010 | 0111= 7 | 0010= 2 |
1 | 111 | 01101111 | 0110=6 | 1111=15 |
2 | 109 | 01101101 | 0110=6 | 1101=13 |
3 | 97 | 01100001 | 0110=6 | 0001=1 |
4 | 110 | 01101110 | 0110=6 | 1110=14 |
5 | 101 | 01100101 | 0110=6 | 0101=5 |
最終獲得 Hex(「romane」) = [7 2 6 15 6 13 6 1 6 14 6 5 16]
// 源碼實現 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 // 最後一位存入標示符 表明是hex編碼 return nibbles }
Hex-Prefix編碼
數學公式定義:
Hex-Prefix 編碼是一種任意量的半字節轉換爲數組的有效方式,還能夠在存入一個標識符來區分不一樣節點類型。 所以 HP 編碼是在由一個標識符前綴和半字節轉換爲數組的兩部分組成。存入到數據庫中存在節點 Key 的只有擴展節點和葉子節點,所以 HP 只用於區分擴展節點和葉子節點,不涉及無節點 key 的分支節點。其編碼規則以下圖:
前綴標識符由兩部分組成:節點類型和奇偶標識,並存儲在編碼後字節的第一個半字節中。 0 表示擴展節點類型,1 表示葉子節點,偶爲 0,奇爲 1。最終能夠獲得惟一標識的前綴標識:
當偶長度時,第一個字節的低四位用0
填充,當是奇長度時,則將 key[0] 存放在第一個字節的低四位中,這樣 HP 編碼結果始終是偶長度。 這裏爲何要區分節點 key 長度的奇偶呢?這是由於,半字節 1
和 01
在轉換爲 bytes 格式時都成爲<01>
,沒法區分二者。
例如,上圖 「以太坊 MPT 樹的哈希計算」中的控制節點1的key 爲 [ 7 2 6 f 6 d]
,由於是偶長度,則 HP[0]= (00000000) =0,H[1:]= 解碼半字節(key)。 而節點 3 的 key 爲 [1 6 e 6 5]
,爲奇長度,則 HP[0]= (0001 0001)=17。
HP編碼的規則以下:
十六進制前綴編碼至關於一個逆向的過程,好比輸入的是[6 2 6 15 6 2 16],
根據第一個規則去掉終止符16。根據第二個規則key前補一個四元組,從右往左第一位爲1表示葉子節點,
從右往左第0位若是後面key的長度爲偶數設置爲0,奇數長度設置爲1,那麼四元組0010就是2。
根據第三個規則,添加一個全0的補在後面,那麼就是20.根據第三個規則內容壓縮合並,那麼結果就是[0x20 0x62 0x6f 0x62]
HP 編碼源碼實現:
func hexToCompact(hex []byte) []byte { terminator := byte(0) //初始化一個值爲0的byte,它就是咱們上面公式中提到的t if hasTerm(hex) { //驗證hex有後綴編碼, terminator = 1 //hex編碼有後綴,則t=1 hex = hex[:len(hex)-1] //此處只是去掉後綴部分的hex編碼 } ////Compact開闢的空間長度爲hex編碼的一半再加1,這個1對應的空間是Compact的前綴 buf := make([]byte, len(hex)/2+1) ////這一階段的buf[0]能夠理解爲公式中的16*f(t) buf[0] = terminator << 5 // the flag byte if len(hex)&1 == 1 { //hex 長度爲奇數,則邏輯上說明hex有前綴 buf[0] |= 1 << 4 ////這一階段的buf[0]能夠理解爲公式中的16*(f(t)+1) buf[0] |= hex[0] // first nibble is contained in the first byte hex = hex[1:] //此時獲取的hex編碼無前綴無後綴 } decodeNibbles(hex, buf[1:]) //將hex編碼映射到compact編碼中 return buf //返回compact編碼 }
以上三種編碼方式的轉換關係爲:
以下圖:
以上介紹的MPT樹,能夠用來存儲內容爲任何長度的key-value
數據項。假若數據項的key
長度沒有限制時,當樹中維護的數據量較大時,仍然會形成整棵樹的深度變得愈來愈深,會形成如下影響:
SLOAD
指令讀取該樹節點的內容,形成系統執行效率極度降低;爲了解決以上問題,以太坊對MPT再進行了一次封裝,對數據項的key進行了一次哈希計算,所以最終做爲參數傳入到MPT接口的數據項實際上是(sha3(key), value)
優點:
劣勢:
sha3(key)
與key
之間的對應關係;完整的編碼流程如圖:
上面的MPT樹,有兩個問題:
爲了解決上述問題,以太坊使用了一種緩存機制,能夠稱爲是輕節點機制,大致以下:
內存中只有這麼一個輕節點,可是我要添加一個數據,也就是要給完整的MPT樹中添加一個葉子節點,怎麼添加?大致以下圖所示:
到此以太坊的MPT樹的基礎講解結束。
https://github.com/blockchainGuide 文章及視頻學習資料
https://eth.wiki/en/fundamentals/patricia-tree
https://ethereum.github.io/yellowpaper/paper.pdf#appendix.D
https://ethfans.org/toya/articles/588
https://learnblockchain.cn/books/geth/part3/mpt.html