以太坊数据结构与存储分析

当前位置:首页 > 币圈百科 > 以太坊数据结构与存储分析

以太坊数据结构与存储分析

2022-11-12币圈百科552

一、概述

在以太坊中,数据存储大致分为三部分,分别是:状态数据、区块链和底层数据。

其中,底层数据以[k,v]键-值对的形式存储以太坊中的所有数据,目前使用的数据库是LevelDB;所有与交易和操作相关的数据都存储在链中;StateDB用于管理帐户,每个帐户都是一个stateObject。

二。区块部分

区块结构:

区块链是以太坊的核心之一。所有事务和结构都存储在块中。接下来,我们来看看以太坊中的方块结构。

以太坊中所有的结构定义基本都可以在core/types中找到,block结构定义在block.go中

:

受比特币区块链的数据结构影响体。我以为块可以简单的分为头。但是看了以太坊的源代码,发现以太坊并不是这么设计的。在block.go中以太坊区块链的方块结构定义为:

蓑衣网小编2022

可以看到身体头部!=街区.此外,block.go文件中还定义了很多结构,比如

协议的extblock,存储的storageblock等。

如你所见,页眉结构使用非常频繁。接下来看看header结构是怎么定义的:

header成员变量的解释如下:

括号:指向parentBlock的指针。除了Genesis块,每个块只有一个父块。比特币基地:找出这个街区矿工的地址。用于发放奖励。uncle hash:uncles的哈希值,块结构的成员。注意,这个字段是一个头数组。root:StateDB中“状态Trie”根节点的哈希值。块中,每个账户由一个stateObject对象表示,账户用地址唯一标识,在执行相关事务时,账户信息会被修改。所有的账户对象都可以一个一个地插入到一个Merkle-PatricaTrie(MPT)结构中,形成“stateTrie”。Txhash:块中“tx Trie”根节点的哈希值。Block的成员变量transactions中的所有tx对象被逐个插入到一个MPT结构中,形成“tx Trie”。Receipt hash:块中“Receipt Trie”根节点的哈希值。在执行完Block的所有事务后,会生成一个收据数组,该数组中的所有收据将被逐一插入到一蓑衣网小编2022个MPT结构中,形成一个“收据Trie”。(比特币块中有一棵用于存储交易的梅克尔树,而以太坊中有三棵功能不同的M-P树)Bloom:Bloom Filter,用于快速判断一组已知日志中是否存在某个参数日志对象。难度:街区的难度。块的难度是根据parentBlock的时间和难度通过consensus算法计算出来的,它将应用于块的‘挖掘’阶段。编号:块的序号。块的编号等于其父块的编号1。时间:“应该”创建块的时间。由共识算法确定。一般来说,要么等于parentBlock。时间10s或等于当前系统时间。GasLimit:一个区块所有天然气消耗量的理论上限。该值在创建块时设置,并且与父块相关。具体是根据父块的GasUsed和GasLimit * 2/3的大小关系来计算的。GasUsed:执行块中所有事务时实际消耗的气体总量。Nonce:用于“挖掘”的64位散列数。

块存储:

块的数据最终以[k,v]的形式存储在数据库中。数据库在主机中的存储位置是:

datadir/geth/chaindata。具体代码在core/database _ util.go中,在块存储中,head和

body是分开存储的。

以writeheader为例:

存储头时,内容部分的键是:前缀num(uint 64 be gendian)hash;值是块

报头的rlp码。 显然shortNode 的设计体现了PatriciaTrie 的特点,通过合并只有一个子节点的父节点和其子节点来缩短trie 的深度,结果就是有些节点会有长度更长的key。

valueNode?充当MPT 的叶子节点。它其实是字节数组[]byte 的一个别名,不带子节点。在使用中,valueNode 就是所携带数据部分的RLP 哈希值,长度32byte,数据的RLP 编码值作为valueNode 的匹配项存储在数据库里。

这三种类型覆盖了一个普通Trie(也许是PatriciaTrie)的所有节点需求。任何一个[k,v]类型数据被插入一个MPT 时,会以k 字符串为路径沿着root 向下延伸,在此次插入结束时首先成为一个valueNode,k 会以自顶点root 起到到该节点止的key path 形式存在。但之后随着其他节点的不断插入和删除,根据MPT 结构的要求,原有节点可能会变化成其他node 实现类型,同时MPT 中也会不断裂变或者合并出新的(父)节点。比如:

假设一个shortNode S 已经有一个子节点A,现在要新插入一个子节点B,那么会有两种可能,要么新节点B 沿着A 的路径继续向下,这样S 的子节点会被更新;要么S 的Key 分裂成两段,前一段分配给S 作为新的Key,同时裂变出一个新的fullNode 作为S 的子节点,以同时容纳B,以及需要更新的A。

如果一个fullNode 原本只有两个子节点,现在要删除其中一个子节点,那么这个fullNode 就会退化为shortNode,同时保留的子节点如果是shortNode,还可以跟它再合并。

如果一个shortNode 的子节点是叶子节点同时又被删除了,那么这个shortNode 就会退化成一个valueNode,成为一个叶子节点。诸如此类的情形还有很多,提前设想过这些案例,才能正确实现MPT 的插入/删除/查找等操作。当然,所有查找树(search tree)结构的操作,免不了用到递归。

hashNode 跟valueNode 一样,也是字符数组[]byte 的一个别名,同样存放32byte 的哈希值,也没有子节点。不同的是,hashNode 是fullNode 或者shortNode 对象的RLP 哈希值,所以它跟valueNode 在使用上有着莫大的不同。

