以太坊简介:基于比特币的新特性,Merkle Patricia tree/trie(MPT树)

原文链接:郭老师的备课资料
以太坊的概念首次在2013至2014年间由程序员Vitalik Buterin受比特币启发后提出,大意为“下一代加密货币与去中心化应用平台”,在2014年通过ICO众筹开始得以发展。截至2018年2月,以太币是市值第二高的加密货币,仅次于比特币。可以查看参考[1]了解以太坊的发展历史。

介绍完之前关于比特币的内容之后,我们应该可以看懂以太坊白皮书前面一部分的内容了。白皮书中对比特币的重要技术和特征作了一个回顾和总结。

接下来,我们主要讲的是从白皮书中对比特币脚本语言的限制的描述,通过分析以太坊对比特币的缺陷,我们来理解以太坊的特征。

比特币系统的脚本语言存在一些严重的限制:

  1. 缺少图灵完备性
    这就是说,尽管比特币脚本语言可以支持多种计算,但是它不能支持所有的计算。最主要的缺失是循环语句。不支持循环语句的目的是避免交易确认时出现无限循环。理论上,对于脚本程序员来说,这是可以克服的障碍,因为任何循环都可以用多次重复if
    语句的方式来模拟,但是这样做会导致脚本空间利用上的低效率,例如,实施一个替代的椭圆曲线签名算法可能将需要256次重复的乘法,而每次都需要单独编码。
  2. 价值盲(Value-blindness)。UTXO脚本不能为账户的取款额度提供精细的的控制。例如,预言机合约(oracle
    contract)的一个强大应用是对冲合约,A和B各自向对冲合约中发送价值1000美元的比特币,30天以后,脚本向A发送价值1000美元的比特币,向B发送剩余的比特币。虽然实现对冲合约需要一个预言机(oracle)决定一比特币值多少美元,但是与现在完全中心化的解决方案相比,这一机制已经在减少信任和基础设施方面有了巨大的进步。然而,因为UTXO是不可分割的,为实现此合约,唯一的方法是非常低效地采用许多有不同面值的UTXO(例如对应于最大为30的每个k,有一个2^k的UTXO)并使预言机挑出正确的UTXO发送给A和B。
  3. 缺少状态 –
    UTXO只能是已花费或者未花费状态,这就没有给需要任何其它内部状态的多阶段合约或者脚本留出生存空间。这使得实现多阶段期权合约、去中心化的交换要约或者两阶段加密承诺协议(对确保计算奖励非常必要)非常困难。这也意味着UTXO只能用于建立简单的、一次性的合约,而不是例如去中心化组织这样的有着更加复杂的状态的合约,使得元协议难以实现。二元状态与价值盲结合在一起意味着另一个重要的应用-取款限额-是不可能实现的。
  4. 区块链盲(Blockchain-blindness)-
    UTXO看不到区块链的数据,例如随机数和上一个区块的哈希。这一缺陷剥夺了脚本语言所拥有的基于随机性的潜在价值,严重地限制了博彩等其它领域应用。

在白皮书中,引入以太坊的时候,这样介绍:

“以太坊的目的是基于脚本、竞争币和链上元协议(on-chain meta-protocol)概念进行整合和提高,使得开发者能够创建任意的基于共识的、可扩展的、标准化的、特性完备的、易于开发的和协同的应用。……并且因为图灵完备性、价值知晓(value-awareness)、区块链知晓(blockchain-awareness)和多状态所增加的力量而比比特币脚本所能提供的智能合约强大得多。”

既然以太坊社区选择让以太坊拥有如此强大的功能,那么相对应的,他们也必须为此做好准备。我们一条条看一下,为了实现对比特币的改进,以太坊必须添加什么功能。

新特性

1. 图灵完备性

