我们经常看到人们编写的数据流管道不能很好地扩展。这是令人沮丧的,因为Dataflow旨在透明地扩展,但Dataflow管道中仍然存在一些难以扩展的反模式。什么是常见的反模式和避免它们的提示?
答案 0 :(得分:26)
嗨,Reuven Lax在这里。我是Dataflow工程团队的成员,负责领导流媒体运行器的设计和实现。在Dataflow之前,我领导了建立MillWheel多年的团队。 MillWheel在this VLDB 2013 paper中有描述,是基础Dataflow的流媒体技术的基础。
数据流通常无需您过多地考虑如何进行管道扩展。许多工作已经用于复杂的算法,可以自动并行化并调整您的管道在许多机器上。但是,与任何此类系统一样,有一些反模式可能会大规模地阻塞您的管道。在这篇文章中,我们将讨论这些反模式中的三种,并讨论如何解决它们。假设您已经熟悉Dataflow编程模型。如果没有,我建议从我们的Getting Started guide和Tyler Akidau的Streaming 101和Streaming 102博客文章开始。您还可以阅读2015年VLDB中发布的Dataflow model paper。
今天我们将谈论扩展您的管道 - 或者更具体地说,为什么您的管道可能不会扩展。当我们说可伸缩性时,我们指的是随着输入大小的增加和密钥分发的变化,管道有效运行的能力。场景:您编写了一个很酷的新Dataflow管道,我们提供的高级操作易于编写。您已使用DirectPipelineRunner
在您的计算机上本地测试此管道,一切看起来都很好。您甚至尝试将其部署在少量的Compute VM上,事情看起来仍然很美好。然后你尝试扩展到更大的数据量,图片变得非常糟糕。对于批处理管道,管道完成所需的时间远远超过预期。对于流式传输管道,随着管道越来越落后,Dataflow UI中报告的延迟会不断增加。我们将解释一些可能发生的原因,以及如何解决这些问题。
我们看到的一个常见问题是对每个处理过的记录执行不必要的昂贵或慢速操作的管道。从技术上讲,这不是一个难以扩展的瓶颈 - 如果有足够的资源,Dataflow仍然可以在足够的机器上分配这个管道,以使其运行良好。但是,当运行数百万或数十亿条记录时,这些每记录操作的成本加起来意外地大。通常这些问题在较低规模下根本不明显。
以下是一个此类操作的示例,取自真实的Dataflow管道。
import javax.json.Json;
...
PCollection<OutType> output = input.apply(ParDo.of(new DoFn<InType, OutType>() {
public void processElement(ProcessContext c) {
JsonReader reader = Json.createReader();
// Perform some processing on entry.
...
}
}));
乍一看这个代码没什么问题是不明显的,但是当大规模运行时,这个管道的运行速度非常慢。
由于我们代码的实际业务逻辑不应该导致速度减慢,我们怀疑某些东西正在为我们的管道增加每个记录的开销。为了获得更多相关信息,我们不得不通过ssh到VM来获取工作人员的实际线程配置文件。经过一些挖掘,我们发现线程经常卡在下面的堆栈跟踪中:
java.util.zip.ZipFile.getEntry(ZipFile.java:308)
java.util.jar.JarFile.getEntry(JarFile.java:240)
java.util.jar.JarFile.getJarEntry(JarFile.java:223)
sun.misc.URLClassPath$JarLoader.getResource(URLClassPath.java:1005)
sun.misc.URLClassPath$JarLoader.findResource(URLClassPath.java:983)
sun.misc.URLClassPath$1.next(URLClassPath.java:240)
sun.misc.URLClassPath$1.hasMoreElements(URLClassPath.java:250)
java.net.URLClassLoader$3$1.run(URLClassLoader.java:601)
java.net.URLClassLoader$3$1.run(URLClassLoader.java:599)
java.security.AccessController.doPrivileged(Native Method)
java.net.URLClassLoader$3.next(URLClassLoader.java:598)
java.net.URLClassLoader$3.hasMoreElements(URLClassLoader.java:623)
sun.misc.CompoundEnumeration.next(CompoundEnumeration.java:45)
sun.misc.CompoundEnumeration.hasMoreElements(CompoundEnumeration.java:54)
java.util.ServiceLoader$LazyIterator.hasNextService(ServiceLoader.java:354)
java.util.ServiceLoader$LazyIterator.hasNext(ServiceLoader.java:393)
java.util.ServiceLoader$1.hasNext(ServiceLoader.java:474)
javax.json.spi.JsonProvider.provider(JsonProvider.java:89)
javax.json.Json.createReader(Json.java:208)
<.....>.processElement(<filename>.java:174)
对Json.createReader
的每次调用都在搜索类路径,试图找到已注册的JsonProvider
。从堆栈跟踪中可以看出,这涉及加载和解压缩JAR文件。在高规模管道上按照记录执行此操作的可能性不是很好!
这里的解决方案是让用户创建静态JsonReaderFactory
并使用它来实例化各个读者对象。您可能想要在Dataflow的startBundle
方法中为每个记录包创建一个JsonReaderFactory
。但是,虽然这对于批处理管道很有效,但在流式处理模式下,这些包可能非常小 - 有时只是几条记录。因此,我们不建议每捆包做昂贵的工作。即使您认为您的管道仅用于批处理模式,您将来也可能希望将其作为流管道运行。因此,通过确保它们在任一模式下都能正常运行,确保您的管道能够满足未来需求!
Dataflow中的基本原语是GroupByKey
。 GroupByKey
允许用户对PCollection
个键值对进行分组,以便将特定键的所有值组合在一起以作为一个单元进行处理。大多数Dataflow的内置聚合转换 - Count
,Top
,Combine
等 - 在封面下使用GroupByKey
。如果一个工作人员非常忙,可能会遇到热键问题(例如,通过查看该工作的GCE工作人员确定高CPU使用率),而其他工作人员处于空闲状态,但管道落后的距离越来越远。
处理DoFn
结果的GroupByKey
的输入类型为KV<KeyType, Iterable<ValueType>>
。这意味着该键的所有值的整个集合(如果使用窗口,则在当前窗口内)被建模为单个Iterable
元素。特别是,这意味着该密钥的所有值必须在同一台机器上处理,实际上是在同一个线程上。存在热键时可能会出现性能问题 - 当一个或多个键接收数据的速度快于单个cpu上可处理的数据时。例如,请考虑以下代码段
p.apply(Read.from(new UserWebEventSource())
.apply(new ExtractBrowserString())
.apply(Window.<Event>into(FixedWindow.of(1, Duration.standardSeconds(1))))
.apply(GroupByKey.<String, Event>create())
.apply(ParDo.of(new ProcessEventsByBrowser()));
此代码通过用户的Web浏览器键入所有用户事件,然后将每个浏览器的所有事件作为一个单元进行处理。然而,有一些非常流行的浏览器(如Chrome,IE,Firefox,Safari),这些密钥将非常热 - 可能太热,无法在一个CPU上处理。除性能外,这也是一个可扩展性瓶颈。如果有四个热键,则向管道添加更多工作人员将无济于事,因为这些密钥最多可以在四个工作人员上处理。您已经构建了管道,以便Dataflow无法在不违反API合同的情况下进行扩展。
缓解这种情况的一种方法是将ProcessEventsByBrowser
DoFn构造为组合器。组合器是一种特殊类型的用户函数,它允许对迭代进行分段处理。例如,如果目标是计算每秒浏览器的事件数,则可以使用Count.perKey()
而不是ParDo
。数据流能够将部分组合操作提升到GroupByKey
以上,这允许更多的并行性(对于那些来自数据库世界的人来说,这类似于推断谓词);一些工作可以在前一阶段完成,希望能更好地分发。
不幸的是,虽然使用组合器通常有帮助,但可能还不够 - 特别是如果热键非常热;对于流媒体管道尤其如此。使用组合的全局变体(Combine.globally()
,Count.globally()
,Top.largest()
等时,您可能也会看到这一点。)。在这些操作下,这些操作在单个静态键上执行每键组合,如果此键的音量太高,则可能无法很好地执行。为解决此问题,我们允许您使用Combine.PerKey.withHotKeyFanout
或Combine.Globally.withFanout
提供额外的并行性提示。这些操作将在管道中创建额外的步骤,以便在目标计算机上执行最终聚合之前在多台计算机上预先聚合数据。这些操作没有神奇的数字,但一般的策略是将任何热键分成足够的子分片,这样任何单个分片都可以很好地满足管道可以承受的每个工作者吞吐量。
Dataflow提供了一个复杂的窗口设施,用于根据时间对数据进行分段。这在处理无界数据时在流管道中最有用,但是,批量,有界管道也完全支持它。当窗口策略附加到PCollection时,任何后续分组操作(最值得注意的是GroupByKey
)都会对每个窗口执行单独的分组。与仅提供全局同步窗口的其他系统不同,Dataflow分别为每个密钥分别打开数据。这就是我们提供灵活的按键窗口,例如sessions。有关详细信息,建议您阅读Dataflow文档中的windowing guide。
由于窗口是每个键的事实,Dataflow缓冲接收器端的元素,同时等待每个窗口关闭。如果使用非常长的窗户 - 例如一个24小时的固定窗口 - 这意味着必须缓冲大量数据,这可能是管道的性能瓶颈。这可能表现为缓慢(如热键),甚至是工作者的内存不足错误(在日志中可见)。我们再次建议使用组合器来减小数据大小。写这个的区别:
pcollection.apply(Window.into(FixedWindows.of(1, TimeUnit.DAYS)))
.apply(GroupByKey.<KeyType, ValueType>create())
.apply(ParDo.of(new DoFn<KV<KeyType, Iterable<ValueType>>, Long>() {
public void processElement(ProcessContext c) {
c.output(c.element().size());
}
}));
......而这......
pcollection.apply(Window.into(FixedWindows.of(1, TimeUnit.DAYS)))
.apply(Count.perKey());
......不仅仅是简洁。在后一个片段中,Dataflow知道正在应用计数组合器,因此只需要为每个键存储计数,无论窗口有多长。相比之下,Dataflow对第一段代码的理解较少,并且被迫在接收器上缓冲一整天的数据,即使这两个片段在逻辑上是等价的! p>
如果无法将您的操作表示为合并器,那么我们建议您查看triggers API。这将允许您在窗口关闭之前乐观地处理窗口的某些部分,从而减少缓冲数据的大小。
请注意,其中许多限制不适用于批处理运行程序。但是,正如上面提到的那样,您最好不要在未来对您的管道进行校对,并确保它在两种模式下都能正常运行。
我们已经讨论过热键,大窗口以及昂贵的每记录操作。其他指导可以在我们的documentation中找到。虽然这篇文章关注的是您在扩展管道时可能遇到的挑战,但Dataflow有许多优点,这些优势在很大程度上是透明的 - 诸如动态工作重新平衡以最小化散乱效应,基于吞吐量的自动缩放和作业资源管理等适应许多不同的管道和数据形状无需用户干预。我们总是试图让我们的系统更具适应性,并计划随着时间的推移自动将上述一些策略纳入核心执行引擎。感谢您的阅读,以及快乐的Dataflowing!