死磕以太坊源码分析之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