区块链学习笔记15——以太坊中的状态树

十五、以太坊中的状态树

以太坊采用基于账户的模式,系统中显式地维护每个账户上有多少余额,今天看一下用什么样的数据结构来实现account-based ledger。

完成的功能:从账户地址到账户状态的映射,addr->state。

addr:账户地址,以太坊中用的账户地址是160位,也就是20个字节,一般表示成40个十六进制的数。

state:外部账户和合约账户的状态,包括余额,交易次数nonce,合约账户还包括代码和存储。

数据结构

几个方案

直观的想法:用哈希表实现,系统中的全节点维护一个哈希表,每次有一个新的账户,插入到哈希表里面,查询账户的余额,就直接在哈希表中查询,如果不考虑哈希碰撞的话,基本上查询的效率是常数时间内完成的,更新也是很容易在哈希表中更新的。

如果用这个哈希表要提供merkle proof怎么提供?

比如说你要跟一个人签合同,希望他能证明一下他有多少钱,怎么提供证明呢?

一种方法是把哈希表中的元素组织成一个Merkle tree,然后算出一个根哈希值,这个根哈希值存在block header里,只要根哈希值是正确的,就能保证底下的树不会被篡改。

如果有新区块发布怎么办,新区块中包含新的交易,执行这个交易必然会使哈希表的内容发生变化,发布下一个区块的时候,再重新把哈希表中的内容组织成一个Merkle tree吗?这个代价是不是太大了,实际上,真正发生变化的账户状态z只是一小部分,因为只有那个区块里所关联的账户才会发生变化,大多数账户的状态是不变的,所以每次你都重新构造一次Merkle tree,这个代价是很大的。

比特币系统当中难道不是每出现一个区块也要重新构造一个Merkle tree吗,那个为什么没有问题,那个Merkle tree是把区块里包含的交易组织成一个Merkle tree,那区块中的交易每次发布一个新的区块又有一系列新的交易,所以比特币中的Merkle tree是immutable的,每次发布一个新的区块对应一个Merkle tree,然后这棵Merkle tree构建完之后是不会再改的,下次再发布一个新的区块再构建一个新的Merkle tree,那区块里有多少个交易呢?最多差不多4000个(按照1M字节,每个交易大概是250M字节左右),其实是一个上限,很多区块的交易数目根本到不了4000个,有好多区块就只有几百个,甚至有可能还有更少的,所以每次发布一个区块,比特币里构建一个Merkle tree,是要把这几百个到几千个交易构成一个Merkle tree,这里如果采用这种方法会是什么情况?是要把所有的以太坊账户一起构成一个Merkle tree,这个就比刚才讲的几百,几千个交易要高出好几个数量级,相当于每次发布一个区块要把所有的账户遍历一遍构建出一个Merkle tree,下次再有一个区块,再把所有的账户遍历一遍,再构建出一个Merkle tree,除了提供Merkle proof证明账户有多少钱之外,这个Merkle tree还有另外一个很重要的作用,就是维护各个全节点之间状态的一致性,如果没有根哈希值不去发不出来,每个节点就是在本地维护一个数据结构,那怎么知道你的数据结构的状态跟别人的数据结构的状态是不是一致呢,要各个全节点保持状态的一致才行,这也是为什么比特币中把根哈希值写在块头里的原因,就是对于当前区块中包含哪些交易,所有的全节点要有一个国共识。

如果每个全节点在本地维护一个哈希表,然后需要构建Merkle tree的时候构建出Merkle tree来,然后根哈希值发到区块头里,这个方法是不行的,哈希表本身的效率是挺好的,插入,更改效率都很好,但是每次构建Merkle tree的代价太大了。