之前在介绍比特币脚本的时候,我们提到过比特币的脚本是故意设计成不完备的,为什么呢?因为之前分析过,在交易中写的脚本,是需要有矿工来执行并进行确认的。如果是图灵完备的,那么有可能会出现死循环的代码,对于矿工而言是非常不利的。

既然以太坊明确地在挑刺儿,也就意味着在以太坊中实现的脚本语言是图灵完备的。既然这样,以太坊必须要解决的问题是,如何应对死循环可能会无限地消耗?

gas

以太坊中引入了gas(瓦斯、油价等中文翻译)的概念。以太坊在区块链上实现了一个运行环境,被称为以太坊虚拟机(EVM),参与到网络的节点都会运行EVM,验证区块中的每个交易并在EVM中运行交易所触发的代码。合约可以利用的每个命令都会有一个相应的费用值,费用使用gas作为单位计数,也即用户付给矿工的佣金。这里列了一些命令的gas消耗。

Gas常用的单位是wei,wei和ether的关系如下所示:
在这里插入图片描述
每笔交易都被要求包括gas limit(startGas,限制)和Gas Price(价格)。Gas Limit 是用户愿意为执行某个操作或确认交易支付的最大Gas量(最少21,000)。Gas Price 是 Gwei 的数量,用户愿意花费于每个 Gas 单位的价钱。如果该交易需要使用的gas数量小于或等于所设置的gas limit,那么这个交易会被处理。**如果gas总消耗超过gas limit,那么所有的操作都会被复原,但是交易费仍然会被矿工收取。**区块链会显示这笔交易完成尝试,但因为没有提供足够的gas导致所有的合约命令都被复原。如果交易里gas没有被消耗完毕,gas都会以以太币的形式打回给交易发起者。因为gas消耗一般只是一个大致估算,所以许多用户会超额支付gas来保证他们的交易会被接受。这样如果恶意用户在交易中包括了死循环,那么不论付出多少gas,最终都会消耗完。另外,也正是因为这样的代价问题,虽然以太坊的脚本语言是图灵完备的,也即当前的所有代码都可以在以太坊区块链上运行,但是作为开发者,需要认真考虑代码的效率。两个同样功能的合同,效率高的那个才能生存下来。一个帮助人们理解以太坊合同实际能力的启发是:这个功能是否能在一个1999年的智能手机上实现?

当进行每笔交易时,用户设定Gas Limit 和Gas Price,在运行时,矿工首先计算 Gas Limit*Gas Price ,就得到了ETH交易佣金的成本,然后这笔费用首先从用户的账户中扣除,交易运行完毕,如果有剩余,再还给用户。如果不够,矿工也不还钱,只是把交易全部回滚。

发送者支付的Gas Price越高,则其交易的优先级越重要,因为矿工的报酬会更高。 但是,通过设置较低燃料价格(GasPrice),发送者可以节省资金。以太坊客户端的Frontier版本有一个默认的gasPrice,即0.05e12 wei。矿工为了最大化他们的收益,如果大量的交易都是使用默认gasPrice即0.05e12 wei,那么基本上就很难又矿工去接受一个低gasPrice交易,更别说0 gasPrice交易了。

除了计算交易的花费之外,将交易或者合约上传也需要费用。虽然读取本地区块链是免费的,但写入和运算是花钱的。储存更是尤其昂贵,因为任何写入的信息都会被永久的储存着。相比之下,CPU运算很便宜。以太坊是图灵完备的,谁也拦不住你写一个视频解码器然后发布在区块链上;只不过估计你没钱运行它。假设这样的程序的代码至少有几千行,即使把它上传到区块链上也不会便宜。譬如,gas limit=2000,假设交易长为170字节,每字节的费用是5,减去850,所以还剩1150,剩下的才是运行交易能使用的gas上限。

除此之外,每个块还有Block gas limit,这个值在创世区块的配置文件中可以指定,譬如查看一个区块的信息如下,也即区块的gas limit是3573388。block gas limit的用意是限制一个区块中能够包含的交易的数量

