以太坊源码分析之十Merkle Patricia Tree(MPT)

以太坊源码分析之十Merkle Patricia TreeMPT

 

以太坊中,MPT是一个非常重要的数据结构,在以太坊中,帐户的交易信息、状态以及相应的状态变更,还有相关的交易信息等都使用MPT来进行管理。在前面也提到过,其是整个数据存储的重要一环。

如果对比特币比较熟悉的都知道在比特币中为了SPV验证使用了默克尔树,但merkle树又有一定的信息量的不足,针对于此,以太坊将Trie树, Patricia Trie, 和Merkle树融合在一起,组成了一个新的数据结构MPT。

在以太坊中,区块的区块头包含三颗MPT树,分别是:

交易树:记录交易的状态和变化。缓存到MTP

收据树(交易收据):交易收据的存储

状态树(账户信息):帐户中各种状态的保存。如余额等。

在美图的相关分支中,其又增加了几条树,目的不外乎是存储更多的状态,不过,可能为了安全和更小的改动,美图新增的树存储在了块内。

在以太坊的源码中,MPT树的源码主要在trie目录下,先初步分析一下三个树,有一个整体的直观印象。

  • Trie树

Trie,即字典树或者前缀树 (prefix tree),一种查找树。在查找树中最为流行的是平衡二叉树它们的区别在于:

  1. KEY的存储。在Trie中是不存储在节点的。而通过树结构的位置体现。通过下图(网上下载,示意图)可以清晰的看到各个子节点通过不断共享上一层父节点的KEY,来实现整体的KEY的真实情况。这样,就会发现,ROOT节点的KEY一定为空。

3、Trie树的数据存储只存在于叶子节点和部分内部节点,而非面子节点主要用来生成KEY的前缀。

 

  • Patricia Trie树

又名RadixTree 或紧凑前缀树 (compact prefix tree),它对 Trie树的空间使用率进行了优化。在PatriciaTrie 树中,如果父节点和子节点是一一映射,那么父节点与子节点将会合并。从而减少 Trie树 中的深度,在存储和遍历树节点时,降低时间和空间的开销。

 

三、Merkle树

也叫哈希树,在比特币中,用它来做轻钱包spv的验证,网上有好多的资料和生成的方法和示例。

默克尔树可以认为是一个两两哈希递归成一个ROOT的二叉树。看下面的图形很好理解。在实际的使用中,如果生成父哈希时,不够偶数,就把最后一个复制一次,形成一个偶数对。使用默克尔树的优点在于可以大幅的降低数据的下载,特别是在数据量增长较快较大时,数据本身成倍数增长,但默克尔树的数据量则增长变化非常小。

 

 

四、Merkle-Patricia Trie(MPT) 的实现

先看一下它们的数据结构相关,在tire目录下,包含tire/trie.go和 tire/node.go:

type Trie struct {

         db           *Database

         root         node

         originalRoot common.Hash

 

         // Cache generation values.

         // cachegen increases by one with each commit operation.

         // new nodes are tagged with the current generation and unloaded

         // when their generation is older than than cachegen-cachelimit.

         cachegen, cachelimit uint16

}

type node interface {

         fstring(string) string

         cache() (hashNode, bool)

         canUnload(cachegen, cachelimit uint16) bool

}

 

type (

         fullNode struct {

                   Children [17]node // Actual trie node data to encode/decode (needs custom encoder)

                   flags    nodeFlag

         }

         shortNode struct {

                   Key   []byte

                   Val   node

                   flags nodeFlag

         }

         hashNode  []byte

         valueNode []byte

)

 

// EncodeRLP encodes a full node into the consensus RLP format.

func (n *fullNode) EncodeRLP(w io.Writer) error {

         return rlp.Encode(w, n.Children)

}

type nodeFlag struct {

         hash  hashNode // cached hash of the node (may be nil)

         gen   uint16   // cache generation counter

         dirty bool     // whether the node has changes that must be written to the database

}

它们的关系图如下:

 

1、Trie树数据结构

