死磕以太坊源码分析之MPT树-上

前缀树Trie

前缀树(又称字典树),通常来说,一个前缀树是用来存储字符串的。前缀树的每一个节点代表一个字符串前缀)。每一个节点会有多个子节点,通往不同子节点的路径上有着不同的字符。子节点代表的字符串是由节点本身的原始字符串,以及通往该子节点路径上所有的字符组成的。如下图所示:

Trie的结点看上去是这样子的:

[ [Ia, Ib, … I*], value]

其中 [Ia, Ib, ... I*] 在本文中我们将其称为结点的 索引数组 ,它以 key 中的下一个字符为索引,每个元素I*指向对应的子结点。 value 则代表从根节点到当前结点的路径组成的key所对应的值。如果不存在这样一个 key,则 value 的值为空。

前缀树的性质:

  1. 每一层节点上面的值都不相同;

  2. 根节点不存储值;除根节点外每一个节点都只包含一个字符,代表的字符串是由节点本身的原始字符串,以及通往该子节点路径上所有的字符

  3. 前缀树的查找效率是$O(m)$,$m$为所查找节点的长度,而哈希表的查找效率为$O(1)$。且一次查找会有 m 次 IO开销,相比于直接查找,无论是速率、还是对磁盘的压力都比较大。

  4. 当存在一个节点,其内容很长(如一串很长的字符串),当树中没有与他相同前缀的分支时,为了存储该节点,需要创建许多非叶子节点来构建根节点到该节点间的路径,造成了存储空间的浪费。

压缩前缀树Patricia Tree

基数树(也叫基数特里树压缩前缀树)是一种数据结构,是一种更节省空间的前缀树,其中作为唯一子节点的每个节点都与其父节点合并,边既可以表示为元素序列又可以表示为单个元素。 因此每个内部节点的子节点数最多为基数树的基数 r ,其中 r 为正整数, x 为 2 的幂, x≥1 ,这使得基数树更适用于对于较小的集合(尤其是字符串很长的情况下)和有很长相同前缀的字符串集合。

图中可以很容易看出数中所存储的键值对:

  • 6c0a5c71ec20bq3w => 5
  • 6c0a5c71ec20CX7j => 27
  • 6c0a5c71781a1FXq => 18
  • 6c0a5c71781a9Dog => 64
  • 6c0a8f743b95zUfe => 30
  • 6c0a8f743b95jx5R => 2
  • 6c0a8f740d16y03G => 43
  • 6c0a8f740d16vcc1 => 48

默克尔树Merkle Tree

Merkle树看起来非常像二叉树,其叶子节点上的值通常为数据块的哈希值,而非叶子节点上的值,所以有时候Merkle tree也表示为Hash tree,如下图所示:

在构造Merkle树时,首先要计算数据块的哈希值,通常,选用SHA-256等哈希算法。但如果仅仅防止数据不是蓄意的损坏或篡改,可以改用一些安全性低但效率高的校验和算法,如CRC。然后将数据块计算的哈希值两两配对(如果是奇数个数,最后一个自己与自己配对),计算上一层哈希,再重复这个步骤,一直到计算出根哈希值。

