Authenticated Dynamic Dictionaries in Ethereum

hi 歡迎來到小祕課堂第四期,今天我們來講講 Authenticated Dynamic Dictionaries in Ethereum 的那些事兒,歡迎主講人

編輯:vivi

Merkle Tree

Merkle Tree 是利用 Hash 對內容的正確性進行快速鑑定的數據結構,其結構示意圖如下:

  • Merkle Tree 父節點包含所有子節點的 Hash 值;

  • Merkle Tree 的 Tree 可以是任何形式的 Tree ,任何經典的 tree 構造算法都可以經過簡單的修改應用到 Merkle Tree 上;

  • Merkle Tree 的 Branch Node 也可以存儲數據,儘管大多數實現中只在 Leaf Node 上存儲數據;

  • Merkle Tree 對單個數據的校驗最多需要 O(h)O(h) 時間,其中 hh 爲樹高,如果樹足夠平衡,則驗證單個節點的複雜度爲 O(log(n))O(log⁡(n)).

在 BitCoin 中,Merkle Tree 主要用來實現輕節點,輕節點只存 Top Hash,當輕節點要驗證一筆交易時(比如上圖中的 L4 ),則向存有全部數據的完全節點驗證,完全節點將L4 到 root 的路徑上的所有 node(1-1,1-0,1,0,Top Hash),輕節點對根據這條路徑計算得到 Top Hash,如果計算得到的 Top Hash 與本地的 Top Hash 相等,則輕節點就可以相信完全節點傳過來的數據。

在 Ethereum 中,Merkle Tree 除用來實現輕節點外,還用來實現增量存儲。

MPT

爲了能夠支持高效的檢索及動態的插入、刪除、修改,Ethereum 使用 Trie 來構建 Merkle tree,Ethereum 將其命名爲 Merkle Patricia Tree(MPT),其示意圖如下:

Ethereum 使用的是一棵 16 叉 Trie 樹,並進行了路徑壓縮。可以將其理解爲一棵普通的 Trie 樹,只不過指向子節點的指針變成了子節點的 Hash。

2.1 Node類型

MPT 有三種類型的節點,分別是:Leaf Node、Branch Node、Extension Node。Leaf Node 由一個 Key 域(nibbles,key 的片段)及數據域組成;

Branch Node 由分支域(16個指向下一個節點的指針,這裏的指針可以理解爲 KV 數據庫中的 Key)及數據域(如果存有數據,則此數據的 Key 是從根到達該 Node 所經過的所有 nibbles )組成;Extension Node 由 Key 域(Shared nibbles,這實際上是對路徑進行壓縮,否則路徑的長度就是 key 的長度,在 Node 數量不多的情況下,樹會非常的不平衡)及分支域(指向下一個 Branch Node 的指針)組成。

Parity 中 MPT 定義如下:

enum Node {
    /// Empty node.
    Empty,
    /// A leaf node contains the end of a key and a value.
    /// This key is encoded from a `NibbleSlice`, meaning it contains
    /// a flag indicating it is a leaf.
    Leaf(NodeKey, DBValue),
    /// An extension contains a shared portion of a key and a child node.
    /// The shared portion is encoded from a `NibbleSlice` meaning it contains
    /// a flag indicating it is an extension.
    /// The child node is always a branch.
    Extension(NodeKey, NodeHandle),
    /// A branch has up to 16 children and an optional value.
    Branch(Box<[Option<NodeHandle>; 16]>, Option<DBValue>)
}
enum NodeHandle {
    /// Loaded into memory.
    InMemory(StorageHandle),
    /// Either a hash or an inline node
    Hash(H256),
}

注意此處的 NodeHandle ,我們可以只用 H256 來作爲指向下一個節點的指針,這完全可行且符合協議,但是每次找子節點都要從數據庫中查詢勢必會帶來效率上的問題,Parity 採用了一個非常巧妙的辦法,將查詢過的 Node 都存放到內存中,下次需要找這些節點時直接用指針訪問就好。當然,將其存到 DB 中時還是要將 InMemory(StorageHandle) 轉化成 H256 格式,實際存儲格式如下:

