优化Apache Flink应用程序的7个技巧

571 阅读17分钟

优化Apache Flink应用程序的7个技巧

在Shopify,我们已经采用了Apache Flink作为标准的有状态流引擎,为我们的BFCM Live Map等各种用例提供动力。我们的Flink应用被部署在Kubernetes环境中,利用谷歌Kubernetes引擎。我们的集群被配置为使用高可用性模式,以避免作业管理器成为单一故障点。我们还使用RocksDB状态后端,并将我们的检查点和保存点写到谷歌云存储(GCS)。

确保我们的Flink应用程序保持性能和弹性是我们的首要任务之一。这也是我们最大的挑战之一。保持大型有状态应用程序的弹性是困难的。一些数据模型需要存储巨大的状态(例如,13TB的销售数据,正如我们在《永远存储状态》中所分享的。为什么它对你的分析有好处),我们在性能调整上花了很多时间,在这一过程中吸取了很多教训。

下面我们将向您介绍优化大型有状态Apache Flink应用程序的关键经验。我们将从推荐的工具开始,然后重点讨论性能和弹性方面。

1.找到正确的分析工具

首先要做的是。拥有正确的剖析工具是洞察如何解决性能问题的关键。在部署我们的第一个应用程序时,我们发现在调试Flink时,使用以下一组工具很有用。

  • Async-profiler:一个为Java虚拟机(JVM)建立的剖析工具,用于追踪许多种类的事件,包括CPU周期、Java Heap分配,以及性能计数器,如缓存缺失和页面故障。它对火焰图的支持对于检查你的任务管理器在哪里花费时间特别有用。这个工具对于帮助我们调试Kryo序列化的性能差异特别有用。
  • VisualVM:另一个JVM分析工具。你可以把这个工具连接到正在运行的JVM实例上,以实时查看堆分配和CPU使用率。它对交互式调试很有用。我们经常使用它作为调查内存问题的初始工具。它有一个友好的用户界面,不需要很多设置。
  • jemalloc+ jeprof:一个通用的malloc实现,从1.12版本开始被作为Flink的默认内存分配器。结合起来,你可以设置你的任务管理器和工作管理器来自动转储内存配置文件,然后你可以用jeprof进行分析。我们发现这对观察较长时间内的内存趋势非常有用,可以帮助我们在RocksDB中检测一个应用程序的内存泄漏。
  • Eclipse内存分析器(Eclipse MAT)。Eclipse MAT是一个Java堆分析器,用于检查JVM堆转储的内存利用率,寻找内存泄漏等。它可以用来读取由jemalloc输出的堆转储,以提供额外的分析和解释层。当我们需要调查我们的一个应用程序在GCS文件汇中面临内存不足的问题时,这个工具被证明是非常有用的,我们将在下面描述。

这些工具可以普遍用于任何Flink应用程序,但它们绝不是唯一的工具--只是那些对我们有用的工具。JVM有一个丰富的剖析工具生态系统,从jmap这样的基本内置命令到Java Flight Recorder这样的现代高级功能,都值得研究。

2.避免Kryo序列化

Flink为其使用的数据结构提供了各种不同的序列化器。大多数时候,我们使用Scala的案例类或Flink中支持的Avro记录。它们可能不是性能最好的选择,但它们对开发者的经验来说是很好的。

当Flink使用内置的case类或Avro序列化器无法序列化一条记录时,它会退回到Kryo序列化。Kryo序列化很慢,比你通常使用的其他数据类型慢得多。实际上,在你使用async-profiler这样的工具之前,你是不会注意到这种性能下降的。例如,当我们使用async-profiler来调试不相关的性能问题时,我们观察到Kryo类在内存flamegraph中占据了多少空间。我们禁用了Kryo的回退(`env.getConfig().disableGenericTypes();`),浮出了导致回退的各种序列化失败。下面是我们遇到的一些例子,以及我们如何修复它们。

  • Scala的BigDecimal:Flink不支持序列化Scala的BigDecimal值,但它可以序列化Java的BigDecimal值。默认使用Java的BigDecimal来避免这种序列化失败的情况。当你处理货币值时,你可能会遇到这个问题。
  • Scala ADTs:Flink不支持序列化用一个密封属性和一些案例对象实现的Scala ADT,这些对象通常代表一个类似枚举的数据结构。然而,它确实支持Scala枚举,所以你可以使用这些来代替。

