区块链底层-存储

本文最后更新于:2023年6月19日 晚上

本文聚焦区块链底层技术–存储技术。主要包含世界状态、账户状态和 MPT 树。
参考:https://learnblockchain.cn/books/geth/part3/statedb.html

StateDB-世界状态


从程序设计角度,StateDB 有多种用途:

  1. 维护账户状态到世界状态的映射。
  2. 支持修改、回滚、提交状态。
  3. 支持持久化状态到数据库中。
  4. 是状态进出默克尔树的媒介。

实际上** StateDB 充当状态(数据)Trie(树)LevelDB(存储)**的协调者。

实例化 StateDB

在对状态的任何操作前,我们要先构建一个 StateDB 来操作状态。

1
2
db: = state.NewDatabase(levelDB)
statedb, err := state.New(block.Root(), db)

首先,我们要告诉 StateDB ,我们要使用哪个状态。因此需要提供 StateRoot 作为默克尔树根去构建树。StateRoot 值相当于数据版本号,根据版本号可以明确的知道要使用使用哪个版本的状态。当然,数据内容并没在树中,需要到一个数据库中读取。因此在构建 State DB 时需要提供 stateRoot 和 db 才能完成构建。
任何实现 state.Database 接口的 db 都可以使用

1
2
3
4
5
6
7
8
9
10
11
// core/state/database.go:42
type Database interface {
OpenTrie(root common.Hash) (Trie, error)
OpenStorageTrie(addrHash, root common.Hash) (Trie, error)
CopyTrie(Trie) Trie
ContractCode(addrHash, codeHash common.Hash) ([]byte, error)
ContractCodeSize(addrHash, codeHash common.Hash) (int, error)

// TrieDB retrieves the low level trie database used for data storage.
TrieDB() *trie.Database
}

通过 db 可以访问:

  1. OpenTrie: 打开指定状态版本(root)的含世界状态的顶层树。
  2. OpenStorageTrie: 打开账户(addrHash)下指定状态版本(root)的账户数据存储树
  3. CopyTrie: 深度拷贝树。
  4. ContractCode:获取账户(addrHash)的合约,必须和合约哈希(codeHash)匹配。
  5. ContractCodeSize 获取指定合约大小
  6. TrieDB:获得 Trie 底层的数据驱动 DB,如:** levedDB** 、内存数据库、远程数据库

当前有两种类型的 DB 实现了 Database 接口,轻节点使用的 odrDatabase ,和正常节点端使用的带有缓存的 cachingDB 。 因为轻节点并不存储数据,需要通过向其他节点查询来获得数据,而 odrDatabase 就是这种数据读取方式的封装。一个普通节点已内置 levelDB,为了提高读写性能,使用 cachingDB 对其进行一次封装。
在实例化 StateDB 时,需要立即打开含有世界状态的 Trie 树。如果 root 对应的树不存在,则会实例化失败 ①。实例化的 StateDB 中将记录多种信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//core/state/statedb.go:59
type StateDB struct {
db Database
trie Trie
stateObjects map[common.Address]*stateObject
stateObjectsDirty map[common.Address]struct{}
dbErr error
refund uint64

thash, bhash common.Hash
txIndex int
logs map[common.Hash][]*types.Log
logSize uint

preimages map[common.Hash][]byte
journal *journal
validRevisions []revision
nextRevisionId int
}

  • db: 操作状态的底层数据库,在实例化 StateDB 时指定 ②。
  • trie: 世界状态所在的树实例对象,现在只有以太坊改进的默克尔前缀压缩树。
  • stateObjects: 已账户地址为键的账户状态对象,能够在内存中维护使用过的账户
  • stateObjectsDirty: 标记被修改过的账户。
  • journal: 是修改状态的日志流水,使用此日志流水可回滚状态。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//core/state/statedb.go:92
func New(root common.Hash, db Database) (*StateDB, error) {
tr, err := db.OpenTrie(root)//①
if err != nil {
return nil, err
}
return &StateDB{
db: db,//②
trie: tr,
stateObjects: make(map[common.Address]*stateObject),
stateObjectsDirty: make(map[common.Address]struct{}),
logs: make(map[common.Hash][]*types.Log),
preimages: make(map[common.Hash][]byte),
journal: newJournal(),
}, nil
}

上面对的代码实例化了一个 statedb。

读写 StateDB 状态

你所访问的任何数据必然属于某个账户下的状态,世界状态态仅仅是通过一颗树来建立安全的映射。因此你所访问的数据可以分为如下几种类型:

  1. 访问账户基础属性:Balance、Nonce、Root、CodeHash
  2. 读取合约账户代码
  3. 读取合约账户中存储内容

在代码实现中,为了便于账户隔离管理,使用不开放的 stateObject 来维护。 stateObject 注意代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type stateObject struct {
address common.Address//对应的账户地址
addrHash common.Hash // 账户地址的哈希值
data Account //账户属性
db *StateDB //底层数据库

//...
// 写缓存
trie Trie // 存储树,第一次访问时初始化
code Code // contract bytecode, which gets set when code is loaded
//...
}
type Account struct {
Nonce uint64
Balance *big.Int
Root common.Hash // merkle root of the storage trie
CodeHash []byte
}