在 Trie 数据结构体中,包含五个成员:db, root,originalRoot,cachegen,cachelimit。五个成员中,root 为整个 MPT 的根节点。

db是做为的KV存储的指针,在最终commit时,提交到leveldb中,在前面分析了数据最终保存的过程和源码,如果有什么疑问可以去看一下代码的分析。

originalRoot 用来在Trie对象中通过传入的 hashNode在数据库中恢复完整的tire树。

cachegen 有英文注释,它就是一个是 cache 的计数器,每当Trie 的变动并提交后cachegen 的值会增加1。

cachegen的值在Tried上默认和node节点(node.nodeFlag.gen)相等,按上面所说,如果每次提交后,node的也同步保持更新。那么node的cachegen的值会再次赋值与trie一样。反之,node中的cachegen值会小于Trie中的cachegen的值。

那这么做有干什么用呢?如果长时间间的二者的cachegen的值无法保持一致,即node上的cachegen值一直小于Trie中的值,并且Trie中的cachegen的值达到achegen – cachelimit,表明node基本不使用,那么,就可以把node从cache中删除,空闲出内存。这其实也是一种LRU的内存调度算法。

Trie 数据结构体提供一整套对节点的操作函数。Get,Update,Insert,Delete,Commit,Hash等。

2、node模块

在上面的代码可以看到node是一个接口定义。包括: fullNode,shortNode,valueNode,hashNode,需要注意的是只有 fullNode 和 shortNode 能够包含子节点。

fullNode 的定义中包括。

1)一个容量为 17 的 node 数组成员变量 Children。前 16 个数组成员分别对应 16 进制 (hex) 下的 0-9a-f。通过HEX的KEY来存储对应的成员;

数组成员的最后一个,存储fullNode 的数据部分。即每个父节点最多拥有 16 个分支。

2)一个node标志flags,用来标识节点的一些特征。

shortNode 成员中只有一个节点,其中:

 

1)成员 Key 是一个字节数组[]byte。

2)成员变量 Val 指向一个子节点。

3)最后是一个节点的标志变量。

这其实就是上面说到的帕特里夏树中的如果父节点只包含一个子节点,就合并起来。减少树的复杂度。

valueNode 是MPT数据结构中存储数据部分的节点。它的数据定义和hashNode一样就是一个比特数组,它存储的是数据的32字节长度RLP 哈希值,最终会被映射到数据库中。

这三项就足以形成一个完整的帕特里夏树,当一个[k,v]类型数据插入到MPT时,首先以k字符串的路径沿root向下生长,生成一个shortNode,形成类似于前面说的位置路径,即key path。在实际的环境中,MPT会处于不断的动态的生长变化中,节点会不断的增加删除。

需要说明的是:黄皮书中把节点类型概括为了分支节点、扩展节点和叶子节点。fullNode对应了黄皮书里面的分支节点,shortNode对应了黄皮书里面的扩展节点和叶子节点(通过shortNode.Val的类型来对应到底是叶子节点还是分支节点,如果是valueNode,就是叶子节点,否则是分支节点)

还有最后一个hashNode:

hashNode 存储了fullNode 或者 shortNode对象的RLP哈希值,这是它与上面的valueNode不同之处。看一下最终它返回的代码:

func (t *Trie) hashRoot(db *Database, onleaf LeafCallback) (node, node, error) {

         if t.root == nil {

                   return hashNode(emptyRoot.Bytes()), nil, nil

         }

         h := newHasher(t.cachegen, t.cachelimit, onleaf)

         defer returnHasherToPool(h)

         return h.hash(t.root, db, true)

}

在 MPT 中,hashNode很难单独存在,基本上在nodeFlag中以nodeFlag.hash为fullNode 和 shortNode间接持有。而在实际应用的场景中,这二者一旦改变,hashNode就一定会被更新。其实Block 的成员变量 Root、TxHash、ReceiptHash都与此有关。

这样,这四个变量形成了一个完整的MPT树。

 

五、MPT中对KEY的编码

在MPT中对KEY有三种编码:

1、比特编码

