Geth 中 5 个数据库的故事
特别感谢 Gary Rong 和 Guillaume Ballet 的反馈和讨论。
在神话般的 Geth 之地,有五个数据库。本文的作者(也就是我)很难分辨它们并正确称呼它们。statedb
在这片土地上广为人知,而很少有人听说过 state.Database
的故事。所以作者写了这篇参考文章,以便将来记忆。如果能对其他访问这片神秘土地的访客有所帮助,他将非常高兴。
如果你曾经打开过 go-ethereum 的源代码,你可能会遇到各种带有 DB 字样的对象。毕竟,不是有人说区块链是被美化的数据库吗?
Geth 的用户可能熟悉底层数据库的选择。这是通过 --db.engine
配置的。选项有 leveldb
和 pebbledb
(现在是默认选项)。这些是 Geth 依赖的第三方键值数据库。它们处理 datadir/geth/chaindata
和 datadir/geth/nodes
中的文件。
第二种类型的支持数据库被称为 freezer 或 ancients。用户可能会在 datadir/geth/chaindata/ancients
中认出它。直到去年,它只包括古老的链历史。如今,它也被用来保存状态历史。稍后会详细介绍。历史大多是静态的,因此不需要 SSD 提供的更快 I/O 速度,这意味着可以为键值存储节省宝贵的 SSD 空间。还有计划将 freezer 用于其他数据,因为它提供了高效的单次和范围访问读取,只要它们由单调递增的整数索引链接。
本文的重点是存储在键值存储中的状态。因此,提到的支持数据库指的是键值存储。
ethdb
,它的名字是第一个,是一个抽象支持数据库的包。事实上,其他地方都没有直接使用支持数据库。这允许轻松地从一个数据库切换到另一个数据库。为了实现这个目的,ethdb
提供了一个接口和一些在代码库中广泛使用的实现。除了 leveldb 和 pathdb,memorydb 是另一个值得注意的实现,因为它支持 Geth 的开发模式并广泛用于测试。
我想指出的是,ethdb
有多个接口集合。我们关心的是 ethdb.KeyValueStore
,它大致(为了可读性我简化了它)如下所示。正如预期的那样,它有方法来检索、设置和删除键值:
// KeyValueReader wraps the Has and Get method of a backing data store.
type KeyValueStore interface {
// Has retrieves if a key is present in the key-value data store.
Has(key []byte) (bool, error)
// Get retrieves the given key if it's present in the key-value data store.
Get(key []byte) ([]byte, error)
// Put inserts the given value into the key-value data store.
Put(key []byte, value []byte) error
// Delete removes the key from the key-value data store.
Delete(key []byte) error
}
ethdb.Database
接口扩展了 ethdb.KeyValueStore
,添加了对 freezer 的读写访问方法。这个接口经常用于链数据。因为最近的区块存储在键值存储中,而被认为是不可变的更早的区块则迁移到 freezer 中。
一个 ethdb
实例的生命周期与程序相同。它在程序开始时启动,在节点停止时结束。它是唯一传递给 core.Blockchain
的与数据库相关的对象,并从那里传递给各种其他结构。它确实是数据持久性的本质。可以说是 Geth 的生命之树。它的根深深扎入磁盘,它的枝条向上延伸到 EVM 及更远的地方。
ethdb:扎根于磁盘,提供 EVM、状态树和 RPC 等功能
系好安全带,这将是本文的重点部分。接下来是 triedb
。它位于 trie 和磁盘层之间。它的全部工作是存储和检索 trie 节点。一个 triedb
实例在程序开始时创建,在节点停止时结束。它在创建时接受一个 ethdb
实例作为参数,并保持一个Handle以处理实际的持久化。目前 triedb 有两个后端可供选择:hashdb
和 pathdb
。
首先让我们检查节点检索,因为它是一个更简单的操作。Triedb 后端必须返回一个 database.Reader
,其接口如下:
type Reader interface {
Node(owner common.Hash, path []byte, hash common.Hash) ([]byte, error)
}
它根据路径和相应的节点哈希从树中查找节点。注意返回值是一个简单的字节数组。Triedb 对 trie 节点的实际内容是不可知的。它没有账户和存储的概念,甚至没有叶节点和分支节点的概念。owner
参数决定了节点所在的 trie。对于账户 trie,owner 将留空。正如你可能知道的,合约的存储存储在单独的 trie 中。当查找存储槽时,owner 将指向它所属的合约。
Triedb 历史上使用哈希作为键,编码节点作为值来持久化 trie 节点。这种持久化 trie 节点的方案现在被称为 hashdb。它很简单,但让我们能够存储多个 trie。每个 trie 可以根据其根哈希进行检索和遍历。这种方案还意味着相同的子 trie,因为它们将具有完全相同的节点哈希,将无需额外努力地去重。这是一个很好的特性,因为状态 trie 非常大,并且大部分在一个区块到下一个区块之间保持不变。
重要的是要意识到 hashdb 并不会为每个区块持久化 trie。只有在节点处于归档模式时才会发生这种情况,而这只适用于少数节点。相反,它将 trie 的更新可能保存在内存中多个区块。那么你可能会问,内存中的更新在什么条件下会被刷新到磁盘?
内存中的更新会定期刷新。刷新之间的间隔取决于区块的执行时间,并不是直接可预测的。为了给你一个概念,默认值是 5 分钟的区块处理时间。
或者当缓存容量达到时。
或者当节点关闭时。
hashdb 的复杂性很大程度上来自于它试图垃圾回收内存中的节点。假设一个合约在一个区块中创建,在下一个区块中销毁。没有理由再将与该合约相关的 trie 节点保存在内存中。此外,如果该合约有相应的存储 trie,那么整个存储 trie 也应该被清除。
Pathdb 是 triedb
的新后端 。它改变了 trie 节点持久化到磁盘和保存在内存中的方式。如上所述,hashdb
将节点存储在它们的哈希值下。事实证明,这种方法使得修剪状态中未使用的部分变得非常困难。解决这个问题一直是 Geth 内部的一个长期项目。
Pathdb 与 hashdb
有很大的不同:
Trie 节点按其 trie 路径存储在键值数据库中。存储 trie 中的节点以它们所属的账户哈希为前缀。
Hashdb
定期将一个区块的完整状态持久化到磁盘。这意味着你的节点仍然会保留你可能不关心的旧区块的完整状态。在 pathdb
中,任何时候只有一个 trie 持久化在磁盘上。每个区块都会更新同一个 trie。由于键是路径而不是哈希,修改的节点可以简单地被覆盖。清除的节点可以安全地从数据库中删除,因为没有其他 trie 引用它们。
持久化的 trie 不在链的头部,而是至少落后 128 个区块。对于最近的 128 个区块中的每一个,都有一个对应的内存层来跟踪它们的 trie 更改。这允许在内存中轻松处理小的重组。应当刷新到磁盘的节点首先在内存中聚合,然后批量持久化。
为了处理更大的重组,pathdb
在 freezer 中为每个区块保留反向状态差异。按需可以将这些 stateDiffs 反向应用到磁盘层以到达分叉点。
如果你对基于路径的方案感兴趣,我鼓励你查看上面链接的问题。我希望你从本节中了解到的是,triedb 及其后端位于 trie 和磁盘之间。Triedb 允许高效地获取和持久化节点。插入或删除叶子等 trie 操作不在这里执行,而是在更高的抽象层中执行。
这是一个薄接口层,具有用于打开给定区块的账户或存储 trie 的实用方法。它还公开了底层的 ethdb
和 triedb
实例。因此,它充当了 statedb
数据库需求的一站式商店或忠实的助手。它由 state.cachingDB
实现。state.cachingDB
的关键功能是跨多个区块缓存合约代码。因此,state.Database
的生命周期与 Blockchain
的生命周期相同。
你可能会说 state.Database 只是一个代码缓存加上一些实用方法。然而,这很快就会发生巨大变化。当 Verkle 树到来时,这个对象将扮演关键角色。它将成为跟踪 Verkle 转换 的核心部分。转换是将整个状态从当前结构(即默克尔树)迁移到新的 Verkle 结构的过程。由于状态的大小,这个过程将在许多区块的跨度内发生。
女士们,先生们,我向你介绍 StateDB
。
EVM 如何看待 statedb
好吧,开玩笑到此为止,我坚持 statedb
受欢迎的原因是因为这是大多数 Geth 分叉修改以适应其逻辑的结构。例如,Arbitrum 更改 StateDB 以管理其 Stylus 程序。EVMOS 更改 StateDB 以跟踪对其有状态预编译的调用。原因是 StateDB 是唯���暴露给 EVM 的状态相关接口。EVM 关心的是账户和存储槽,而不是 trie 节点和键值存储。事实证明,大多数依赖 Geth 源代码的项目也不关心底层的 ethdb 或 triedb。它们工作��常,为什么要动它们。
让我们从 StateDB 的生命周期是单个区块这一事实开始。处理并提交一个区块后,它将被丢弃并且不再起作用。它在内存中管理一组状态对象。每个状态对象代表一个账户。EVM 第一次读取一个地址时,它会从数据库中获取并为其初始化一个新的状态对象。这被认为是一个干净的对象。当交易与账户交互并对其进行更改时,该对象变得脏。状态对象跟踪原始账户数据以及所有变更后的数据。它管理其对应的存储槽及其干净/脏状态。
如你所知,调用和交易可以回滚。在回滚时,状态必须返回到交易之前的状态。StateDB 通过记录所有对状态的修改来管理这一点。日志更像是一个变更集的堆栈,因为一个调用可以成功,即使它调用的合约回滚。如果整个交易成功,statedb.Finalise
将被调用,负责清除自毁的合约以及重置日志和退款计数器。
最后,在区块中的所有交易都处理完后,调用 statedb.Commit
。在此之前,trie 完全没有改变。只有现在 statedb
才会根据累积的更改更新存储 trie 以计算它们各自的根。这反过来决定了账户的最终状态。然后,脏状态对象被刷新到账户 trie 以更新其结构并计算新的状态根。最后,脏节点集被传递给我们之前遇到的 triedb
。triedb
根据后端缓存这些节点,并最终将它们持久化到磁盘,以防它们没有被重组出去。
你可以将 rawdb
视为键值存储上的模式层,即处理各种数据对象如何键入它们在数据库中的读/写位置。它还为这些对象定义了 getter 和 setter。举个例子,让我们看看代码是如何从键值存储中获取的。
var CodePrefix = []byte("c") // CodePrefix + code hash -> account code
// codeKey = CodePrefix + hash
func codeKey(hash common.Hash) []byte {
return append(CodePrefix, hash.Bytes()...)
}
// ReadCodeWithPrefix retrieves the contract code of the provided code hash.
func ReadCodeWithPrefix(db ethdb.KeyValueReader, hash common.Hash) []byte {
data, _ := db.Get(codeKey(hash))
return data
}
这个包还包含 freezer。我在关于后备数据库的部分中提到了 freezer。除了 freezer 逻辑本身,rawdb
中还有一些助手可以无论数据存储在哪里都能获取数据。举个例子,最近的区块存储在键值存储中。一旦它们成熟,它们将被转移到 freezer。因此,区块体 getter 函数将首先搜索 freezer,如果没有找到区块,将尝试在键值存储中找到它。
访客,你现在已经认识了这五个角色。你准备好更好地了解它们了吗?那么最好直接前往源码 。现在是最好的时机,因为在短期内这五个数据库将迎来许多冒险。有传言称 pathdb
正在瞄准归档模式。一个名为 verkledb
的 triedb
新后端一直在健身房努力训练,等待它登上舞台的时刻。就此,我说再见。在 X 上关注我
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!