2. 解决Value-blindness(价值盲)

同样的,来回忆一下,比特币为什么放弃了基于账户的概念,而采取UTXO机制的呢?

使用UTXO可以避免维护账户余额的麻烦。为什么维护余额很麻烦呢?因为为了使用余额,也即能够让矿工检查和验证余额,余额必须是全局可见的数据。而基本上每个交易都会对余额产生影响和带来变化。也即,余额必须要全局可见,能快速地更新,而且可以验证。

【UTXO的优点: 1.较高程度的隐私保护。如果用户每次交易都使用一个新的地址,那么账户之间的相互关联就很困难。这样做适用于对安全性要求高的货币系统。2.潜在地可扩展性。 UTXO在理论上可扩展性更好。对于维护交易的Merkle树,即使所有的人(包括数据的拥有者)都遗忘了某一数据,真正受损也只有数据的拥有者,其他人不受影响。 但在以太坊账户系统中,任何人弄丢了一个账户对应在默克尔树中信息,那么将无法处理任何能够影响账户的消息。】

既然以太坊期望可以有明确的价值,必须能够实现余额的概念。余额相对于UTXO也有一些优点:

  • 1.节省空间。

如果一个账户有5个UTXO,则从UTXO模式转成账户模式所需空间会从300字节降到30字节。具体计算如下: 300 = (20+32+8)* 5 (20是地址字节数,32是TX的id字节数,8是交易金额值字节数); 30 = 20 + 8 + 2 ( 20是地址字节数,8是交易金额值字节数,
2是nonce②字节数) 但实际节约并没有这么大,因为账户需要被存储在帕特里夏树中。另外以太坊中交易也比比特币中的更小(以太坊中100字节,比特币中200-250字节),因为每次交易只需要生成一次引用,一次签名,以及一个输出。

  • 2.可替代性更高。

在UTXO结构中,“有效输出”的源码实现中没有区块链层的概念,所以不管是在技术还是法律上,通过建立一个红名单/黑名单,并依据的这些“有效输出”的来源区分它们并不是很实际。

  • 3.简单。

以太坊编码更简单、更易于理解,尤其是在涉及到复杂脚本时。尽管任何去中心化应用都可以用UTXO方式来实现,但这种方式实质上是通过赋予一个脚本限制给定的UTXO能够使用以及请求的UTXO的种类的方式来实现,包括脚本评估的应用更改根状态的默克尔树证明。因此,UTXO实现方式比以太坊使用账户的方式要复杂的多。

  • 4.轻客户端

轻客户端可以随时通过沿指定方向扫描状态树来访问与账户相关的所有数据。在UTXO方式中,引用随着每个交易的变化而变化,这对于长时间运行并使用了上文提到的UTXO根状态传播机制的dapp应用来说,无疑是繁重的。

账户方式的一个弱点是:为了阻止重播攻击,每笔交易必须有nonce,nonce的值是上一次使用的nonce值+1,这就使得账户需要跟踪nonce的使用情况,并且必须确认交易的Nonce值比上次使用的Nonce值大1。这就意味着,即使不再使用的账户,也不能从账户状态中移除。解决这个问题的一个简单方法是让交易包含一个区块号,这样过一段时间之后,交易便不能重放了。

为了实现账户方式,以太坊的做法是采用状态(state)的概念存储一系列账户,每个账户都有自己的余额,以及以太坊特有的数据(代码或内部存储器)。

以太坊的状态由每个交易改变,
在这里插入图片描述
上图中,起始状态保护了四个账户,分别是14c5f8ba、bb75a980、892bf92f、4096ad65。其中14c5f8ba和4096ad65仅维护了余额;bb75a980和892bf92f除了余额之外,还包括一部分的代码和数据。交易数据由14c5f8ba发送到bb75a980,转账10个以太币。同时发送了两个数据2和“charlie”。这个数据传递到账户bb75a980时,触发了bb75a980账户中的代码,意思是如果在对应于交易中携带的数据中的第一个(作为索引),也即tx.data[0],相对应的位置处的数据是0,则对该位置出的数据进行更新,更新为第二个数据,也即‘charlie’。