在修复了所有这些问题之后,我们注意到吞吐量增加了20%。你可以遵循同样的方法:禁用Kryo回退,并修复弹出的问题,直到Flink不再使用Kryo。

3.根据工作负载调整配置

当涉及到配置时,Flink提供了无数的选项,但调整真的取决于你的应用程序的状态和负载。例如,在Shopify,一个典型的流媒体管道可能会受到不同的系统负载情况的影响,具体而言。

  • 回填:从时间开始,消耗输入源中所有可用的历史消息,直到管道赶上现在的时间(也就是说,源滞后接近零)。
  • 稳态:管道正在消耗接近实时的消息,源滞后最小(即秒)。
  • 极端或季节性事件:管道正在消耗接近实时的消息,但资源使用是尖锐的,滞后可能会增加。对于Shopify来说,这种情况的一个例子是大批量的销售时刻,如闪购和黑色星期五。

让我们专注于前两种情况,因为它们定义了我们管道的关键操作模式。在回填的开始阶段,管道积压的规模最大,而在稳定状态下,管道积压的规模最小。我们希望回填能尽快赶上现在,以减少需要从头开始重新处理所有数据的任务和代码修改的时间成本。

大数据量和速度标准使得回填成为一项具有挑战性和计算强度的工作。对于一些大型应用,这可能意味着在几个小时内处理数百亿条信息。这就是为什么我们调整我们的管道,将吞吐量优先于新鲜度。另一方面,对于稳定状态,我们把我们的应用程序调整为以非常低的延迟运行,并最大限度地提高所有输出的新鲜度。这导致了两种不同的配置文件,它们的设置取决于一个应用程序的当前状态。

虽然你需要考虑你的系统负载以及它如何影响你的调谐,但下面是一些可以应用于两种负载配置文件的考虑因素。

  • 输入源分区(例如,Kafka分区)。在稳定状态下,积压是最小的,所以少量的输入分区很可能为一个健康的管道提供足够的并行性,而且滞后最小。然而,在回填期间,确保你的管道能够从输入源获得尽可能多的吞吐量是非常重要的--输入分区越多,吞吐量就越大。因此,当你创建你的来源时,一定要考虑到回填的情况。
  • 背压性:能瓶颈会导致背压,当数据产生的速度超过下游运营商的消费速度时,会给上游运营商带来背压。如果你的管道是健康的,你不太可能在稳定状态下看到背压。然而,在回填的时候,管道的瓶颈会变得很明显(在作业图用户界面中显示为红色)。利用这个机会,确定你的管道的缓慢阶段,如果可能的话,对它们进行优化。
  • 水槽节流:即使你的应用程序代码是高度优化的,仍然有可能流水线不能像你希望的那样快速地写入汇中。水槽可能不支持许多连接,或者即使它支持,它也可能被太多的并发写入所淹没。在可能的情况下,扩大水槽的资源(例如,给数据库增加更多的节点或给Kafka主题增加更多的分区),当这不在考虑之列时,考虑减少水槽的并行性或出站连接的数量。
  • 网络网络缓冲区最早是为了提高资源利用率和增加吞吐量而引入的,代价是缓冲区队列中的消息被延迟。为了增加并行性,你可以增加更多的任务管理器,提供更多的任务槽。但要注意的是,如果流水线图相当复杂并包含几个洗牌操作,这往往需要同时增加网络缓冲区的数量(通过taskmanager.memory.network.fraction )。
  • 检查点为:了减少从故障中恢复的时间,在稳定状态下保持检查点频率(execution.checkpointing.interval)很重要。然而,在回填期间,最好降低频率,避免相关的开销。根据管道状态的大小,可能需要调整任务管理器的堆,以便有足够的内存用于上传文件。另外,如果状态大小很大,考虑使用增量检查点(state.backend.incremental)。最后,如果有必要,可以考虑增加检查点的超时时间 (execution.checkpointing.timeout)。