pub enum Node<'a> {
    /// Null trie node; could be an empty root or an empty branch entry.
    Empty,
    /// Leaf node; has key slice and value. Value may not be empty.
    Leaf(NibbleSlice<'a>, &'a [u8]),
        /// Extension node; has key slice and node data. Data may not be null.
    Extension(NibbleSlice<'a>, &'a [u8]),
    /// Branch node; has array of 16 child nodes (each possibly null) and an optional immediate node data.
    Branch([&'a [u8]; 16], Option<&'a [u8]>)
}

2.2 操作

除了最基本的增刪改查操作以外,Parity 還有一個 commit 操作,該操作允許只有在調用 commit 時才重新計算Hash並寫入數據庫,這使得如果一個 node 被多次訪問,這個 node 只重新計算一次 Hash,重新寫入數據庫一次。

commit 的另一個好處是在交易執行完之後再計算 Hash,這相當於做了路徑聚合,去掉了重複無用的 Hash 計算,減少了 Hash 的計算量。目前 Parity 在每個交易執行完畢後都會調用 commit(),在我們的實現中將 commit 操作移到了塊中所有交易完成以後,提高了效率。自 EIP98 以後,transaction 下的 state root 已經成了可選項,相信不久 Parity 也好把 commit 移到塊中所有交易執行完之後。

/// Commit the in-memory changes to disk, freeing their storage and
    /// updating the state root.
    pub fn commit(&mut self) {
        trace!(target: "trie", "Committing trie changes to db.");

        // always kill all the nodes on death row.
        trace!(target: "trie", "{:?} nodes to remove from db", self.death_row.len());
        for hash in self.death_row.drain() {
            self.db.remove(&hash);
        }

        let handle = match self.root_handle() {
            NodeHandle::Hash(_) => return, // no changes necessary.
            NodeHandle::InMemory(h) => h,
        };

        match self.storage.destroy(handle) {
            Stored::New(node) => {
                let root_rlp = node.into_rlp(|child, stream| self.commit_node(child, stream));
                *self.root = self.db.insert(&root_rlp[..]);
                self.hash_count += 1;
trace!(target: "trie", "root node rlp: {:?}", (&root_rlp[..]).pretty());
                self.root_handle = NodeHandle::Hash(*self.root);
            }
            Stored::Cached(node, hash) => {
                // probably won't happen, but update the root and move on.
                *self.root = hash;
                self.root_handle = NodeHandle::InMemory(self.storage.alloc(Stored::Cached(node, hash)));
            }
        }
    }

    /// commit a node, hashing it, committing it to the db,
    /// and writing it to the rlp stream as necessary.
    fn commit_node(&mut self, handle: NodeHandle, stream: &mut RlpStream) {
        match handle {
            NodeHandle::Hash(h) => stream.append(&h),
            NodeHandle::InMemory(h) => match self.storage.destroy(h) {
                Stored::Cached(_, h) => stream.append(&h),
                Stored::New(node) => {
                    let node_rlp = node.into_rlp(|child, stream| self.commit_node(child, stream));
                    if node_rlp.len() >= 32 {
                        let hash = self.db.insert(&node_rlp[..]);
                        self.hash_count += 1;
                        stream.append(&hash)
                    } else {
                        stream.append_raw(&node_rlp, 1)
                    }
                }
            }
        };
    }

注:Node 在存入數據庫之前都要先經過 RLP 序列化,Node 從數據庫中取出時要先反序列化。

2.3 FatTrie & SecTrie

Parity 對上面的 TrieDB 進行了一層封裝,分別是 SecTrie與 FatTrie。SecTrie 對傳進來的 Key 做了一次 Sha3(),用 Sha3() 後的值作爲 Key,這樣做的原因是:Trie 並不是一棵平衡樹,攻擊者可以構造一棵極端不平衡的樹來進行 DDos 攻擊,用 Sh3() 後的值做 Key,這極大增加了攻擊難度,使構造極端不平衡的樹幾乎不可能。FatTrie 除了對 Key 做 Sha3() 外,還額外將 Sha3() 前的 Key 存了下來。

