[EuroSys‘22] Gadget: 针对流处理系统中状态存储的性能测试工具

261 阅读18分钟

前言

事情是这样的。

那天我在看波士顿大学博士生 Showan Esmail Asyabi 的主页 [2] 时,发现了这么一则消息:

当时我正在给 RisingWave现已开源!)状态存储写 benchmark 工具,看到消息后就联系了 Showan。

这个工作后来中了计算机系统领域的顶会 EuroSys’22。然后过了几天,老板吴英骏(@Yingjun Wu)把 Showan 请来给 talk 了 [1],所以我在 talk 时借着机会问了 Showan 几个问题。这个在结尾的锐评环节会分享一下。

不管是 Storm、Samza、Flink 这样的流处理引擎,还是 Materialize、RisingWave 这样的流数据库,现代流处理系统一般都会拿 KV 模型来作为状态存储。流处理引擎和流数据库的差别之一就在于,流处理引擎会拿现成的 KV 存储(基本上是 RocksDB)作为自己的依赖,把状态存储的任务外包给了这些现成的 KV 存储,而流数据库则是自己写。

评估这些存储在流处理中的性能很麻烦,因为它需要配置和部署一个集成了相应存储的流处理系统,然后跑一些代表性的查询来收集测量结果。另外很多工业界的朋友们(比如 @廖嘉逸)也发现 RocksDB 并不是一个非常适合流处理系统的存储引擎 [10] [11]。

所以我们开发新的状态存储引擎时,就需要一个 benchmark 工具来测量性能表现,而 Gadget 正是第一个面向状态存储的 benchmark。

文章一作是 Showan,二作是王远立(@Pentium PRO) [12],都是波士顿大学博士生。主要的指导老师是 Vasia,我之前详细介绍过了 [5]。

原文dl.acm.org/doi/pdf/10.…

代码github.com/CASP-System…

本来这篇 review 应该是远立亲自写的,但是他忙着赶另一篇 paper,所以这篇 review 由我代劳。那我们现在就来鉴赏下这篇论文吧。

摘要

本文先后讲了以下内容:

  • 通过分析 Flink 对 RocksDB 的状态访问 trace,得出了一些经验规律。

  • 通过实验论证 YCSB 等现有的 KV存储 benchmark 模拟不来这些 trace。

  • 实现了 Gadget。这是一个新的 benchmark 工具,它通过模拟流处理算子的逻辑来生成逼真的状态访问 workload,再用这些状态访问 workload 对不同 KV 存储进行性能评估。

  • 使用 Gadget 分析了四个不同的 KV 存储在流处理场景下的性能,发现RocksDB 在不同情况下的性能都很稳定,但在近半的测试中表现得不如 FASTER 和BerkeleyDB 好。

流处理基础知识

测试对象

Gadget 的测试对象主要是那些基于 Dataflow 模型的流处理系统。Dataflow 模型是现代流处理的基石,相关资料非常多,就不细说了,感兴趣的可以看看 Tyler Akidau 发的那篇 VLDB'15 [6]。

所以,在 Gadget 的测试对象中,每个算子都有自己的本地状态,状态存于嵌入式 KV 存储中,容量可能会大于内存容量。每个传入的事件都会触发本地状态中一系列的状态访问,这些状态访问的类型和数量则取决于算子的逻辑和实现方法,不同的流处理系统可能采用不同的方法。由于每个算子都是连续地处理一个又一个的事件,所以一个算子对于其 KV 存储的状态访问请求都是有序的,不存在并发访问。

Streaming state access overview

状态访问流

Gadget 将状态访问流定义为一个算子在处理输入事件时产生的状态访问序列。一个状态访问请求可以被形式化为一个 tuple: (p,k,v,t) —— 其中,p 是操作类型(可以是 get、put、merge 和 delete),k 是 key,v 是 value(可以为 null,比如操作类型为 get 时就是 null),t 是操作被执行时的时间戳。举个例子,一个状态访问流 s 可以是(get, ki, null, t1),(put, ki, v, t2),(get, ki+1, null, t3)...

流处理算子