为了维护这个状态,和比特币不同,比特币在区块中只包括了交易的merkle树根,而以太坊在区块头部中会包括三棵树,分别是状态树(state trie),交易树(transaction trie)以及receipt树。

在这里插入图片描述

3.状态

4.解决区块链盲

在脚本图灵完备以及维护了状态树的情况下,以上两个特征实现起来就容易了。

由于以上特性,以太坊的轻客户端比Bitcoin的轻客户端功能更强。Bitcoin的轻客户端可以证明包含的交易,但是它不能进行涉及当前状态的证明(如数字资产的持有,名称注册,金融合约的状态等)。而以太坊的轻客户端能够轻松地进行并核实以下类型的查询答案:

  1. 这笔交易被包含在特定的区块中了么?
  2. 告诉我这个地址在过去30天中,发出X类型事件的所有实例(例如,一个众筹合约完成了它的目标)
  3. 目前我的账户余额是多少?
  4. 这个账户是否存在?
  5. 假装在这个合约中运行这笔交易,它的输出会是什么?

第一种是由交易树(transaction tree)来处理的;第三和第四种则是由状态树(state tree)负责处理,第二种则由收据树(receipt tree)处理。计算前四个查询任务是相当简单的。服务器简单地找到对象,获取Merkle分支,并通过分支来回复轻客户端。

第五种查询任务同样也是由状态树处理,但它的计算方式会比较复杂。这里,我们需要构建一个Merkle状态转变证明(Merkle state transition proof)。从本质上来讲,这样的证明也就是在说“如果你在根S的状态树上运行交易T,其结果状态树将是根为S’,log为L,输出为O” (“输出”作为存在于以太坊的一种概念,因为每一笔交易都是一个函数调用;它在理论上并不是必要的)。

为了推断这个证明,服务器在本地创建了一个假的区块,将状态设为 S,并在请求这笔交易时假装是一个轻客户端。也就是说,如果请求这笔交易的过程,需要客户端确定一个账户的余额,这个轻客户端(由服务器模拟的)会发出一个余额查询请求。如果需要轻客户端在特点某个合约的存储中查询特定的条目,这个轻客户端就会发出这样的请求。也就是说服务器(通过模拟一个轻客户端)正确回应所有自己的请求,但服务器也会跟踪它所有发回的数据。

MPT(Merkle Patricia Tries)

能够做到这一点,需要一个非常重要的数据结构。默克尔帕特里夏树(Merkle Patricia tree/trie),由Alan Reiner提出设想,并在瑞波协议中得到实现,是以太坊的主要数据结构,用于存储所有账户状态,以及每个区块中的交易和收据数据。MPT是默克尔树和帕特里夏树的结合缩写,结合这两种树创建的结构具有以下属性:

  • 每个唯一键值对唯一映射到根的hash值;在MPT中,不可能仅用一个键值对来欺骗成员(除非攻击者有~2^128 的算力);
  • 增、删、改键值对的时间复杂度是对数级别。

MPT为我们提供了一个高效、易更新、且代表整个状态树的“指纹”。
在这里插入图片描述
二叉默克尔树对于验证“清单”格式的信息而言,是非常好的数据结构,本质上来讲,它就是一系列前后相连的数据块。对于交易树来说,它们也同样是不错的,因为一旦树已经建立,花多少时间来编辑这颗树并不重要,树一旦建立了,它就会永远存在。

而对状态树来说,情况会更复杂些。以太坊中的状态树基本上包含了一个键值映射,其中的键是地址还有各种值,包括账户的声明、余额、随机数、代码以及每一个账户的存储(其中存储本身就是一颗树)。例如,the Morden testnet 的创始状态如下所示:

