死磕以太坊原始碼分析之state

mindcarver發表於2021-01-13

死磕以太坊原始碼分析之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個交易,第一個區塊的最終狀態產生,第二個區塊的第一筆交易執行後將會改變第一個區塊鏈的最終狀態,以此類推,從而產生最終的區塊狀態。

image-20210112090020770

以太坊狀態資料庫

區塊的狀態資料並非儲存在鏈上,而是將這些狀態維護在默克爾壓縮字首樹中,在區塊鏈上僅記錄對應的Trie Root 值。使用LevelDB維護樹的持久化內容,而這個用來維護對映的資料庫叫做 StateDB

首先我們用一張圖來大致瞭解一下StateDB

image-20210112165612767

可以看到圖中一共有兩種狀態,一個是世界狀態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(沒有就提交到儲存樹中)

最後看一下以太坊資料庫的讀寫過程:

image-20210113111013494

如果覺得文章還可以,關注下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

相關文章