Merkle Patricia Tree
[1],梅克爾帕特里夏樹,提供了一個基于加密學的,自校驗防篡改的數據結構,用來存儲鍵值對關系。后文中將簡稱為MPT
。盡管在本規范范圍內,我們限定鍵值的類型只能是字符串(但仍對所有的類型適用,因為只需提供一個簡單的序列化和反序化機制,將要存儲的類型與字符串進行轉換即可)。
MPT
是確定的。確定性是指同樣內容的鍵值,將被保證找到同樣的結果,有同樣的根哈希。關于效率方面,對樹的插入,查找,刪除的時間復雜度控制在O(log(n))
。相較于紅黑樹來說,MPT
更好理解和編碼實現。
1. 前言:基數樹(Radix Tree)
在一個標準的基數樹里,要存儲的數據,按下述所述:
[i0, i1, ... iN, value]
其中的i0
到iN
的表示一般是二進制或十六進制的格式的字母符號。value
表示的是樹節點中存儲的最終值。每一個i0
到iN
槽位的值,要么是NULL
,要么是指向另一個節點的指針(在當前這個場景中,存儲的是其它節點的哈希值)。這樣我們就實現了一個簡單的鍵值對存儲。舉個例子來說,如果你想在這個基數樹中,找到鍵dog
所對應的值。首先需要將dog
轉換為比如ascii碼值(十六進制表示是646f67
)。然后按字母序形成一個逐層向下的樹。沿著字母組成的路徑,在樹的底部葉節點上,即找到dog
對應的值。具體來說,首先找到存儲這個鍵值對數據的根節點,找到下一層的第6個節點,然后再往下一層,找到節點4,然后一層一層往下找,直到完成了路徑 root -> 6 -> 4 -> 6 -> f -> 6 -> 7。這樣你將最終找到值的對應節點。
基數樹的更新和刪除操作比較簡單,可以按下面的定義:
def update(node,key,value):
if key == '':
curnode = db.get(node) if node else [ NULL ] * 17
newnode = curnode.copy()
newnode[-1] = value
else:
curnode = db.get(node) if node else [ NULL ] * 17
newnode = curnode.copy()
newindex = update(curnode[key[0]],key[1:],value)
newnode[key[0]] = newindex
db.put(hash(newnode),newnode)
return hash(newnode)
def delete(node,key):
if key == '' or node is NULL:
return NULL
else:
curnode = db.get(node)
newnode = curnode.copy()
newindex = delete(curnode[key[0]],key[1:])
newnode[key[0]] = newindex
if len(filter(x -> x is not NULL, newnode)) == 0:
return NULL
else:
db.put(hash(newnode),newnode)
return hash(newnode)
1.1 數據校驗問題 - Merkle Tree
基數樹的節點關系,一般是使用比如C語言的32位或64位的內存地址指針來串聯起來的。但在以太坊中為了實現數據的防篡改及校驗,我們引入了Merkle Tree
,使用節點的哈希值來建立節點關系。這樣,如果一個給定的前綴的根哈希值是已知的,那么任何人都可以根據這個前綴來檢查。對于一個攻擊者,不可能能證明一個不存在鍵值對存在,因為根哈希最終依賴所有的下面的哈希值,所以任何的修改都會導致根哈希值的改變。
1.2 效率問題 - Patricia樹
基數樹另一個主要的缺陷是低效。即使你只想存一個鍵值對,但其中的鍵長度有幾百字符長,那么每個字符的那個層級你都需要大量的額外空間。每次查找和刪除都會有上百個步驟。在這里我們引入Patricia
樹來解決這個問題。
2. 核心規范
2.1 鍵數據的編碼算法
在介紹完整規范前,我們先介紹一個對鍵的編碼算法,十六進制序列的帶可選結束標記的壓縮編碼。傳統的編碼十六進制字符串的方式,是將他們轉為了十進制。比如0f1248
表示的是三個字節的[15,18,72]
。然而,這個方式有點小小的問題,如果16進制的字符長度為奇數呢。在這種情況下,就沒有辦法知道如何將十六進制字符對轉為十進制了。額外的,MPT
需要一個額外的特性,十六進制字符串,在結束節點上,可以有一個特殊的結束標記(一般用T
表示)。結束標記僅在最后出現,且只出現一次。或者說,并不存在一個結束標記,而是存在一個標記位,標記當前節點是否是一個最終結點,存著我們要查找的值。如果不含結束標記,則是表明還需指向下一個節點繼續查找。
為了解決上述的這些問題。我們強制使用最終字節流第一個半字節(半個字節,4位,也叫nibble),編碼兩個標記位。標記是否是結束標記和當前字節流的奇偶性(不算結束標記),分別存儲在第一個半字節的低兩位。如果數據是偶數長,我們引入一個0值的半字節,來保證最終是偶數長,由此可以使用字節來表示整個字節流。編碼方式可以參考:
def compact_encode(hexarray):
term = 1 if hexarray[-1] == 16 else 0
if term: hexarray = hexarray[:-1]
oddlen = len(hexarray) % 2
flags = 2 * term + oddlen
if oddlen:
hexarray = [flags] + hexarray
else:
hexarray = [flags] + [0] + hexarray
// hexarray now has an even length whose first nibble is the flags.
o = ''
for i in range(0,len(hexarray),2):
o += chr(16 * hexarray[i] + hexarray[i+1])
return o
上面的代碼可以看出來,如果要表示的是T
結尾的字符串,term
值取1
,否則取0
。如果為奇數長,取值1
,否則取值0
。由于term
標記是兩個標記中的較高位,所以將term
乘2來左移一位。如果不算后面的結束標記的字節流是奇數的,就不補位。如果是偶數數位,就補一個值為零的半字節。
一些實際的轉換例子:
> [ 1, 2, 3, 4, 5 ]
'\x11\x23\x45' ( Here in python, '\x11#E' because of its displaying unicodes. )
//不含結束,所以沒有結束標記,由于字節流是奇數,標記位取值1,不補位,所以前面只補一個半字節就好。
> [ 0, 1, 2, 3, 4, 5 ]
'\x00\x01\x23\x45'
//不含結束標記的偶數,且由于是偶數第一個nibble是0,由于是偶數位,需要補一個值為零的nibble,所以是00。緊跟后面的值。
> [ 0, 15, 1, 12, 11, 8, T ]
'\x20\x0f\x1c\xb8'
//由于有結束標記,除結束標記的長度為偶數,所以第一個nibblie是2,由于是偶數長補位一個值為0的nibble,所以最后加20。
> [ 15, 1, 12, 11, 8, T ]
'\x3f\x1c\xb8'
//由于有結束標記,且為奇數,第一個值為3,又由于是奇數不需要補位,值是3加后面的值。
2.2 Merkle Patricia Tree
MPT
在解決低效的問題時,對當前的數據結構進行了一些改進。MPT
的節點類型定義如下。
- NULL(空字符串)
- 兩個元素的數組[k, v](又名鍵值對節點)
- 一個17個元素的數組。[v0 ... v15, vt]。(又名分支結點)
思路是,是當出現一些一個元素的節點,但卻有很長路徑的情況時,將這種層級關系縮減為一個鍵值對節點[k, v]。其中鍵值為層級樹的路徑元素,使用為上述編碼的十六進制串,值為節點的哈希值,就像標準的基數樹一樣。另外,我們增加了一個概念的優化,關于內部節點不能存值,只有那些沒有孩子節點的可以存值。但為了讓這個鍵值對存儲方案變得更通用,可以同時存儲dog
和doge
。我們增加了一個結束標記16
到字母表,所以不會存在一個值被錯誤指向到另一個值的情況。
對于一個鍵值對節點,兩個元素的數組[k, v]。v只能是一個值或者節點。
- 當v是一個值時,k必須是含結束標記的半字節按上述的緊湊的編碼串。
- 當v是指向另一個節點時,k必須是不含結束標記的半字節按上述的緊湊編碼串。
對于一個分支結點,一個17個元素的數組[ v0 ... v15, vt]。在v0到v15的每個元素,要么是一個節點,要么是空,而vt則總是一個值,或空。所以如果只是在v0到v15中的其中一個元素中存儲值,我們應該使用鍵值對節點,其中的k是對包含結尾標記的一個空的半字節列表編碼結果。
下面是在MPT中獲取一個節點的代碼:
def get_helper(node,key):
if key == []: return node
if node = '': return ''
curnode = rlp.decode(node if len(node) < 32 else db.get(node))
if len(curnode) == 2:
(k2, v2) = curnode
k2 = compact_decode(k2)
if k2 == key[:len(k2)]:
return get(v2, key[len(k2):])
else:
return ''
elif len(curnode) == 17:
return get_helper(curnode[key[0]],key[1:])
def get(node,key):
key2 = []
for i in range(len(key)):
key2.push(int(ord(key) / 16))
key2.push(ord(key) % 16)
key2.push(16)
return get_helper(node,key2)
例子,假設我們有一個樹有這樣一些值('dog', 'puppy'), ('horse', 'stallion'), ('do', 'verb'), ('doge', 'coin')
。首先,我們將它們轉為十六進制格式:
[ 6, 4, 6, 15, 16 ] : do => 'verb'
//64 6f
[ 6, 4, 6, 15, 6, 7, 16 ] : dog => 'puppy'
//64 6f 67
[ 6, 4, 6, 15, 6, 7, 6, 5, 16 ] : doge => 'coin'
//64 6f 67 65
[ 6, 8, 6, 15, 7, 2, 7, 3, 6, 5, 16 ] : horse => 'stallion'
//68 6f 72 73 65
創建的樹,如下圖所示:
ROOT: [ '\x16', A ]
A: [ '', '', '', '', B, '', '', '', C, '', '', '', '', '', '', '', '' ]
B: [ '\x00\x6f', D ]
D: [ '', '', '', '', '', '', E, '', '', '', '', '', '', '', '', '', 'verb' ]
E: [ '\x17', F ]
F: [ '', '', '', '', '', '', G, '', '', '', '', '', '', '', '', '', 'puppy' ]
G: [ '\x35', 'coin' ]
C: [ '\x20\x6f\x72\x73\x65', 'stallion' ]
樹的構造邏輯是root結點,要構造一個指向下一個結點的kv節點。先對鍵編碼,由于當前節點不是結束結點,存值的鍵為奇數字符數,所以前導值為1,又由于奇數不補位,最終存鍵為0x16
。它指向的是一個全節點A。下一層級,要編碼的是d和h的第二個半字節,4和6。所以在A節點的第五個位置(從零開始)和第七個位置,我們可以看到分別被指向到了B和C兩個節點。對于B節點往后do,dog,doge來說。他們緊接著的都是一個編碼為6f的o字符。所以這里,B節點被編碼為指向D的kv結點,數據是指向D節點的。其中鍵值存6f,由于是指向另一個節點的kv節點,不包含結束標記,且是偶數,需要補位0,得到00,最終的編碼結果是006f。后續節點也以此類推。
當在一個節點中引用另一個節點時,其中包含的是H(rlp.encode(x))
。其中哈希算法是H(x) = sha3(x) if len(x) >= 32 else x
,這里的rlp.encode
,是使用RLP編碼函數的方法。需要注意的是當更新一個超過32字節前綴時,你需要保存鍵值對(sha3(x), x)
在一個持久化的只查表中。當小于32字節時,則不需要轉存任何東西。因為f(x)始終等于它本身值x。
關于作者
專注基于以太坊(Ethereum)的相關區塊鏈(Blockchain)技術,了解以太坊,Solidity,Truffle,web3.js。
個人博客: http://me.tryblockchain.org
版權所有,轉載注明出處