{ "0000000000000000000000000000000000000001": { "balance": "1" }, "0000000000000000000000000000000000000002": { "balance": "1" }, "0000000000000000000000000000000000000003": { "balance": "1" }, "0000000000000000000000000000000000000004": { "balance": "1" }, "102e61f5d8f9bc71d0ad4a084df4e65e05ce0e1c": { "balance": "1606938044258990275541962092341162602522202993782792835301376" } }

不同于交易历史记录,状态树需要经常地进行更新:账户余额和账户的随机数nonce经常会更变,更重要的是,新的账户会频繁地插入,存储的键( key)也会经常被插入以及删除。MPT的数据结构设计,使得我们可以在一次插入、更新编辑或者删除操作之后,快速地计算出新的树根(tree root),而无需重新计算整颗树。此外,它还有两个非常好的特性:

  1. 树的深度是有限制的。考虑到攻击者会故意地制造一些交易,使得这棵树尽可能地深,从而可以通过操纵树的深度,执行拒绝服务攻击(DOS
    attack),使得更新变得极其缓慢。
  2. 树的根只取决于数据,和其中的更新顺序无关。换个顺序进行更新,甚至重新从头计算树,并不会改变根。

Merkle Patricia Tree(又称为Merkle Patricia Trie)是一种经过改良的、融合了默克尔树和前缀树两种树结构优点的数据结构,是以太坊中用来组织管理账户数据、生成交易集合哈希的重要数据结构。

MPT结合了(1)radix trie(2)Merkle tree两种树结构的特点与优势 ,因为Merkle树之前已经详细介绍过,所以这里直接讨论前缀树以及压缩前缀树(patricia树)。

Trie树

Trie树,又称前缀树或字典树,是一种有序树,用于保存关联数组,其中的键通常是字符串。与二叉查找树不同,键不是直接保存在节点中,而是由节点在树中的位置决定。一个节点的所有子孙都有相同的前缀,也就是这个节点对应的字符串,而根节点对应空字符串。一般情况下,不是所有的节点都有对应的值,只有叶子节点和部分内部节点所对应的键才有相关的值。

如下图所示,图中共有6个叶子节点,其键值对分别为{‘to’:7},{‘tea’:3},{‘ted’:4},{‘ten’:12},{‘A’:15},{‘inn’:9};以及两个中间节点对应这的键值对:{‘i’:11}和{‘in’:5}。

在这里插入图片描述

以上只是简化表示,实际上,trie每个节点是一个确定长度的数组,数组中每个节点的值是一个指向子节点的指针,最后有个标志域,标识这个位置为止是否是一个完整的字符串,并且有几个这样的字符串。

常见的用来存英文单词的trie每个节点是一个长度为27的指针数组,index0-25代表a-z字符,26为标志域。如图:
在这里插入图片描述
优势:

相比于哈希表,使用前缀树来进行查询拥有共同前缀key的数据时十分高效,例如在字典中查找前缀为pre的单词,对于哈希表来说,需要遍历整个表,时间效率为O(n);然而对于前缀树来说,只需要在树中找到前缀为pre的节点,且遍历以这个节点为根节点的子树即可。

但是对于最差的情况(前缀为空串),时间效率为O(n),仍然需要遍历整棵树,此时效率与哈希表相同。

相比于哈希表,在前缀树不会存在哈希冲突的问题。

劣势:

  • 直接查找效率低下

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

  • 可能会造成空间浪费

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

改进:Patricia树

Patricia trie,压缩前缀树,是一种更节省空间的Trie。对于基数树的每个节点,如果该节点是唯一的儿子的话,就和父节点合并。

如之前所提及的,前缀树中会出现严重的存储空间浪费的情况,如下图。图中右侧有一长串节点,这些节点大部分只是充当非叶子节点,用来构建一条路径,目的只是为了存储该路径上的叶子节点。

