死磕以太坊原始碼分析之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
。賬戶狀態儲存一下資訊:
- nonce: 表示此賬戶發出的交易數量
- balance: 賬戶餘額
- storageRoot: 賬戶儲存樹的Root根,用來儲存合約資訊
- codeHash: 賬戶的 EVM 程式碼雜湊值,當這個地址接收到一個訊息呼叫時,這些程式碼會被執行; 它和其它欄位不同,建立後不可更改。如果 codeHash 為空,則說明該賬戶是一個簡單的外部賬戶,只存在
nonce
和balance
。
接下來將會分析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
建立狀態物件會在兩個地方進行呼叫:
- 檢索或者建立狀態物件
- 建立賬戶
最終都會去呼叫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
主要就做了兩件事:
- updateTrie將快取的儲存修改寫入物件的儲存Trie。
- 將所有節點寫入到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
s.finalise()
將dirtyStorage
中的所有資料移動到pendingStorage
中- 根據賬戶雜湊和賬戶
root
開啟賬戶儲存樹 - 將
key
與trie
中的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
,呼叫了trie
的insert
方法進行處理。
②:將所有節點寫入到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
}
大致就做了以下幾件事:
- 先從
StateDB
中獲取stateObjects
,有的話就返回。 - 如果沒有的話就從
stateDB
的trie
中獲取賬戶狀態資料,獲取到rlp
編碼的資料之後,將其解碼。 - 根據狀態資料
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_object
的finalise
方法是一個方式,底層就是呼叫了obj.finalise
將dirty
狀態的所有資料全部推入到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方法完成。
- 計算狀態Trie的當前根雜湊
- 將狀態物件中的所有更改寫入到儲存樹
第一步在上面已經講過了,第二步的內容如下:
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://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