作者:sedited

来源:https://thecharlatan.ch/Validation/

令人意外,Bitcoin Core 中的区块验证逻辑很复杂。经过十多年的为提升性能而作的工程,Bitcoin Core 已经积累了许多缓存层、子步骤以及优化,甚至在节点调用脚本解释器之前就开始工作了。在下文中,我会宽泛地介绍这些逻辑,先是粗略地介绍各种验证的执行顺序(经常会跳过具体的细节),然后解释为什么它们要以当前这种方式实现,相关的代价是什么。我会略过并不显著影响架构的优化,比如底层的 CPU 指令。内容都基于我的理解,可能是错的,也必定有所偏倚。

验证一个区块

当一个 Bitcoin Core 节点收到一个区块时,它最终会将区块传给 ProcessNewBlock 函数。这是区块验证逻辑的入口。一个区块所要经历的检查,会要求越来越多的 “带状态的” 信息。这些 “带状态的” 数据结构包括所有已知的区块头以及它们的树状结构(之所以是树状的,是因为其中也包括陈腐区块,因此是一棵区块树)、当前已知最好的区块链、UTXO 集合,以及多种缓存的数据结构。

第一类验证函数是 Check* 层级的函数。它们验证工作量证明、默克尔根、体积限制以及其它东西,还有一些健康检查,用来确保这个区块在结构上是可靠的。虽然这些检查是非常便宜的,节点还是会缓存一个区块有没有通过这些检查的状态,如果以前已经检查过,就直接放行。