关于其他可能有用的Flink部署配置的列表,请查阅Flink文档。

4.配置文件堆

Flink提供了一个File Sink,能够将文件写入文件系统或对象存储,如HDFS、S3或GCS(Shopify使用)。配置File Sink是非常直接的,但要让它有效和可靠地工作是很棘手的。

Flink的File Sink在内存中维护一个分区(或桶)的列表。每个桶是由一个BucketAssigner决定的。例如,一个自定义的BucketAssigner可以使用所提供的记录中的一个时间戳字段来生成一个看起来像date=2021-01-01 。这是Hive使用的一种极为流行的分区格式。

我们配置了一个File Sink,并天真地将其添加到现有的DataStream中:

val records: DataStream[Record] = … 
val fileSink: SinkFunction[Record] = …
records.addSink(fileSink)

这在测试中是可行的,但是当我们把它部署到实际环境中并试图在回填过程中处理所有的历史数据时,我们立即观察到了内存问题。我们的应用程序很快就用完了所有可用的Java堆,并崩溃了!而且,即使我们把它部署到实际环境中,并试图在回填过程中处理所有的历史数据,它也一直崩溃。而且,即使我们多次增加内存,它还是不断崩溃。我们了解到,可能需要一些内存来缓冲桶中的记录,但可能不是几十GB的。

所以我们在应用程序几乎准备崩溃的时候做了一些堆转储,并用Eclipse MAT分析了它们。结果看起来确实令人担忧。

Eclipse MAT:概述

在上面的堆转储中,你可以看到两个大对象几乎占据了整个堆。支配者树报告清楚地显示(如红色突出显示)两个HashMaps,它们支持File Sink桶作为违规者。

Eclipse MAT: Dominator tree

在进一步探索了堆转储和应用程序日志之后,我们意识到了发生了什么。由于我们没有应用任何数据重新洗牌,每个任务管理器消耗的记录最终都可能出现在任何桶中,这意味着:

  • 每个任务管理器都需要在内存中持有一个大的桶的列表:我们经常观察到超过500个桶。
  • 由于上述原因,滚动和冲刷文件的时间大大增加:每个任务管理器上没有足够的数据来快速完成。

我们可以应用一个简单的解决方案来解决这个问题--在将记录写入水槽之前,只需通过分区字符串来键入记录。

通过这样做,我们保证将具有相同分区或桶的记录路由到同一个任务管理器,从而使每个任务管理器在内存中持有更少的东西,并更快地冲刷文件。没有更多的内存问题!而堆转储分析显示,每个任务管理器的活动桶的数量减少了90%。

如果所选的分区具有良好的分布,这种方法效果很好。但是,如果你的一些日子的数据明显多于其他日子(在做历史回填时可以预期),你最终可能会有一个大的倾斜,导致内存问题。稍微改变一下分区,通过在分区键上增加小时数来改善分布,可以很好地解决这个问题。

数据定位是分布式系统的一个重要方面,这个经验清楚地表明了这一点。从逻辑上洗刷数据以实现良好的数据并行性的简单技术,也可以应用于其他汇和运算器。

5.使用SSD来存储RocksDB

尽管RocksDB--最流行和最值得推荐的Flink状态备份--在内存中保留了一些数据,但大部分的状态是在磁盘上保存的。因此,在处理大型有状态的应用程序时,需要性能非常好的磁盘。这并不明显,尤其是在刚开始使用Flink的时候。例如,最初我们开始使用网络文件系统(NFS)卷来处理RocksDB的状态后端,当时部署的应用程序的状态很少(例如,Kafka消费者偏移)。我们没有注意到任何性能问题,但受益于NFS提供的额外弹性。

然而,网上有很多资源推荐像本地固态硬盘这样的快速磁盘,所以我们尝试使用GCP提供的一个状态超过8TB的应用程序。通过使用本地固态硬盘,我们注意到由于磁盘I/O率的提高,处理速度提高了大约10倍。同时,如果一个实例发生故障,GCP中的本地SSD可能会丢失,但由于Flink检查点和保存点的存在,状态可以很容易地恢复。