考虑第二种方案:
能不能不要哈希表了,直接用一个Merkle tree把所有的账户都放进去,要改的时候直接在Merkle tree里改,因为每个区块更新的只是一小部分账户,所以改的时候只是Merkle tree里的一小部分,这个方法的问题就在于Merkle tree没有提供一个高效的查找,更新的方法,比特币的Merkle tree是怎么构建的,就最底下一层是tx,然后哈希值放到上面节点里,两两结合,然后再取一个哈希往上整,他没有提供一个快速查找,更新的方法,还有一个问题是,如果这样构建Merkle tree,就直接把账户放到Merkle tree里,这个Merkle tree要不要排序,以前讲过这个Sorted Merkle tree,如果不排序会怎么样,除了查找速度会慢,还有一个问题,叶节点是这些账户的信息,如果不规定这些账户在叶节点出现的顺序,那么这样构建出来的Merkle tree不是唯一的,比如,系统中有很多全节点,每个全节点按照自己的某个顺序,比如说他听到某个交易的顺序构建一个Merkle tree,那么叶结点的顺序是乱的,每个节点都是自己决定的,最后构建出的Merkle tree是不一样的,算出的根哈希值也是不一样的,比特币中的节点也是不排序的,那为什么比特币就没有问题呢,因为比特币中的每个全节点收到的交易的顺序也是不一样的,理论上说构建的Merkle tree的根哈希值也是不一样的,但是比特币有一个区别,比特币中虽然也没用排序的Merkle tree,但是他那个顺序是唯一的,是发布区块的那个节点确定的,那为什么这里不能这样做,因为如果也要这么做的话,需要把账户的状态发布到区块里,也可以说每个全节点怎么把账户组织成一个Merkle tree,账户状态可以维护在本地,而且大部分账户状态是不变的,一个区块里的交易只能改很少的账户,大多数账户是不变的,而且重复发布,每隔十几秒发布一个新的区块,把所有状态都打包发布一遍,下次再过十几秒再发布一遍,这个是不可行,刚才说明了不排序的Merkle tree是不行的。

第三种方案:
用Sorted Merkle tree,也会有问题,新增一个账户怎么办,产生一个账户的地址是随机的,他的叶节点的位置很可能是插在中间的,那后面这些树的结构都得变,插入,删除代价都很大,区块链是不可篡改的,是说添东西容易,删东西难,其实以太坊中没有显式地删除账户的操作,有的账户上就一点钱,就一两个Wei,你也不能把他删掉。

上面的方案都不行。

那么以太坊中采用的方法是用一个叫MPT的结构。讲这个之前先讲一个简单的数据结构。

trie

trie:字典树,前缀树,也是一种key value的树,一般来说key用字符串用的比较多,比如说一些单词排成一个trie的数据结构,比如说有general,genesis(区块链的第一个区块叫做genesis block,创世纪块),god,go,good。

下图就是一个trie的结果,这几个单词都是以G开头的,然后第二个字母就开始分裂了,左边是E,右边是O,左边这前两个单词都是N和E,然后下面再分开,R和S,然后是后三个字母,右边这个分支,O这个分支,Go就已经结束了,从这个可以看到单词可能在trie的中间节点结束,然后左边是D,右边是O,左边变成了God,右边下来是Good。

区块链学习笔记15——以太坊中的状态树_第1张图片

这个结构有一个特点:

  1. 在trie当中,每个节点的分支数目取决于key值里每个元素的取值范围,这个例子当中,每个都是英文单词,而且是小写的,所以每个节点的分叉数目最多是26个,加上一个结束标志位,表示这个单词到这个地方就结束了。

    那在以太坊中,是什么样的呢?

    地址是表示成40个十六进制的数,所以分叉数目有时也叫做branching factor,是17,因为是十六进制的0~f,再加上结束标志位,是17

  2. trie的查找速率取决于key的长度,键值越长,查找需要访问的次数就越多,跟这个例子不同,以太坊中,所有键值都是40,因为地址都是40位十六进制的数,比特币和以太坊的地址是不通用的,两个地址的格式长度都是不一样的,但有一点是类似的,以太坊中的地址也是公钥经过转换得来的,其实就是公钥取哈希,然后前面的不要,只要后面这部分,就得到一个160bit的地址

  3. 只要两个地址不一样,最后肯定映射到树中的不同分支,所以trie是不会出现碰撞的

  4. 前面讲Merkle tree,如果不排序的话,一个问题是账户插入到Merkle tree 的顺序不一样,得到的树的结构也不一样,那trie呢,比如这五个单词,换一个顺序插到这个树里面,得到的是一个不同的树吗?其实是一样的,只要给定的输入不变,无论输入怎么打乱重排,最后插入到trie当中,得到的树是一样的。不同的节点,不论你怎么按照顺序去插入这些账户,最后构造出来的树是一样的

  5. 跟更新相关,每次发布一个区块,系统中绝大多数账户的状态是不变的,只有个别受到影响的账户才会变,所以更新操作的局部性很重要,这个例子中的局部性呢,比如说,我要更新genesis这个key对应的value,这个图当中只画出了key,没有花出value,只要访问genesis的那个分支,其他分支不用访问的,也不用遍历整棵树,更新的局部性是很好的

