作者:刘玮,RisingWave Labs 内核开发工程师
我们在《 开源分布式流数据库|RisingWave 中的状态管理机制 》一文中,已经向大家介绍了 RisingWave 的内核存储引擎 Hummock 及其存储架构。本文将着重介绍 Hummock 针对 Streaming 所做的一些优化。
1. 一致性快照
与 RocksDB 类似,Hummock 提供一致性读取(Snapshot Read),这对于增量数据的 Join 计算是十分有必要的,参考《 揭秘流数据库中的共享索引和增量 Join 》,而本文中我们将介绍 Hummock 的一致性读取是如何实现的。
Hummock 使用与 Barrier 绑定的 Epoch 作为所有写入数据的 MVCC 版本号,因此我们在查询时可以利用当前算子所流过的 Barrier 来指定读取 Hummock 中对应的版本,对于指定的查询 Epoch ,如果某个目标 key 存在比 Epoch 更大的版本号,则忽略该版本数据,并且查询定位到小于等于 Epoch 的最大(新)的那个版本。
类似的,如果用户查询创建的 Materialize View 或者数据的中间状态,由于查询的数据可能涉及到多个 ComputeNode 节点,我们需要一个一致性的快照来保证查询结果的正确,否则可能会出现 "读未提交" 或者"写入丢失"之类的让用户难以理解的错误。
具体的做法是 Frontend 在每一条 SQL 或者事务开始时会向 MetaServer 获取当前已提交的最新 Barrier (对于新鲜度要求不高的场景也可以直接使用 Frontend 缓存的 epoch 来提高性能)作为查询 Epoch 版本号,之后 Frontend 发送给所有 ComputeNode 的查询都会使用 Epoch 来查询数据。
假设一个 key 存在多个版本:
key1: epoch=5, value=v5
key1: epoch=4, value=v4
key1: epoch=3, value=v3
如果此时仍然有一条查询 epoch=4 的用户查询尚未结束,那么尽管 epoch=4 这个版本已经被更新的 epoch=5 所覆盖,我们依然要在 Compaction 的过程中保留这条数据,只能删除 epoch=3 的版本。
那么如何确定哪些数据能回收呢?
RisingWave 在所有的 Frontend 节点维护了正在查询中的查询(事务)的 epoch ,并且定期向 MetaServer 汇报当前尚未结束的查询的最小 epoch,MetaServer 收集了所有 frontend 汇报的 epoch 值,以及当前已经提交的 barrier,取最小的值(safe epoch)发送给 Compactor 节点,Compactor 会按照前面所描述的规则,只回收低于 safe epoch 历史版本数据。
对于 Streaming 算子而言,由于 Streaming 算子的查询必定大于等于已经提交的 barrier,也必定大于等于当前系统的 safe epoch,因此无需维护额外的数据结构。
2. Schema-aware Bloom Filter
LSM Tree 架构的存储引擎的数据文件都按照写入顺序或者其他规则分割成多层,这意味着即便只读取某一极小范围的数据,依然避免不了要查询多个文件,这将带来额外的 IO 与计算操作。一种通用的做法是为同一个文件的所有 key 建立 Bloom Filter,遇到查询时先通过 Bloom Filter 过滤掉不必要的文件,然后再对剩下的文件进行查询。
通常的 LSM Tree 引擎会为整个 key 建立 Bloom Filter,因此这样的优化也主要用于 Point Get 查询中,而 RisingWave 会根据算子的不同,截取出最合适的部分建立 BloomFilter,例如对于下面这条 SQL 而言, RisingWave 会为 A 与 P 创建不同的 State Table, State Table A 的 key 将按照 table_id + seller(join key) + A.id 存储,创建 Bloom Filter 时则只会选取 seller 这一字段的部分,这样的话,当 State Table P 更新了一条数据时,算子需要查询 A 侧的 join key对应的数据,会通过 P.id 这个字段计算 Bloom Filter 的哈希值,然后过滤出 State Table A 包含的 A.seller=P.id 数据的文件。
通过这种方式建立的 Bloom Filter 能够在更多的场景发挥作用,不仅避免了无效 IO,而且大幅度提高了查询性能。
css
CREATE MATERIALIZED VIEW nexmark_q3
AS
SELECT P.name,
P.city,
P.state,
A.id
FROM auction AS A
INNER JOIN person AS P on A.seller = P.id
WHERE A.category = 10
3. Sub Level
为了提高 L0 文件的 Compact 速度,我们参考了 CockroachDB 存储引擎 pebble 的设计。
如图所示, 同一个 barrier 执行 checkpoint 所提交的文件会在同一个 sub level 内,被称作 overlapping level,之后这个 overlapping level 会经历一次 compact 变为 non-overlapping level,内部的多个文件相互之间不重叠。
因此,我们就可以在选取 l0 到 l1 的 compact 任务时,仅仅选择 L1 的部分文件参与合并,避免选出一个巨大而缓慢的任务,进而提高并行度与吞吐。
4. Fast Checkpoint
作为一个高效的流处理数据库,RisingWave 提供了秒级的数据实时性,也就是说,用户输入的数据最快仅需要一秒就能反应到最新的查询结果中,在此基础之上,RisingWave 默认每10秒执行一次 Checkpoint ,如果用户的集群节点因为各种原因而宕机,那么在集群恢复之后,RisingWave 仅需要重新处理最近十秒的历史数据,便可以跟上用户的最新输入,极大地减轻了故障对业务造成的影响。
为了支持如此高频率的 Checkpoint,我们在存储引擎上做了许多优化:
- 刷新任务会持有内存数据的引用,在后台将内存数据序列化为文件格式并且上传到 S3 中,因此,频繁的 Checkpoint 并不会阻塞数据流的计算。
- RisingWave 将整个集群的数据划分为了多个 group (初始时只有2个,一个存储状态,另一个存储 Matieriallize View),同一个计算节点上的同一个 group 的所有算子的状态变更,会合并写入到同一个文件中,对单节点集群来说,一次 Checkpoint 只会生成2个文件。
- 但是某些场景下,这样产生的文件仍然是非常小的。为了避免加重写放大的负担,我们增加了多种策略来判断是否应当先将当前 Level 0 的多个小文件合并为更大的文件后,再合并到更下层,见 L0 Intra Compaction 中的介绍。
- 如果某个 group 的数据量过大,为了降低单个 LSM Tree 执行 Compaction 时的写放大开销,RisingWave 会自动分裂数据量过大的 group。
5. L0 Intra Compaction
不同的业务场景的写入流量与数据分布都有很大区别。
当 base level 正处于 compact 中时,如果写入数据有热点,或者因为别的原因导致该部分范围的数据 compact 任务过慢,此时 l0 的数据就会堆积,由于 LSM Tree 查询本质上是多路归并,l0 的数据过多会拖慢查询速度,因此 RisingWave 会根据一定的策略选取部分 L0 的文件合并,将结果仍然输出到 L0,以加速查询,这样的任务我们称之为 L0 Intra Compaction Task。
由于可能存在写入的数据较小的情况,合并时 RisingWave 会判断通过参与合并的文件中,最大的那个文件所占的比例来计算写放大。
举例说明:如果 4 个文件参与合并,其中最大的一个文件占总输入大小比例为 50%,这意味着我们执行了 100% 的计算与 IO,但是其中只有 50% 的数据是无序的。通过100% 的计算使得额外 50% 的数据变为有序,我们记录写放大值为 2;如果 3 个文件参与合并,最大的文件占总输入大小为66.6%, 那么此时的写放大为 3。为了尽可能降低写放大,我们目前会过滤掉 Intra Level Compaction 中写放大超过 3 的任务。
6. 总结
Hummock 从设计之初就是为了流计算而生的云原生存储引擎,我们会为了更快的计算、更低的成本而不断演进。接下来我们会逐步推出提升 IO 效率的本地文件缓存 Disk Cache、根据负载自动扩缩容的 Severless Compaction 服务,进一步降低云上流计算的成本。