可以看到 stateObject 中维护关于某个账户的所有信息,涉及账户地址、账户地址哈希、账户属性、底层数据库、存储树等内容。
当你访问状态时,需要指定账户地址。比如获取账户合约,合约账户代码,均是通过账户地址,获得获得对应的账户的 stateObject。因此,当你访问某账户余额时,需要从世界状态树 Trie 中读取账户状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// core/state/statedb.go:408
func (self *StateDB) getStateObject(addr common.Address) (stateObject *stateObject) {
if obj := self.stateObjects[addr]; obj != nil {//①
if obj.deleted {
return nil
}
return obj
}

enc, err := self.trie.TryGet(addr[:])//②
if len(enc) == 0 {
self.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
}
obj := newObject(self, addr, data)//④
self.setStateObject(obj)
return obj
}

state.getStateObject(addr)方法,将返回指定账户的 StateObject,不存在时 nil。
state 的 stateObject Map 中记录这从实例化 State 到当下,所有访问过的账户的 StateObject。 因此,获取 StateObject 时先从 map 缓存中检查是否已打开 ①,如果存在则返回。** 如果是第一次使用,则以账户地址为 key 从树中查找读取账户状态数据②。读取到的数据,是被 RLP 序列化过的,因此,在读取到数据后,还需要进行反序列化 ③。为了降低 IO 和在内存中维护可能被修改的 Account 信息,会将其组装成 **StateObjec ④ 存储在 State 实例中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//core/state/state_object.go:108
func newObject(db *StateDB, address common.Address, data Account) *stateObject {
if data.Balance == nil {
data.Balance = new(big.Int)
}
if data.CodeHash == nil {
data.CodeHash = emptyCodeHash
}
return &stateObject{
db: db,
address: address,
addrHash: crypto.Keccak256Hash(address[:]),//⑤
data: data,
originStorage: make(Storage),
dirtyStorage: make(Storage),
}
}

newObject 就是将对 Account 的操作进行辅助,其中记录了账户地址、地址哈希 ⑤ 等内容,最终你读写状态都经过 stateObject 完成。

StateDB 完成持久化

在区块中,将交易作为输入条件,来根据一系列动作修改状态。
在完成区块挖矿前,只是获得在内存中的状态树的 Root 值。 StateDB 可视为一个内存数据库,状态数据先在内存数据库中完成修改,所有关于状态的计算都在内存中完成。 在将区块持久化时完成有内存到数据库的更新存储,此更新属于增量更新,仅仅修改涉及到被修改部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// core/state/statedb.go:680
func (s *StateDB) Commit(deleteEmptyObjects bool) (root common.Hash, err error) {
defer s.clearJournalAndRefund()

for addr := range s.journal.dirties {//①⑧⑨⑩
s.stateObjectsDirty[addr] = struct{}{}
}
for addr, stateObject := range s.stateObjects {//②
_, isDirty := s.stateObjectsDirty[addr]
switch {
case stateObject.suicided || (isDirty && deleteEmptyObjects && stateObject.empty()):
//③
s.deleteStateObject(stateObject)
case isDirty:
if stateObject.code != nil && stateObject.dirtyCode {//④
s.db.TrieDB().InsertBlob(common.BytesToHash(stateObject.CodeHash()), stateObject.code)
stateObject.dirtyCode = false
}
if err := stateObject.CommitTrie(s.db); err != nil {//⑤
return common.Hash{}, err
}
s.updateStateObject(stateObject)//⑥
}
delete(s.stateObjectsDirty, addr)
}
//...
root, err = s.trie.Commit(func(leaf []byte, parent common.Hash) error {//⑦
var account Account
if err := rlp.DecodeBytes(leaf, &account); err != nil {
return nil
}
if account.Root != emptyRoot {
s.db.TrieDB().Reference(account.Root, parent)
}
code := common.BytesToHash(account.CodeHash)
if code != emptyCode {
s.db.TrieDB().Reference(code, parent)
}
return nil
})
return root, err
}


如上图所示,上半部分均属于内存操作,仅仅在 stateDB.Commit()时才将状态通过树提交到 leveldb 中。

MPT-默克尔压缩前缀树

是一种经过改良的、融合了默克尔树和前缀树两种树结构优点的数据结构,是以太坊中用来组织管理账户数据、生成交易集合哈希的重要数据结构。一个非叶节点存储在 leveldb 关系型数据库中,数据库中的 key 是节点的 RLP 编码的 sha3 哈希,value 是节点的 RLP 编码。想要获得一个非叶节点的子节点,只需要根据子节点的 hash 访问数据库获得节点的 RLP 编码,然后解码就行了。
以太坊有四种前缀树:

  1. 世界状态树包括了从地址到账户状态之间的映射。 世界状态树的根节点哈希值由区块保存(在 stateRoot 字段),它标示了区块创建时的当前状态。整个网络中只有一个世界状态树。
  2. 账户存储树保存了与某一智能合约相关的数据信息。由账户状态保存账户存储树的根节点哈希值(在 storageRoot 字段)。每个账户都有一个账户存储树。
  3. 交易树包含了一个区块中的所有交易信息。由区块头(在 transactionsRoot 区域)保存交易树的根节点哈希值。每个区块都有一棵交易树。
  4. 交易收据树包含了一个区块中所有交易的收据信息。同样由区块头(在 receiptsRoot 区域)保存交易收据树的根节点哈希值;每个区块都有对应的交易收据树。

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!