缺点

存储浪费,像左边分支都只有一个子节点,对于这种一脉单传的情况,如果能把节点进行合并,那么可以减小存储的开销,同时也提高了查找的效率,不用一次一个一个的往下找了,那么就引入了Patricia tree,也有人写成Patricia trie,这都没有什么关系,这都是经过路径压缩的前缀树,有时候也管它叫压缩前缀树。

Patricia tree

这个如果进行路径压缩就变成下图的样子

区块链学习笔记15——以太坊中的状态树_第2张图片

可以看到,G下面还是E和O进行分叉,E下面之后跟的都是NE,再往下就是E和S分叉,然后后面都和在一起了,右边都是一样的。

所以这样压缩之后有什么好处,直观上看,这个高度明显缩短了,访问内存的次数会大大减少,效率提高了。

注意,对于Patricia tree来说,新插入一个单词,原来压缩的路径可能需要扩展开,比如这个例子当中,加入geometry,左边的分支就不能那样压缩了。

路径压缩在什么情况下效果比较好?

树中插入键值的分布如果是比较稀疏的情况下,做不做路径压缩效果差距比较大,比如说,这个例子当中是用英文单词,比如说每个单词都很长,但是一共没有几个单词,举个例子,比如说有misunderstanding,decentralization(去中心化),disintermediation(非中间化)(intermediaries:中间商)。这三个单词插入到一个普通的trie里面就成了下图的样子

区块链学习笔记15——以太坊中的状态树_第3张图片

可以看到这样的结构效率是比较低的,基本上是一条线了,如果用Patricia tree的话,参考下图

区块链学习笔记15——以太坊中的状态树_第4张图片

这个数的高度明显改善多了。

所以键值分布比较稀疏的时候,路径压缩效果比较好。

我们这个应用中键值是不是稀疏呢?

这个应用中键值是地址,地址是160位的,所以地址空间有2的160次方大,这是一个非常非常大的数,如果你设计一个计算机程序的算法,他需要进行运算的次数是2的160次方,那这个在我们所有人的有生之年都不可能算出来,全世界的以太坊的账户数目加在一起也远远没有这么大,跟这个数比,是微乎其微的。

为什么要弄这么稀疏,不把地址长度缩短一点,这样访问效率也快,也没必要那么稀疏了?

以太坊中普通账户跟比特币的创建方法是一样的,没有一个中央的节点,就每个用户独立创建账户,你在本地产生一个公私钥对,就是一个账户,那怎么防止两个人的账户碰撞,产生的一样呢,这种可能性是存在的,但是这个概率比地球爆炸还要小,怎么达到这么小的概率,就是地址要足够长,分布足够稀疏,才不会产生碰撞,这个可能看上去有点浪费,但是这是去中心化的系统防止账户冲突的唯一办法,所以他是非常稀疏的,这就是为什么在数据结构中,要用Patricia tree。

MPT(Merkle Patricia tree)

区块链与普通链表的区别:把普通指针换成了哈希指针,

所有的账户组织成一个Patricia tree,用路径压缩提高效率,然后把普通指针换成哈希指针,所以就可以计算出一个根哈希值,也是写在block header里面,比特币的block header里只有一个根哈希值,就是区块里包含的交易组成的Merkle tree组成的根哈希值,以太坊中有三个,以太坊中也有一个交易组成的,叫做交易树,现在讲的是状态树,账户状态最后组织成了一个Merkle tree,他的根哈希值。