然后介绍一下本文主要涉及到的三种流处理算子:

  • Window。依旧是 tumbling、sliding、session 这三种经典 window,这些在 Flink 等系统中很常见,有详细的介绍 [7]。例如,在 tumbling window 中,流被分成等长的段,输入流中的每个事件都属于且仅属于一个 tumbling window。例如,集群监控应用中的 tumbling window 查询计算每 5 秒提交给集群的作业数量。这三种 window 都可以细分为两类:一个是代数式的聚合算子,如 min、average,我们称之为 incremental 类型;否则,我们称其为 holistic 类型,例如,median、rank。算子是 incremental 还是 holistic 对于不同 KV 存储的影响差别很大,后面会分析。

  • Join。流处理系统里面通常提供的 join 算子是 window join,这会在 window 过期后丢弃里面的事件,从而使得数据占用量不会无限大。相比 window join,有两种自定义的join 算子更具表现力和灵活性,这两种 join 也是本文主要测试的 join 算子。一个是interval join,它定义了一个相对的 time interval,在这个 time interval 内,一个流的事件可以匹配另一个流的事件。这在两个输入源的时钟出现倾斜的情况下很有用。另一个是 continuous join,它在数据流本身编码了一个有效间隔或过期时间戳时很有用,比如计算出在下车时间前共享的出租车其费用事件的总量。

  • Aggregation。Continuous aggregation 会针对每个 key 计算出一个滚动聚合值(比如sum、count、min、max)。这些算子通常很轻量级,状态小到足够放在内存中,但随着流数据的不断涌入,流中的 keyspace 会增加,导致这些算子的状态会不断增大。

状态访问的 workload 特征

方法和设置

为了捕捉真实流处理场景下 KV 存储收到的状态请求,他们魔改了 Flink,通过检测 Flink 与 RocksDB 交互的状态管理层,使 Flink 运行时能够输出状态访问的 trace。然后他们找了三个真实场景下的事件数据集,把这些数据集往 Flink 里面灌,让 Flink 把这些事件数据集翻译成状态访问的 trace 再吐出来。

这三个数据集是:

  • Borg:由 250 万个任务事件和 2.6 万个工作事件组成,提取自 Google 集群。选用 jobID 作为 event key。

  • Taxi:由 100 万个出租车行程(接送事件)和 50 万相应的出租车费用事件组成,这些事件来自 2013 年纽约市 TLC 行程记录数据。选用 medallionID 作为 event key。

  • Azure:由 400 万虚拟机创建事件的完整 trace 组成,来自 2017 年 Azure 虚拟机的workload。选用 subscriptionID 作为 event key。

对于流式状态访问负载的分析

Workload composition(负载构成)

结论:

  • 对于相同的输入流,不同算子的 workload 构成差别很大;对于相同的算子,不同输入流有一定差别。

  • 状态访问的 workload 是 update-heavy 和 write-heavy 的,delete 操作的占比取决于流的 arrival rate 和 window 的长度。

具体情况见下图:

由图可知:

  • 有的 workload 是 update-heavy,get 操作和 put/merge 操作比例五五开。

  • 有的 workload 是 write-heavy,merge 操作的比例相当高。

  • 除了 aggregation 算子外,delete 操作在所有 workload 中都很普遍,占比从 1.4% 到 43% 不等。

Amplification(放大效应)

结论:

  • 状态存储收到的请求量要比事件流的到达率高得多。
  • 在大多数 workload 中,event key 的分布和 state key 的分布也不相同。

状态存储中有两种amplification:

  • event amplification:收到一个事件平均会引起多少次状态访问。这影响了状态存储收到的请求数。
  • keyspace amplification: (输入流中不同 event key 的数量)与(状态访问流中不同 state key 的数量)的比率。这决定了所生成的状态的大小。

举个例子,Flink 的 window 算子中使用了 W-ID 策略来将 event key 映射成 state key。

其中,每个 window 在 KV 存储中就是一个 KV pair,key 是开始或结束的时间戳,value 是一个包含 window 内容的 bucket。当一个事件到达时,window 算子从 KV 存储中获取相应的 bucket(value),更新这个 bucket 的内容,然后将其写回去。收到水印后,算子找出过期的 window,在 KV 存储中删除相应的 bucket 的内容。

因此,window 算子每收到一个事件都要向状态中发送一对 get-put(即 holistic window 对应的 merge)操作,并在 window 失效时发送一对 get-delete 操作。对于 sliding window,由于 window 间可能有重叠,所以一个事件可能属于多个 window,也因此一个事件会被分配到若干个 window buckets 中。

