RocksDB 与 Bitcoin 索引

巨量的 UTXO

比特币网络从诞生到现在快有一百万个区块了,每个区块又包含了几千条交易,而每条交易又包含若干 UTXO (Unspent transaction outputs)。想要知道某个地址的所有的历史记录以及可花费的 UTXO,就需要对区块上的数据做索引。但是这个索引的数据量非常大,使用普通的关系数据库来存储的话效率会非常的低。于是需要考虑使用 Key/Value 数据库,而研究了众多的 K/V 数据库引擎后,最后决定使用 RocksDB。

就着对 Bitcoin 进行索引这件事情,介绍 RocksDB 的特点,用法以及优化方式。

RocksDB

现在计算机的存储设备基本上都换成了 SSD,而 SSD 的特点是,写特别慢,读取特别快,而顺序写又要比随机写要快得多(事实上 HDD 也一样)。所以 RocksDB 针对了 SSD 使用了 LSM (Log-structured Merge) Tree,将所有的写的操作尽量变成顺序写,这使使用这一结构的数据库,在数据写入时速度非常快。

使用了 LSM Tree 的 RocksDB 在写入一项数据时,会先将数据写到内存表 (Mem-table) 里,然后再统一存储到磁盘文件 (SST) 中,然后再把 SST 的内容整理 (Merge) 到其它的 SST 文件里。所谓的整理 (Merge),其实就是归并排序,按照特定的规则将 Key 按顺序和分组保存到 SST 文件中。

优势

  • 使用 LSM Tree 写入性能高 – 几乎都是顺序写,写的吞吐量要远高于 B+Tree。顺序写 SST,后台执行 Compaction。
  • 支持非常大的数据量,几百 GB 甚至几 TB 都能胜任。
  • Column Family – 使用 Column Family 就像是将你的数据库分成多个子库,它拥有独立的缓存,查询策略等,当然它也独立占一份内存。
  • Bloom Filter – 在定位一个 Key 前就可以通过筛选器来确认这个 Key 有没有可能存在,减少很多的不必要的读取。
  • Prefix Extractor – 通过定长的前缀来进行高速的枚举。

一些策略及实现代码

设置 Bloom filter

Bloom filter 用于快速判断指定的 Key 在 SST 文件中是否有存在的可能。它对 Key 计算后获取某特征位,然后将这些特征位设置到 Bloom filter 中。在下次查询的时候,只需要将被查询的 Key 做计算后与 Bloom filter 做比对,可以快速获得其是否有可能存在于这个 Bloom filter 对应的 SST 文件中。如果答案是否,则可以完全不需要打开这个文件了。

什么时候需要用到 Bloom filter?简单来说,如果你的数据需要经常判断是否存在,那么就需要打开 Bloom filter。但如果你的数据在查询的时候一定是存在的,比如在后面提到的 Transaction 表,当你查找 Txindex -> Txid 时,给出的 Txindex 一定是存在的,那么这个表就不需要 Bloom filter。

我们可以使用下面的代码来开启 Bloom filter:

block_opts.set_bloom_filter(10.0, false);

第一个参数,是 Bloom filter 使用的位数,即:每个 Key 使用 10 位的 Bloom filter 空间。那么,假设有 1000 万个 Key,将会占用 :

1000000010812MB\frac{10000000*10}{8}\approx12 MB

每一个 Key 用于 Bloom filter 的位数越多,就意味着在判断它是否存在时的准确率越高,可以参考下面的表格:

bits/key误判率(FPR)
5~10%
8~2%
10~1%
12~0.3%
15~0.1%

可以看出来,我们使用 10 位,有 1% 的误判率,这已经是可以接受的范围了。若使用 11 位误判率会更低,但是同时也会占用更多的内存,而选择 10 位则很好地平衡了误判率与内存占用之间的关系。

第二个参数为 false 表示整个 SST 文件共享一个 Bloom filter,如果是 true 则会按照 Block 来分配 Bloom filter。现在基本上都使用整个 SST 文件共享一个 Bloom filter,因为按照 Block 来分配 Bloom filter 误判率高,且效率还低。

设置 Prefix extractor

假设我们有一个表,Address, Txindex -> () ,一个地址对应多条 Txindex,一对多的关系。当我们在查询某个地址对应的交易时,就需要先定位到该地址相同的第一条记录,然后枚举记录直到碰到别的记录或 EOF。若不打开 Prefix extractor,我们在枚举数据的时候会很慢,因为这些前缀相同的数据没有被关联起来。假设这个地址占用 20 字节,使用如下的代码来开启:

opts.set_prefix_extractor(
    SliceTransform::create_fixed_prefix(20)
);

Bloom filter 也会提升 Prefix extractor 的效率,当你同时打开了 Bloom filter 和 Prefix extractor,在你进行 Prefix seek 的操作时,Bloom filter 会帮助判断该 Prefix 是否存在。所以一般来说,打开了 Prefix extractor 也会同时打开 Bloom filter。

优化策略

频繁写时,不要同时读

LSM Tree 在写数据时,会先将数据保存到内存表里,然后再会写到第 0 层 SST 文件中,在这一层的 SST 文件数量可以为一个或多个。虽然这一层中的数据已经排序,但是若你有多个 SST 文件,那么这些文件的 Key 是有可能重叠的。例如,在第 0 层的 SST 中的第一个文件里,有可能包含 Key=1..20,那么在第二个文件里,有可能包含 Key=2..30。它需要被后台的 Compactor 陆续整理到其它的对 Key 分组了的SST 文件中,才能被快速查询。