在这里插入图片描述
针对这种情况,MPT树对此进行了优化:当MPT试图插入一个节点,插入过程中发现目前没有与该节点Key拥有相同前缀的路径。此时MPT把剩余的Key存储在叶子/扩展节点的Key字段中,充当一个”Shortcut“。

下图中对应着将5个单词test、toaster、toasting、slow、slowly插入到树中,其中有三个单词共享t前缀,两个单词共享s前缀,t和s不同,所以在根节点上有两个分支。在左边的分支(t)上,有两个分支,而且est是没有和其他任何单词共享前缀,也即父节点只有一个孩子,则est可以被压缩。类似地oast和slow以及ly可以进行压缩。

在这里插入图片描述
这种做法有以下几点优势:

  • 提高节点的查找效率,避免过多的磁盘访问;
  • 减少存储空间浪费,避免存储无用的节点;

在以太坊中的MPT树中,树节点可以分为以下四类

  • 空节点:(represented as the empty string)
  • 分支节点:A 17-item node[ v0 … v15, vt ]
  • 叶子节点:A 2-item node[ encodedPath, value ]
  • 扩展节点:A 2-item node[ encodedPath, key ]

空节点,简单的表示空,在代码中是一个空串。

叶子节点(leaf),表示为[key,value]的一个键值对,其中key是key的一种特殊十六进制编码,value是value的RLP编码。

扩展节点(extension),也是[key,value]的一个键值对,但是这里的value是其他节点的hash值,这个hash可以被用来查询数据库中的节点。也就是说通过hash链接到其他节点。

分支节点(branch),因为MPT树中的key被编码成一种特殊的16进制的表示,再加上最后的value,所以分支节点是一个长度为17的list,前16个元素对应着key中的16个可能的十六进制字符,如果有一个[key,value]对在这个分支节点终止,最后一个元素代表一个值,即分支节点既可以搜索路径的终止也可以是路径的中间节点。

分支节点

分支节点用来表示MPT树中所有拥有超过1个孩子节点以上的非叶子节点, 其定义如下所示:

type fullNode struct {
        Children [17]node // Actual trie node data to encode/decode (needs custom encoder)
        flags    nodeFlag
}

// nodeFlag contains caching-related metadata about a node.
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
}

与前缀树相同,MPT同样是把key-value数据项的key编码在树的路径中,但是key的每一个字节值的范围太大([0-127]),因此在以太坊中,在进行树操作之前,首先会进行一个key编码的转换(下节会详述),将一个字节的高低四位内容分拆成两个字节存储。通过编码转换,key的每一位的值范围都在[0, 15]内。因此,一个分支节点的孩子至多只有16个。以太坊通过这种方式,减小了每个分支节点的容量,但是在一定程度上增加了树高。

分支节点的孩子列表中,最后一个元素是用来存储自身的内容。

此外,每个分支节点会有一个附带的字段nodeFlag,记录了一些辅助数据:

节点哈希:若该字段不为空,则当需要进行哈希计算时,可以跳过计算过程而直接使用上次计算的结果(当节点变脏时,该字段被置空);
脏标志:当一个节点被修改时,该标志位被置为1;
诞生标志:当该节点第一次被载入内存中(或被修改时),会被赋予一个计数值作为诞生标志,该标志会被作为节点驱除的依据,清除内存中“太老”的未被修改的节点,防止占用的内存空间过多;

叶子节点&&扩展节点

叶子节点与扩展节点的定义相似,如下所示:

type shortNode struct {
        Key   []byte
        Val   node
        flags nodeFlag
}

其中关键的字段为:

  1. Key:用来存储属于该节点范围的key;
  2. Val:用来存储该节点的内容;

其中Key是MPT树实现树高压缩的关键。当MPT试图插入一个节点,插入过程中发现目前没有与该节点Key拥有相同前缀的路径。此时MPT把剩余的Key存储在叶子/扩展节点的Key字段中,充当一个”Shortcut“。【可以结合上面toasting的例子】

