死磕以太坊源码分析之state

配合以下代码进行阅读:https://github.com/blockchainGuide/

希望读者在阅读过程中发现问题可以及时评论哦,大家一起进步。

源码目录

|-database.go 底层的存储设计
|-dump.go 用来dumpstateDB数据
|-iterator.go,用来遍历Trie
|-journal.go,用来记录状态的改变
|-state_object.go 通过state object操作账户值,并将修改后的storage trie写入数据库
|-statedb.go,以太坊整个的状态
|-sync.go,用来和downloader结合起来同步state

基础概念

状态机

以太坊的本质就是一个基于交易的状态机(transaction-based state machine)。在计算机科学中,一个 状态机 是指可以读取一系列的输入,然后根据这些输入,会转换成一个新的状态出来的东西。

我们从创世纪状态(genesis state)开始,在网络中还没有任何交易的时候产生状态。当第一个区块执行第一个交易时候开始产生状态,直到执行完N个交易,第一个区块的最终状态产生,第二个区块的第一笔交易执行后将会改变第一个区块链的最终状态,以此类推,从而产生最终的区块状态。

以太坊状态数据库

区块的状态数据并非保存在链上,而是将这些状态维护在默克尔压缩前缀树中,在区块链上仅记录对应的Trie Root 值。使用LevelDB维护树的持久化内容,而这个用来维护映射的数据库叫做 StateDB

首先我们用一张图来大致了解一下StateDB

可以看到图中一共有两种状态,一个是世界状态Trie,一个是storage Trie,两者都是MPT树,世界状态包含了一个个的账户状态,账户状态通过以账户地址为键,维护在表示世界状态的树中,而每个账户状态中存储这账户存储树的Root。账户状态存储一下信息:

  1. nonce: 表示此账户发出的交易数量
  2. balance: 账户余额
  3. storageRoot: 账户存储树的Root根,用来存储合约信息
  4. codeHash: 账户的 EVM 代码哈希值,当这个地址接收到一个消息调用时,这些代码会被执行; 它和其它字段不同,创建后不可更改。如果 codeHash 为空,则说明该账户是一个简单的外部账户,只存在 noncebalance

接下来将会分析State相关的一些类,着重关注statedb.go、state_object.go、database.go,其中涉及的Trie相关的代码可以参照:死磕以太坊源码分析之MPT树-下

关键的数据结构

Account

Account存储的是账户状态信息。

type Account struct {
Nonce uint64 //账户发出的交易数量
Balance *big.Int // 账户的余额
Root common.Hash //账户存储树的Root根,用来存储合约信息
CodeHash []byte // 账户的 EVM 代码哈希值
}

StateObject

表示一个状态对象,可以从中获取到账户状态信息。