这个根哈希值的用处

  1. 防止篡改,只要根哈希值不变,整个树的任何部分都没有办法被篡改,也就是说每个账户的状态都能保证他是没有被篡改过的
  2. Merkle proof,能证明账户的余额是多少,你这个账户所在的分支自己向上作为Merkle proof发给轻节点,轻节点可以验证你的账户上有多少钱
  3. 给一个地址转账之前,验证一下全节点里有没有这个账户信息,说的更直白一点,能不能证明MPT中某个键值是不存在的,答案是可以的,证明方法跟Sorted Merkle tree类似,如果存在的话,是在什么样的分支,把这个分支作为Merkle proof发过去,可以证明他是不存在的

以太坊中用到的不是原生的MPT,是Modified MPT,就是对MPT做一些修改,这些修改不是很本质的修改

看个例子,这个例子当中有四个账户(右上角),为了简单起见,地址都比较短,假设只有7位的地址,而不是40位,账户状态也只显示出了余额,其他账户状态没有显示出来,第一个账户有45个以太币,第二个账户比较穷,只有1WEI,这个是以太坊中最小的计量单位,1WEI基本上可以忽略不计,这个例子当中,节点分为三种

Extension Node:如果这个树中出现了路径压缩就会有一个Extension Node。

这四个地址前两位都是一样的a7,所以他的根节点就是一个Extension Node,shared nibble(nibble:16进制数的意思),一个nibble就是一个16进制数,然后第三位就分开了,有1,7,f,所以就跟了一个Branch Node,先说1,这个1之后就是1355,只有这一个地址,就跟了Leaf Node,这个7有两个地址,连着路径压缩d3,然后再往下3和9分开了,跟着一个Branch Node,下面两个Leaf Node,都是7,最后一个f,就跟着一个Leaf Node,9365。这就是状态树,另外这个树的根节点取哈希之后得到的一个根哈希值,是要写在块头里的。

区块链学习笔记15——以太坊中的状态树_第5张图片

每次发布一个新的区块的时候,这个状态树中,有一些节点的值会发生变化,这些改变不是在原地改,而是新建一个分支,原来的状态其实是保留下来的。

下面这个例子当中,有两个相邻的区块

区块链学习笔记15——以太坊中的状态树_第6张图片

Block N Header:这个State Root就是状态树的根哈希值,下面显示的是这棵树

Block N+1 Header:这个是新的区块的状态树,可以看到,虽然每一个区块都有一个状态树,但是这两颗树的大部分节点是共享的,右边这棵树主要都是指向左边这颗树的节点,只有那些发生改变的节点是需要新建一个分支。

这个例子当中,是这个账户发生了变化,这个账户是一个合约账户,因为有Code,还有存储,合约账户的存储也是有MPT保存下来的,这个存储其实也是一个Key Value Store,维护的是从这个变量到这个变量取值的一个映射,也是一个Key Value Store,在以太坊当中,也是用的一棵MPT,所以以太坊中的这个结构是一个大的MPT,包含很多小的MPT,每一个合约账户的存储都是一棵小的MPT。

这个账户的新的区块里,交易次数的Nonce是发生变化的,Balance余额也发生变化,代码是不变的,所以codehash指向原来树中那个节点,存储是变的,存储下面这个叫存储树,树中,大部分节点也是没有改变,这个例子当中,只有一个节点,从29变成了45,所以新建了一个分支。

所以,系统中每个全节点需要维护的不是一颗MPT,而是每次出现一个区块,都要新建一个MPT,只不过这些状态树中,大部分的节点是共享的,只有少部分发生变化的节点要新建分支。

为什么要保留历史状态,为什么不在原地直接改了?

系统当中有时候会出现分叉,临时性的分叉是很普遍的,以太坊把出块时间降低到十几秒之后,这种临时性的分叉是常态,因为区块在网上传播时间可能也需要十几秒。

假设由有个分叉,这两个节点同时获得记账权,这两个分叉最终上面那个胜出了,下面这个分叉的节点该怎么办,这个时候就要回滚(roll back),就这个节点当前的状态,就接受了下面这个节点的状态要取消掉,退回到上一个节点的状态,然后沿着上面那条链往下推进。