6.避免动态类加载

Flink有几种加载类的方式,供Flink应用程序使用,从调试类加载

  • Java Classpath:这是Java的公共classpath,它包括JDK库,以及Flink的/lib文件夹中的所有代码(Apache Flink的类和一些依赖项)。
  • Flink插件组件:插件代码文件夹位于Flink的/plugins文件夹中。Flink的插件机制在启动时动态地加载它们一次。
  • 动态用户代码:这些都是包含在动态提交作业的JAR文件中的类(通过REST、CLI、Web UI)。它们在每个作业中被动态加载(和卸载)"。

动态用户代码是在每个作业开始时加载的,因此,如果对旧类的引用挥之不去,就会发生泄漏。每当Flink应用程序需要从瞬时故障中恢复时,它就会重新启动作业,并从最近的检查点恢复,同时重新加载所有的动态用户代码。

禁用动态类加载之前和之后的元空间内存

在这些重启过程中,我们观察到内存泄漏,表现为 "java.lang.OutOfMemoryError:Metaspace "的错误。正如你在上面的截图中所看到的,每次应用程序在修复之前重新启动,都会增加Metaspace的内存用量。

通过将我们的应用代码放在Java的公共classpath上,禁用动态类加载,解决了这个问题。修复后的上述截图显示,内存并没有随着重启而增加。

如果你在应用模式下运行Flink集群,并且不需要支持在一个Flink集群上运行多个作业,那么这个解决方案就适用。

7.了解RocksDB的内存使用情况

我们还观察到另一个与内存有关的问题,这个问题调试起来非常复杂,只要我们。

  • 启动一个有很多状态的Flink应用程序
  • 等待了至少一个小时
  • 手动终止了一个任务管理器容器。

我们期望一个替换的任务管理器被添加到集群中(感谢Kubernetes部署),并且应用程序的恢复会在那之后不久发生。但相反,我们注意到另一个任务管理器因 "内存不足 "的错误而崩溃,导致崩溃和重启的无休止循环。

有OOM错误的Flink容器的内存使用情况

我们确认,这个问题只发生在一个有很多状态的应用程序上,而且至少已经运行了一个小时。我们惊讶地发现,"内存不足 "的错误并不是来自JVM-堆分析,使用async-profiler和VisualVM并没有显示任何问题。但是,仍然有一些东西导致了内存使用的增加,并最终迫使Kubernetes运行时杀死了一个违反其内存限制的pod。

因此,我们开始怀疑RocksDB的状态后端在JVM之外使用了大量的本地内存。我们配置了jemalloc来定期将堆转储写入本地文件系统,这样我们就可以用jeprof来分析它们了。我们能够在另一个 "Out of Memory "错误之前捕获一个堆转储,并确认RocksDB试图分配比它被配置为使用的更多内存。

在这个特定的例子中,Flink管理的内存被配置为使用5.90GB,但配置文件清楚地显示正在使用6.74GB。

我们发现了一个相关的RocksDB问题来支持这一说法:在过去三年中,许多库的用户报告了各种与内存有关的问题。我们按照问题中的一个建议,尝试用一个自定义的RocksDBOptionsFactory来禁用RocksDB块缓存。

它成功了!现在,即使在杀死一个任务管理器后,我们也没有观察到任何内存问题。

没有OOM错误的Flink容器的内存使用情况

禁用RocksDB块缓存并没有影响性能。事实上,我们只在填充缓存的时间内看到了差异。但是,禁用了RocksDB块缓存的Flink应用和完整的RocksDB块缓存的Flink应用在性能上没有任何区别。这也解释了为什么我们需要等待才能重现这个问题:我们在等待块缓存的填充。后来,我们通过启用RocksDB本地指标来证实了这一点。

就这样,你拥有了它!Apache Flink是一个非常强大的流处理引擎,但是用它来构建复杂的应用程序会带来性能和弹性方面的挑战,需要进行一些调整和优化工作。我们希望你喜欢这次Flink的旋风之旅,以及我们学到的一些经验。