此外Val字段用来存储叶子/扩展节点的内容,对于叶子节点来说,该字段存储的是一个数据项的内容;而对于扩展节点来说,该字段可以是以下两种内容:

  • Val字段存储的是其孩子节点在数据库中存储的索引值(其实该索引值也是孩子节点的哈希值);
  • Val字段存储的是其孩子节点的引用;

key值编码

在以太坊中,MPT树的key值共有三种不同的编码方式,以满足不同场景的不同需求,在这里单独作为一节进行介绍。

三种编码方式分别为:

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

Raw编码

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

例如一条key为“cat”,value为“dog”的数据项,其Raw编码就是[‘c’, ‘a’,
‘t’],换成ASCII表示方式就是[63, 61, 74]

Hex编码

在介绍分支节点的时候,我们介绍了,为了减少分支节点孩子的个数,需要将key的编码进行转换,将原key的高低四位分拆成两个字节进行存储。这种转换后的key的编码方式,就是Hex编码。

从Raw编码向Hex编码的转换规则是:

  • 将Raw编码的每个字符,根据高4位低4位拆成两个字节;
  • 若该Key对应的节点存储的是真实的数据项内容(即该节点是叶子节点),则在末位添加一个ASCII值为16的字符作为终止标志符;
  • 若该key对应的节点存储的是另外一个节点的哈希索引(即该节点是扩展节点),则不加任何字符;

key为“cat”, value为“dog”的数据项,其Hex编码为[6, 3, 6, 1, 7, 4, 10]

Hex编码用于对内存中MPT树节点key进行编码

HP编码

MPT树中另外一个重要的概念是一个特殊的十六进制前缀(hex-prefix, HP)编码,用来对key进行编码。因为有两种[key,value]节点(叶节点和扩展节点),所以需要对它们进行区分。此时,引进一种特殊的标识(一个bit即可)来标识key所对应的是值是叶子,还是其他节点的hash。如果标识符是1,那么key对应的是叶节点,反之则是扩展节点。

另外需要注意的一点是,在某个节点处,当前路径的长度可能是奇数。此时会面临的一个问题是,因为路径本身是按照4位,也即一个nibble为单位的,但是存储的时候总是以字节为单位的。假设当前有两个路径分别是‘136’和‘0136’,在存储的时候是没有办法区分的,因为以字节为单位进行存储的时候,总是会转化为01 + 36两个字节。所以,在HP编码中,还必须有一个标识进行路径长度奇偶性的标识。

所以在MPT树中,对每个路径(叶子节点首先移除末尾的16),总是要首先加上一个nibble,这个Nibble的最低位表示节点路径长度奇偶性,第二低位表示节点的性质。

如果key是偶数长度,那么因为又加了一个四个,所以需要加上另外一个值为0的nibble,使得整体长度为偶数。

HP编码的规则如下

  • 若原key的末尾字节的值为16(即该节点是叶子节点),去掉该字节;
  • 在key之前增加一个半字节,其中最低位用来编码原本key长度的奇偶信息,key长度为奇数,则该位为1;低2位中编码一个特殊的终止标记符,若该节点为叶子节点,则该位为1;
  • 若原本key的长度为偶数,则在key之前再增加一个值为0x0的半字节;
  • 将原本key的内容作压缩,即将两个字符以高4位低4位进行划分,存储在一个字节中(Hex扩展的逆过程);

若Hex编码为[6, 3, 6, 1, 7, 4, 10],则HP编码的值为[20, 63, 61, 74]

所添加的nibble的值和所对应的的节点的路径的性质表如下:
在这里插入图片描述

根据bits可以判断出节点的类型和搜索路径长度的奇偶性。0000,查看最后两位的情况,倒数第二位是0,说明是extension节点;最后一位是0,说明路径长度是偶数。0011,最后两位的值是11,第二低位是1说明是扩展节点,最后一位是1说明路径长度为奇数。剩下两种情况类似可分析。