下一类是 Accept* 层级的函数。它们与区块树互动。区块树是这样一种结构,每一个区块都有一个指针,指向其父区块,因此允许一直追溯到创世区块。树上可能有分叉,从而形成有一条长的主干和许多短的分支的树。这一类函数还包含一个子类别,是 ContextualCheck* 层级的函数,它们不仅会检查当前区块的前序区块已经在区块树上、时间戳是形式正确的,还会强制执行一些围绕软分叉的规则,比如检查区块版本号、见证数据是否被正确地序列化。一旦一个区块通过了这些检查,它就会被添加到区块树上、写入磁盘。要紧的是,这就意味着,如果一个区块通过了这些检查、但稍后又被发现是无效的(比如其中一笔交易使用了一个无效的脚本,那它也不会从磁盘中删除。为了防范攻击者借此塞爆节点的磁盘,低于特定累计工作量证明阈值的区块会被直接拒绝。

然后,这个区块要传入 Chain 层级的验证函数。它们与两种数据结构互动:一个向量,叫做 CChain,由从创世区块到当前已知最佳区块的区块树条目组成,用于以区块高度为索引的快速访问;另一个就是 “UTXO 集” (CCoinsViewCache),基本上就是一套映射,从一个交易输出点映射程对应的交易输出(即它的 Coin)。这既提供了对持久化到磁盘的 UTXO 集的 “视野”、以及一套记录已经检索出的钱币以及在同一区块中花费的钱币的 “缓存”,又让它们统一起来。

如果这个区块延长了链条,那么节点会从 CCoinsViewCache 检索出被这个区块内的交易花费的钱币。这个检索过程会检查所有必要的钱币都存在,并且没有重复花费。这些钱币也被用来检查交易的的输入和输出的数额平衡。然后,为区块中的每一笔交易的每一个输调用脚本解释器。如果这些检查都通过,那么新的钱币会添加到缓存出。所有在这个区块中被花费的输出被添加到一个单独的数据结构(CBlockUndo)中,并持久化到磁盘。也就是说,在验证之后,每一个持久化到磁盘的区块,都厚一个对应的 CBlockUndo 数据,包含了该区块花费掉的所有输出。这些 “undo” 数据会在区块链重组中派上用场:如果一个区块不再是链的一本书,那么其所花费的钱币会被重新添加回 CCoinsViewCache 。最后,这个区块会被标记为有效的,并添加到 CChain

验证的优化

催生这套架构的主要设计目标是允许所谓的 “究极修剪”:节点可以将磁盘占用量减少到仅保留 UTXO 集,外加阈值数量的区块(以应对链重组的可能性)当前,修剪后的存储空间占用量在 18GB 左右,而一个存储了所有区块的全节点会占用 800GB 。超过阈值数量的区块会被不断删除,哪怕节点还在初始化区块下载(IBD)期间,从而释放磁盘空间,并让磁盘占用量保持在恒定水平。也就是说,UTXO 集实际上被用作验证的一种状态累加器。因为没有复杂的数据方案,从磁盘删除区块也比较简单。

在区块验证上,主要有两个性能瓶颈:求值所有脚本(包括其中的签名),以及跟 UTXO 集互动。两个都位于 Chain 层面的验证函数中,也让这些较晚发生的验证变成最耗费资源的验证。求值许多签名需要投入大量计算,而 UTXO 集的规模(大约 10GB)会让交互变得更加昂贵。

脚本验证上的囧素,主要源于对签名验证代码的优化,既有 libsecp256k1 库的代码,也有产生签名消息哈希值的代码。以区块为单位对交易的脚本求值时,也会用多核 CPU 来并行化(CCheckQueueControl)。脚本验证时当前唯一一个并行发生的验证步骤。

在历史上,被花费的钱币中有 80% 都是在创建后的一天内花掉的(Liu et al.)。这使我们可以使用一种高效的缓存策略:通过将最新一批钱币保留在内存中(通过 CCoinsViewCache),可以实现一种捷径式优化,避免将寿命较短的钱币写入磁盘然后又从中读取。添加、读取和移除这些钱币,将完全在内存中发生,无需与硬盘交互。

为了尽可能提高验证程序的时间占比,而不是在等待对等节点传输区块中浪费时间,区块可以乱序处理,从而允许连续不断地从对等节点获取区块。如果一个乱序的区块通过了 Accept 级别的验证,Chain 级别的程序会跳过验证、提早返回。一旦该区块的祖先被添加到了区块链、并且它成了最佳区块链的一个候选,将从磁盘取回它、继续钱币和脚本求值。

在下载完整区块之前,节点会先同步已知最佳的区块头链条。区块头提及很小(每个只有 80 字节),所以跟同步区块相比要快得多。节点先通过 CheckBlockHeaderContextualCheckBlockHeaderAcceptBlockHeader 函数来验证这些区块头,然后将它们添加到区块树上。一旦区块头完成同步,该节点就开始依据区块头链,从对等节点请求完整的区块。

Bitcoin Core 也优化了区块转发和递送区块的速度。更快递送区块,意味着其它对等节点的 IBD 会更快。而及时转发区块,则是决定矿工收益的重要因素。AcceptBlock 会将区块的全网通行的序列化格式存储到磁盘。这使得节点可以立即响应 GETDATA 请求,无需等待反序列化。节点也会在区块通过 Accept 级别的检查之后立即转发区块,不等昂贵的脚本和钱币验证步骤。这意味着,节点可能会转发带有无效交易的区块,不过,这同样因为已经检查其工作量证明超过了阈值而得到缓解。

交易池中交易的验证提供了进一步的优化项,可以在初始化同步期间加速区块验证。 为交易池验证交易的脚本会产生两套缓存:签名以及脚本缓存。这个过程会运行条款检查,其规则会比区块层面的验证更为严格。在这种检查中,通过验证的签名会被添加到签名缓存中,以签名数据的哈希值作为搜索的键。第二轮的检查更加便宜,因为有效的签名已经缓存了,在验证期间可以跳过。如果该交易进入了某个区块,并且其脚本已经通过了验证,那么程序会查询签名脚本缓存,一些脚本求值可以跳过。

Bitcoin Core 推出了默认启用的 “assumevalid” 特性,它会跳过一定目标区块以前的所有历史区块的脚本验证。这一特性带来一个额外的信任假设:埋得足够深的区块,如果带有无效脚本,一定被大多数矿工拒绝掉了。这并非所谓的 “检查点”:它只会为区块跳过脚本验证,到达目标区块再启用。这个目标区块是 Bitcoin Core 的开发者在发布软件时设定的,但用户可以自己指定。钱币层面的检查依然会运行,也即,这种特性的用户依然会运行所有 Check*Accept* 层面的检查,并保证没有钱币会被重复花费。对于缺乏处理能力的平台,比如小型的单板计算机,这意味着同步时间可以大幅缩减。

架构性的取舍

验证程序的主体是串行发生的,每次只处理一个区块。UTXO 会一个区块一个区块这样原子化地更新。未来的优化允许从磁盘并行检索钱币(Bitcoin Core PR #35295),可以扩大瓶颈。这本质上是预热 CCoinsViewCache 的缓存。不过,依然不会同时进行其它验证工作,节点还是会被卡住,无法推进其它任务。

一种互斥锁 cs_main 保护着验证缓存、UTXO 集以及区块树。这就意味着,这些数据结构的任何检索和修改都不能并行发生。如果取消 cs_main、为每一个数据结构安排一个独立的互斥锁,至少可能会让多种验证检查相互赛跑。我希望看到更多工程放在减少 cs_main 的责任以及并行化更多验证任务上。

让区块验证的速度依赖于历史行为,可能也有缺点。在区块包含了大量非标准交易的时候,脚本验证不能从脚本缓存中得到任何好处。如果钱币的平均存活时间发生了变化,比如网络中出现了粉尘攻击,那么作为捷径的钱币缓存也就不那么有效率。在比特币的历史上,这样的时候都很短暂,并且,因为架构性的成本主要是代码复杂性,而不是更加根本的限制,所以这些优化是值得的。

将完全序列化的区块写入磁盘、而不是持久化保存一个完全关系化模型,意味着 Bitcoin Core 在通过其接口给钱包或者区块浏览器提供这些数据时,要做更多的工作。Bitcoin Core 使用可选的索引来编目和暴露一部分序列化之后的数据。客户端也可以实现自己的索引逻辑,比如将地址映射到交易。

评判这些取舍并不容易。基于硬件以及网络条件,验证可能被 CPU、磁盘 IO 或者网络速度制约。用户本身也有不同需要。对矿工来说,(IBD 之后的)快速验证和区块转发会决定他们的收益。钱包用户最在意的则是不要苦等区块链同步,以及从节点获得丰富的数据。其他人可能需要修剪功能,因为磁盘空间不够。对我来说,无法确定有没有一种实现能在所有这些方面都做到最好,其中有没有一些无法回避的取舍。Bitcoin Core应该尝试尽最大限度利用可得的硬件资源。对于绝大部分设备来说,这大概意味着将瓶颈进一步从 CPU 和磁盘转移到网络带宽。

(完)