type stateObject struct {
address common.Address
addrHash common.Hash // 账户地址哈希
data Account
db *StateDB // 所属的StateDB
dbErr error //VM不处理db层的错误,先记录下来,最后返回,只能保存1个错误,保存的第一个错误 // Write caches.
trie Trie // storage trie, 使用trie组织stateObj的数据
code Code // 合约字节码,在加载代码时设置 //将原始条目的存储高速缓存存储到dedup重写中,为每个事务重置
originStorage Storage //在整个块的末尾需要刷新到磁盘的存储条目
pendingStorage Storage //在当前事务执行中已修改的存储条目
dirtyStorage Storage

StateDB

用来存储状态对象。

type StateDB struct {
db Database
trie Trie // 当前所有账户组成的MPT树 // 这几个相关账户状态修改
stateObjects map[common.Address]*stateObject // 存储缓存的账户状态信息
stateObjectsPending map[common.Address]struct{} // 状态对象已经完成但是还没有写入到Trie中
stateObjectsDirty map[common.Address]struct{} // 在当前执行中修改的状态对象 ,用于后续commit
}

三者之间的关系:

StateDB->Trie->Account->stateObject

从StateDB中取出Trie根,根据地址从Trie树中获取账户的rlp编码数据,再进行解码成Account,然后根据Account生成stateObject

StateDB存储状态

StateDB读写状态主要关心以下几个文件:

  • database.go
  • state_object.go
  • statedb.go

接下来分别介绍这么几个文件,相当关键。

database.go

根据世界状态root打开世界状态树

StateDB中打开一个Trie大致经历以下过程:

OpenTrie(root common.Hash)->NewSecure->New

根据账户地址和 stoage root打开状态存储树

创建一个账户的存储Trie过程如下:

OpenStorageTrie(addrHash, root common.Hash)->NewSecure-New

Account和StateObject

以太坊的账户分为普通账户和合约账户,以Account表示,Account是账户的数据,不包含账户地址,账户需要使用地址来表示,地址在stateObject中。

type Account struct {
Nonce uint64
Balance *big.Int
Root common.Hash // 存储树的merkle树根 账户状态
CodeHash []byte //合约账户专属,合约代码编译后的Hash值
}
type stateObject struct {
address common.Address // 账户地址
addrHash common.Hash // 账户地址哈希
data Account
db *StateDB // 所属的StateDB
dbErr error //VM不处理db层的错误,先记录下来,最后返回,只能保存1个错误,保存存的第一个错误
trie Trie // storage trie, 使用trie组织stateObj的数据
code Code // 合约字节码,在加载代码时设置
originStorage Storage //将原始条目的存储高速缓存存储到dedup重写中,为每个事务重置
pendingStorage Storage //在整个块的末尾需要刷新到磁盘的存储条目
dirtyStorage Storage //在当前事务执行中已修改的存储条目
}

创建StateObject

创建状态对象会在两个地方进行调用:

  1. 检索或者创建状态对象
  2. 创建账户

最终都会去调用createObject创建一个新的状态对象。如果有一个现有的帐户给定的地址,老的将被覆盖并作为第二个返回值返回

func (s *StateDB) createObject(addr common.Address) (newobj, prev *stateObject) {
prev = s.getDeletedStateObject(addr)// 如果存在老的,获取用来以后删除掉 newobj = newObject(s, addr, Account{})
newobj.setNonce(0)
if prev == nil {
s.journal.append(createObjectChange{account: &addr})
} else {
s.journal.append(resetObjectChange{prev: prev})
}
s.setStateObject(newobj)
return newobj, prev
}

state_object.go

state_object.go是很重要的文件,我们直接通过比较重要的函数来了解它。

增加账户余额

AddBalance->SetBalance

将对象的存储树保存到db

主要就做了两件事:

  1. updateTrie将缓存的存储修改写入对象的存储Trie。
  2. 将所有节点写入到trie的内存数据库中
func (s *stateObject) CommitTrie(db Database) error {
s.updateTrie(db)
...
root, err := s.trie.Commit(nil)
...
}

第一件事会在下面继续讲,第二件事可以参照我之前关于 死磕以太坊源码分析之MPT树-下的讲解。

①:将缓存的存储修改写入对象的存储Trie

主要流程: 最终还是调用了trie.go的insert方法

updateTrie->TryUpdate->insert

  1. s.finalise()dirtyStorage中的所有数据移动到pendingStorage
  2. 根据账户哈希和账户root打开账户存储树
  3. keytrie中的value关联,更新数据
func (s *stateObject) updateTrie(db Database) Trie {
s.finalise() ①
... tr := s.getTrie(db) ②
for key, value := range s.pendingStorage {
...
if (value == common.Hash{}) {
s.setError(tr.TryDelete(key[:]))
continue
}
...
s.setError(tr.TryUpdate(key[:], v)) ③
}
...
}

整个核心也就是updateTrie,调用了trieinsert方法进行处理。

②:将所有节点写入到trie的内存数据库,其key以sha3哈希形式存储

流程:

trie.Commit->t.trie.Commit->t.hashRoot

func (t *SecureTrie) Commit(onleaf LeafCallback) (root common.Hash, err error) {
if len(t.getSecKeyCache()) > 0 {
t.trie.db.lock.Lock()
for hk, key := range t.secKeyCache {
t.trie.db.insertPreimage(common.BytesToHash([]byte(hk)), key)
}
t.trie.db.lock.Unlock() t.secKeyCache = make(map[string][]byte)
}
return t.trie.Commit(onleaf)
}

如果KeyCache中已经有了,直接插入到磁盘数据库,否则的话插入到Trie的内存数据库。

将trie根设置为的当前根哈希

func (s *stateObject) updateRoot(db Database) {
s.updateTrie(db)
if metrics.EnabledExpensive {
defer func(start time.Time) { s.db.StorageHashes += time.Since(start) }(time.Now())
}
s.data.Root = s.trie.Hash()
}

方法也比较简单,底层调用UpdateTrie然后再更新root.

State_object.go的核心方法也就这么些内容。

statedb.go

创建账户

创建账户的核心就是创建状态对象,然后再初始化值。

func (s *StateDB) CreateAccount(addr common.Address) {
newObj, prev := s.createObject(addr)
if prev != nil {
newObj.setBalance(prev.data.Balance)
}
}
func (s *StateDB) createObject(addr common.Address) (newobj, prev *stateObject) {
prev = s.getDeletedStateObject(addr) newobj = newObject(s, addr, Account{})
newobj.setNonce(0)
if prev == nil {
s.journal.append(createObjectChange{account: &addr})
} else {
s.journal.append(resetObjectChange{prev: prev})
}
s.setStateObject(newobj)
return newobj, prev
}

删除、更新、获取状态对象

func (s *StateDB) deleteStateObject(obj *stateObject)
func (s *StateDB) updateStateObject(obj *stateObject)
func (s *StateDB) getStateObject(obj *stateObject) {

这三个方法底层分别都是调用Trie.TryDelete、Trie.TryUpdate、Trie.TryGet方法来分别获取。

这里大致的讲一下getStateObject,代码如下:

func (s *StateDB) getDeletedStateObject(addr common.Address) *stateObject {
// Prefer live objects if any is available
if obj := s.stateObjects[addr]; obj != nil {
return obj
}
// Track the amount of time wasted on loading the object from the database
if metrics.EnabledExpensive {
defer func(start time.Time) { s.AccountReads += time.Since(start) }(time.Now())
}
// Load the object from the database
enc, err := s.trie.TryGet(addr[:])
if len(enc) == 0 {
s.setError(err)
return nil
}
var data Account
if err := rlp.DecodeBytes(enc, &data); err != nil {
log.Error("Failed to decode state object", "addr", addr, "err", err)
return nil
}
// Insert into the live set
obj := newObject(s, addr, data)
s.setStateObject(obj)
return obj
}

大致就做了以下几件事:

  1. 先从StateDB中获取stateObjects,有的话就返回。
  2. 如果没有的话就从stateDBtrie中获取账户状态数据,获取到rlp编码的数据之后,将其解码。
  3. 根据状态数据Account 构造stateObject

余额操作

余额的操作大致有添加、减少、和设定。我们就拿添加来分析:

根据地址获取stateObject,然后addBalance.

func (s *StateDB) AddBalance(addr common.Address, amount *big.Int) {
stateObject := s.GetOrNewStateObject(addr)
if stateObject != nil {
stateObject.AddBalance(amount)
}
}

储存快照和回退快照

func (s *StateDB) Snapshot() int
func (s *StateDB) RevertToSnapshot(revid int)

储存快照和回退快照,我们可以在提交交易的流程中找到:

func (w *worker) commitTransaction(tx *types.Transaction, coinbase common.Address) ([]*types.Log, error) {
snap := w.current.state.Snapshot() receipt, err := core.ApplyTransaction(w.chainConfig, w.chain, &coinbase, w.current.gasPool, w.current.state, w.current.header, tx, &w.current.header.GasUsed, *w.chain.GetVMConfig())
if err != nil {
w.current.state.RevertToSnapshot(snap)
return nil, err
}
w.current.txs = append(w.current.txs, tx)
w.current.receipts = append(w.current.receipts, receipt) return receipt.Logs, nil
}

首先我们会对当前状态进行快照,然后执行ApplyTransaction,如果在预执行交易的阶段出错了,那么会回退到备份的快照位置。之前的修改全部会回退。

计算状态Trie的当前根哈希

计算状态Trie的当前根哈希是由IntermediateRoot来完成的。

①:确定所有的脏存储状态(简单理解就是当前执行修改的所有对象)

func (s *StateDB) Finalise(deleteEmptyObjects bool) {
for addr := range s.journal.dirties {
obj, exist := s.stateObjects[addr]
if !exist {
continue
}
if obj.suicided || (deleteEmptyObjects && obj.empty()) {
obj.deleted = true
} else {
obj.finalise()
}
s.stateObjectsPending[addr] = struct{}{}
s.stateObjectsDirty[addr] = struct{}{}
}
s.clearJournalAndRefund()
}

其实这个跟state_objectfinalise方法是一个方式,底层就是调用了obj.finalisedirty状态的所有数据全部推入到pending中去,等待处理。

②:处理stateObjectsPending中的数据

先更新账户的Root根,然后再将将给定的对象写入trie

for addr := range s.stateObjectsPending {
obj := s.stateObjects[addr]
if obj.deleted {
s.deleteStateObject(obj)
} else {
obj.updateRoot(s.db)
s.updateStateObject(obj)
}
}

将状态写入底层内存Trie数据库

这部分功能由commit方法完成。

  1. 计算状态Trie的当前根哈希
  2. 将状态对象中的所有更改写入到存储树

第一步在上面已经讲过了,第二步的内容如下:

for addr := range s.stateObjectsDirty {
if obj := s.stateObjects[addr]; !obj.deleted {
....
if err := obj.CommitTrie(s.db); err != nil {
return common.Hash{}, err
}
}
}

核心就是objectCommitTrie,这也是上面state_object的内容。

总结流程如下:

1.IntermediateRoot

2.CommitTrie->updateTrie->trie.Commit->trie.db.insertPreimage(已经有了直接持久化到硬盘数据库)

​ ->t.trie.Commit(没有就提交到存储树中)

最后看一下以太坊数据库的读写过程:

如果觉得文章还可以,关注下https://github.com/blockchainGuide此项目哦,欢迎有想法的人一起维护。

参考

https://mindcarver.cn

https://github.com/blockchainGuide

https://www.jianshu.com/p/20d7f7c37b03

https://hackernoon.com/getting-deep-into-ethereum-how-data-is-stored-in-ethereum-e3f669d96033

https://web.xidian.edu.cn/qqpei/files/Blockchain/4_Data.pdf

http://www.ltk100.com/article-112-1.html

https://learnblockchain.cn/books/geth/part3/statedb.html

死磕以太坊源码分析之state的更多相关文章

  1. 死磕以太坊源码分析之Kademlia算法

    死磕以太坊源码分析之Kademlia算法 KAD 算法概述 Kademlia是一种点对点分布式哈希表(DHT),它在容易出错的环境中也具有可证明的一致性和性能.使用一种基于异或指标的拓扑结构来路由查询 ...

  2. 死磕以太坊源码分析之p2p节点发现

    死磕以太坊源码分析之p2p节点发现 在阅读节点发现源码之前必须要理解kadmilia算法,可以参考:KAD算法详解. 节点发现概述 节点发现,使本地节点得知其他节点的信息,进而加入到p2p网络中. 以 ...

  3. 死磕以太坊源码分析之rlpx协议

    死磕以太坊源码分析之rlpx协议 本文主要参考自eth官方文档:rlpx协议 符号 X || Y:表示X和Y的串联 X ^ Y: X和Y按位异或 X[:N]:X的前N个字节 [X, Y, Z, ... ...

  4. 死磕以太坊源码分析之Fetcher同步

    死磕以太坊源码分析之Fetcher同步 Fetcher 功能概述 区块数据同步分为被动同步和主动同步: 被动同步是指本地节点收到其他节点的一些广播的消息,然后请求区块信息. 主动同步是指节点主动向其他 ...

  5. 死磕以太坊源码分析之Ethash共识算法

    死磕以太坊源码分析之Ethash共识算法 代码分支:https://github.com/ethereum/go-ethereum/tree/v1.9.9 引言 目前以太坊中有两个共识算法的实现:cl ...

  6. 死磕以太坊源码分析之downloader同步

    死磕以太坊源码分析之downloader同步 需要配合注释代码看:https://github.com/blockchainGuide/ 这篇文章篇幅较长,能看下去的是条汉子,建议收藏 希望读者在阅读 ...

  7. 死磕以太坊源码分析之txpool

    死磕以太坊源码分析之txpool 请结合以下代码阅读:https://github.com/blockchainGuide/ 写文章不易,也希望大家多多指出问题,交个朋友,混个圈子哦 交易池概念原理 ...

  8. 死磕以太坊源码分析之MPT树-上

    死磕以太坊源码分析之MPT树-上 前缀树Trie 前缀树(又称字典树),通常来说,一个前缀树是用来存储字符串的.前缀树的每一个节点代表一个字符串(前缀).每一个节点会有多个子节点,通往不同子节点的路径 ...

  9. 死磕以太坊源码分析之MPT树-下

    死磕以太坊源码分析之MPT树-下 文章以及资料请查看:https://github.com/blockchainGuide/ 上篇主要介绍了以太坊中的MPT树的原理,这篇主要会对MPT树涉及的源码进行 ...

随机推荐

  1. 半夜删你代码队 Day2冲刺

    一.每日站立式会议 1.站立式会议 成员 昨日完成工作 今日计划工作 遇到的困难 陈惠霖 整理任务 了解相关网页设计 任务安排有的不合理,需改进 侯晓龙 学习了解相关知识 尝试写第一个实例子 无 周楚 ...

  2. es6交换两个值

    let a='a',b='b' let [a,b]=[b,a];//借助数组解构 let {a:b,b:a}={a,b}//利用别名进行对象解构

  3. js内存泄漏的问题?

    内存泄漏指任何对象在您不再拥有或需要它之后仍然存在. 垃圾回收器定期扫描对象,并计算引用了每个对象的其他对象的数量.如果一个对象的引用数量为 0(没有其他对象引用过该对象),或对该对象的惟一引用是循环 ...

  4. 题解 CF504E 【Misha and LCP on Tree】

    PullShit 倍增和树剖的差距!!! 一个 TLE, 一个 luogu 最优解第三!!! 放个对比图(上面倍增,下面轻重链剖分): 不过这是两只 log 非正解... Solution \(LCP ...

  5. JAVA字符配置替换方案

    在JAVA中,很多时候,我们后台要对数据进行变量配置,希望可以在运行时再进行变量替换.我们今天给大空提供的是org.apache.commons.text方案. 1.首先,引用org.apache.c ...

  6. JetBrains系列产品使用记录

    1.PyCharm中from  import提示找不到定义,提示错误,但其实是没有错误的 右键项目的根路径,Mark Directory As Source Root 2.自动换行 在Editor-& ...

  7. 最详细Python批量字典暴力破解zip密码

    工具破解 前两天在网上下来了一波项目案例,结果全是加密的压缩包,于是去网上找了一个压缩包破解的工具 苦于工具破解太慢,一个压缩包要好久,解压了三个之后就放弃了,准备另寻他法 密码字典 巧的是破解的三个 ...

  8. 从零开始了解多线程 之 深入浅出AQS -- 上

    java锁&AQS深入浅出学习--上 上一篇文章中我们一起学习了jvm缓存一致性.多线程间的原子性.有序性.指令重排的相关内容, 这一篇文章便开始和大家一起学习学习AQS(AbstractQu ...

  9. MySQL02-约束

    1.DQL查询语句 1.1 排序查询 语法:order by 排序字段1 排序方式1 ,  排序字段2 排序方式2... 排序方式: ASC:升序,默认的. DESC:降序. 注意: 如果有多个排序条 ...

  10. Java IO流 BufferedInputStream、BufferedOutputStream的基本使用

    BufferedInputStream.BufferedOutputStream的基本使用 BufferedInputStream是FilterInputStream流的子类,FilterInputS ...