原文🔗:
Patricia Tree
Merkle Patricia tries
提供一个密码认证的数据结构,可用于存储所有(键,值)绑定,虽然在本文的范围内,我们将键和值限制为字符串(要删除此限制,只需使用任何序列化格式其他数据类型)。它们是完全确定性的,这意味着具有相同(键值)绑定的Patricia trie
确保与最后一个字节完全相同,因此具有相同的根散列,提供了O(log(n))
插入、查找和删除的效率,并且比诸如红黑树等更复杂的基于比较的替代方法更容易理解和编码。
序言:Basic Radix Tries
在一个basic radix trie中,每个节点看起来如下:
[i0, i1 ... in, value]
其中i0 ... in
表示字母的符号(通常是二进制或十六进制),value
是节点处的终端值,并且i0 ... in
slots中的值是NULL
或指向(在本例中为其他节点的散列)的值。这形成了一个基本(key, value)
存储; 例如,如果您对当前映射dog
到trie中的值感兴趣,则应先将其转换dog
为字母表中的字母(given 64 6f 67
),然后沿着该路径追溯到trie,直到读完该路径的终端值。也就是说,您首先要查找key/value
数据库中的根哈希以查找trie的根节点(基本上是其他节点的键的数组),请使用索引处的值6
作为一个关键字(并在key/value
数据库中查找它)使节点向下一级,然后选择索引4来查找下一个值,然后选择该索引的索引6
,依此类推,直到你遵循路径:root -> 6 -> 4 -> 6 -> 15 -> 6 -> 7
,你查找你拥有的节点的值并返回结果。
请注意,查看“trie”中的内容与底层key/value
“数据库”之间存在差异。它们都定义了key/values
的排列,但底层数据库可以对键进行传统的1步查找,而在查找树中的键时需要多个底层数据库查找才能达到上述最终值。为了消除歧义,让我们把后者称为a path
。
radix tries
的更新和删除操作很简单,可以大致如下定义:
def update(node,path,value):
if path == '':
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[path[0]],path[1:],value)
newnode[path[0]] = newindex
db.put(hash(newnode),newnode)
return hash(newnode)
def delete(node,path):
if node is NULL:
return NULL
else:
curnode = db.get(node)
newnode = curnode.copy()
if path == '':
newnode[-1] = NULL
else:
newindex = delete(curnode[path[0]],path[1:])
newnode[path[0]] = newindex
if len(filter(x -> x is not NULL, newnode)) == 0:
return NULL
else:
db.put(hash(newnode),newnode)
return hash(newnode)
radix trie
的“Merkle”部分出现这样一个事实,即一个节点的确定性密码散列用作指向该节点的指针(对于key/value数据库中的每个查找key == sha3(rlp(value)
),而不是某些32位或64位内存位置,这可能发生在用C实现的更传统的trie
中。这为数据结构提供了一种形式的密码认证;如果给定的trie
的根hash
是公开的,那么任何人都可以提供一个证明,通过提供节点上升的每一步,攻击者不可能提供(path, value)
对的证明,因为根散列最终基于下面的所有散列,所以任何修改都会改变根散列。
如上所述一次遍历1 nibble
路径时,大多数节点包含17个元素的数组。对于路径中的下一个十六进制字符(nibble
)所保持的每个可能值,都有1
索引,并且在路径已经完全遍历的情况下,1
保持最终目标值。这些17个元素的数组节点被称为branch
节点。
主要说明:Merkle Patricia Trie
然而,radix tries
有一个主要限制:它们效率低下。如果你想在路径所在的地方存储一个(path,value)
绑定(在ethereum状态的情况下),长度为64个字符(nibbles bytes32
字节数),则需要超过一千字节的额外空间来存储一层的每个字符,每次查找或删除将采取完整的64个步骤。这里介绍的Patricia trie
解决了这个问题。
优化
Merkle Patricia tries
通过为数据结构增加一些额外的复杂性来解决低效率问题。Merkle Patricia trie
中的节点是以下之一:
-
NULL
(表示为空字符串) -
branch
17个item的节点[ v0 ... v15, vt ]
-
leaf
2个item的节点[ encodedPath, value ]
-
extension
2个item的节点[ encodedPath, key ]
对于64个字符的路径,在遍历该trie的前几层级后,不可避免地会到达一个至少部分路径不存在发散路径的节点。要求这样的节点除了目标索引(路径中的下一个nibble
)之外,在每个索引中都有空值(对于16个十六进制字符中的每一个都是一个值),这将是天真的。相反,我们通过设置extension
表单的节点来快速查询[encodedPath, key]
,其中encodedPath
包含“部分路径”以跳过之前的步骤(使用下面描述的紧凑编码),并且key
用于下一个数据库查找。
在leaf
节点的情况下,可以通过第一个nibble
中的一个标志确定encodedPath
,上述情况就会发生,并且跳过的“部分路径”将完成路径的全部剩余部分。在这种情况下value
是目标值本身。
然而,上面的优化引入了一些模糊特性。
当以nibbles
遍历路径时,我们可能会以奇数个nibbles
来遍历,但因为所有数据都以bytes
格式存储,所以不可能区分例如nibble 1
和nibble 01
(两者都必须存储为<01>
)。要指定奇数长度,而且部分路径的前缀是一个标志。
说明:使用可选终止符对十六进制序列进行紧凑编码
如上所述的奇数与偶数剩余部分路径长度以及叶节点与扩展节点的标记位于任何2项节点的部分路径的第一个nibble
中。它们导致以下结果:
hex char bits | node type partial path length
----------------------------------------------------------
0 0000 | extension even
1 0001 | extension odd
2 0010 | terminating (leaf) even
3 0011 | terminating (leaf) odd
对于剩余的路径长度(0
或2
),另一个0
“填充”nibble
将始终伴随。
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
例子:
> [ 1, 2, 3, 4, 5, ...]
'11 23 45'
> [ 0, 1, 2, 3, 4, 5, ...]
'00 01 23 45'
> [ 0, f, 1, c, b, 8, 10]
'20 0f 1c b8'
> [ f, 1, c, b, 8, 10]
'3f 1c b8'
以下是在Merkle Patricia trie
中获取节点的扩展代码:
def get_helper(node,path):
if path == []: 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 == path[:len(k2)]:
return get(v2, path[len(k2):])
else:
return ''
elif len(curnode) == 17:
return get_helper(curnode[path[0]],path[1:])
def get(node,path):
path2 = []
for i in range(len(path)):
path2.push(int(ord(path[i]) / 16))
path2.push(ord(path[i]) % 16)
path2.push(16)
return get_helper(node,path2)
示例Trie
假设我们要包含四个path/value
对的trie
(‘do’, ‘verb’),(‘dog’, ‘puppy’),(‘doge’, ‘coin’),(‘horse’, ‘stallion’)。
首先,我们将两个paths
和values
转换为bytes
。下面,路径的实际字节表示被表示<>,虽然values
仍然显示为字符串,表示''
为了易于理解(它们也实际上是bytes
):
<64 6f> : 'verb'
<64 6f 67> : 'puppy'
<64 6f 67 65> : 'coin'
<68 6f 72 73 65> : 'stallion'
现在,我们在底层数据库中使用以下key/value
对构建这样一个trie
:
rootHash: [ <16>, hashA ]
hashA: [ <>, <>, <>, <>, hashB, <>, <>, <>, hashC, <>, <>, <>, <>, <>, <>, <>, <> ]
hashC: [ <20 6f 72 73 65>, 'stallion' ]
hashB: [ <00 6f>, hashD ]
hashD: [ <>, <>, <>, <>, <>, <>, hashE, <>, <>, <>, <>, <>, <>, <>, <>, <>, 'verb' ]
hashE: [ <17>, hashF ]
hashF: [ <>, <>, <>, <>, <>, <>, hashG, <>, <>, <>, <>, <>, <>, <>, <>, <>, 'puppy' ]
hashG: [ <35>, 'coin' ]
当一个节点在另一个节点内被引用时,包含H(rlp.encode(x))
,其中H(x) = sha3(x) if len(x) >= 32 else x
和rlp.encode
是RLP编码函数。
请注意,更新一个trie
时,如果新创建的节点的长度大于等于32,则需要将该key/value
对(sha3(x), x)
存储在永久性查找表中。但是,如果节点短于此值,则不需要存储任何内容因为函数f(x)= x
是可逆的。
在以太坊上的尝试
在Ethereum中的所有merkle
尝试使用Merkle Patricia Trie
。
从区块头开始,这些尝试中有3个根来自3个这样的tries
。
- stateRoot
- transactionsRoot
- receiptsRoot
State Trie
有一个全局状态索引,并随着时间的推移而更新。其中,path
为:sha3(ethereumAddress)
;value
为:rlp(ethereumAccount)
。更具体地说,以太坊account
是一个包含4个item的数组[nonce,balance,storageRoot,codeHash]
。在这一点上值得注意的是,这storageRoot
是另一个patricia trie
的根:
Storage Trie
Storage Trie
是所有合同数据所在的地方。每个帐户都有一个单独的存储索引。这个trie
的path
有点复杂,可以参考这个。
Transactions Trie
每个区块都有单独的transactions
。path
在这里为:rlp(transactionIndex)
。transactionIndex
是其开采区块内的指标。排序主要由矿工决定,因此在开采之前这些数据是未知的。区块被挖出后,transaction trie
不会更新。
Receipts Trie
每个块都有自己的1Receipts trie
。path
在这里为:rlp(transactionIndex)
。transactionIndex
是其开采区块内的指标。从不更新。