所以我们可以简单总结出merkle Tree 有以下几个性质:

  • 校验整体数据的正确性
  • 快速定位错误
  • 快速校验部分数据是否在原始的数据中
  • 存储空间开销大(大量中间哈希

以太坊的改进方案

使用[]byte作为key类型

在以太坊的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树新增了几种不同类型的树节点,通过针对不同节点不同操作来解决效率以及存储上的问题。

  1. 空白节点 :简单的表示空,在代码中是一个空串
  2. 分支节点 :分支节点有 17 个元素,回到 Nibble,四元组是 key 的基本单元,四元组最多有 16 个值。所以前 16 个必将落入到在其遍历中的键的十六个可能的半字节值中的每一个。第 17 个是存储那些在当前结点结束了的节点(例如, 有三个 key,分别是 (abc ,abd, ab) 第 17 个字段储存了 ab 节点的值)
  3. 叶子节点:只有两个元素,分别为 key 和 value
  4. 扩展节点 :有两个元素,一个是 key 值,还有一个是 hash 值,这个 hash 值指向下一个节点

此外,为了将 MPT 树存储到数据库中,同时还可以把 MPT 树从数据库中恢复出来,对于 Extension 和 Leaf 的节点类型做了特殊的定义:如果是一个扩展节点,那么前缀为 0,这个 0 加在 key 前面。如果是一个叶子节点,那么前缀就是 1。同时对key 的长度就奇偶类型也做了设定,如果是奇数长度则标示 1,如果是偶数长度则标示 0。

以太坊中使用到的MPT树结构

  • State Trie 区块头中的状态树

    • key => sha3(以太坊账户地址 address)
    • value => rlp(账号内容信息 account)
  • Transactions Trie 区块头中的交易树
    • key => rlp(交易的偏移量 transaction index)
    • 每个块都有各自的交易树,且不可更改
  • Receipts Trie 区块头中的收据树
    • key = rlp(交易的偏移量 transaction index)
    • 每个块都有各自的交易树,且不可更改
  • Storage Trie 存储树
    • 存储只能合约状态
    • 每个账号有自己的 Storage Trie

这两个区块头中,state roottx rootreceipt root分别存储了这三棵树的树根,第二个区块显示了当账号 17 5的数据变更(27 -> 45)的时候,只需要存储跟这个账号相关的部分数据,而且老的区块中的数据还是可以正常访问。

key编码规则

三种编码方式分别为:

  1. Raw编码(原生的字符);
  2. Hex编码(扩展的16进制编码);
  3. Hex-Prefix编码(16进制前缀编码);

Raw编码

Raw编码就是原生的key值,不做任何改变。这种编码方式的keyMPT对外提供接口的默认编码方式

例如一条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编码的转换规则是:

  • Raw编码输入的每个字符分解为高 4 位和低 4 位
  • 如果是叶子节点,则在最后加上Hex0x10表示结束
  • 如果是分支节点不附加任何Hex

例如:字符串 “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:偶长度的扩展节点
  • 1:奇长度的扩展节点
  • 2:偶长度的叶子节点
  • 3:奇长度的叶子节点

当偶长度时,第一个字节的低四位用0填充,当是奇长度时,则将 key[0] 存放在第一个字节的低四位中,这样 HP 编码结果始终是偶长度。 这里为什么要区分节点 key 长度的奇偶呢?这是因为,半字节 101 在转换为 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编码的规则如下:

  • key结尾为0x10,则去掉这个终止符
  • key之前补一个四元组这个Byte第0位区分奇偶信息,第 1 位区分节点类型
  • 如果输入key的长度是偶数,则再添加一个四元组0x0在flag四元组后
  • 将原来的key内容压缩,将分离的两个byte以高四位低四位进行合并

十六进制前缀编码相当于一个逆向的过程,比如输入的是[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编码
}

以上三种编码方式的转换关系为:

  • Raw编码:原生的key编码,是MPT对外提供接口中使用的编码方式,当数据项被插入到树中时,Raw编码被转换成Hex编码;
  • Hex编码:16进制扩展编码,用于对内存中树节点key进行编码,当树节点被持久化到数据库时,Hex编码被转换成HP编码;
  • HP编码:16进制前缀编码,用于对数据库中树节点key进行编码,当树节点被加载到内存时,HP编码被转换成Hex编码;

如下图:

以上介绍的MPT树,可以用来存储内容为任何长度的key-value数据项。倘若数据项的key长度没有限制时,当树中维护的数据量较大时,仍然会造成整棵树的深度变得越来越深,会造成以下影响:

  • 查询一个节点可能会需要许多次 IO 读取,效率低下;
  • 系统易遭受 Dos 攻击,攻击者可以通过在合约中存储特定的数据,“构造”一棵拥有一条很长路径的树,然后不断地调用SLOAD指令读取该树节点的内容,造成系统执行效率极度下降;
  • 所有的 key 其实是一种明文的形式进行存储;

为了解决以上问题,以太坊对MPT再进行了一次封装,对数据项的key进行了一次哈希计算,因此最终作为参数传入到MPT接口的数据项其实是(sha3(key), value)

优势

  • 传入MPT接口的 key 是固定长度的(32字节),可以避免出现树中出现长度很长的路径;

劣势

  • 每次树操作需要增加一次哈希计算;
  • 需要在数据库中存储额外的sha3(key)key之间的对应关系;

完整的编码流程如图:

MPT轻节点

上面的MPT树,有两个问题:

  • 每个节点都包含有大量信息,并且叶子节点中还包含有完整的数据信息。如果该MPT树并没有发生任何变化,并且没有被使用,则会白白占用一大片空间,想象一个以太坊,有多少个MPT树,都在内存中,那还了得。
  • 并不是任何的客户端都对所有的MPT树都感兴趣,若每次都把完整的节点信息都下载下,下载时间长不说,并且会占用大量的磁盘空间。

解决方式

为了解决上述问题,以太坊使用了一种缓存机制,可以称为是轻节点机制,大体如下:

  • 若某节点数据一直没有发生变化,则仅仅保留该节点的32位hash值,剩下的内容全部释放
  • 若需要插入或者删除某节点,先通过该hash值db中查找对应的节点,并加载到内存,之后再进行删除插入操作

轻节点中添加数据

内存中只有这么一个轻节点,但是我要添加一个数据,也就是要给完整的MPT树中添加一个叶子节点,怎么添加?大体如下图所示:

到此以太坊的MPT树的基础讲解结束。

参考

https://mindcarver.cn

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

https://blog.ethereum.org/2015/11/15/merkling-in-ethereum/

https://arxiv.org/pdf/1909.11590.pdf

死磕以太坊源码分析之MPT树-上的更多相关文章

  1. 死磕以太坊源码分析之MPT树-下

    死磕以太坊源码分析之MPT树-下 文章以及资料请查看:https://github.com/blockchainGuide/ 上篇主要介绍了以太坊中的MPT树的原理,这篇主要会对MPT树涉及的源码进行 ...

  2. 死磕以太坊源码分析之state

    死磕以太坊源码分析之state 配合以下代码进行阅读:https://github.com/blockchainGuide/ 希望读者在阅读过程中发现问题可以及时评论哦,大家一起进步. 源码目录 |- ...

  3. 死磕以太坊源码分析之Kademlia算法

    死磕以太坊源码分析之Kademlia算法 KAD 算法概述 Kademlia是一种点对点分布式哈希表(DHT),它在容易出错的环境中也具有可证明的一致性和性能.使用一种基于异或指标的拓扑结构来路由查询 ...

  4. 死磕以太坊源码分析之p2p节点发现

    死磕以太坊源码分析之p2p节点发现 在阅读节点发现源码之前必须要理解kadmilia算法,可以参考:KAD算法详解. 节点发现概述 节点发现,使本地节点得知其他节点的信息,进而加入到p2p网络中. 以 ...

  5. 死磕以太坊源码分析之rlpx协议

    死磕以太坊源码分析之rlpx协议 本文主要参考自eth官方文档:rlpx协议 符号 X || Y:表示X和Y的串联 X ^ Y: X和Y按位异或 X[:N]:X的前N个字节 [X, Y, Z, ... ...

  6. 死磕以太坊源码分析之Fetcher同步

    死磕以太坊源码分析之Fetcher同步 Fetcher 功能概述 区块数据同步分为被动同步和主动同步: 被动同步是指本地节点收到其他节点的一些广播的消息,然后请求区块信息. 主动同步是指节点主动向其他 ...

  7. 死磕以太坊源码分析之Ethash共识算法

    死磕以太坊源码分析之Ethash共识算法 代码分支:https://github.com/ethereum/go-ethereum/tree/v1.9.9 引言 目前以太坊中有两个共识算法的实现:cl ...

  8. 死磕以太坊源码分析之downloader同步

    死磕以太坊源码分析之downloader同步 需要配合注释代码看:https://github.com/blockchainGuide/ 这篇文章篇幅较长,能看下去的是条汉子,建议收藏 希望读者在阅读 ...

  9. 死磕以太坊源码分析之txpool

    死磕以太坊源码分析之txpool 请结合以下代码阅读:https://github.com/blockchainGuide/ 写文章不易,也希望大家多多指出问题,交个朋友,混个圈子哦 交易池概念原理 ...

随机推荐

  1. 老猿学5G:融合计费场景的离线计费会话的Nchf_OfflineOnlyCharging_Create创建操作

    ☞ ░ 前往老猿Python博文目录 ░ 一.Nchf_OfflineOnlyCharging_Create消息交互流程 Nchf_OfflineOnlyCharging_Create服务化操作请求是 ...

  2. HTTP请求头和响应头详解【转】

    最近老猿在开始学习爬虫相关的知识,由于老猿以前只做非web的后台应用,发现相关知识太过匮乏,导致学习很困难,为此不得不从一些基础知识恶补开始,对于这些知识,老猿会将网上找到的比较认可的内容直接转发. ...

  3. WebService-问题

    1.引用问题 在用C#对接webservice的时候,常用的方法是下载vs中引用webservice的地址.然后,new对应的client就可以使用了.但在,实际应用中往往会遇到webservice访 ...

  4. STL——容器(Set & multiset)的迭代器

    1.set.insert(elem);     //在容器中插入元素. 2.set.begin();         //返回容器中第一个数据的迭代器. 3.set.end();          / ...

  5. 从零开始了解多线程 之 深入浅出AQS -- 上

    java锁&AQS深入浅出学习--上 上一篇文章中我们一起学习了jvm缓存一致性.多线程间的原子性.有序性.指令重排的相关内容, 这一篇文章便开始和大家一起学习学习AQS(AbstractQu ...

  6. MISC-吹着贝斯扫二维码

    题目 [安洵杯 2019]吹着贝斯扫二维码 解压附件,有36个文件和一个压缩包,压缩包带密码和备注 分析 文件类型 随便打开一个不明文件,是jpg图片啊(FF D8 FF) 改一个试试,有一个小块二维 ...

  7. JWT 原理

    1.COOKIE使用和优缺点 参考博客:https://baijiahao.baidu.com/s?id=1608021814182894637&wfr=spider&for=pc 用 ...

  8. Unity 保存游戏,读取游戏,退出游戏

    1 using System.Collections; 2 using System.Collections.Generic; 3 using UnityEngine; 4 using System. ...

  9. maven中引入jstl

    <!--jsp标签--> <dependency> <groupId>taglibs</groupId> <artifactId>stand ...

  10. redis位操作

    setbit 设置指定key的偏移量处的值 key:键值 offset:二进制数据的偏移量(注意从左开始为第0位) value:要设置的值(0或1) getbit 获取对应key的offset处的值 ...