转换关系:
在这里插入图片描述
以上三种编码方式的转换关系为:

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

下面结合一个具体的例子再来描述一遍这个过程。
在这里插入图片描述

在上图中,共有四个键值对。四个key有共同的前缀a7,因此,第一个节点(也是root节点)是一个扩展节点。之后接下来有三个分支,分别是1、7和f。因此,扩展节点之后跟着一个分支节点,分支节点中有三个分支。其中分支1和f之后只有一个节点,因此直接到达了叶子节点。分支7之后有两个节点,并且这两个节点有共同的前缀‘d3’,所以分支7连着一个扩展节点。扩展节点之后有两个分支,3和9,因此再跟上一个分支节点。分支节点之后是两个叶子节点。

然后再分析一下前缀的情况。在路径为’a7’的扩展节点中,路径长度为偶数,本身为扩展节点,因此,第一个nibble为0000,长度为偶数,添加一个nibble 0000,前缀应该为00。第二层的两个叶子节点,长度都为偶数,所以第一个nibble是0010,再添加一个nibble 0000,所以最终前缀应该为20。第二层中的扩展节点,同样长度为偶数,所以前缀应该是00。最后一层的叶子节点中,长度为奇数,所以添加的nibble是0011,也即3。

再看几个节点的Hex编码到HP编码的例子:

[ 1, 2, 3, 4, 5, …] 【扩展结点,路径长度为奇数,添加01(bit),也即1(nibble)】
‘11 23 45’
[ 0, 1, 2, 3, 4, 5, …]【扩展结点,路径长度为偶数,添加00,也即0,然后补一个0】
‘00 01 23 45’
[ 0, f, 1, c, b, 8, 10] 【叶子节点,长度为偶数(最后一个10也即16,补充位,需要移除),添加10,也即0,然后补一个0】
‘20 0f 1c b8’
[ f, 1, c, b, 8, 10] 【叶子节点,长度为奇数,添加11,也即3】
‘3f 1c b8’

再看例子:画出下面的MPT。
假设我们想要一个包含四个路径/值对的trie:
(‘do’, ‘verb’), (‘dog’, ‘puppy’), (‘doge’, ‘coin’), (‘horse’, ‘stallion’).

首先,我们将路径和值都转换为字节。 下面,路径的实际字节表示用<>表示,尽管值仍然显示为字符串,用’'表示,以便于理解(它们实际上也是字节):
(Raw)
<64 6f> : ‘verb’
<64 6f 67> : ‘puppy’
<64 6f 67 65> : ‘coin’
<68 6f 72 73 65> : ‘stallion’

现在,我们在底层数据库中使用以下键/值对构建这样的trie:
(HP)
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’ ]

以上四个键值对,共享一个公共前缀6,所以第一个节点应该是扩展节点,其后4和8有两个分支,因此扩展节点之后跟着分支节点,其中第一个分支节点4之后有三个有共同前缀‘6f’键值对,所以分支4之后是一个扩展节点。而8之后只有一个节点,所以8之后是一个叶子节点。4之后的扩展节点有共同前缀‘6f’,‘6f‘之后有两种情况,分别是空和‘6’。所以该扩展节点之后跟着分支节点。分支节点中6之后跟着扩展节点,因为有两个键值对共享7前缀。空的位置则对应着‘verb’值本身。6之后的扩展节点的共享前缀是’7’,7之后有两种情况,分别是空和’6’。类似地,空的位置对应着单词’puppy’;分支6之后对应着叶子节点‘coin’。

在这里插入图片描述

最后,再构造好Particia树之后,是怎么样和Merkle树结合起来了呢?这里主要是对所有的节点的值都做了Hash。

在这里插入图片描述
在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/z714405489/article/details/83934618