Geth 中 5 个数据库的故事

  • SINA
  • 更新于 2024-08-06 15:35
  • 阅读 1070

Geth 中 5 个数据库的故事

特别感谢 Gary Rong 和 Guillaume Ballet 的反馈和讨论。

在神话般的 Geth 之地,有五个数据库。本文的作者(也就是我)很难分辨它们并正确称呼它们。statedb 在这片土地上广为人知,而很少有人听说过 state.Database 的故事。所以作者写了这篇参考文章,以便将来记忆。如果能对其他访问这片神秘土地的访客有所帮助,他将非常高兴。

如果你曾经打开过 go-ethereum 的源代码,你可能会遇到各种带有 DB 字样的对象。毕竟,不是有人说区块链是被美化的数据库吗?

支持数据库

Geth 的用户可能熟悉底层数据库的选择。这是通过 --db.engine 配置的。选项有 leveldbpebbledb(现在是默认选项)。这些是 Geth 依赖的第三方键值数据库。它们处理 datadir/geth/chaindatadatadir/geth/nodes 中的文件。

第二种类型的支持数据库被称为 freezer 或 ancients。用户可能会在 datadir/geth/chaindata/ancients 中认出它。直到去年,它只包括古老的链历史。如今,它也被用来保存状态历史。稍后会详细介绍。历史大多是静态的,因此不需要 SSD 提供的更快 I/O 速度,这意味着可以为键值存储节省宝贵的 SSD 空间。还有计划将 freezer 用于其他数据,因为它提供了高效的单次和范围访问读取,只要它们由单调递增的整数索引链接。

本文的重点是存储在键值存储中的状态。因此,提到的支持数据库指的是键值存储。

Ethdb

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

系好安全带,这将是本文的重点部分。接下来是 triedb。它位于 trie 和磁盘层之间。它的全部工作是存储和检索 trie 节点。一个 triedb 实例在程序开始时创建,在节点停止时结束。它在创建时接受一个 ethdb 实例作为参数,并保持一个Handle以处理实际的持久化。目前 triedb 有两个后端可供选择:hashdbpathdb

首先让我们检查节点检索,因为它是一个更简单的操作。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 将指向它所属的合约。

Hashdb

Triedb 历史上使用哈希作为键,编码节点作为值来持久化 trie 节点。这种持久化 trie 节点的方案现在被称为 hashdb。它很简单,但让我们能够存储多个 trie。每个 trie 可以根据其根哈希进行检索和遍历。这种方案还意味着相同的子 trie,因为它们将具有完全相同的节点哈希,将无需额外努力地去重。这是一个很好的特性,因为状态 trie 非常大,并且大部分在一个区块到下一个区块之间保持不变。

重要的是要意识到 hashdb 并不会为每个区块持久化 trie。只有在节点处于归档模式时才会发生这种情况,而这只适用于少数节点。相反,它将 trie 的更新可能保存在内存中多个区块。那么你可能会问,内存中的更新在什么条件下会被刷新到磁盘?

  • 内存中的更新会定期刷新。刷新之间的间隔取决于区块的执行时间,并不是直接可预测的。为了给你一个概念,默认值是 5 分钟的区块处理时间。

  • 或者当缓存容量达到时。

  • 或者当节点关闭时。

hashdb 的复杂性很大程度上来自于它试图垃圾回收内存中的节点。假设一个合约在一个区块中创建,在下一个区块中销毁。没有理由再将与该合约相关的 trie 节点保存在内存中。此外,如果该合约有相应的存储 trie,那么整个存储 trie 也应该被清除。

Pathdb

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 操作不在这里执行,而是在更高的抽象层中执行。

State.Database

这是一个薄接口层,具有用于打开给定区块的账户或存储 trie 的实用方法。它还公开了底层的 ethdbtriedb 实例。因此,它充当了 statedb 数据库需求的一站式商店或忠实的助手。它由 state.cachingDB 实现。state.cachingDB 的关键功能是跨多个区块缓存合约代码。因此,state.Database 的生命周期与 Blockchain 的生命周期相同。

你可能会说 state.Database 只是一个代码缓存加上一些实用方法。然而,这很快就会发生巨大变化。当 Verkle 树到来时,这个对象将扮演关键角色。它将成为跟踪 Verkle 转换 的核心部分。转换是将整个状态从当前结构(即默克尔树)迁移到新的 Verkle 结构的过程。由于状态的大小,这个过程将在许多区块的跨度内发生。

Statedb

女士们,先生们,我向你介绍 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 以更新其结构并计算新的状态根。最后,脏节点集被传递给我们之前遇到的 triedbtriedb 根据后端缓存这些节点,并最终将它们持久化到磁盘,以防它们没有被重组出去。

荣誉提名:rawdb

你可以将 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 正在瞄准归档模式。一个名为 verkledbtriedb 新后端一直在健身房努力训练,等待它登上舞台的时刻。就此,我说再见。在 X 上关注我

我是 AI 翻译官,为大家转译优秀英文文章,如有翻译不通的地方,在这里修改,还请包涵~

点赞 0
收藏 1
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
SINA
SINA
Interested in decentralized technology and open access to them