Locality and ephemerality(局部性和时效性)

结论:

  • 状态访问具有很强的时间和空间locality。
  • 状态是短暂的,key 的生存时间很短。

首先是 locality,它包括两种:

  • 时间局部性(temporal locality)是指最近被访问的 key 在不久的将来再次被访问的可能性。

论文中使用 stack distance 作为量化指标来评估时间局部性,这一指标在 cache 设计领域广为使用。stack distance 指在对一个内存对象的先后两次访问之间,系统访问了多少其他不同的对象。对于这一指标的形象化理解就是,在 KV cache 中,写入的 KV pair 会被放置在 LRU stack 结构中。所以在下一次访问时这个 key 在 stack 中的位置就等于它的 stack distance,这个距离越小说明对其的访问越频繁。

  • 空间局部性(spatial locality)是指如果一个 key 的邻居被访问了,那这个 key 本身被访问的概率会增加。这对于 prefetching 等策略很重要。

论文中使用“key序列一定长度内的相邻子序列的基数”作为其量化指标,大致含义就是,给你一段 key 被访问的时间序列,让你穷举这个序列的相邻子序列,要求是子序列的长度小于等于某一给定值,然后统计一共有多少不同的子序列。这一指标越小说明空间局部性越高。

其次是 ephemerality,它主要是指算子的 working set 的大小,就是指某一时刻留存在状态存储中的 KV pair的总数。

  • Continuous aggregation 的 working set 一般会随时间增加,因为一个算子中会从输入流中不断收到不同的 key,并且后续也不会删掉这些 key。
  • Tumbling window 每次“翻转”时都会除旧迎新,刷新 key space,把原来的 key 全部干掉,所以 working set 的大小会周期性波动。
  • Interval join 的 working set 大小也会随着时间的推移而变化,因为每次超过有效间隔后,新的事件会被添加到状态中,而旧的事件会被删除。

所以,在 event-time 算子中,working set 的大小变化趋势取决于 watermark 的频率,频率低的话状态就会变大。给个粗略参考:在文中的实验里,watermark 发得慢的话会使最大 working set 大小增加 3 倍。

YCSB不能很好地模拟流处理场景

作者尝试着通过配置 YCSB 尽力模拟流处理场景下状态访问,但发现生成的 trace 和 Flink 实际产生的 trace 相差太大。

所以,他们得出结论:尽管通过调整配置后 YCSB 生成的 trace 可以具有一定时间或空间局部性,但不能两者兼而有之。实验发现,这些 trace 和真实的 trace 相去甚远,在 request distributions、temporal and spatial locality、ephemerality 这三个方面都不太像,连 continuous aggregation 这样简单的算子都无法模拟。

这就论述了 Gadget 诞生的必要性。

Gadget架构

由上图可知,Gadget 架构由四个核心组件构成。

接下来,我们详细描述各个组件。

Event generator

  • 作用:根据配置文件生成了事件流。除了内置的分布(如 Zipfian、指数、均匀分布等),event generator 也支持用户自定义的分布函数,还支持用户直接拿 Borg 等现有的事件 trace 重放。
  • 参数:
    • KV benchmark 基本参数:key 或 value 的分布及长度。
    • 模拟流计算专用参数:arrival rate 分布、watermark 频率、迟到事件的比例、迟到事件的迟到时间分布。
  • 例子:
    • 每 3 个单位时间就发送一个 watermark,2% 生成的事件会迟到,这些迟到事件的迟到时间由 0 到 3 个单位时间均匀分布。

Driver

  • 作用:将输入事件映射成状态对象(比如:PutState、GetState、DeleteState 等),这些状态对象在后面要讲的 workload generator 中会触发相应的状态机。
  • 步骤:
    • 根据相应算子的实现(不同流系统实现不同,Gadget 默认采用 Flink 的实现),找到每个输入的事件会影响的那些状态机,然后把事件映射成改变这些状态机的状态对象。其实主要就是将 event key 映射到 KV 存储的 key 上,一个事件可能被映射成好几个状态对象。
    • 对于 continuous aggregation:
      • 当一个事件到达时,driver 根据其 event key 将其映射成某一个状态对象。
    • 对于一个 sliding window,一个事件可能会影响多个 window,那就生成多个状态对象,比如说多个 GetObject,来触发这些状态机。
  • 以采用了 W-ID strategy 的 window 算子为例:
    • 当一个事件到达时,driver 根据其所属 window(比如,如果在 sliding window 中一个事件就可能属于多个 window)生成相应的状态对象(比如说 GetObject)。

    • 当一个 watermark 到达时,driver 生成 DeleteObject 来删除这些过期的window。

