作为Cockroach实验室工程师的最后一项任务,我写了一篇题为“为什么我们在RocksDB之上构建CockroachDB”的博客文章,其中我讨论了RocksDB在当时是CockroachDB存储引擎的良好选择的广泛原因。因此,我想我最终不可避免地会解释为什么我们在Materialize中选择不同的方法来构建状态管理。
Materialize不使用RocksDB作为其底层存储引擎。但是,其他几个流媒体框架也是这样做的,比如Flink和Kafka Streams/KSQL(但不是全部:Spark Streaming使用与Spark相同的弹性分布式数据集存储层,因为它本质上是以微批处理方式运行的Spark)。考虑到我过去使用RocksDB作为OLTP数据库的存储引擎的积极经验,并且考虑到RocksDB是流系统中状态管理器的默认选择,为什么我们选择不在Materialize中使用它呢?
RocksDB是一个嵌入式存储引擎,它使用不可变平面文件的日志结构合并树作为底层索引数据结构。RocksDB维护该索引表示,以便高效地执行点查找、插入和范围扫描。在执行这些操作时,RocksDB还提供对强隔离保证的支持,当有多个并发读取器和写入器访问相同状态时,RocksDB会以高性能这样做,同时在其他后台线程中继续进行LSM压缩。
这是一个基于OLTP工作负载的优秀存储引擎,这就是为什么几个主要考虑OLTP的系统都构建在RocksDB之上的原因:比如CockroachDB、YuabyteDB和TiKV(和TiDB)。流处理器已经跟随这一趋势:Flink、Kafka Streams等。为什么我们不重复这个(经过试验和测试)的经验,从而节省宝贵的工程时间呢?我的观点是,虽然RocksDB是一个很棒的OLTP存储引擎,但它不太适合流媒体。
在我们讨论这个之前,我们必须花一点时间在实时数据流上,这是实现的核心流引擎。在其物理计算模型中,Time Dataflow是一个与Flink和Kafka Streams等其他流处理框架完全不同的框架。我在卡内基梅隆大学(Carnegie Mellon University)的一次演讲中略微描述了这些不同之处(这里有视频和文字记录),但为了快速概述一下,在谈到持久存储时,有几个设计选择是相关的。虽然提到的所有流引擎都是基于推送的数据流引擎(与几个数据库使用的基于拉的火山执行模型相反),但在分布式CPU核心之间的数据流图形的分片模式上存在重大设计差异。
流数据流图是一组操作符节点(直观地说,是熟悉的关系节点,如“Join”、“Reduce”、“MAP”等),流更新沿着图的边缘移动。当获取此逻辑数据流图并实例化一组物理运算符时,需要做出一个重要决策,即如何跨可用物理资源(CPU核心,可能跨多台机器)布局运算符(以及操作符间流)。
Flink和Kafka Streams都选择通过将物理CPU核心专用于操作员(粉色分隔线是CPU核心之间的分隔符,标记为“工作者”)来划分工作,并在一组核心之间切分数据流。如果某些逻辑运算符需要更多CPU,则可以跨多个内核复制它们(例如,下图中的“计数”运算符)。这意味着每个边缘都在核心之间流动(并且可能在机器之间流动)。这会导致大量的跨核心数据移动,即使在常见情况下成本也很高。因此,这些流处理器受到严重的存储器带宽限制。
Flink和Kafka流使用一个(或多个)专用于每个运营商的核心。相比之下,及时的数据流核心以不同方式进行分片,有意将跨核心数据移动降至最低:
及时的数据流群集中的每个核心都有逻辑数据流计划的完整副本,并且每个操作员都跨每个核心进行分区。在此分片方案中,工作器上的运算符是协同调度的,并且必须仔细设计为急于屈服,否则它们将阻止其他运算符执行,从而有可能使整个数据流图停滞不前。虽然这是额外的编程负担,但这里的好处是,当不需要交换数据时,操作员可以以融合的方式执行,直接调用下一个函数,几乎不需要昂贵的数据移动。虽然这不是Time DataFlow众多性能记录的全部原因,但这种对核心-本地数据局部性的关注意味着,在许多常见情况下,及时数据流避免了跨核心数据传输和随之而来的缓存收回。从经验上讲,在构建高性能并发系统时要仔细注意内存层次结构。
这就是RocksDB的问题所在:RocksDB被设计为自由地使用后台线程来执行物理压缩的计算。在OLTP设置中,这正是您想要的-大量并发写入器推送到LSM树的更高级别,大量读取器使用快照隔离语义访问整个树的不可变的SSTables,并且使用额外的核心在后台进行压缩。但是,在流设置中,最好将这些核心用作数据流执行的附加并发单元,这意味着压缩发生在前台。因此,压缩发生时的调度不能外包,必须与所有其他运营商一起考虑。
Time Dataflow为运算符提供了调度和执行层,而差分数据流是Time之上的库,它仔细地实现了常见的关系运算符。其中一些操作符需要维护大量状态-例如,两个输入流上的连接将需要维护两个流上的索引(以连接条件为关键),以快速执行查找。
这就是许多其他流处理器使用RocksDB实例来维护这些索引的地方,它会将自己的核心部署为后台线程,以便执行物理压缩。维护索引的工作主要在计算中进行,而不是在存储中。RocksDB非常适合这种范例,可以将其视为与传统关系操作符(“JOIN”、“GROUP BY”等)并列的另一个操作符(“状态管理器”)。虽然我们没有孤立地衡量计算开销,但Kalavri和Liagouris的一篇研究论文声称,尽管许多流处理器使用RocksDB,但他们“质疑这种通用存储对流工作负载的适用性,并辩称它们会带来不必要的开销,以换取状态管理能力”。
RocksDB还针对开箱即用的OLTP设置进行了优化,在OLTP设置中,读取数通常比写入数多10:1。但是,在流设置中,读写比更接近1:1,这使得默认压缩算法不适合使用。虽然原则上可以通过编写针对流进行优化的自定义压缩算法来修复这一问题(几年前我在我的个人博客上写了RocksDB的简要历史,在那里我概述了压缩算法可以针对任何读/写设置在很大程度上调优LSM树),但我们所知道的流处理器都没有做到这一点。
Differential Dataflow采用一种不同的方法来构建和维护索引,它使用一个名为Arrange的类似LSM的自定义操作符。Arrange接受不变的批处理作为输入(就像RocksDB一样)并压缩它们。不同之处在于,安排由工作人员进行分片,并像任何其他及时数据流操作符一样进行协作调度,但代价是不可持久性。有关安排的细节将在即将发布的一篇VLDB论文中介绍,但仔细控制压缩发生的时间是在流更新中保持一致的低延迟,同时保持紧凑的内存占用空间的关键部分。
排列是内存中的数据结构。因为每个工作进程都维护自己的专用内存空间,所以它可以通过系统分页正常地溢出到磁盘,因此它不限于可用的RAM。但是,在系统崩溃或重新启动时,所有这些状态都会丢失。
但是请注意,索引维护和持久化流是(微妙的)正交问题!虽然使用RocksDB作为持久索引存储可以提供持久性,但使用RocksDB并导致每个数据的跨核心数据移动的高性能成本意味着,这远不是免费的午餐。其次,还有相当多的工作要在所有运营商之间排好队,以便于恢复!例如,要做到这一点,Flink使用Chandy-Lamport全局同步算法,在定期设置检查点时会出现延迟峰值
我们的观点是,如果索引构建和维护足够快,那么对索引进行检查点操作就不那么重要了。只需在重新启动时重建索引!这降低了存储原始流这一挑战的持久性,而不是存储复杂的全局分布的状态快照,这是一个容易得多的问题。只需将流记录到仅附加文件,这是一种非常适合将计算与存储分离的方法,这意味着流存储可以是轻量级、廉价和快速的。这是我们在Materialize中实现持久性的初步方法,我们打算在接下来的几个月发布。最终,我们将迭代持久性以持久地存储索引状态,但是我们认为优化重新启动时间应该由…来解决。嗯,根据经验对重新启动时间进行基准测试,而不是通过检查点索引状态来指定解决方案。
将索引维护与持久性分离是我们在构建物化时做出的几个有意的设计决策之一。虽然几年来在生产中,安排一直是差异数据流的核心部分,但是持久性目前计划在我们的下一个版本0.5中发布。同时,今天您可以通过下载Materialize、在Github上查看我们的源代码或在浏览器中试用演示来查看我们当前的0.4版本!