·Merkel-Patricia Tree中文名称梅克尔-帕特里夏树
·MPT提供了一个基于密码学验证的底层数据结构,用来存储键值对(key-value)关系
MPT主要是什么呢,是一个基于密码学验证的底层数据结构,这个基于密码学验证其实就是梅克尔树的特性:可以进行数据校验,防止篡改,然后mpt的目的是用来存储键值对关系,这就跟梅克尔树有点不一样了,因为梅克尔树存的就是hash,所有的hash两两算一个父hash,一直算到根节点,而mpt不仅要存储hash,还要存储键值对关系,那肯定就跟帕特里夏树有关了,后面再详细的讲。
·MPT是确定性的
这是指在一颗MPT上,一组键值对是唯一确定的,在没有修改的前提下,每次访问,根据同一个键可以保证找到同样的值,并且有同样的根哈希(root hash)。
·MPT的插入、查找、删除操作的时间复杂度都是O(log(n))
提到数据结构,尤其是树结构,就会关心树的增删改查的时间复杂度,MPT的插入、查找、删除操作的时间复杂度都是O(log(n)),这个的效率还是非常好的,而且相对于其它基于复杂比较的树结构(比如红黑树),MPT更容易理解,也更易于编码实现。
在讲解梅克尔-帕特里夏树之前,首先从一个简单的树结构——字典树说起。
字典树(Trie)也称前缀树(prefix tree),是一种有序的树结构 字典树用于存储动态的集合或映射,其中的键通常是字符串,很多数据库的底层都采用的是树结构,以太坊最初的想法也是这样,但字典树还远远不够,主要问题是访问效率很低。
举个例子,如果我们只想存一个bytes32类型的键值对,在以太坊定义的Hex字符集下访问路径长度就是64;每一级访问的节点都至少需要存储16个字节,这样就需要至少1k字节的额外空间,而且每次查找和删除都必须完整地执行64次下探访问。而且对于64个字符的路径长度,很有可能在某个节点处会发现,下面至少有一段路径没有分叉;那么如何优化字典树的访问效率呢,那就出现了Radix Tree。
基数树又叫压缩前缀树(compact prefix tree),是一种空间优化后的字典树。如果一个节点只有唯一的子节点,那么这个子节点就会与父节点合并存储。将字典树冗长的层级关系进行压缩,避免不必要的空间浪费。
以太坊所用的帕特里夏树也属于基数树。
那什么是帕特里夏树呢?如果一个基数树的“基数”(radix)为2或2的整数次幂,就被称为“帕特里夏树”,有时也直接认为帕特里夏树就是基数树 以太坊中采用Hex字符作为key的字符集,也就是基数为16的基数树,每个节点最多可以有16个子节点,再加上value,所以共有17个“插槽”(slot)位置。
那么帕特里夏树优化了访问效率,能不能直接应用到以太坊的树结构呢,可以,这里还有一个问题没有解决。
基数树节点之间的连接方式是指针,一般是用32位或64位的内存地址作为指针的值,比如C语言就是这么做的。但这种直接存地址的方式无法提供对数据内容的校验,而这在区块链这样的分布式系统中非常重要。
所以,在基数树的基础上,不仅要快速查询,还要校验数据,那就可以想到比特币已经有很现成的解决方案MerkelTree。
梅克尔树就是最经典的解决数据校验的一种方式,就是用每个节点的hash值来建立对应的关系,底层的叶子节点都算一个hash,这是一个二叉树,两两hash之间再算一次hash,不断往上计算得出top hash算作一个根节点存到区块里面,去校验的时候,如果叶子节点发生改动,按照规则两两一hash计算得出的根节点会不一样,就知道数据发生了变动。
所以梅克尔树可以实现数据校验,防止篡改,那么以太坊中怎么去应用梅克尔树呢,拿什么东西去做hash呢。以太坊要去做hash的是整个要存储内容的RLP编码,所以以太坊相当于把自己的value先做RLP编码,然后再去求hash,然后把最后得到的hash值作为在数据库中存储的位置,所以在mpt中的节点里面用hash作为key,访问的的时候根据hash在数据库中找到对应的值。
前面讲了那么多树结构,那么哪些是以太坊能够借鉴的呢,下面总结一下以太坊对数据结构的要求:
以太坊不同于比特币的 UXTO 模型,在账户模型中,账户存在多个属性(余额、代码、存储信息),属性(状态)需要经常更新。因此需要一种数据结构来满足几点要求:
① 在执行插入、修改或者删除操作后能快速计算新的树根,而无需重新计算整个树,并且可以快速检验节点的正确性。
② 即使攻击者故意构造非常深的树,它的深度也是有限的。否则,攻击者可以通过构建足够深的树使得每次树更新变得极慢,从而执行拒绝服务攻击。 ③ 对节点的访问效率要求较高。
要求①是梅克尔树特性,但要求②③并不是梅克尔树的优势。 对于要求②,可将数据 Key 进行一次哈希计算,得到确定长度的哈希值参与树的构建。而要求③则是利用帕特里夏树提高访问效率和优化存储空间。
所以以太坊里面,引入默克尔树和帕特里夏树,把这种结构定义为梅克尔-帕特里夏树,这两种树都是现成的,但是以太坊提出了结合,形成了一种新的树结构MPT。
·空节点(NULL):表示空字符串。空字符串就不说了,剩下的三类节点是主要能够用到的数据结构,真正去存储数据和在寻址过程中用到的节点。
·分支节点(branch):17个元素的节点,结构为[ v0 ... v15, vt ]。分支节点是一个包含17个元素的数组;其中16个插槽位置,分别以hex字符作为索引值,代表路径中下级节点的指针,还有一个value,这是存值的地方。与传统做法不同,MPT分支节点里面存的不是地址,而是用指向节点的hash来代表这个指针,每个节点将下个节点的hash作为自己存储内容的一部分,如果改动子节点的内容,那相当于他的hash就要变,那父节点的内容也要变,一直往上推,那根节点也会变,这样就实现了Merkel树结构,保证了数据校验的有效性。
·扩展节点(extension):拥有两个元素,编码路径encodedPath和键key。扩展节点(extension node)的内容形式有两个元素,一个叫做encodedPath,这个包含了下面不分叉的那部分路径,也就是把路径压缩的那部分,另外,还会有一个key,是指向下一个节点的hash(hash,也即在底层db中的存储位置)。
·叶子节点(leaf):拥有两个元素,编码路径encodedPath和值value。对应的叶子节点也有类似的数据结构,他的数据结构是一个编码路径encodedPath,还有一个值,因为是叶子节点嘛,后面就没有分叉路径,它的第二个元素就是自己的value。
接下来还有一个问题,就是以太坊里面路径的表示是hex字编码,每个Hex字符就是一个nibble(半字节),但是存储是按照字节来存的,从来没有说存储是按照半个字节来存的,那用hex字符的规则去存储的时候还是要占用整个字节,这就相当于浪费了一倍的存储空间,那这个可以去改进吗,所以以太坊就提出来一种方式来改良:要采用一种紧凑编码的方式,叫compact coding,怎么去紧凑编码呢,思路非常简单,将两个nibble整合在一个字节中保存,这就避免了不必要的空间浪费
那两个nibble合成一个字节的话,你得保证nibble的个数是偶数啊,如果hex字符串中nibble总数是一个奇数呢;那就必须分别处理奇偶两种情况,所以为了区分路径长度的奇偶性,我们在encodedPath中引入标识位。
Hex序列的压缩编码规则:
在encodedPath中,加入一个nibble作为前缀,它的后两位用来标识节点类型和路径长度的奇偶性。
MPT中还有一个可选的“结束标记”(用T表示),值为0x10 (十进制的16),它仅能在路径末尾出现,代表节点是一个最终节点(叶子节点) 如果路径是奇数,就与前缀nibble凑成整字节;如果是偶数,则前缀nibble后补0000构成整字节。
编码示例:
·[ 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]带结束位T的偶路径:'20 0f 1c b8'
·[ f, 1, c, b, 8, 10]带结束位T的奇路径:'3f 1c b8'
看这张经典的图,是一个世界状态树,他是一个mpt,节点有不同的类型,先从上面开始看,最上面是根节点,他是一个扩展结点,那扩展节点有什么特点呢,首先存一个压缩路径,然后存一个指向下一个节点的hash,把压缩路径的前缀单独领出来了,实际上是存储的时候是合在一起存的,他的前缀给的是0,因为后面的压缩起来的路径是偶数,偶数还是扩展结点,前缀的二进制表示就是0000,还要补0000,但是这里显示的只是前缀,没有显示补0的操作。
然后后面存一个hash指向下一个节点。下一个节点是一个分支节点,因为我们发现这个地方没法去压缩路径,因为他有不同的路径出现,所以就岔开了。分支节点里面1这个插槽对应的是一个叶子节点,前缀是2,因为他后面压缩的路径是偶数并且是叶子节点, 后面还有value,所以我们这里存的从根节点到分支节点,再到叶子节点。表示了一个什么键值对存储呢,他的key就是这个路径,从前面压缩出来的路径a7,然后往下走到1,然后1355,他的值是45eth,所以要存储的键是:a711355,值是45这样一个键值对,在mpt中就是这样组织存储。
接下来是关于以太坊里面的的树结构,以太坊中所有的merkel树都是MPT 。在一个区块的头部(block head)中,有三颗MPT的树根。大家可以看到一个区块头里面,除了一些常规数据,还有三个很重要的数据就是三个梅克尔-帕特里夏树的树根,通过树根就可以访问以太坊底层数据库内的数据,stateRoot•状态树的树根,transactionRoot•交易树的树根,还有receiptsRoot•收据树的树根。详细解释如下:
·State trie:世界状态树,随时更新;它存储的键值对(path, value)可以表示为(sha3(ethereumAddress), rlp(ethereumAccount) )。其中account是4个元素构成的数组:[nonce, balance, storageRoot,codeHash]。
·Storage trie:存储树是保存所有合约数据的地方;每个合约账户都有一个独立的存储空间。 ·Transaction trie:每个区块都会有单独的交易树;它的路径(path)是rlp(transactionIndex),只有在挖矿时才能确定;一旦出块,不再更改。
·Receipts trie:每个区块也有自己的收据树;路径也表示为rlp(transactionIndex)。
另外还有交易树和收据树,这是针对区块来讲的,每个区块都有自己的交易树和收据树,交易树他的路径就跟状态树不一样了,他的路径直接就是transactionIndex的rlp编码,也就是说,在区块里面,交易会有一个顺序,每笔交易会有一个index,然后做rlp编码作为路径,那value就是交易hash,这个键值对只有在挖矿的时候才能确定,既然key跟块里交易的顺序有关,那挖矿之前怎么知道交易的顺序呢,所以在成块之后才能确定,不能再修改了。
这张图就可以大致体现了以太坊数据的存储结构
上面是区块头,有个stateroot,是状态数的树根,状态树是一个世界状态,他包含了所有账户的状态的集合,所以他下面的树根据每个账户的地址hash作为key,然后存每个账户的数据。所以说如果访问一个账户,按照刚才的规则找路径,直到找到某一个叶子节点,他的叶子节点里面存的是什么呢,是账户account的内容,同时,codehash只是一个32字节的hash,他还对应到真正的code的存储空间 去,storageroot是梅克尔帕特里夏树的树根,它对应的是底层数据库中合约数据的存储位置。