所以,这样就有个问题,当你在写的过程中同时进行读,此时你有大量的数据被保存在内存表,或者第 0 层的 SST 文件中,那么读取的时候就需要打开多个文件来进行查询,这样效率非常低下。好的策略是,在同步时,把数据存储下来,同时也把需要查询的数据也存储到库里,完全同步工作后,再去处理那些需要查询的工作。

所以,在同步比特币网络的数据时,可以分以下几个阶段:

  1. 同步数据 – 按照区块将每一个区块的数据中的 UTXO 都取出来保存到 RocksDB 里,Spent 的 UTXO 只记录在一个 spent 表里,不去关心这个 Spent 影响了哪个地址。这个阶段,没有任何的查询操作。
  2. 建立索引 – 读 Spent 表,将这个表里对应的 UTXO 都删除,同时在这里建立索引,将地址与交易信息及 UTXO 之间的关系保存到数据库中。在这个阶段我们会频繁使用查询,但是这个阶段已经不是频繁的数据写的阶段了,所以查询以及集中建立相关的索引会在此时被逐步完成。这个阶段也会耗费不少的时间,但是若把这个阶段和第一阶段混在一起处理,则会因为频繁的查询第 0 层的数据而让整个同步时间变长很多,因为混乱的未整理的数据在查询起来会需要打开大量的文件,进而浪费掉大量的不必要的时间。
  3. 开启服务 – 数据同步完成后,开启比特币网络的监视,同时打开 HTTP 服务,提供整理好的数据给前端。

优化数据库设计

正确地设计 Column Family

每一个 RocksDB 的 Column Family 都可以设置一个读缓存,以及若干个写缓存。这些缓存的大小和个数会影响内存的使用量。如何合理的设计 Column Family呢?要从 RocksDB 的视角来看问题,而不是从业务的视角。一张表是否只写不删除不修改?一张表的数据量有多大?这张表需要什么类型的查询?

假设有两张表,一张保存 Utxo,另一张保存 Transaction。其中 Utxo 中的数据会频繁写同时频繁删除,而另一张 Transaction 只写却几乎不删除。那么这两张表就不适合放到同一个 Column Family 里。因为当我们频繁删除 Utxo 的时候,对 Transaction 表的数据操作会因 Utxo 的频繁删除而受到影响,但 Transaction 表几乎不删除数据。这就相当于 Transaction 表承担了频繁删除的负面效果,但是它却不是这个负面效果的原因。

我们再来看另外两张表:Address -> Utxo, Address -> Txid。这两张表是地址对 Utxo 的索引,和地址对 Txid 的索引。它们通常都只添加数据,没有删除操作。同时它们都有一个 Prefix Seek: Address。尽管在业务上来看,这两张表之间没有什么关系,但是它们却应该放到同一个 Column Family 中,因为从 RocksDB 的角度来看,它们就是同一类型的数据,放到同一个 Column Family 里会获得最好的效果。

优化数据写入量

在比特币网络中,表示一个有效地址的哈希值需要 32 个字节,其实这 32 个字节是可以被压缩成为 11 个字节的,这个可以通过阅读我之前写的文章《生日悖论》来了解。

另外,Txid 也是 32 个字节,但是 Utxo 里会有大量重复的 Txid,以及与地址相关的交易索引中也会存在有大量的 Txid,这部分的空间占用也是巨大的。我们可以建立一个表格:Txindex -> Txid,用一个 u64 (8字节) 来替代 32 字节的 Txid。而我们在填充这个表格的内容的时,不需要做任何的查询操作,因为 Txindex 是一个持续增长的整型值,在同步每一条 Transaction 时,将其自增即可,同步完成后将这个值写回到状态表中,在下一次同步开始时接着这个值继续自增即可。这样在数据库中使用 Txindex 将所有的 Txid 替换掉,可以节省掉很多存储空间。写入的数据变少,意味着同步数据的时间将被缩短。

监控

内存情况

通常我会使用 sar -W 3 来监控 Swap 文件的使用情况。若 si/so 都为 0,那么就表示 Swap 文件与内存之间没有进行数据交换,这说明内存压力不大。若出现频繁的数据交换,那就要考虑停掉其它占用内存的服务,腾出内存来给重要的服务。另外,若在同步阶段,基本上不需要使用 LRU Cache,在这个阶段可以将该值调小。

磁盘与 CPU

我会同时打开 btopatop 来对当前的系统情况进行监视。若出现 CPU 占用非常低,而磁盘 i/o 非常高的情况,就得要注意,有可能是磁盘读写已经严重拖慢了系统速度,特别该磁盘上存储有 Swap 文件,这时同时需要检查 si/so 是否也很高。千万不要让系统把时间浪费在与 Swap 文件的数据交换上。

另外,RocksDB 的 Compactor 的数量也要注意,默认系统只使用 一个 Background compactor 来对数据进行 Merge,如果设置 Compactor 的数量过多,这些 Compactor 就会同时去抢磁盘 i/o,这样反而会拖慢处理速度,得不尝失。除非你的磁盘速度吞吐本身就非常高,否则保持一个 Background compactor 就可以。

This article was written by matthew

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注