Workload generator

  • 作用:将状态对象转换为状态访问操作。
  • 过程:
    • 对每个 state key 初始化一个状态机。

    • 根据状态对象触发这个状态机,生成若干状态访问请求。所以,一个事件能生成一个或者多个(比如有 sliding window)state object,一个状态对象能生成若干个状态访问操作(比如 get-modify-write)。

Performance evaluator

  • 作用:把状态访问请求发给 KV 存储,然后收集测出来的性能指标

所以,如果开发人员要让 Gadget 支持一个新的算子,其工作将包括:

  • 定义 event key 到 state key 的转换

  • 写个 switch 语句来定义状态机的转换逻辑,从而利用状态对象生成状态访问请求

如果开发人员要测一个新的 KV 存储,则需要将(get, put, merge, delete)这四种操作类型映射到新 KV 存储自身的接口。比如如果这个新 KV 存储是 FASTER,则需要将 get 映射到了 read,put 映射到 upsert,merge 映射到 rmv。

要 benchmark 状态存储有 3 种方式:改流处理器、改KV存储、做一个专门的 benchmark 工具。

  • 魔改流处理器(比如 Flink),记录流处理器和状态存储通信的 trace。这样的问题在于,收集 trace 可能需要多次配置和部署整个系统,而且要对流处理器代码做不少改动,任务量大,需要熟悉代码库。

  • 魔改 KV 存储,记录请求。这种情况下无法拿到 event key,也无法拿到 event key和 state key 之间的关系。

  • 专门搞一个 benchmark 工具,比如 Gadget。和上面两种相比,Gadget 即使是在笔记本电脑上也可以生成大量的事件,从而生成大量状态访问请求,而且事件和状态访问请求之间的关系很清晰,自主可控。

结果分析

我们分析一下使用 Gadget 测出来的不同 KV 存储的表现。

先回顾一下,holistic window 算子会将输入的事件收集到桶中,并在 trigger 时才调用聚合函数。因此,如果 KV 存储不支持 lazy updates(比如 RocksDB 中的 merge),那么每次向 window 中插入一个事件时都需要读出 key 对应的 list —— 给这个 list 追加一个新元素 —— 把这个 list 写回去,进行这么一个 read-modify-write 的三连操作,这个 list 会不断变长。这使得 FASTER 和 BerkeleyDB 在 holistic 算子中吞吐量不及 RocksDB。

所以,基于 LSMTree 的存储引擎在 holistic aggregation 方面表现良好,因为它们支持 lazy update。RocksDB 可以高效地将新值追加到日志中,并在 window 被触发时再收集 window 的内容。

相对应的,基于 hash 或 B+ 树的存储引擎支持 in-pace update,更适合 incremental 算子。例如,由于高效的 lookup 和 in-pace update,FASTER在 incremental 算子上的性能比 RocksDB 要好一个数量级。

总的来说,RocksDB 在不同场景下性能比较稳定:它们的 tail latency 在任何 workload 下都不超过 100us,并且在许多情况下保持在 10us 以下。如果我们只能选择一个单一的存储来管理状态存储,RocksDB 确实是目前最明智的选择。

锐评

就论文结构而言,我觉得这篇 EuroSys'22 和 Zhichao Cao 那篇知名的 FAST'20 [4] 非常像。

那篇 FAST'20 先是刻画了一下 Facebook 内部的 RocksDB workload 的特征,然后分析 YCSB 的不足,再展示自己研发的新 benchmark 工具。

这篇 EuroSys'22 则是在刻画了流处理的特征后,分析 YCSB 在模拟流处理上的不足,提出了 Gadget 这个新 benchmark,再分享了拿 Gadget 评估几个 KV 存储后的发现。

这样同时描述 workload 特征和 benchmark 工具的好处之一就在于加强了文章的分量,也使得工作更加完整。相比而言,那篇 FAST 对 workload 特征的分析更好一些,毕竟是 Facebook 亲自分析自己成名已久的 RocksDB;这篇 EuroSys 提出来的 benchmark 工具的创新性更好一些,毕竟是完全来自学术界的工作。