这个比较简单,使用原生的字节流即可,这是大部分API的编码格式。

2、Hex编码

当KV数据插入MPT时,需要要将其转换成HEX来存储,上面已经说过,它只能存储不超过16个节点。

编码方法:将 keybytes 中的一个字节,高 4bit 和低 4bit 分别放到两个字节中,最后在尾部加 1byte 标记当前属于 Hex 格式(即原来的一个字节变成了三个字节)。新产生的KEY字节中的有效位只有4bit,这样的数据被称为nibble。然后就可以将其存储进fullNode.Children[] 数组了。

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

         return nibbles

}

 

// hexToKeybytes turns hex nibbles into key bytes.

// This can only be used for keys of even length.

func hexToKeybytes(hex []byte) []byte {

         if hasTerm(hex) {

                   hex = hex[:len(hex)-1]

         }

         if len(hex)&1 != 0 {

                   panic("can't convert hex key of odd length")

         }

         key := make([]byte, (len(hex)+1)/2)

         decodeNibbles(hex, key)

         return key

}

3、Compact编码

这种编码的格式 和Hex编码正好相反,它是反推Hex到keybytes的格式。同时要加入Compact这种格式的标记。编码方法如下:

第一步先将 Hex 尾部标记 byte 去掉,然后把两个 nibble 的数据合并到一他宽限;

第二步,增加 一个字节在数据头部并填充 Compact 格式标记位00100000;

第三步,如果Hex字符串有效长度为奇数,就将 Hex 字符串的第一个 nibble 放置在标记位字节中的低 4bit,并增加奇数位标志 0011xxxx。

节点存储到数据库时候的key使用的就是Compact编码格式,这样可以可以节省磁盘空间。

func hexToCompact(hex []byte) []byte {

         terminator := byte(0)

         if hasTerm(hex) {

                   terminator = 1

                   hex = hex[:len(hex)-1]

         }

         buf := make([]byte, len(hex)/2+1)

         buf[0] = terminator << 5 // the flag byte

         if len(hex)&1 == 1 {

                   buf[0] |= 1 << 4 // odd flag

                   buf[0] |= hex[0] // first nibble is contained in the first byte

                   hex = hex[1:]

         }

         decodeNibbles(hex, buf[1:])

         return buf

}

 

func compactToHex(compact []byte) []byte {

         base := keybytesToHex(compact)

         base = base[:len(base)-1]

         // apply terminator flag

         if base[0] >= 2 {

                   base = append(base, 16)

         }

         // apply odd flag

         chop := 2 - base[0]&1

         return base[chop:]

}

func decodeNibbles(nibbles []byte, bytes []byte) {

         for bi, ni := 0, 0; ni < len(nibbles); bi, ni = bi+1, ni+2 {

                   bytes[bi] = nibbles[ni]<<4 | nibbles[ni+1]

         }

}

六、KEY的加密

在以太坊中,为了安全起见,在security_trie.go中对KEY进行了了一下包装,即使用keccak256的算法计算哈希值,但在实际的数据里存储的存储了二者的对应关系。

type SecureTrie struct {

         trie             Trie  //原始树

         hashKeyBuf       [common.HashLength]byte//hash值的buf

         secKeyCache      map[string][]byte//hash和KEY的映射

         secKeyCacheOwner *SecureTrie // Pointer to self, replace the key cache on mismatch

}

看一下生成的代码:

func NewSecure(root common.Hash, db *Database, cachelimit uint16) (*SecureTrie, error) {

         if db == nil {

                   panic("trie.NewSecure called without a database")

         }

         trie, err := New(root, db)

         if err != nil {

                   return nil, err

         }

         trie.SetCacheLimit(cachelimit)

         return &SecureTrie{trie: *trie}, nil

}

在这个文件中,还提供了类似于原始树的增删改查等操作。这里不再赘述。

整体上流程性的源码解读就基本到此结束,下来针对以太坊的源码分析就是聚焦于特定的模块或者特定的技术,比如存储等,主要从模块的设计架构来分析。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值