Merkle AVL+Tree

由於 Sha3 運算的開銷並不低(約 15Cycles/byte15Cycles/byte),爲了提高效率,我們希望在每次更新樹時能夠計算 Sha3 的數量最少,而更新一個節點所需要重新計算的 Sha3 量約爲 mlogmnmlogm⁡n,其中 mm 爲分支數,故當 m=2m=2 時,Sha3 的計算量最小。又 AVL Tree 的平衡性更好(儘管這意味着更多的旋轉操作,幸運的是旋轉操作並不增加 Sha3 計算),因此選用 AVL Tree 來構建 Merkle Tree 似乎是一個不錯的選擇,我們簡稱之爲 MAT。

3.1 Node 類型

爲了簡化操作,我們只在葉子節點存放數據(這樣可以減少重新計算 Sha3 的量),MAT 有兩種類型的 Node:Branch Node 和 Leaf Node:

/// Node types in the AVL.
#[derive(Debug)]
enum Node {
    /// Empty node.
    Empty,
    /// A leaf node contains a key and a value.
    Leaf(NodeKey, DBValue),
    /// A branch has up to 2 children and a key.
    Branch(u32, NodeKey, Box<[Option<NodeHandle>; 2]>),
}

Leaf Node 由 Key 域(不同於 MPT,這裏存了完整的 key)及數據域組成,Branch Node 由 Key 域(這裏仍然存儲的是完整的 Key,其 Key 與其子樹中最左邊的葉子節點的 Key 相同)及分支域(兩個分支)組成。

3.2 操作

MAT 支持的操作同 MPT,不同的是爲了保持樹的平衡,會有 LL、LR、RR、RL 四種旋轉操作。

對比

兩種樹更新一個 node 時所需的 Sha3 計算量對比如下圖所示:

測試發現,當 node 數在 1000,0001000,000 時,MPT 的平均路徑長度爲 7,MAT 的平均路徑長度爲 20,可以推算 MPT 的 Sha3 計算量爲16×7=11216×7=112,MAT 的 Sha3 計算量爲 2×20=402×20=40,這與上圖數據吻合。

4.1 MPT存在的問題

  • trie 並不是平衡樹,這意味着攻擊者可以通過特殊的數據構造一棵很高的樹,從而進行拒絕服務攻擊。(實際上這很難進行,因爲在 Ethereum 中 Key 是經過 Sha3 後的值,想找到特定值的 Sha3 是困難的。);

  • 內存佔用率高。每個 Branch Node 都 16 個子節點指針,大多數情況下其子節點的數量並不多;

  • 不易於編碼。雖然 MPT 易於理解,但是編寫卻非常複雜,MPT 的代碼量比 MAT 要高出許多。

4.2 AVL+ 存在的問題

  • 不同的插入順序得到的 AVL Tree 並不相同,這意味着不能像 MPT 那樣將 DB 理解爲內存模型,合約設計者必須意識到不同的寫入順序將帶來不同的結果,也就是說,AVL 天然的不支持合約內的並行;

  • 每個 Node 節點都存有完整的 Key,Key 的存儲開銷要比 MPT 大;

  • 每個 Key 的大小是 256bit,Key最長可能要做 32 次 u8 比較,但從概率上來講,大部分 Node 會在前幾次比較就得出結果。

  • 由於 AVL 是二叉樹,這意味着 AVL 的樹高要比 Trie 樹高,也就是說 AVL 操作 DB 的次數要比 Trie 樹多,從而 IO 開銷要比 Trie 樹高。

由於 IO 開銷,實際性能 AVL 要比 Trie 差:

祕猿科技 repo:https://github.com/cryptape

連接開發者與運營方的合作平臺 CITAHub:https://www.citahub.com/

有任何技術問題可以在論壇討論:https://talk.nervos.org

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章