对了,for your information, Zhichao Cao 博士从 Facebook 离职后重回了学术界,正忙着招人 [3]。

正如我前面所说,我们公司非常喜欢这篇论文,甚至在这篇文章中稿前就盯上了它。

当然了,白璧微瑕,下面简单说下我的个人看法。

论文里提到,由于 Gadget 是针对 Dataflow 模型,所以每个状态存储只会被一个线程访问。但是,如果你想扩展到 external 状态存储,则只需要同时运行几个 Gadget 实例就行。

Finally, even though we have considered embedded state in this paper, some streaming frameworks, such as MillWheel and Pravega, rely on distributed KV stores. We believe that Gadget can be easily extended to support evaluation of external state management approaches by running multiple concurrent instances of the workload generator and implementing the respective KV store wrappers.

在 talk 的 Q&A 环节,我问 Showan,Gadget 评估的是 embedded 状态存储,而为了利用好云上基础设施,我们完全可以考虑使用 S3 作为状态存储后端。你对于这种 external 状态存储有什么想法吗?

Showan 复述了上面这段论文,同时表示大概多写 200 行 C++ 就行。

但其实真实的工作量要比 Showan 说的大得多。因为问题在于,用了 external 状态存储后一般计算节点可能会维护 cache,减少和 external 状态存储的交互次数。在这种情况下,cache policy 对于性能的影响至关重要。而这样一来,benchmark 中怎么模拟这些 cache policy 就很关键了。

吴英骏表示,实际上这足以支撑起另一篇论文了(或许可以作为后续工作的方向?)。

顺便一提,虽然 Samza 也是使用 RocksDB 做状态存储,但是为了减少和 RocksDB 交互带来的频繁序列化和反序列化,它也会在计算层自己维护 cache。[13]

结论

总体来说,我感觉这篇文章的还是很不错的。像是吴英骏就在推特上表示:“This is one of my favorite papers in the stream processing area!”。[8]

这篇文章中,关于流处理性能部分和以往的工作相比做得比较详细全面,而 Gadget 的设计则能带来一定的启发性,对于那些使用了嵌入式存储引擎的流处理系统而言也是个挺好的 benchmark。

最后帮论文作者打个广告(

我们组(Complex Analytics & Scalable Processing (CASP) Research Lab at Boston University)[9] 针对未来的分布式流处理和数据分析系统还在进行一系列的工作,包括流处理系统的动态调优、云上的 stream processing service、基于多方安全计算(MPC)的隐私数据分析、图计算和 streaming graph Machine Learning 等等。欢迎关注我们的后续工作。

Reference

  1. Singularity Data 宣传邀请 Showan 给 talk 的推文: twitter.com/Singularity…
  2. Showan 的主页:cs-people.bu.edu/easyabi/
  3. Zhichao Cao 亲自发的招生帖:instant.1point3acres.com/thread/8211…
  4. FAST'20《Characterizing, Modeling, and Benchmarking RocksDB Key-Value Workloads at Facebook》:www.usenix.org/system/file…
  5. “世界范围内有哪些研究流处理(stream processing)的高校和团队? - 孙挺Sunt的回答”:www.zhihu.com/question/51…
  6. VLDB'15《The Dataflow Model: A Practical Approach to Balancing Correctness, Latency, and Cost in Massive-Scale, Unbounded, Out-of-Order Data Processing》:dl.acm.org/doi/10.1477…
  7. Flink 文档中的 window 算子介绍:nightlies.apache.org/flink/flink…
  8. 吴英骏宣传这个 talk 的推文:twitter.com/YingjunWu/s…
  9. CASP Research Lab 的主页:sites.bu.edu/casp/resear… 和官方Twitter:twitter.com/CASPSystems
  10. 廖嘉逸的知乎文章《Why not RocksDB in Streaming State?》:zhuanlan.zhihu.com/p/458148962
  11. HotStorage'20《In support of workload-aware streaming state management》:www.usenix.org/conference/…
  12. 王远立的主页 pentium3.github.io/ 和知乎 www.zhihu.com/people/pdev
  13. VLDB'17《Samza: Stateful Scalable Stream Processing at LinkedIn》:www.vldb.org/pvldb/vol10…