在MPT 中,hashNode 几乎不会单独存在(有时遍历遇到一个hashNode 往往因为原本的node 被折叠了),而是以nodeFlag 结构体的成员(nodeFlag.hash)的形式,被fullNode 和shortNode 间接持有。一旦fullNode 或shortNode 的成员变量(包括子结构)发生任何变化,它们的hashNode 就一定需要更新。所以在trie.Trie 结构体的insert(),delete()等函数实现中,可以看到除了新创建的fullNode、shortNode,那些子结构有所改变的fullNode、shortNode 的nodeFlag 成员也会被重设,hashNode 会被清空。在下次trie.Hash()调用时,整个MPT 自底向上的遍历过程中,所有清空的hashNode 会被重新赋值。这样trie.Hash()结束后,我们可以得到一个根节点root 的hashNode,它就是此时此刻这个MPT结构的哈希值。

Header 中的成员变量Root、TxHash、ReceiptHash 的生成,正是源于此。明显的,hashNode 体现了MerkleTree 的特点:每个父节点的哈希值来源于所有子节点哈希值的组合,一个顶点的哈希值能够代表一整个树形结构。hashNode 加上之前的fullNode,shortNode,valueNode,构成了一个完整的Merkle-PatriciaTrie 结构,很好的融合了各种原型结构的优点,非常值得研究。

节点增删查改用到的函数都定义于trie.go。在MPT 的查找,插入,删除过程中,如果在遍历时遇到一个hashNode,首先需要从数据库里以这个哈希值为k,读取出相匹配的v,然后再将v 解码恢复成fullNode 或shortNode。在代码中这个过程叫resolve。

五StateDB

在系统设计中,底层数据库模块和业务模型之间,往往需要设置本地存储模块,它面向业务模型,可以根据业务需求灵活的设计各种存储格式和单元,同时又连接底层数据库,如果底层数据库(或者第三方API)有变动,可以大大减少对业务模块的影响。在以太坊中,StateDB 就担任这个角色,它通过大量的stateObject 对象集合,管理所有“账户”信息。

StateDB 有一个trie.Trie 类型成员trie,它又被称为storage trie 或stte trie,这个MPT 结构中存储的都是stateObject 对象,每个stateObject 对象以其地址(20 bytes)作为插入节点的Key;每次在一个区块的交易开始执行前,trie 由一个哈希值(hashNode)恢复出来。另外还有一个 map 结构,也是存放 stateObject,每个 stateObject 的地址作为 map 的 key。那么问题来了,这些数据结构之间是怎样的关系呢?

如上图所示,每当一个 stateObject 有改动,亦即“账户”信息有变动时,这个stateObject 对象会更新,并且这个 stateObject 会标为 dirty,此时所有的数据改动还仅仅存储在 map 里。当 IntermediateRoot()调用时,所有标为 dirty 的 stateObject 才会被一起写入 trie。而整个 trie 中的内容只有在 CommitTo()调用时被一起提交到底层数据库。可见,这个 map 被用作本地的一级缓存,trie 是二级缓存,底层数据库是第三级,各级数据结构的界限非常清晰,这样逐级缓存数据,每一级数据向上一级提交的时机也根据业务需求做了合理的选择。

StateDB 还可以管理账户状态的版本。这个功能用到了几个结构体:journal,revision,先来看看 UML 关系图:

其中 journal 对象是 journalEntry 的散列,长度不固定,可任意添加元素,接口journalEntry 存在若干种实现体,描述了从单个账户操作(账户余额,发起合约次数等),到account trie 变化(创建新账户对象,账户消亡)等各种最小事件。revision 结构体,用来描述一个‘版本’,它的两个整型成员 jd 和 journalIndex,都是基于 journal 散列进行操作的。

上图简述了 StateDB 中账户状态的版本是怎么样管理的。首先 journal 散列会随着系统运行不断的增长,记录所有发生过的单位事件;当某个时刻需要产生一个账户状态版本时, 代码中相应的是 Snapshop()调用,会产生一个新 revision 对象,记录下当前 journal 散列的长度,和一个自增 1 的版本号。

基于以上的设计,当发生回退要求时,只要根据相应的 revision 中的 journalIndex,在journal 散列上,根据所记录的所有 journalEntry,即可使所有账户回退到那个状态。每个 stateObject 对象管理着以太坊中的一个“账户”。stateObject 有一个成员变量data,类型蓑衣网小编2022是 Accunt 结构体,里面存有账户 Ether 余额,合约发起次数,最新发起合约指令集的哈希值,以及一个 MPT 结构的顶点哈希值。

stateObject 内部也有一个 Trie 类型的成员 trie,被称为 storage trie,它里面存放的是一种被称为 State 的数据。State 跟每个账户相关,格式是[Hash, Hash]键值对。有意思的是,stateObject 内部也有类似 StateDB 一样的二级数据缓存机制,用来缓存和更新这些State。

stateObject 定义了一种类型名为 storage 的 map 结构,用来存放[Hash,Hash]类型的数据对,也就是 State 数据。当 SetState()调用发生时,storage 内部 State 数据被更新,相应标示为”dirty”。之后,待有需要时(比如 updateRoot()调用),那些标为”dirty”的 State 数据被一起写入 storage trie,而 storage trie 中的所有内容在 CommitTo()调用时再一起提交到底层数据库。

以太坊数据结构与存储分析 | 分享给朋友: