以太坊状态树

以太坊采用的是基于账户的模式,系统中显式维护每个账户的余额

用什么数据结构来实现这种模式?

我们要完成的是账户地址到账户状态的映射,addr→state,以太坊用的账户地址是160bits,一般表示成40个十六进制数,状态是外部账户的状态和合约账户的状态,包括余额,交易数,对于合约账户,还包括代码和存储。

第一种方案,用hash表+merkle tree。从直观上看,映射就是一个key-value对,很自然的想法是用一个hash表来实现。系统中的全节点维护一个hash表,每次有一个新的账户插入到hash表里,查询账户的余额就查hash表,如果不考虑hash碰撞,查询速度可以在常数级别,更新也很方便。这种方案的问题是,以太坊要防止所有账户的state被篡改,就要像比特币一样构建一个merkle tree,里面的交易变成账户状态,如果每次发布一个合法交易,某个账户状态发生改变,那么每个全节点都要重新构造merkle tree,代价太大。
第二种方案,不用hash表,直接用merkle tree,修改的时候直接改merkle tree。这个方法的问题在于merkle tree没有提供高效的查找和更新的方法,另外不同节点构造的merkle tree 叶子节点顺序不同也会导致merkle root 不同 ,所以得用sorted merkle tree ,但是sorted merkle tree 也有问题,如果新创建的用户地址在hash值在中间,那么插入merkle tree之后,merkle tree几乎重构。

有一种数据结构叫trie(字典树或前缀树),从retrieval(检索)中来,下面是一个一些单词组织成一个trie的例子
以太坊状态树_第1张图片
上图中,单词根据每一位的不同进行分裂,比如第二列有e和o就有两个分叉,第三列有在第二列是e的基础上只有n,所以只有一个分叉,单词有可能在非叶子节点结束。这个结构有一些特点。

第一个特点,在trie中,每个节点的分支数目取决于key中每个元素的取值范围,这个例子中,每个都是小写英文单词,所以每个节点的分叉数目最多27(26个小写字母+1个结束标志位)个,结束标志位表示到这个地方,这个单词就结束了。在以太坊中,地址是40位十六进制数,分叉数目有时候也叫branching factor 是17。
第二个特点,trie的查找效率取决于key的长度,键值越长,查找需要访问内存的次数就越多,以太坊中,键值都是40位。
第三个特点,如果用hash表存储key-value对,有可能出现碰撞,trie不会出现碰撞,只要地址不同,最后一定是不同分支
第四个特点,mekle tree不排序的话插入的账户位置不一样导致树的结构不一样,trie不论插入顺序如何,插入内容一致,最后的树就是一样的,这个对于以太坊非常有用
第五个特点,每次发布交易的时候,系统中大部分账户不变,只有少部分账户的状态需要更新,trie的局部更新性很好,只需要访问对应分支(注意,上图只画出了key)就可以找到value进行修改。

但是trie有一个缺点,trie的存储比较浪费,像上图有些节点只有一个子节点。如果能把这些节点合并,就可以减少存储的开销,也提高了查找的效率。

还有一种数据结构叫patricia tree或patricia trie(压缩前缀树),用上图例子进行路径压缩的结果如下
以太坊状态树_第2张图片
直观上看,树的高度减少了,存储更密集了。但是,如果新插入一个单词,原来压缩的路径可能需要扩展开来,假设上图加入geometry,就不能压缩成EN节点了。路径压缩有时候效果明显,有时候不明显。树中插入的键值分布如果比较稀疏情况下,路径压缩效果明显。比如假如上图的每个英文单词都很长,但是一共没有几个单词(misunderstanding、decentralized、disintermediation(去中心商,意思是让系统中的价值提供者和消费者直接交互)),这个时候插入到trie中,就会变成下图
以太坊状态树_第3张图片
如果用了压缩树,就会变成
以太坊状态树_第4张图片
因此键值分布比较稀疏的时候,路径压缩效果较好。而在以太坊中,键值是地址,160位,总的地址空间有2160位,非常大。以太坊的账户数和2160相比微乎其微,所以键值分布非常稀疏