区块链学习笔记15——以太坊中的状态树_第7张图片

有时候可能要把当前状态退回到没有处理到这个区块中交易的前一个状态。

那怎么实现回滚呢,就是要维护这些历史纪录。这个跟比特币还不太一样,如果是比特币的话,他的交易类型比较简单,有的时候可以通过这种反向操作推算出前一个状态,比如说就是简单的转账交易

区块链学习笔记15——以太坊中的状态树_第8张图片

上图这个对账户余额有什么影响呢,A的账户上少了10个比特币,B的状态多了10个比特币,假如这个状态要回滚了,退回到前一个状态,那就把B这个账户减少10个比特币,把A这个账户加回去10个比特币就行了,简单的转账交易回滚其实是比较容易的,以太坊中为什么不行?因为以太坊中有智能合约,是图灵完备的,编程功能是很强的,从理论上说,可以实现很复杂的功能,跟比特币简单的脚本还不太一样,所以以太坊中如果不保存前面的状态,智能合约执行完之后,想在推算出前面是什么状态,这是不可能的,所以要想支持回滚,必须保存历史状态

下面是以太坊中代码的数据结构

这个是block header

第一个是ParentHash,是父区块的哈希值,就是区块链中前一个区块的哈希值,不是区块的块头的哈希值

第二个UncleHash,是叔父区块的哈希值,后面讲Ghost协议的时候,每个区块还有叔父区块,而且后面会看到,比较奇怪的一点是,大多数人表面上看ParentHash,UncleHash是一个辈分的,但是以太坊中不是这样的,这个Uncle比Parent可能大好多辈分

第三个Coinbase,是挖出这个矿的这个区块的矿工的地址

下面这三个就是本讲相关的三棵树的根哈希,以太坊中有三棵树,有状态树,交易数和收据树,这个Root是讲的状态树的根哈希值,TxHash是交易树的根哈希值,跟比特币中的那个根哈希值,ReceiptHash是收据树的根哈希值。

下面这个Bloom,跟收据树是相关的,提供一个高效的查询,符合某种条件的交易的执行结果。

Diffculty是挖矿难度,要根据需要调整

GasLimit和GasUsed是跟汽油费相关的,智能合约要消耗汽油费,类似于比特币中的交易费

Time是区块的大致的产生时间

MixDigest和Nonce是跟挖矿过程相关的,这个nonce也是挖矿时猜的那个随机数,类似于比特币的挖矿,以太坊中的挖矿也是要猜很多个随机数,写在块头里的随机数是最后找到的,符合难度要求的,MixDigest是从nonce这个随机数经过一些计算,算出一个哈希值。

区块链学习笔记15——以太坊中的状态树_第9张图片

下面这是区块的结构,对这篇文章来说比较相关的就是前面三个域,header,uncles和transactions

第一个header就是指向block header的指针,就是上面那张图

uncles是指向叔父区块的header的指针,而且是个数组,因为一个区块可以有多个叔父区块,transactions就是这个区块中交易的列表

区块链学习笔记15——以太坊中的状态树_第10张图片

这个区块在网上发布的时候,发布的就是这些信息,其实就是刚刚看到的前三项真正发布出去

区块链学习笔记15——以太坊中的状态树_第11张图片

状态树中保存的是(key,value),key就是地址,讲到现在,主要讲的是键值,这个地址的管理方式,那么这个value呢,这个账户的状态呢,是怎么存储在状态树当中的,实际上是要经过一个序列化(RLP)的过程,然后再存储

RLP:Recursive Length Prefix,是一种序列化方法,特点是简单,极简主义,越简单越好

Protocal buffer:简称Protobuf,是个很有名的做序列化的库

跟这些库相比,RLP的理念就是越简单越好,只支持一种类型,nested array bytes,字节数组,一个一个字节组成的数组,可以嵌套,以太坊里的所有的其他类型,整数也好,比较复杂的哈希表也好,最后都要变成nested array bytes,所以实现RLP要比实现Protocal buffer简单很多,因为难得东西,他都不做,都推给应用层了。

你可能感兴趣的:(区块链,以太坊,数据结构)