第三种方案 先提一下MPT(merkle patricia tree)。MPT和PT的区别就是连接节点之间的指针用的是hash指针,最后会保留一个关于状态树的merkle root,它的作用之一也是防止账户状态被篡改,作用之二是merkle proof证明账户余额,将账户状态所在分支发给轻节点即可证明,作用之三是证明账户不存在,如果账户存在,把应当存在的账户的所在分支发给轻节点验证,验证失败则不存在。以太坊的状态树用的就是MMPT(modified MPT),下图是一个例子。
以太坊状态树_第5张图片
右上角有4个账户,为了简单起见,地址都非常短,就是上面的keys,账户状态只显示余额,就是上面的values。树中的节点分为三种,Extension Node,如果树的某一部位进行了压缩,就会出现一个Extension Node。因为4个地址的前两位开头都是a7,所以根节点就是Extension Node。下一层出现了分叉,所以出现了一个Branch Node。1的后面只有一个1355,所以它就是一个Leaf Node。7的后面有两个地址都是d3,所以压缩,再往下是3和9就分开来了,所以是一个Branch Node,再下面就是Leaf Node了。最右边的f后面就只有一个9365,所以是一个Leaf Node。

每次发布一个新的区块的时候,状态树中有一些节点的值会发生变化,这些改变不是原地修改,而是新建分支,原来的分支被保存。
以太坊状态树_第6张图片
上图是两个相邻的区块,State Root是状态树的根hash值,虽然每一个区块只有一个State Root,但是两棵树的大部分节点是共享的,右边的树主要指向左边这棵树的节点,只有发生改变的节点需要新建分支。上图例子中是合约账户(包括nonce,balance,codehash,storage root的那个节点)发生变化,每一个合约账户的存储都是一棵小的MPT,上图交易次数nonce发生变化,balance发生变化,代码不变,所以codehash指向原来的code,存储变了,但是存储树中的大部分节点也是没有改变,唯一的改变的29变成了45,所以新建了一个分支。所以系统中要维护的不只是一颗MPT,而是每次出现一个区块,都要新建一个MPT,只不过这些状态树中大部分节点是共享的,只有少数发生变化的节点要新建分支。为什么要保留历史状态?系统当中有时候会出现分叉,临时性分叉非常普遍,假设出现一个分叉,两个节点同时获得记账权,如果上面一个节点胜出,下面的节点可以roll back(回滚对账户状态的修改)然后顺着上面的节点所在分支继续挖,这个和比特币不太一样,比特币交易类型比较简单,有的时候可以反向操作推断出前一个状态,比如说转账交易,A给B转了10个比特币,回滚只需要给A加10个比特币,B减去10个比特币,但是以太坊中不行,因为以太坊中有智能合约,智能合约执行完成后再推算之间的状态是不可能的,所以要想支持回滚就要记录历史状态。

以太坊中代码的数据结构以太坊状态树_第7张图片
上图是block header结构
ParentHash:前一个区块块头的hash
UncleHash:叔叔区块的hash,可能比Parent大好几辈
Coinbase:挖出区块的矿工地址
Root:状态树的根hash
TxHash:交易树的根hash,类似于比特币中的merkle root
ReceiptHash:收据树的根hash
Bloom:布隆过滤器,和收据树相关,提供高效的查询符合某种条件的交易的执行结果
Difficulty:挖矿难度
GasLimit和GasUsed和汽油费相关,智能合约消耗汽油费,类似于比特币中的交易费
Time:区块产生时间
MixDigest和Nonce和挖矿过程相关
以太坊状态树_第8张图片
上图是区块结构,header是指向block header的指针,uncles是指向叔叔区块的指针,而且是数组,transactions是交易列表
以太坊状态树_第9张图片
上图是区块在网上发布的真实结构,其实就是区块结构的前三项。

我们知道状态树保存的是key-value pairs,key就是地址,value是账户状态,账户状态要经过序列化过程才能保存进状态树中,序列化用的是RLP(Recursive Length Prefix),特点是简单,只支持nested array of bytes ,意思是字节数组可以嵌套,以太坊中所有数据类型最后都要变成nested array of bytes,

你可能感兴趣的:(区块链)