mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-16 06:03:45 +08:00
mod
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
<audio id="audio" title="30 | Apache Beam实战冲刺:Beam如何run everywhere?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1f/38/1fe60ac4b76f46a5e8b3fb6f2f7e1638.mp3"></audio>
|
||||
|
||||
你好,我是蔡元楠。
|
||||
|
||||
今天我要与你分享的主题是“Apache Beam实战冲刺:Beam如何run everywhere”。
|
||||
|
||||
你可能已经注意到,自第26讲到第29讲,从Pipeline的输入输出,到Pipeline的设计,再到Pipeline的测试,Beam Pipeline的概念一直贯穿着文章脉络。那么这一讲,我们一起来看看一个完整的Beam Pipeline究竟是如何编写的。
|
||||
|
||||
## Beam Pipeline
|
||||
|
||||
一个Pipeline,或者说是一个数据处理任务,基本上都会包含以下三个步骤:
|
||||
|
||||
1. 读取输入数据到PCollection。
|
||||
1. 对读进来的PCollection做某些操作(也就是Transform),得到另一个PCollection。
|
||||
1. 输出你的结果PCollection。
|
||||
|
||||
这么说,看起来很简单,但你可能会有些迷惑:这些步骤具体该怎么做呢?其实这些步骤具体到Pipeline的实际编程中,就会包含以下这些代码模块:
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
// Start by defining the options for the pipeline.
|
||||
PipelineOptions options = PipelineOptionsFactory.create();
|
||||
|
||||
// Then create the pipeline.
|
||||
Pipeline pipeline = Pipeline.create(options);
|
||||
|
||||
PCollection<String> lines = pipeline.apply(
|
||||
"ReadLines", TextIO.read().from("gs://some/inputData.txt"));
|
||||
|
||||
PCollection<String> filteredLines = lines.apply(new FilterLines());
|
||||
|
||||
filteredLines.apply("WriteMyFile", TextIO.write().to("gs://some/outputData.txt"));
|
||||
|
||||
pipeline.run().waitUntilFinish();
|
||||
|
||||
```
|
||||
|
||||
从上面的代码例子中你可以看到,第一行和第二行代码是创建Pipeline实例。任何一个Beam程序都需要先创建一个Pipeline的实例。Pipeline实例就是用来表达Pipeline类型的对象。这里你需要注意,一个二进制程序可以动态包含多个Pipeline实例。
|
||||
|
||||
还是以之前的美团外卖电动车处理的例子来做说明吧。
|
||||
|
||||
比如,我们的程序可以动态判断是否存在第三方的电动车图片,只有当有需要处理图片时,我们才去创建一个Pipeline实例处理。我们也可以动态判断是否存在需要转换图片格式,有需要时,我们再去创建第二个Pipeline实例。这时候你的二进制程序,可能包含0个、1个,或者是2个Pipeline实例。每一个实例都是独立的,它封装了你要进行操作的数据,和你要进行的操作Transform。
|
||||
|
||||
Pipeline实例的创建是使用Pipeline.create(options)这个方法。其中options是传递进去的参数,options是一个PipelineOptions这个类的实例。我们会在后半部分展开PipelineOptions的丰富变化。
|
||||
|
||||
第三行代码,我们用TextIO.read()这个Transform读取了来自外部文本文件的内容,把所有的行表示为一个PCollection。
|
||||
|
||||
第四行代码,用 lines.apply(new FilterLines()) 对读进来的PCollection进行了过滤操作。
|
||||
|
||||
第五行代码 filteredLines.apply(“WriteMyFile”, TextIO.write().to(“gs://some/outputData.txt”)),表示把最终的PCollection结果输出到另一个文本文件。
|
||||
|
||||
程序运行到第五行的时候,是不是我们的数据处理任务就完成了呢?并不是。
|
||||
|
||||
记得我们在第24讲、第25讲中提过,Beam是延迟运行的。程序跑到第五行的时候,只是构建了Beam所需要的数据处理DAG用来优化和分配计算资源,真正的运算完全没有发生。
|
||||
|
||||
所以,我们需要最后一行pipeline.run().waitUntilFinish(),这才是数据真正开始被处理的语句。
|
||||
|
||||
这时候运行我们的代码,是不是就大功告成呢?别急,我们还没有处理好程序在哪里运行的问题。你一定会好奇,我们的程序究竟在哪里运行,不是说好了分布式数据处理吗?
|
||||
|
||||
在上一讲《如何测试Beam Pipeline》中我们学会了在单元测试环境中运行Beam Pipeline。就如同下面的代码。和上文的代码类似,我们把Pipeline.create(options)替换成了TestPipeline.create()。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
Pipeline p = TestPipeline.create();
|
||||
|
||||
PCollection<String> input = p.apply(Create.of(WORDS)).setCoder(StringUtf8Coder.of());
|
||||
|
||||
PCollection<String> output = input.apply(new CountWords());
|
||||
|
||||
PAssert.that(output).containsInAnyOrder(COUNTS_ARRAY);
|
||||
|
||||
p.run();
|
||||
|
||||
```
|
||||
|
||||
TestPipeline是Beam Pipeline中特殊的一种,让你能够在单机上运行小规模的数据集。之前我们在分析Beam的设计理念时提到过,Beam想要把应用层的数据处理业务逻辑和底层的运算引擎分离开来。
|
||||
|
||||
现如今Beam可以做到让你的Pipeline代码无需修改,就可以在本地、Spark、Flink,或者在Google Cloud DataFlow上运行。这些都是通过Pipeline.create(options) 这行代码中传递的PipelineOptions实现的。
|
||||
|
||||
在实战中,我们应用到的所有option其实都是实现了PipelineOptions这个接口。
|
||||
|
||||
举个例子,如果我们希望将数据流水线放在Spark这个底层数据引擎运行的时候,我们便可以使用SparkPipelineOptions。如果我们想把数据流水线放在Flink上运行,就可以使用FlinkPipelineOptions。而这些都是extends了PipelineOptions的接口,示例如下:
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
options = PipelineOptionsFactory.as(SparkPipelineOptions.class);
|
||||
Pipeline pipeline = Pipeline.create(options);
|
||||
|
||||
```
|
||||
|
||||
通常一个PipelineOption是用PipelineOptionsFactory这个工厂类来创建的,它提供了两个静态工厂方法给我们去创建,分别是PipelineOptionsFactory.as(Class)和PipelineOptionsFactory.create()。像上面的示例代码就是用PipelineOptionsFactory.as(Class)这个静态工厂方法来创建的。
|
||||
|
||||
当然了,更加常见的创建方法是从命令行中读取参数来创建PipelineOption,使用的是PipelineOptionsFactory#fromArgs(String[])这个方法,例如:
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
public static void main(String[] args) {
|
||||
PipelineOptions options = PipelineOptionsFactory.fromArgs(args).create();
|
||||
Pipeline p = Pipeline.create(options);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
下面我们来看看不同的运行模式的具体使用方法。
|
||||
|
||||
## 直接运行模式
|
||||
|
||||
我们先从直接运行模式开始讲。这是我们在本地进行测试,或者调试时倾向使用的模式。在直接运行模式的时候,Beam会在单机上用多线程来模拟分布式的并行处理。
|
||||
|
||||
使用Java Beam SDK时,我们要给程序添加Direct Runner的依赖关系。在下面这个maven依赖关系定义文件中,我们指定了beam-runners-direct-java这样一个依赖关系。
|
||||
|
||||
```
|
||||
pom.xml
|
||||
<dependency>
|
||||
<groupId>org.apache.beam</groupId>
|
||||
<artifactId>beam-runners-direct-java</artifactId>
|
||||
<version>2.13.0</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
```
|
||||
|
||||
一般我们会把runner通过命令行指令传递进程序。就需要使用PipelineOptionsFactory.fromArgs(args)来创建PipelineOptions。PipelineOptionsFactory.fromArgs()是一个工厂方法,能够根据命令行参数选择生成不同的PipelineOptions子类。
|
||||
|
||||
```
|
||||
PipelineOptions options =
|
||||
PipelineOptionsFactory.fromArgs(args).create();
|
||||
|
||||
```
|
||||
|
||||
在实验程序中也可以强行使用Direct Runner。比如:
|
||||
|
||||
```
|
||||
PipelineOptions options = PipelineOptionsFactory.create();
|
||||
options.setRunner(DirectRunner.class);
|
||||
// 或者这样
|
||||
options = PipelineOptionsFactory.as(DirectRunner.class);
|
||||
Pipeline pipeline = Pipeline.create(options);
|
||||
|
||||
```
|
||||
|
||||
如果是在命令行中指定Runner的话,那么在调用这个程序时候,需要指定这样一个参数–runner=DirectRunner。比如:
|
||||
|
||||
```
|
||||
mvn compile exec:java -Dexec.mainClass=YourMainClass \
|
||||
-Dexec.args="--runner=DirectRunner" -Pdirect-runner
|
||||
|
||||
```
|
||||
|
||||
## Spark运行模式
|
||||
|
||||
如果我们希望将数据流水线放在Spark这个底层数据引擎运行的时候,我们便可以使用Spark Runner。Spark Runner执行Beam程序时,能够像原生的Spark程序一样。比如,在Spark本地模式部署应用,跑在Spark的RM上,或者用YARN。
|
||||
|
||||
Spark Runner为在Apache Spark上运行Beam Pipeline提供了以下功能:
|
||||
|
||||
1. Batch 和streaming的数据流水线;
|
||||
1. 和原生RDD和DStream一样的容错保证;
|
||||
1. 和原生Spark同样的安全性能;
|
||||
1. 可以用Spark的数据回报系统;
|
||||
1. 使用Spark Broadcast实现的Beam side-input。
|
||||
|
||||
目前使用Spark Runner必须使用Spark 2.2版本以上。
|
||||
|
||||
这里,我们先添加beam-runners-spark的依赖关系。
|
||||
|
||||
```
|
||||
<dependency>
|
||||
<groupId>org.apache.beam</groupId>
|
||||
<artifactId>beam-runners-spark</artifactId>
|
||||
<version>2.13.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.spark</groupId>
|
||||
<artifactId>spark-core_2.10</artifactId>
|
||||
<version>${spark.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.spark</groupId>
|
||||
<artifactId>spark-streaming_2.10</artifactId>
|
||||
<version>${spark.version}</version>
|
||||
</dependency>
|
||||
|
||||
```
|
||||
|
||||
然后,要使用SparkPipelineOptions传递进Pipeline.create()方法。常见的创建方法是从命令行中读取参数来创建PipelineOption,使用的是PipelineOptionsFactory.fromArgs(String[])这个方法。在命令行中,你需要指定runner=SparkRunner:
|
||||
|
||||
```
|
||||
mvn exec:java -Dexec.mainClass=YourMainClass \
|
||||
-Pspark-runner \
|
||||
-Dexec.args="--runner=SparkRunner \
|
||||
--sparkMaster=<spark master url>"
|
||||
|
||||
```
|
||||
|
||||
也可以在Spark的独立集群上运行,这时候spark的提交命令,spark-submit。
|
||||
|
||||
```
|
||||
spark-submit --class YourMainClass --master spark://HOST:PORT target/...jar --runner=SparkRunner
|
||||
|
||||
```
|
||||
|
||||
当Beam程序在Spark上运行时,你也可以同样用Spark的网页监控数据流水线进度。
|
||||
|
||||
## Flink运行模式
|
||||
|
||||
Flink Runner是Beam提供的用来在Flink上运行Beam Pipeline的模式。你可以选择在计算集群上比如 Yarn/Kubernetes/Mesos 或者本地Flink上运行。Flink Runner适合大规模,连续的数据处理任务,包含了以下功能:
|
||||
|
||||
1. 以Streaming为中心,支持streaming处理和batch处理;
|
||||
1. 和flink一样的容错性,和exactly-once的处理语义;
|
||||
1. 可以自定义内存管理模型;
|
||||
1. 和其他(例如YARN)的Apache Hadoop生态整合比较好。
|
||||
|
||||
其实看到这里,你可能已经掌握了这里面的诀窍。就是通过PipelineOptions来指定runner,而你的数据处理代码不需要修改。PipelineOptions可以通过命令行参数指定。那么类似Spark Runner,你也可以使用Flink来运行Beam程序。
|
||||
|
||||
同样的,首先你需要在pom.xml中添加Flink Runner的依赖。
|
||||
|
||||
```
|
||||
<dependency>
|
||||
<groupId>org.apache.beam</groupId>
|
||||
<artifactId>beam-runners-flink-1.6</artifactId>
|
||||
<version>2.13.0</version>
|
||||
</dependency>
|
||||
|
||||
```
|
||||
|
||||
然后在命令行中指定flink runner:
|
||||
|
||||
```
|
||||
mvn exec:java -Dexec.mainClass=YourMainClass \
|
||||
-Pflink-runner \
|
||||
-Dexec.args="--runner=FlinkRunner \
|
||||
--flinkMaster=<flink master url>"
|
||||
|
||||
```
|
||||
|
||||
## Google Dataflow 运行模式
|
||||
|
||||
Beam Pipeline也能直接在云端运行。Google Cloud Dataflow就是完全托管的Beam Runner。当你使用Google Cloud Dataflow服务来运行Beam Pipeline时,它会先上传你的二进制程序到Google Cloud,随后自动分配计算资源创建Cloud Dataflow任务。
|
||||
|
||||
同前面讲到的Direct Runner和Spark Runner类似,你还是需要为Cloud Dataflow添加beam-runners-google-cloud-dataflow-java依赖关系:
|
||||
|
||||
```
|
||||
<dependency>
|
||||
<groupId>org.apache.beam</groupId>
|
||||
<artifactId>beam-runners-google-cloud-dataflow-java</artifactId>
|
||||
<version>2.13.0</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
```
|
||||
|
||||
我们假设你已经在Google Cloud上创建了project,那么就可以用类似的命令行提交任务:
|
||||
|
||||
```
|
||||
mvn -Pdataflow-runner compile exec:java \
|
||||
-Dexec.mainClass=<YourMainClass> \
|
||||
-Dexec.args="--project=<PROJECT_ID> \
|
||||
--stagingLocation=gs://<STORAGE_BUCKET>/staging/ \
|
||||
--output=gs://<STORAGE_BUCKET>/output \
|
||||
--runner=DataflowRunner"
|
||||
|
||||
```
|
||||
|
||||
## 小结
|
||||
|
||||
这一讲我们先总结了前面几讲Pipeline的完整使用方法。之后一起探索了Beam的重要特性,就是Pipeline可以通过PipelineOption动态选择同样的数据处理流水线在哪里运行。并且,分别展开讲解了直接运行模式、Spark运行模式、Flink运行模式和Google Cloud Dataflow运行模式。在实践中,你可以根据自身需要,去选择不同的运行模式。
|
||||
|
||||
## 思考题
|
||||
|
||||
Beam的设计模式是对计算引擎动态选择,它为什么要这么设计?
|
||||
|
||||
欢迎你把答案写在留言区,与我和其他同学一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
<audio id="audio" title="31 | WordCount Beam Pipeline实战" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/19/00/19a90a6272de404e3f9eb39c971aa700.mp3"></audio>
|
||||
|
||||
你好,我是蔡元楠。
|
||||
|
||||
今天我要与你分享的主题是“WordCount Beam Pipeline实战”。
|
||||
|
||||
前面我们已经学习了Beam的基础数据结构PCollection,基本数据转换操作Transform,还有Pipeline等技术。你一定跃跃欲试,想要在实际项目中使用了。这一讲我们就一起学习一下怎样用Beam解决数据处理领域的教科书级案例——WordCount。
|
||||
|
||||
WordCount你一定不陌生,在[第18讲](https://time.geekbang.org/column/article/97658)中,我们就已经接触过了。WordCount问题是起源于MapReduce时代就广泛使用的案例。顾名思义,WordCount想要解决的问题是统计一个文本库中的词频。
|
||||
|
||||
比如,你可以用WordCount找出莎士比亚最喜欢使用的单词,那么你的输入是莎士比亚全集,输出就是每个单词出现的次数。举个例子,比如这一段:
|
||||
|
||||
```
|
||||
HAMLET
|
||||
|
||||
ACT I
|
||||
|
||||
SCENE I Elsinore. A platform before the castle.
|
||||
|
||||
[FRANCISCO at his post. Enter to him BERNARDO]
|
||||
|
||||
BERNARDO Who's there?
|
||||
|
||||
FRANCISCO Nay, answer me: stand, and unfold yourself.
|
||||
|
||||
BERNARDO Long live the king!
|
||||
|
||||
FRANCISCO Bernardo?
|
||||
|
||||
BERNARDO He.
|
||||
|
||||
FRANCISCO You come most carefully upon your hour.
|
||||
|
||||
BERNARDO 'Tis now struck twelve; get thee to bed, Francisco.
|
||||
|
||||
FRANCISCO For this relief much thanks: 'tis bitter cold,
|
||||
And I am sick at heart.
|
||||
|
||||
BERNARDO Have you had quiet guard?
|
||||
|
||||
FRANCISCO Not a mouse stirring.
|
||||
|
||||
BERNARDO Well, good night.
|
||||
If you do meet Horatio and Marcellus,
|
||||
The rivals of my watch, bid them make haste.
|
||||
|
||||
FRANCISCO I think I hear them. Stand, ho! Who's there?
|
||||
|
||||
```
|
||||
|
||||
在这个文本库中,我们用“the: 数字”表示the出现了几次,数字就是单词出现的次数。
|
||||
|
||||
```
|
||||
The: 3
|
||||
And: 3
|
||||
Him: 1
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
那么我们怎样在Beam中处理这个问题呢?结合前面所学的知识,我们可以把Pipeline分为这样几步:
|
||||
|
||||
1. 用Pipeline IO读取文本库(参考[第27讲](https://time.geekbang.org/column/article/102578));
|
||||
1. 用Transform对文本进行分词和词频统计操作(参考[第25讲](https://time.geekbang.org/column/article/101735));
|
||||
1. 用Pipeline IO输出结果(参考[第27讲](https://time.geekbang.org/column/article/102578));
|
||||
1. 所有的步骤会被打包进一个Beam Pipeline(参考[第26讲](https://time.geekbang.org/column/article/102182))。
|
||||
|
||||
整个过程就如同下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c6/cd/c6b63574f6005aaa4a6aba366b0a5dcd.jpg" alt="">
|
||||
|
||||
## 创建Pipeline
|
||||
|
||||
首先,我们先用代码创建一个PipelineOptions的实例。PipelineOptions能够让我们对Pipeline进行必要的配置,比如配置执行程序的Runner,和Runner所需要的参数。我们在这里先采用默认配置。
|
||||
|
||||
记得第30讲中我们讲过,Beam Pipeline可以配置在不同的Runner上跑,比如SparkRunner,FlinkRunner。如果PipelineOptions不配置的情况下,默认的就是DirectRunner,也就是说会在本机执行。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
PipelineOptions options = PipelineOptionsFactory.create();
|
||||
|
||||
```
|
||||
|
||||
接下来,我们就可以用这个PipelineOptions去创建一个Pipeline了。一个Pipeline实例会去构建一个数据处理流水线所需要的数据处理DAG,以及这个DAG所需要进行的Transform。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
Pipeline p = Pipeline.create(options);
|
||||
|
||||
```
|
||||
|
||||
## 应用Transform
|
||||
|
||||
在上面的设计框图中,我们可以看到,我们需要进行好几种Transform。比如TextIO.Read、ParDo、Count去读取数据,操纵数据,以及存储数据。
|
||||
|
||||
每一种Transform都需要一些参数,并且会输出特定的数据。输入和输出往往会用PCollection的数据结构表示。简单回顾一下,PCollection是Beam对于数据集的抽象,表示任意大小、无序的数据,甚至可以是无边界的Streaming数据。
|
||||
|
||||
在我们这个WordCount例子中,我们的Transform依次是这样几个。
|
||||
|
||||
第一个Transform,是先要用TextIO.Read来读取一个外部的莎士比亚文集,生成一个PCollection,包含这个文集里的所有文本行。这个PCollection中的每个元素都是文本中的一行。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
PCollection<String> lines = p.apply(TextIO.read().from("gs://apache-beam-samples/shakespeare/*"));
|
||||
|
||||
```
|
||||
|
||||
第二个Transform,我们要把文本行中的单词提取出来,也就是做分词(tokenization)。
|
||||
|
||||
这一步的输入PCollection中的每个元素都表示了一行。那么输出呢?输出还是一个PCollection,但是每个元素变成了单词。
|
||||
|
||||
你可以留意一下,我们这里做分词时,用的正则表达式[^\p{L}]+,意思是非Unicode Letters所以它会按空格或者标点符号等把词分开。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
PCollection<String> words = lines.apply("ExtractWords", FlatMapElements
|
||||
.into(TypeDescriptors.strings())
|
||||
.via((String word) -> Arrays.asList(word.split("[^\\p{L}]+"))));
|
||||
|
||||
```
|
||||
|
||||
第三个Transform,我们就会使用Beam SDK提供的Count Transform。Count Transform会把任意一个PCollection转换成有key/value的组合,每一个key是原来PCollection中的非重复的元素,value则是元素出现的次数。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
PCollection<KV<String, Long>> counts = words.apply(Count.<String>perElement());
|
||||
|
||||
```
|
||||
|
||||
第四个Transform会把刚才的key/value组成的PCollection转换成我们想要的输出格式,方便我们输出词频。因为大部分的时候,我们都是想要把输出存储到另一个文件里的。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
PCollection<String> formatted = counts.apply("FormatResults", MapElements
|
||||
.into(TypeDescriptors.strings())
|
||||
.via((KV<String, Long> wordCount) -> wordCount.getKey() + ": " + wordCount.getValue()));
|
||||
|
||||
```
|
||||
|
||||
最后一个Transform就是TextIO.Write用来把最终的PCollection写进文本文档。PCollection中的每一个元素都会被写为文本文件中的独立一行。
|
||||
|
||||
## 运行Pipeline
|
||||
|
||||
调用Pipeline的run()方法会把这个Pipeline所包含的Transform优化并放到你指定的Runner上执行。这里你需要注意,run()方法是异步的,如果你想要同步等待Pipeline的执行结果,需要调用waitUntilFinish()方法。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
p.run().waitUntilFinish();
|
||||
|
||||
```
|
||||
|
||||
## 改进代码的建议
|
||||
|
||||
代码看起来都完成了,不过,我们还可以对代码再做些改进。
|
||||
|
||||
### 编写独立的DoFn
|
||||
|
||||
在上面的示例代码中,我们把Transform都inline地写在了apply()方法里。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
lines.apply("ExtractWords", FlatMapElements
|
||||
.into(TypeDescriptors.strings())
|
||||
.via((String word) -> Arrays.asList(word.split("[^\\p{L}]+"))));
|
||||
|
||||
```
|
||||
|
||||
但是这样的写法在实际工作中很难维护。
|
||||
|
||||
一是因为真实的业务逻辑往往比较复杂,很难用一两行的代码写清楚,强行写成inline的话可读性非常糟糕。
|
||||
|
||||
二是因为这样inline的Transform几乎不可复用和测试。
|
||||
|
||||
所以,实际工作中,我们更多地会去继承DoFn来实现我们的数据操作。这样每个DoFn我们都可以单独复用和测试。
|
||||
|
||||
接下来,我们看看怎样用用DoFn来实现刚才的分词Transform?
|
||||
|
||||
其实很简单,我们继承DoFn作为我们的子类ExtracrtWordsFn,然后把单词的拆分放在DoFn的processElement成员函数里。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
static class ExtractWordsFn extends DoFn<String, String> {
|
||||
private final Counter emptyLines = Metrics.counter(ExtractWordsFn.class, "emptyLines");
|
||||
private final Distribution lineLenDist =
|
||||
Metrics.distribution(ExtractWordsFn.class, "lineLenDistro");
|
||||
|
||||
@ProcessElement
|
||||
public void processElement(@Element String element, OutputReceiver<String> receiver) {
|
||||
lineLenDist.update(element.length());
|
||||
if (element.trim().isEmpty()) {
|
||||
emptyLines.inc();
|
||||
|
||||
|
||||
// Split the line into words.
|
||||
String[] words = element.split(“[^\\p{L}]+”, -1);
|
||||
|
||||
// Output each word encountered into the output PCollection.
|
||||
for (String word : words) {
|
||||
if (!word.isEmpty()) {
|
||||
receiver.output(word);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 创建PTransform合并相关联的Transform
|
||||
|
||||
PTransform类可以用来整合一些相关联的Transform。
|
||||
|
||||
比如你有一些数据处理的操作包含几个Transform或者ParDo,你可以把他们封装在一个PTransform里。
|
||||
|
||||
我们这里试着把上面的ExtractWordsFn和Count两个Transform封装起来。这样可以对这样一整套数据处理操作复用和测试。当定义PTransform的子类时,它的输入输出类型就是一连串Transform的最初输入和最终输出。那么在这里,输入类型是String,输出类型是KV<String, Long>。就如同下面的代码一样。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
/**
|
||||
* A PTransform that converts a PCollection containing lines of text into a PCollection of
|
||||
* formatted word counts.
|
||||
*
|
||||
* <p>This is a custom composite transform that bundles two transforms (ParDo and
|
||||
* Count) as a reusable PTransform subclass. Using composite transforms allows for easy reuse,
|
||||
* modular testing, and an improved monitoring experience.
|
||||
*/
|
||||
|
||||
public static class CountWords
|
||||
extends PTransform<PCollection<String>, PCollection<KV<String, Long>>> {
|
||||
@Override
|
||||
public PCollection<KV<String, Long>> expand(PCollection<String> lines) {
|
||||
|
||||
// Convert lines of text into individual words.
|
||||
PCollection<String> words = lines.apply(ParDo.of(new ExtractWordsFn()));
|
||||
|
||||
// Count the number of times each word occurs.
|
||||
PCollection<KV<String, Long>> wordCounts = words.apply(Count.perElement());
|
||||
|
||||
return wordCounts;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 参数化PipelineOptions
|
||||
|
||||
刚才我们把输入文件的路径和输出文件的路径都写在了代码中。但实际工作中我们很少会这样做。
|
||||
|
||||
因为这些文件的路径往往是运行时才会决定,比如测试环境和生产环境会去操作不同的文件。在真正的实际工作中,我们往往把它们作为命令行参数放在PipelineOptions里面。这就需要去继承PipelineOptions。
|
||||
|
||||
比如,我们创建一个WordCountOptions,把输出文件作为参数output。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
public static interface WordCountOptions extends PipelineOptions {
|
||||
@Description("Path of the file to write to")
|
||||
@Required
|
||||
String getOutput();
|
||||
|
||||
void setOutput(String value);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
完成上面两个方面的改进后,我们最终的数据处理代码会是这个样子:
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
public static void main(String[] args) {
|
||||
WordCountOptions options =
|
||||
PipelineOptionsFactory.fromArgs(args).withValidation().as(WordCountOptions.class);
|
||||
|
||||
Pipeline p = Pipeline.create(options);
|
||||
|
||||
p.apply("ReadLines", TextIO.read().from(options.getInputFile()))
|
||||
.apply(new CountWords())
|
||||
.apply(ParDo.of(new FormatAsTextFn()))
|
||||
.apply("WriteCounts", TextIO.write().to(options.getOutput()));
|
||||
|
||||
p.run().waitUntilFinish();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### DoFn和PTransform的单元测试
|
||||
|
||||
如同[第29讲](https://time.geekbang.org/column/article/103750)“如何测试Pipeline”中所讲的那样,我们用PAssert测试Beam Pipeline。具体在我们这个例子中,我一再强调要把数据处理操作封装成DoFn和PTransform,因为它们可以独立地进行测试。
|
||||
|
||||
什么意思呢?比如,ExtractWordsFn我们想要测试它能把一个句子分拆出单词,比如“" some input words ",我们期待的输出是[“some”, “input”, “words”]。在测试中,我们可以这样表达:
|
||||
|
||||
```
|
||||
/** Example test that tests a specific {@link DoFn}. */
|
||||
@Test
|
||||
public void testExtractWordsFn() throws Exception {
|
||||
DoFnTester<String, String> extractWordsFn = DoFnTester.of(new ExtractWordsFn());
|
||||
|
||||
Assert.assertThat(
|
||||
extractWordsFn.processBundle(" some input words "),
|
||||
CoreMatchers.hasItems("some", "input", "words"));
|
||||
Assert.assertThat(extractWordsFn.processBundle(" "), CoreMatchers.hasItems());
|
||||
Assert.assertThat(
|
||||
extractWordsFn.processBundle(" some ", " input", " words"),
|
||||
CoreMatchers.hasItems("some", "input", "words"));
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 小结
|
||||
|
||||
这一讲我们应用前面学习的PCollection,Pipeline,Pipeline IO,Transform知识去解决了一个数据处理领域经典的WordCount问题。并且学会了一些在实际工作中改进数据处理代码质量的贴士,比如写成单独可测试的DoFn,和把程序参数封装进PipelineOptions。
|
||||
|
||||
## 思考题
|
||||
|
||||
文中提供了分词的DoFn——ExtractWordsFn,你能利用相似的思路把输出文本的格式化写成一个DoFn吗?也就是文中的FormatAsTextFn,把PCollection<KV<String, Long>> 转化成PCollection<string>,每一个元素都是<word> : <count>的格式。</count></word></string>
|
||||
|
||||
欢迎你把答案写在留言区,与我和其他同学一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
<audio id="audio" title="32 | Beam Window:打通流处理的任督二脉" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/01/e2/015d9edb70f6ba6ab8167501c02827e2.mp3"></audio>
|
||||
|
||||
你好,我是蔡元楠。
|
||||
|
||||
今天我要与你分享的主题是“Beam Window:打通流处理的任督二脉”。
|
||||
|
||||
在上一讲中,我们一起用Beam编写了第一个完整的WordCount项目,我们所用的例子是统计莎士比亚的文集中最常使用到的一些单词。
|
||||
|
||||
这里我们所用到的“莎士比亚文集”这种类型的数据集是一个静态的数据集。也就是说,我们在生成输入数据集的时候,就已经知道了这个数据集是完整的,并不需要再等待新的数据进来。
|
||||
|
||||
根据前面的内容,我们可以把这种数据集归类为有界数据集(Bounded Dataset)。这里我们的数据流水线就是一个批处理的数据流水线。
|
||||
|
||||
这个时候你可能会有一个疑问,如果我们想要统计的内容是一个正在连载的小说,我们在编写数据流水线的时候,这个小说还并没有完结,也就是说,未来还会不断有新的内容作为输入数据流入我们的数据流水线,那我们需要怎么做呢?
|
||||
|
||||
这个时候我们就需要用到窗口(Window)这个概念了。
|
||||
|
||||
## 窗口
|
||||
|
||||
在Beam的世界中,窗口这个概念将PCollection里的每个元素根据时间戳(Timestamp)划分成为了不同的有限数据集合。
|
||||
|
||||
当我们要将一些聚合操作(Aggregation)应用在PCollection上面的时候,或者我们想要将不同的PCollections连接(Join)在一起的时候,其实Beam是将这些操作应用在了这些被窗口划分好的不同数据集合上的。
|
||||
|
||||
无论是有界数据集还是无界数据集,Beam都会一视同仁,按照上面所说的规则进行处理。
|
||||
|
||||
你可能又会有一个疑问,我们在上一讲的例子当中,根本就没有提到过窗口这个概念,但是我刚刚又说,Beam同样会将有界数据集根据窗口划分成不同的有限数据集合来处理,那这些窗口、PCollection中每个元素的时间戳又是从哪里来的呢?
|
||||
|
||||
其实,我们在用I/O连接器读取有界数据集的过程中,Read Transform会默认为每个元素分配一个相同的时间戳。在一般情况下,这个时间戳就是你运行数据流水线的时间,也就是处理时间(Processing Time)。而Beam也会为这个数据流水线默认地分配一个全局窗口(Global Window),你可以把它理解为是一个从无限小到无限大的时间窗口。
|
||||
|
||||
如果你想要显式地将一个全局窗口赋予一个有界数据集的话,可以使用如下代码来完成:
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
PCollection<String> input = p.apply(TextIO.read().from(filepath));
|
||||
|
||||
PCollection<String> batchInputs = input.apply(Window.<String>into(new GlobalWindows()));
|
||||
|
||||
```
|
||||
|
||||
需要注意的是,你在处理有界数据集的时候,可以不用显式地将一个窗口分配给一个PCollection数据集。但是,在处理无边界数据集的时候,你必须要显式地分配一个窗口给这个无边界数据集。而这个窗口不可以是前面提到的全局窗口,否则在运行数据流水线的时候会直接抛出异常错误。
|
||||
|
||||
在了解过窗口的基本概念之后,接下来我来给你讲讲在Beam中存在的不同窗口类型。
|
||||
|
||||
### 固定窗口(Fixed Window)
|
||||
|
||||
固定窗口在有的数据处理框架中又被称为滚动窗口(Tumbling Window)。固定窗口通常是由一个静态的窗口大小定义而来的。
|
||||
|
||||
例如,要是我们定义一个每小时的窗口,那这个窗口大小就是固定的一个小时,如果我们按照2019年7月8号的0时作为时间的起始点,那么这个固定窗口就可以分为类似下面这样的形式:
|
||||
|
||||
```
|
||||
[July 8, 2019 0:00:00 AM, July 8, 2019 1:00:00 AM),
|
||||
[July 8, 2019 1:00:00 AM, July 8, 2019 2:00:00 AM)
|
||||
[July 8, 2019 2:00:00 AM, July 8, 2019 3:00:00 AM)
|
||||
……
|
||||
|
||||
```
|
||||
|
||||
而一个PCollection中的所有元素,就会根据它们各自自身的时间戳被分配给相应的固定窗口中。
|
||||
|
||||
这里你需要注意一点,因为固定窗口本质上并不可能会重叠在一起,如果我们定义的窗口是固定窗口的话,PCollection中的每一个元素只会落入一个,且是唯一一个窗口中。
|
||||
|
||||
在Beam中,如果要定义一个上述所说的按照每小时分割的窗口,我们可以使用一个Window Transform来完成,如下所示:
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
PCollection<String> input = p.apply(KafkaIO.<Long, String>read()).apply(Values.<String>create());
|
||||
|
||||
PCollection<String> fixedWindowedInputs = input.apply(Window.<String>into(FixedWindows.of(Duration.standardHours(1))));
|
||||
|
||||
```
|
||||
|
||||
### 滑动窗口(Sliding Window)
|
||||
|
||||
滑动窗口通常是由一个静态的窗口大小和一个滑动周期(Sliding Period)定义而来的。
|
||||
|
||||
例如,我们可以定义一个窗口大小为一个小时,滑动周期为30分钟的一个滑动窗口。我们还是以2019年7月8号的0时作为时间的起始点,那这个滑动窗口可以分为下面这样的形式:
|
||||
|
||||
```
|
||||
[July 8, 2019 0:00:00 AM, July 8, 2019 1:00:00 AM)
|
||||
[July 8, 2019 0:30:00 AM, July 8, 2019 1:30:00 AM)
|
||||
[July 8, 2019 1:00:00 AM, July 8, 2019 2:00:00 AM)
|
||||
[July 8, 2019 1:30:00 AM, July 8, 2019 2:30:00 AM)
|
||||
……
|
||||
|
||||
```
|
||||
|
||||
因为Beam对于滑动周期的大小并没有做任何限制,所以你可以看到,滑动窗口和固定窗口不同的是,当滑动周期小于窗口大小的时候,滑动窗口会有部分重叠。也就是说,在一个PCollection里面,同一个元素是可以被分配到不同的滑动窗口中的。
|
||||
|
||||
可能你也会发现到,当滑动窗口的窗口大小和滑动周期一样的时候,这个滑动窗口的性质其实就和固定窗口一样了。
|
||||
|
||||
在Beam中,如果要定义一个上述所说,窗口大小为一个小时而滑动周期为30分钟的一个滑动窗口,我们同样可以使用一个Window Transform来完成,如下所示:
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
PCollection<String> input = p.apply(KafkaIO.<Long, String>read()).apply(Values.<String>create());
|
||||
|
||||
PCollection<String> slidingWindowedInputs = input.apply(Window.<String>into(SlidingWindows.of(Duration.standardHours(1)).every(Duration.standardMinutes(30))));
|
||||
|
||||
```
|
||||
|
||||
### 会话窗口(Sessions Window)
|
||||
|
||||
会话窗口和上面所说的两个窗口有点不一样,它并没有一个固定的窗口长度。
|
||||
|
||||
会话窗口主要是用于记录持续了一段时间的活动数据集。在一个会话窗口中的数据集,如果将它里面所有的元素按照时间戳来排序的话,那么任意相邻的两个元素它们的时间戳相差不会超过一个定义好的静态间隔时间段(Gap Duration)。
|
||||
|
||||
怎么理解这个定义呢?我想用一个例子来解释会比较清晰。
|
||||
|
||||
假设,我们现在正在一个视频流的平台中处理无界数据集,我们想要分析在这个平台中的一些用户行为习惯。
|
||||
|
||||
为了方便说明,我们想要分析的问题非常简单,就是一个用户在线看视频一般会在每天的什么时候开始看多长时间的视频。同时,我们假设只会有一个用户的数据流入我们的输入数据集中,这个数据会带有用户对视频平台发送视频流请求的时间戳。
|
||||
|
||||
我们希望定义一个会话窗口来处理这些数据,而这个会话窗口的间隔时间段为5分钟。
|
||||
|
||||
所有的数据假设都是发生在2019年7月8号中的,流入的数据集如下:
|
||||
|
||||
```
|
||||
(key1, value1, [7:44:00 AM,7:44:00 AM))
|
||||
(key1, value2, [7:45:00 AM,7:45:00 AM))
|
||||
(key1, value3, [7:49:00 AM,7:49:00 AM))
|
||||
(key1, value4, [8:01:00 AM,8:01:00 AM))
|
||||
(key1, value5, [8:02:00 AM,8:02:00 AM))
|
||||
|
||||
```
|
||||
|
||||
那么,这5个数据会形成两个会话窗口,分别是:
|
||||
|
||||
```
|
||||
(key1,[(value1, [7:44:00 AM,7:44:00 AM)), (value2, [7:45:00 AM,7:45:00 AM)), (value3, [7:49:00 AM,7:49:00 AM))])
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
(key1,[(value4, [8:01:00 AM,8:01:00 AM)), (value5, [8:02:00 AM,8:02:00 AM))])
|
||||
|
||||
```
|
||||
|
||||
你可以看到,在第一个会话窗口中,数据的时间戳分别是7:44:00AM,7:45:00AM和7:49:00AM,这个窗口的总长度有5分钟。任意相邻的两个元素之间的时间间隔不会超过我们之前定义好的5分钟。
|
||||
|
||||
而在第二个会话窗口中,数据的时间戳分别是8:01:00AM和8:02:00AM,这个窗口的总长度有1分钟,同样任意相邻的两个元素之间的时间间隔不会超过5分钟。每一个会话窗口都记录了一个用户的在线活跃点和时长。
|
||||
|
||||
在Beam中,如果要定义一个上述所说会话窗口的话,你可以使用以下代码来完成:
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
PCollection<String> input = p.apply(KafkaIO.<Long, String>read()).apply(Values.<String>create());
|
||||
|
||||
PCollection<String> sessionWindowedInputs = input.apply(Window.<String>into(Sessions.withGapDuration(Duration.standardMinutes(5))));
|
||||
|
||||
```
|
||||
|
||||
## 小结
|
||||
|
||||
今天我们一起学习了在处理无界数据集的时候,我们需要显示定义到的一个概念——窗口。
|
||||
|
||||
窗口的定义是后面我们编写流处理数据流水线的一个基石。而窗口这个概念其实就是用来回答我们在第23讲中“WWWH”问题里“Where in event time they are being computed”这个问题的。除去全局窗口,Beam里面总共让我们定义三种不同的窗口类型,分别是固定窗口,滑动窗口和会话窗口。
|
||||
|
||||
## 思考题
|
||||
|
||||
在今天介绍的三种时间窗口类型中,你觉得这三种时间窗口分别适合使用在什么样的应用场景中呢?
|
||||
|
||||
欢迎你把答案写在留言区,与我和其他同学一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
<audio id="audio" title="33 | 横看成岭侧成峰:再战Streaming WordCount" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0c/80/0c0aad9785312cbe8e7002bee2252080.mp3"></audio>
|
||||
|
||||
你好,我是蔡元楠。
|
||||
|
||||
今天我要与你分享的主题是“横看成岭侧成峰:再战Streaming WordCount”。
|
||||
|
||||
在上一讲中,我们学习了Beam窗口(Window)的概念。当时,我们提到窗口技术的产生是因为我们想要根据时间戳去分组处理一个PCollection中的元素。
|
||||
|
||||
我们也提到了在“统计莎士比亚文集词频”这个例子中,如果莎士比亚穿越到了现代,成了一名极客时间的专栏作家,我们就可能需要根据他文章的写作时间来统计词频了。
|
||||
|
||||
举个具体的例子的话,就是我们能不能灵活地得到莎士比亚在2017年9月使用的高频词汇?或者是他在2018年第7个周五偏爱使用的高频词汇呢?
|
||||
|
||||
时效性是数据处理很重要的一部分,类似上面这样的问题还有很多。
|
||||
|
||||
比如,能不能根据实时交通数据,得到最近24小时之内拥堵的道路?能不能根据所有微信分享文章的点击数据,得到过去一周最热门的文章?这些问题都是可以用窗口技术来解决。
|
||||
|
||||
所以今天这一讲,我们就来看看怎样在WordCount这个例子中使用窗口技术。我会介绍怎样在Beam中实现以下六个问题:
|
||||
|
||||
1. 怎样区分有界数据还是无界数据?
|
||||
1. 怎样读取无边界数据?
|
||||
1. 怎样给PCollection数据添加时间戳?
|
||||
1. 怎样在PCollection应用窗口?
|
||||
1. 怎样复用之前的DoFn和PTransform?
|
||||
1. 怎样存储无边界数据?
|
||||
|
||||
### 怎样区分有界数据还是无界数据?
|
||||
|
||||
我们知道,在Beam中你可以用同一个Pipeline处理有边界数据或者无边界数据。但我们在处理两者时的思考方式和操作方法还是有细微的不同的。
|
||||
|
||||
比如,有界数据之所以有界,是因为你在处理数据时,所有的数据就已经准备就绪了。
|
||||
|
||||
在[第31讲](https://time.geekbang.org/column/article/105324)的WordCount例子中,莎士比亚文集早已成为历史,不会有新的作品了。所以,你可以用有界数据的处理方式进行实现。当你的数据输入是有界的时候,下游的数据一般也是有界的。因为你的数据元素是有限的,在数据处理的过程中,不会凭空造出无限多的数据。
|
||||
|
||||
而无边界数据的到来是时刻不停的。在你处理处理流水线的任意时刻,数据都没有完全结束。
|
||||
|
||||
比如,在我们[第1讲](https://time.geekbang.org/column/article/90081)中提到的处理美团外卖电动车例子中,美团外卖电动车的图片就是一直在不停地更新。你不可能说“我已经有了所有的外卖电动车图片了”。在无界数据的处理流水线中,因为输入数据是无界的,所以下游的处理结果一般也是无界的。
|
||||
|
||||
相信你已经掌握了区分区分有界和无界数据方法。在接下来的内容中,我们会看到针对两种数据的不同处理方式。
|
||||
|
||||
但是,不论是有界数据还是无界数据,在Beam中我们都可以用窗口把数据按时间分割成一些有限大小的集合。只是对于无界数据,你必须使用窗口对数据进行分割,然后对每个窗口内的数据集进行处理。
|
||||
|
||||
### 怎样读取无边界数据?
|
||||
|
||||
在[第31讲](https://time.geekbang.org/column/article/105324)WordCount的案例中,我们从一个外部文本文件读取了莎士比亚全集的文本内容。当时,我们使用的是Beam的TextIO:
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
Pipeline p = Pipeline.create(options);
|
||||
|
||||
p.apply("ReadLines", TextIO.read().from(options.getInputFile()))
|
||||
|
||||
```
|
||||
|
||||
这是因为我们当时面对的是有边界的数据,在我们的数据处理流水线运行之前,所有的莎士比亚全集文本早已准备就绪,所以我们可以一股脑儿全部读进来。但是当输入数据是无界的时候,我们就没法这样读取数据了。常见的无界数据读取自logging系统或者Pub/Sub系统。
|
||||
|
||||
由于logging系统一直在不断地运行,新的log在不停地产生,并且每条log都自带时间戳。比如,我们想要根据用户对于微信文章的点击log分析不同时刻的热门文章,我们就可以去读取微信文章的log。而在Pub/Sub系统中,我们订阅的消息也会永无止境地到来,类似的一般Pub/Sub订阅的每条消息也会自带原生的时间戳。
|
||||
|
||||
这一讲中,我们已经假设莎士比亚穿越到现代在极客时间开了个专栏。我们不妨把他的专栏文章更新设计在一个Kafka消息系统中。
|
||||
|
||||
如下图所示,即使你并没有使用过Kafka也没有关系。你只需要知道在我们的数据处理系统中能够不定时地收到来自莎士比亚的文章更新,每一次的文章更新包含了更新的文章标题和更新内容。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4f/85/4f075951e25ad5eb9792468d4c802085.png" alt="">
|
||||
|
||||
这时,我们可以使用Beam的Kafka IO来读取来自Kafka的订阅消息。
|
||||
|
||||
在下面的示例代码中,我们指定了需要读取的Kafka消息主题“shakespeare”,以及Kafka消息的key/value类型都是String。你需要注意这里的读取选项withLogAppendTime(),它的意思是我们用Kafka的log append time作为我们beam PCollection数据集的时间戳。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
pipeline
|
||||
.apply(KafkaIO.<String, String>read()
|
||||
.withBootstrapServers("broker_1:9092,broker_2:9092")
|
||||
.withTopic("shakespeare") // use withTopics(List<String>) to read from multiple topics.
|
||||
.withKeyDeserializer(StringDeserializer.class)
|
||||
.withValueDeserializer(StringDeserializer.class)
|
||||
.withLogAppendTime()
|
||||
)
|
||||
|
||||
```
|
||||
|
||||
### 怎样给PCollection数据添加时间戳?
|
||||
|
||||
一般情况下,窗口的使用场景中,时间戳都是原生的。就如同我们从Kafka中读取消息记录一样,时间戳是自带在每一条Kafka消息中的。
|
||||
|
||||
但Beam也允许我们手动给PCollection的元素添加时间戳。例如第31讲的WordCount例子本身就是一个有界数据集,你还记得吗?那么我们怎么给这些有界数据集添加时间戳呢?
|
||||
|
||||
第31讲的输入数据格式就是简单的文本文件:
|
||||
|
||||
```
|
||||
HAMLET
|
||||
|
||||
ACT I
|
||||
|
||||
SCENE I Elsinore. A platform before the castle.
|
||||
|
||||
[FRANCISCO at his post. Enter to him BERNARDO]
|
||||
|
||||
BERNARDO Who's there?
|
||||
|
||||
FRANCISCO Nay, answer me: stand, and unfold yourself.
|
||||
|
||||
```
|
||||
|
||||
为了方便阐述概念,我们不妨假设一下,现在我们的输入文件变成了如下的格式,每一行的开头都会带有一个时间戳,在冒号分隔符号之后才是我们需要处理的文本:
|
||||
|
||||
```
|
||||
2019-07-05: HAMLET
|
||||
|
||||
2019-07-06: ACT I
|
||||
|
||||
2019-07-06: SCENE I Elsinore. A platform before the castle.
|
||||
|
||||
2019-07-07: [FRANCISCO at his post. Enter to him BERNARDO]
|
||||
|
||||
2019-07-07: BERNARDO Who's there?
|
||||
|
||||
2019-07-07: FRANCISCO Nay, answer me: stand, and unfold yourself.
|
||||
|
||||
```
|
||||
|
||||
当时我们是直接对每一行的文本提取了所有的单词。但在现在这样的输入格式下,我们就可以先把每一行开头的时间戳提取出来。在DoFn的processElement实现中,我们用outputWithTimestamp()方法,可以对于每一个元素附上它所对应的时间戳。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
static class ExtractTimestampFn extends DoFn<String, String> {
|
||||
@ProcessElement
|
||||
public void processElement(ProcessContext c) {
|
||||
String extractedLine = extractLine(c.element());
|
||||
Instant timestamp =
|
||||
new Instant(extractTimestamp(c.element());
|
||||
|
||||
c.outputWithTimestamp(extractedLine, timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 怎样在PCollection应用窗口?
|
||||
|
||||
通过前面的内容,我们已经解决了“PCollection的时间戳来自于哪里”的问题。在无界数据的应用场景中,时间戳往往是数据记录自带的,比如来自Kafka消息。在有界数据的应用场景中,时间戳往往需要自己指定,比如我们读取的自定义的莎士比亚文集格式。
|
||||
|
||||
PCollection元素有了时间戳,我们就能根据时间戳应用窗口对数据进行划分。[第32讲](https://time.geekbang.org/column/article/105707)中,我们已经介绍了常见的窗口种类,有固定窗口、滑动窗口和会话窗口。
|
||||
|
||||
要把特定的窗口应用到PCollection上,我们同样使用PCollection的apply()方法。如果是固定窗口,我们就用FixedWindows类型,如果是滑动窗口就用SlidingWindows类型,相应的如果是会话窗口我们就用Sessions窗口类型。下面的代码示例就是使用FixedWindows的情况:
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
PCollection<String> windowedWords = input
|
||||
.apply(Window.<String>into(
|
||||
FixedWindows.of(Duration.standardMinutes(options.getWindowSize()))));
|
||||
|
||||
```
|
||||
|
||||
### 怎样复用之前的DoFn和PTransform?
|
||||
|
||||
有了窗口,我们下一步就是把之前的DoFn和PTransform应用到数据集上。
|
||||
|
||||
这一步其实是最简单的。因为Beam的Transform不区分有界数据还是无界数据。我们可以一行代码不改,和第31讲用到的例子一样,直接使用之前的CountWords这个PTransform就可以了。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
PCollection<KV<String, Long>> wordCounts = windowedWords.apply(new WordCount.CountWords());
|
||||
|
||||
```
|
||||
|
||||
值得注意的是,在应用了窗口之后,Beam的transform是在每一个窗口中间进行数据处理的。在我们的例子中,词频统计的是每一个窗口里的词频,而不再是全局的词频。
|
||||
|
||||
### 怎样输出无边界数据?
|
||||
|
||||
同数据读取对应,无边界数据的输出也是与有界数据大相径庭。在第31讲中,我们把数据处理结果写进了一个外部文件中,使用了TextIO:
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
pipeline.apply("WriteCounts", TextIO.write().to(options.getOutput()));
|
||||
|
||||
```
|
||||
|
||||
但是在无边界的应用场景中,数据在持续不断地进来。最常见的输出模式是把处理结果还是以Pub/Sub的模式发布出去。
|
||||
|
||||
假设我们用Google Pub/Sub输出我们的处理结果的话,我们可以用PubsubIO.writeStrings()方法。同样,这里的输出结果是针对每一个窗口的,每一个窗口都会输出自己的词频统计结果。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
pipeline.apply("Write to PubSub", PubsubIO.writeStrings().to(options.getOutputTopic()));
|
||||
|
||||
```
|
||||
|
||||
## 小结
|
||||
|
||||
今天我们深入探索了Beam窗口在流处理的场景中的应用。
|
||||
|
||||
我们巩固了区分有界数据还是无界数据的方法,掌握了在Beam中怎样读取无边界数据,怎样给PCollection数据添加时间戳,怎样在PCollection应用窗口,怎样复用之前的DoFn和PTransform和怎样输出无边界数据。
|
||||
|
||||
将这些方法融会贯通后,相信类似的时间性数据处理或者是流处理问题在你手中都能迎刃而解了。
|
||||
|
||||
## 思考题
|
||||
|
||||
你的工作中有哪些应用场景不适合一般的数据批处理呢?能否利用这里介绍窗口方式处理?
|
||||
|
||||
欢迎你把答案写在留言区,与我和其他同学一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
<audio id="audio" title="34 | Amazon热销榜Beam Pipeline实战" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/dd/6b/dd0dada357a66ea147c39f71da0a896b.mp3"></audio>
|
||||
|
||||
你好,我是蔡元楠。
|
||||
|
||||
今天我要与你分享的主题是“Amazon热销榜Beam Pipeline实战”。
|
||||
|
||||
两个月前,亚马逊(Amazon)宣布将关闭中国国内电商业务的消息你一定还记忆犹新。虽然亚马逊遗憾离场,但它依然是目前全球市值最高的电商公司。
|
||||
|
||||
作为美国最大的一家网络电子商务公司,亚马逊的总部位于华盛顿州的西雅图。类似于BAT在国内的地位,亚马逊也是北美互联网FAANG五大巨头之一,其他四个分别是Facebook、Apple、Netflix和Google。
|
||||
|
||||
亚马逊的热销商品系统就如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/df/b0/dff4faae3353f26d7e413a0c1f7983b0.png" alt="">
|
||||
|
||||
当我搜索“攀岩鞋”时,搜索结果的第三个被打上了“热销商品”的标签,这样能帮助消费者快速做出购买决策。
|
||||
|
||||
当我点击这个“Best Seller”的标签时,我可以浏览“攀岩鞋”这个商品分类中浏览销量最高的前100个商品。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a7/4c/a7a0b8187b1caf1b31fcda87720e494c.png" alt="">
|
||||
|
||||
这些贴心的功能都是由热销商品系统实现的。
|
||||
|
||||
这一讲我们就来看看在这样的热销商品系统中,怎样应用之前所学的Beam数据处理技术吧。今天,我们主要会解决一个热销商品系统数据处理架构中的这几个问题:
|
||||
|
||||
1. 怎样用批处理计算基础的热销商品列表、热销商品的存储和serving设计?
|
||||
1. 怎样设计每小时更新的热销榜单?
|
||||
1. 怎样设计商品去重处理流水线和怎样根据商品在售状态过滤热销商品?
|
||||
1. 怎样按不同的商品门类生成榜单?
|
||||
|
||||
### 1.怎样用批处理计算基础的热销商品列表、热销商品的存储和serving设计?
|
||||
|
||||
我们先来看最简单的问题设置,怎样用批处理计算基础的热销商品列表。
|
||||
|
||||
假设你的电商网站销售着10亿件商品,并且已经跟踪了网站的销售记录:商品id和购买时间 {product_id, timestamp},整个交易记录是1000亿行数据,TB级。举个例子,假设我们的数据是这样的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bd/9d/bdafa7f74c568c107c38317e0a1a669d.png" alt="">
|
||||
|
||||
我们可以把热销榜按 product_id 排名为:1,2,3。
|
||||
|
||||
你现在有没有觉得这个问题似曾相识呢?的确,我们在[第3讲“大规模数据初体验”](https://time.geekbang.org/column/article/91125)中用这个例子引出了数据处理框架设计的基本需求。
|
||||
|
||||
这一讲中,我们会从这个基本问题设置开始,逐步深入探索。
|
||||
|
||||
在第3讲中,我们把我们的数据处理流程分成了两个步骤,分别是:
|
||||
|
||||
1. 统计每个商品的销量
|
||||
1. 找出销量前K
|
||||
|
||||
我们先来看第一个步骤的统计商品销量应该如何在Beam中实现。我们在第3讲中是画了这样的计算集群的示意图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8e/8a/8eeff3376743e886d5f2d481ca8ddb8a.jpg" alt="">
|
||||
|
||||
如果你暂时没有思路的话,我们不妨试试换一个角度来思考这个问题。
|
||||
|
||||
统计商品的销量,换句话说,其实就是计算同样的商品id在我们的销售记录数据库中出现了多少次。这有没有让你联想到什么呢?没错,就是我们在[第31讲](https://time.geekbang.org/column/article/105324)中讲到的WordCount例子。WordCount是统计同样的单词出现的次数,而商品销量就是统计同样的商品id出现的次数。
|
||||
|
||||
所以,我们完全可以用WordCount中的部分代码解决商品销量统计的这部分数据处理逻辑。
|
||||
|
||||
在WordCount中,我们用words.apply(Count.perElement())把一个分词后的PCollection转换成了“单词为key,count为value”的一个key/value组合。
|
||||
|
||||
在这里呢,我们同样使用salesRecords.apply(Count.perElement())把一个商品id的PCollection转换成“key为商品id,value为count”的key/value组合。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
// WordCount的统计词频步骤
|
||||
wordCount = words.apply(Count.perElement())
|
||||
|
||||
// 我们这里的统计销量步骤
|
||||
salesCount = salesRecords.apply(Count.perElement())
|
||||
|
||||
```
|
||||
|
||||
解决了统计每个商品的销量步骤,我们再来看看怎样统计销量前K的商品。在第3讲中,我们是画了一个计算集群来解决这个问题。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/38/fc/38933e25ca315bd56321753573d5bbfc.jpg" alt="">
|
||||
|
||||
但是Beam提供了很好的API供我们使用。我们可以使用Top() 这个Transform。
|
||||
|
||||
Top接受的第一个参数就是我们这里的K,也就是我们最终要输出的前几个元素。我们需要实现的是一个Java Comparator interface。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
PCollection<KV<String, Long>> topK =
|
||||
salesCount.apply(Top.of(K, new Comparator<KV<String, Long>>() {
|
||||
@Override
|
||||
public int compare(KV<String, Long> a, KV<String, Long> b) {
|
||||
return b.getValue.compareTo(a.getValue());
|
||||
}
|
||||
}));
|
||||
|
||||
```
|
||||
|
||||
到这里,销量前K的产品就已经被计算出来了。
|
||||
|
||||
和所有数据处理流水线一样,我们需要的是一个完整的系统。那么你就不能仅仅满足于计算出结果,必须要考虑你的数据处理结果将怎样被使用。在本文开头的截图中,你能看到,热销商品都被打上了“Best Seller”的标签,点击“Best Seller”标签我们还能看到完整的热销榜单。
|
||||
|
||||
那么你可以考虑两种serving的方案。
|
||||
|
||||
一种是把热销商品的结果存储在一个单独的数据库中。但是在serving时候,你需要把商品搜索结果和热销商品结果进行交叉查询。如果搜索结果存在于热销商品数据库中,你就在返回的搜索结果元素中把它标注成“Best Seller”。
|
||||
|
||||
另一个可能不太灵活,就是把热销商品的结果写回原来的商品数据库中。如果是热销商品,你就在“是热销商品”这一列做标记。这种方案的缺点是每次更新热销结果后,都要去原来的数据库进行大量更新,不仅要把新成为热销的商品进行标记,还要将落选商品的标记去除。
|
||||
|
||||
两种serving方案的选择影响了你对于数据处理输出的业务需求。相应的,你可以把输出的前K销量产品使用Pipeline output输出到一个单独数据库,也可以统一更新所有数据库中的商品。
|
||||
|
||||
### 2.怎样设计每小时更新的热销榜单?
|
||||
|
||||
在设计完基础的热销商品处理系统后,我们注意到在Amazon的热销榜上有一行小字 “Updated hourly”,也就是“每小时更新”。的确,热销商品往往是有时效性的。去年热销的iPhone X今年就变成了iPhone XS。Amazon选择了以小时为单位更新热销榜单确实是合理的产品设计。
|
||||
|
||||
那么怎样用Beam实现这种定时更新的数据处理系统呢?
|
||||
|
||||
可能你在看到“时间”这个关键词的时候,就马上联想到了第32讲介绍的Beam Window。确实,用基于Window的流处理模式是一个值得考虑的方案。我在这里故意把问题设置得比较模糊。其实是因为这需要取决于具体的业务需求,实际上你也可以选择批处理或者流处理的方式。
|
||||
|
||||
我们先从简单的批处理模式开始。
|
||||
|
||||
在处理工程问题时,我们都是先看最简单的方案能否支撑起来业务需求,避免为了体现工程难度而故意将系统复杂化。采用批处理的方式的话,我们可以每隔一个小时运行一遍上一个小标题中的基础版热销商品系统,也就是部署成cron job的模式。
|
||||
|
||||
但你要注意,如果我们不修改代码的话,每一次运行都会计算目前为止所有销售的商品。如果这不是你的业务需求,你可以在批处理的数据输入步骤中,根据你的销售记录表的时间戳来筛选想要计算的时间段。比如,你可以设置成每一次运行都只计算从运行时间前6个月到该运行时间为止。
|
||||
|
||||
其实,批处理的模式已经能解决我们目前为止的大部分业务需求。但有时候我们不得不去使用流处理。比如,如果存储销售记录的团队和你属于不同的部门,你没有权限去直接读取他们的数据库,他们部门只对外分享一个Pub/Sub的消息队列。这时候就是流处理应用的绝佳场景。
|
||||
|
||||
不知道你还记不记得第33讲中我提到过,在Streaming版本的WordCount中监听一个Kafka消息队列的例子。同样的,这时候你可以订阅来自这个部门的销售消息。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
pipeline
|
||||
.apply(KafkaIO.<String, Long>read()
|
||||
.withBootstrapServers("broker_1:9092,broker_2:9092")
|
||||
.withTopic("sales_record") // use withTopics(List<String>) to read from multiple topics.
|
||||
.withKeyDeserializer(StringDeserializer.class)
|
||||
.withValueDeserializer(StringDeserializer.class)
|
||||
.withLogAppendTime()
|
||||
)
|
||||
|
||||
```
|
||||
|
||||
之后你可以为你的输入PCollection添加窗口,和WordCount一样。不过这时候你很有可能需要滑动窗口,因为你的窗口是每小时移动一次。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
PCollection<String> windowedProductIds = input
|
||||
.apply(Window.<String>into(
|
||||
SlidingWindows.of(Duration.standardMonths(options.getWindowSize()))));
|
||||
|
||||
```
|
||||
|
||||
### 3.怎样设计商品去重处理流水线和怎样根据商品在售状态过滤热销商品?
|
||||
|
||||
通过前面的内容,我们已经设计出了能够每小时更新的热销榜单。但不知道你有没有注意到,其实我们之前的问题设置是过于简化了,忽略了很多现实而重要的问题,比如:
|
||||
|
||||
- 怎样处理退货的商品?
|
||||
- 怎样处理店家因为收到差评故意把商品下架换个马甲重新上架?
|
||||
- 怎样处理那些虽然曾经热销但是现在已经不再出售的商品?
|
||||
|
||||
这些问题都需要使用第28讲中介绍的Pipeline的基本设计模式:过滤模式。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/47/0f/47498fc9b2d41c59ffb286d84c4f220f.jpg" alt="">
|
||||
|
||||
我们这个案例中所需要的过滤条件是:
|
||||
|
||||
- 把退货的商品销量减去
|
||||
- 把重复的商品销量进行叠加
|
||||
- 将在售商品过滤出来
|
||||
|
||||
一起来想想这些过滤条件应该怎么实现吧。
|
||||
|
||||
对于退货商品,我们可以把所有退货的记录挑出来进行统计。同样对于每一个商品id,如果我们把出售的计数减去退货的计数就可以得到成功销售的计数。
|
||||
|
||||
而事实上,实际交易系统对于商品状态的跟踪会详细得多,每一个订单最终落在退货状态还是成功销售状态都可以在交易数据库中查询得到。我们可以把这个封装在isSuccessfulSale()方法中。
|
||||
|
||||
重复的商品在一个成熟的交易系统中一般会有另外一个去重的数据处理流水线。它能根据商品描述、商品图片等推测重复的商品。我们假设在我们系统中已经有了product_unique_id这样一个记录,那么我们只要把之前进行统计的product_id替换成统计product_unique_id就行了。
|
||||
|
||||
过滤在售的商品可能有多种实现方式,取决于你的小组有没有权限读取所需的数据库。
|
||||
|
||||
假如你可以读取一个商品状态的数据库列,那你可以直接根据 [商品状态=在售] 这样的判断条件进行过滤。假如你不能读取商品状态,那你可能需要查询在售的商品中是否有你的这个商品id来进行判断。但在这一讲中,我们先忽略实现细节,把这个过滤逻辑封装在isInStock()方法中。
|
||||
|
||||
最终我们的过滤处理步骤会是类似下面这样的。只有同时满足isSuccessfulSale()和isInStock()的product_unique_id,才会被我们后续的销量统计步骤所计算。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
PCollection<Product> productCollection = ...;
|
||||
|
||||
PCollection<Product> qualifiedProductCollection = productCollection
|
||||
.apply(“uniqueProductTransform”, Distinct.withRepresentativeValueFn(
|
||||
new SerializableFunction<Product, Long>() {
|
||||
@Override
|
||||
public Long apply(Product input) {
|
||||
return input.productUniqueId();
|
||||
}
|
||||
}).withRepresentativeType(TypeDescriptor.of(Long.class))
|
||||
)
|
||||
.apply("filterProductTransform", ParDo.of(new DoFn<Product, Product>(){
|
||||
@ProcessElement
|
||||
public void processElement(ProcessContext c) {
|
||||
if (isSuccessfulSale(c.element()) && isInStockc.element())) {
|
||||
c.output(c.element());
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
```
|
||||
|
||||
### 4.怎样按不同的商品门类生成榜单?
|
||||
|
||||
我们还注意到亚马逊的热销榜是按照不同的商品种类的。也就说每一个商品分类都有自己的榜单。这是很合理的业务设计,因为你不可能会去把飞机的销量和手机的销量相比,手机可能人手一个,飞机无法人手一个。
|
||||
|
||||
这时候我们在第28讲所学的分离模式设计就能大显身手了。分离模式把一个PCollection按照类别分离成了几个小的子PCollection。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c5/85/c5d84c2aab2e02cc6e1d2e9f7c40e185.jpg" alt="">
|
||||
|
||||
在这个案例里,我们也需要对商品进行分离。
|
||||
|
||||
与经典的分离模式不同,我们这里每一个商品可能都属于多个类别。比如一双鞋子,它可能既归类为“户外”,也归类为“潮鞋”。还记得分离模式在Beam中怎么实现吗?没错就是使用output tag。我们先要为每一种分类定义tag,比如刚才说的outdoorTag和fashionTag。再把相应的商品输出进对应的tag中。示例如下:
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
// 首先定义每一个output的tag
|
||||
final TupleTag<Product> outdoorTag = new TupleTag<Product>(){};
|
||||
final TupleTag<Product> fashionTag = new TupleTag<Product>(){};
|
||||
|
||||
PCollection<Product> salesCollection = ...;
|
||||
|
||||
PCollectionTuple mixedCollection =
|
||||
userCollection.apply(ParDo
|
||||
.of(new DoFn<Product, Product>() {
|
||||
@ProcessElement
|
||||
public void processElement(ProcessContext c) {
|
||||
if (isOutdoorProduct(c.element())) {
|
||||
c.output(c.element());
|
||||
} else if (isFashionProduct(c.element())) {
|
||||
c.output(fashionTag, c.element());
|
||||
}
|
||||
}
|
||||
})
|
||||
.withOutputTags(outdoorTag, TupleTagList.of(fashionTag)));
|
||||
|
||||
// 分离出不同的商品分类
|
||||
mixedCollection.get(outdoorTag).apply(...);
|
||||
|
||||
mixedCollection.get(fashionTag).apply(...);
|
||||
|
||||
```
|
||||
|
||||
## 小结
|
||||
|
||||
这一讲我们从基础商品排行榜系统出发,利用到了之前学的数据处理设计模式和Beam编程方法。
|
||||
|
||||
同时,探索了以批处理计算为基础的热销商品列表。我们设计了每小时更新的热销榜单、商品去重处理流水线,根据商品在售状态过滤出热销商品,并按不同的商品门类生成榜单。
|
||||
|
||||
## 思考题
|
||||
|
||||
一个商品排名系统中还有太多需要解决的工程问题,你觉得哪些也可以利用大规模数据处理技术设计解决?
|
||||
|
||||
欢迎你把答案写在留言区,与我和其他同学一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
<audio id="audio" title="35 | Facebook游戏实时流处理Beam Pipeline实战(上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7c/ae/7cecad14dac98bf4d392b462a8a553ae.mp3"></audio>
|
||||
|
||||
你好,我是蔡元楠。
|
||||
|
||||
今天我要与你分享的主题是“Facebook游戏实时流处理Beam Pipeline实战”。
|
||||
|
||||
Facebook这个社交平台我相信你一定早有耳闻。它除了能够让用户发送消息给好友,分享自己的动态图片和视频之外,还通过自身的App Center管理着各式各样的小游戏。许多游戏开发商借助Facebook的好友邀请机制让自己的App火了一把。
|
||||
|
||||
曾经有一段时间,在Facebook上有一款名为糖果传奇(Candy Crush Saga)的游戏风靡了整个北美。各个年龄层的玩家都会在空闲的时间拿出手机,过五关斩六将,希望尽快突破更多的关卡,并且获得高分。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/01/68/01d81679dc22a2049f81de1622532d68.png" alt="">
|
||||
|
||||
当然了,除了消除游戏本身带来的乐趣以外,可以在Facebook里和自己的好友进行积分排名比拼也是另外一个能吸引用户的地方。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/97/6e/971c3a60862a448bedfc0676103bf36e.png" alt="">
|
||||
|
||||
想要一个类似Facebook这样的好友间积分排行榜,你可以有很多种实现方式以及各种优化方法。那么,如果我们要利用Apache Beam的话,该怎样实现一个类似的游戏积分排行榜呢?
|
||||
|
||||
今天我就来和你一起研究,要如何利用Apache Beam的数据流水线来实现一个我们自定义的简单游戏积分排行榜。
|
||||
|
||||
为了简化整个游戏积分排行榜案例的说明,我们先来做几个方面的假设:
|
||||
|
||||
- **面向的群体**:游戏积分排行榜针对的是全局每位用户以及每一个关卡,我们不需要担心如何在Beam的数据流水线中优化每个用户自身的好友积分列表。
|
||||
- **更新时间**:为了保持用户的粘性,我们设定这个游戏积分排行榜的数据每隔一周就会更新一次。也就是说如果一位用户在2019年7月15日成功通关了一次游戏并且分数是这周内自身的最高分,那么这次的最高分数将一直留在2019年7月15日至2019年7月21日这周的排行榜中。但是到了2019年7月22日后,这个分数将不会再出现,需要用户重新通关这个关卡后分数才会重新出现在新的一周游戏积分排行榜中。
|
||||
- **积分排位**:游戏积分排行榜需要显示出这个关卡中得分最高的前100位用户。
|
||||
- **输入数据**:每次用户通关后,这个App都会将用户自身的ID,通关游戏的时间(也就是事件时间)还有分数以CSV格式上传到服务器中,每个用户的游戏积分数据都可以从Google Cloud Bigtable中读取出来。
|
||||
- **输出数据**:最终这个游戏积分排行榜结果可以从一个文件中获得。也就是说,我们的Beam数据流水线需要将最终结果写入文件中。
|
||||
|
||||
有了这些假设,我们就一起来由浅入深地看看有哪些执行方案。
|
||||
|
||||
正如上一讲中所说,如果可以用简单的方法解决战斗,我们当然要避免将问题复杂化了。一种比较直观的做法就是使用crontab定时执行一个Beam数据流水线,将每周需要进行计算排名的开始时间点和结束时间点传入数据流水线中,过滤掉所有事件时间不在这个时间范围内的数据。
|
||||
|
||||
那么,具体要怎么做呢?
|
||||
|
||||
首先,我们先要定义一个类,来保存我们之前假设好用户上传的信息。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
class UserScoreInfo {
|
||||
String userId;
|
||||
Double score;
|
||||
Long eventTimestamp;
|
||||
|
||||
public UserScoreInfo(String userId, Double score, Long eventTimestamp) {
|
||||
this.userId = userId;
|
||||
this.score = score;
|
||||
this.eventTimestamp = eventTimestamp;
|
||||
}
|
||||
|
||||
public String getUserId() {
|
||||
return this.userId;
|
||||
}
|
||||
|
||||
public Double getScore() {
|
||||
return this.score;
|
||||
}
|
||||
|
||||
public Long getEventTimestamp() {
|
||||
return this.eventTimestamp;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个类十分简单,构造函数需要传入的是用户ID、游戏通关时的积分还有通关时间。
|
||||
|
||||
有了这个类之后,整个数据流水线的逻辑就可以围绕着这个类来处理,步骤大致如下:
|
||||
|
||||
1. 从Google Cloud Bigtable中读取保存用户通关积分等信息的所有Bigtable Row出来,得到PCollection<row>。</row>
|
||||
1. 将PCollection<row>转换成我们定义好的类,成为PCollection<userscoreinfo>。</userscoreinfo></row>
|
||||
1. 根据我们传入的开始边界时间和结束边界时间过滤掉不属于这一周里数据,得到有效时间内的PCollection<userscoreinfo>。</userscoreinfo>
|
||||
1. 将PCollection转换成PCollection<KV<String, UserScoreInfo>>,KV里面的Key就用户ID。
|
||||
1. 自定义一个Composite Transform,其中包括三个步骤:利用Top Transform将每一个用户的最高分率选出来,得到PCollection<KV<String, List<userscoreinfo>>>;将PCollection<KV<String, List<userscoreinfo>>>转换成为PCollection<KV<String, UserScoreInfo>>;再次利用Top Transform将PCollection<KV<String, UserScoreInfo>>中前100名高分用户筛选出来。</userscoreinfo></userscoreinfo>
|
||||
1. 将结果写入CSV格式的文件中,格式为“用户ID,分数”。
|
||||
|
||||
在上面所描述的步骤中,第5步出现了一个叫Composite Transform的概念。
|
||||
|
||||
那么,什么是Composite Transform呢?其实Composite Transform并不是指一个具体的Transform,而是指我们可以将多个不同的Transforms嵌套进一个类中,使得数据流水线更加模块化。具体做法是继承PTransform这个类,并且实现expand抽象方法来实现的。
|
||||
|
||||
用我们实现过的WordsCount来举例,我们可以将整个WordsCount数据流水线模块化成一个Composite Transform,示例如下:
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
public static class WordsCount extends PTransform<PCollection<String>,
|
||||
PCollection<KV<String, Long>>> {
|
||||
@Override
|
||||
public PCollection<KV<String, Long>> expand(PCollection<String> lines) {
|
||||
|
||||
PCollection<String> words = lines.apply(
|
||||
ParDo.of(new ExtractWordsFn()));
|
||||
|
||||
PCollection<KV<String, Long>> wordsCount =
|
||||
words.apply(Count.<String>perElement());
|
||||
|
||||
return wordsCount;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在上面这个例子中,输入的参数是每一行字符串PCollection<string>,输出结果是每一个单词对应出现的次数PCollection<KV<String, Long>。在实现expand这个抽象方法的时候,里面总共嵌套了两个不同的Transform,分别是一个ParDo用来提取每一行的单词,还有一个Count Transform统计单词出现的次数。</string>
|
||||
|
||||
所以在第5步中,我们也可以自己定义一个ExtractUserAndScore的Composite Transform来实现上面所描述的多个不同的Transforms。
|
||||
|
||||
好了,为了事先知道游戏积分排行榜中开始的边界时间和结束的边界时间,我们还需要自己实现一个Options接口。方法是继承PipelineOptions这个接口,具体如下所示:
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
public interface Options extends PipelineOptions {
|
||||
@Default.String("1970-01-01-00-00")
|
||||
String getStartBoundary();
|
||||
|
||||
void setStartBoundary(String value);
|
||||
|
||||
@Default.String("2100-01-01-00-00")
|
||||
String getEndBoundary();
|
||||
|
||||
void setEndBoundary(String value);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这样开始的边界时间和结束的边界时间就都可以通过Pipeline option的参数传入。
|
||||
|
||||
例如,我们想要得到2019年7月15日至2019年7月21日这周的排行榜,那在运行数据流水线的时候,参数就可以按照“–startBoundary=2019-07-15-00-00 --etartBoundary=2019-07-21-00-00”传入了。
|
||||
|
||||
整个数据流水线的大致逻辑如下:
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
final class LeaderBoard {
|
||||
static class UserScoreInfo {
|
||||
String userId;
|
||||
Double score;
|
||||
Long eventTimestamp;
|
||||
|
||||
public UserScoreInfo(String userId, Double score, Long eventTimestamp) {
|
||||
this.userId = userId;
|
||||
this.score = score;
|
||||
this.eventTimestamp = eventTimestamp;
|
||||
}
|
||||
|
||||
public String getUserId() {
|
||||
return this.userId;
|
||||
}
|
||||
|
||||
public Double getScore() {
|
||||
return this.score;
|
||||
}
|
||||
|
||||
public Long getEventTimestamp() {
|
||||
return this.eventTimestamp;
|
||||
}
|
||||
}
|
||||
|
||||
private static DateTimeFormatter formatter =
|
||||
DateTimeFormat.forPattern("yyyy-MM-dd-HH-mm")
|
||||
.withZone(DateTimeZone.forTimeZone(TimeZone.getTimeZone("Asia/Shanghai")));
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
Options options = PipelineOptionsFactory.fromArgs(args).withValidation().as(Options.class);
|
||||
Pipeline pipeline = Pipeline.create(options);
|
||||
|
||||
final Instant startBoundary = new Instant(formatter.parseMillis(options.getStartBoundary()));
|
||||
final Instant endBoundary = new Instant(formatter.parseMillis(options.getEndBoundary()));
|
||||
|
||||
pipeline
|
||||
.apply(
|
||||
BigtableIO.read()
|
||||
.withProjectId(projectId)
|
||||
.withInstanceId(instanceId)
|
||||
.withTableId("ScoreTable"))
|
||||
.apply("ConvertUserScoreInfo", ParDo.of(new ConvertUserScoreInfoFn()))
|
||||
.apply(
|
||||
"FilterStartTime",
|
||||
Filter.by((UserScoreInfo info) -> info.getTimestamp() > startBoundary.getMillis()))
|
||||
.apply(
|
||||
"FilterEndTime",
|
||||
Filter.by((UserScoreInfo info) -> info.getTimestamp() < endBoundary.getMillis()))
|
||||
.apply("RetrieveTop100Players", new ExtractUserAndScore())
|
||||
.apply(
|
||||
FileIO.<List<String>>write()
|
||||
.via(
|
||||
new CSVSink(Arrays.asList("userId", "score"))
|
||||
.to("filepath")
|
||||
.withPrefix("scoreboard")
|
||||
.withSuffix(".csv")));
|
||||
|
||||
pipeline.run().waitUntilFinish();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
其中,ConvertUserScoreInfoFn这个Transform代表着第2步转换操作,数据流水线中两个Filter Transform分别代表着第3和第4步。第5步“获得最高分的前100位用户”是由ExtractUserAndScore这个Composite Transform来完成的。
|
||||
|
||||
你可以看到,不算上各种具体Transform的实现,整个数据流水线的逻辑框架大概用60行代码就可以表示出来。
|
||||
|
||||
虽然这个批处理的方法可以用简单的逻辑得到最后我们想要的结果,不过其实它还存在着不少的不足之处。
|
||||
|
||||
因为我们的批处理数据流水线使用crontab来定时运行,所以“运行数据流水线的时间间隔”以及“完成数据流水线”这之间的时间之和会给最终结果带来延迟。
|
||||
|
||||
比如,我们定义crontab每隔30分钟来运行一次数据流水线,这个数据流水线大概需要5分钟完成,那在这35分钟期间用户上传到服务器的分数是无法反应到积分排行榜中的。
|
||||
|
||||
那么,有没有能够缩小延时的办法呢?
|
||||
|
||||
当然有,答案就是将输入数据作为无边界数据集读取进来,进行实时的数据处理。在这里面我们会运用的到第23讲所讲述到的窗口(Window)、触发器(Trigger)和累加模式(Accumulation)的概念。
|
||||
|
||||
我将在下一讲中,与你具体分析怎样运用Beam的数据流水线实现一个实时输出的游戏积分排行榜。
|
||||
|
||||
## 小结
|
||||
|
||||
今天我们一起展开讨论了自己实现一个简易游戏积分排行榜的过程。可以知道的是,我们可以使用Beam的数据流水线来完成这一任务。而在Beam数据流水线的实现方式中,我们又可以分成批处理的实现方式和即将在下一讲中展开讨论的实时流处理的方式。批处理虽然简单,但是存在着延时性高、无法快速更新积分排行榜的缺点。
|
||||
|
||||
## 思考题
|
||||
|
||||
在今天这一讲的最后,我提示了你在实时流处理中需要用到窗口、触发器和累加模式。那我们就先来做个预热,思考一下,在流处理中你会对这三种概念赋予什么值呢?
|
||||
|
||||
欢迎你把答案写在留言区,与我和其他同学一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
<audio id="audio" title="36 | Facebook游戏实时流处理Beam Pipeline实战(下)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4c/2b/4c03f7ee4e15a5ff32dc34471d22b12b.mp3"></audio>
|
||||
|
||||
你好,我是蔡元楠。
|
||||
|
||||
在上一讲中,我们一起对怎样实现一个简易的游戏积分排行榜展开了讨论,也一起研究了如何使用批处理计算的方式在Beam中构建出一个数据流水线来得出排行榜结果。
|
||||
|
||||
我们知道,虽然批处理计算可以得到一个完整的结果,但是它也存在着自身的不足,比如会有一定的延时,需要额外的crontab来管理定时任务,增加了维护成本等等。
|
||||
|
||||
所以在上一讲的末尾,我们提出了使用实时流处理来改进这些不足,而其中就需要用到窗口、触发器和累加模式这几个概念。
|
||||
|
||||
相信学习了[第32讲](https://time.geekbang.org/column/article/105707)的内容后,你对于窗口在Beam中是如何运作的,已经比较了解了。对于有效时间为一周的积分排行榜来说,我们可以赋予一个“窗口时长为一周的固定窗口”给数据流水线。也就是说,我们最终的结果会按照每一周的时长来得出。
|
||||
|
||||
那接下来的问题就剩下我们怎么定义触发器和累加模式了。
|
||||
|
||||
首先,我想先讲讲触发器在Beam中是怎么运作的。在[第23讲](https://time.geekbang.org/column/article/100478)中,我们已经了解了触发器在Beam中的作用。它是用于告诉数据流水线,什么时候需要计算一遍落在窗口中的所有数据的。这在实时流处理中尤为重要。
|
||||
|
||||
在实时流处理当中,我们总是需要在数据结果的**完整性**和**延迟性**上做出一些取舍。
|
||||
|
||||
如果我们设置的触发器比较频繁,例如说每隔几分钟甚至是几秒钟,或者是在时间上很早发生的话,那就表示我们更倾向于数据流水线的延时比较小,但是不一定能够获得完整的数据。
|
||||
|
||||
如果我们设置的触发器是比较长时间的,像每隔一个小时才会触发一次窗口中的计算的话,那就表示我们更希望获得完整的数据集来得到最终结果。
|
||||
|
||||
为什么这么说呢?
|
||||
|
||||
因为在现实世界中,我们是没有办法保证数据流水线可以在某一刻能够得到在这一刻之前所产生的所有数据的。
|
||||
|
||||
就拿获得这个游戏积分排行榜的数据为例子来说明一下。
|
||||
|
||||
在现实生活中,可能很多用户是用手机来通关游戏,并且上传通关时间和积分的。有的地方可能因为信号差,上传数据会有很大的延迟。甚至可能有这样的情况:有一些用户是在坐飞机的时候玩的游戏(在飞行模式之下完成各种通关),等到飞机降落,手机重新有了信号之后,数据才被上传到服务器,这时候可能已经有了好几个小时的延时了。
|
||||
|
||||
如果提早触发窗口中的数据计算,可能会有很多“迟到”的数据未被纳入最终结果中,而这些“迟到”的数据有可能又会影响到游戏积分排行榜。
|
||||
|
||||
所以,在很多复杂的场景下,我们希望尽可能地将所有数据归入正确的时间窗口中,而且还要能够得到正确的结果。因此,除了要对触发器进行设置之外,我们还需要设置到底应不应该在一些“迟到”的数据来到的时候,重新计算一下整个结果。
|
||||
|
||||
在Beam中,我们可以为PCollection设置的触发器有4种模式:
|
||||
|
||||
1.**基于事件时间的触发器**(Event-Time Trigger)
|
||||
|
||||
如果设置了基于事件时间的触发器,那所有的计算都是基于PCollection中所有元素的事件时间的。
|
||||
|
||||
如果我们不显式地设置触发器的话,Beam的默认触发器就是基于事件时间的,如果要显式地设置基于事件时间的触发器,可以使用AfterWatermark类进行设置。
|
||||
|
||||
2.**基于处理时间触发器**(Process-Time Trigger)
|
||||
|
||||
如果设置了基于处理时间的触发器,那一个PCollection中的所有元素都会在数据流水线中的某一个时刻被处理。如果要显式地设置基于处理时间的触发器,可以使AfterProcessingTime类进行设置。
|
||||
|
||||
3.**数据驱动的触发器**(Data-Driven Trigger)
|
||||
|
||||
数据驱动的触发器一般是在每个元素到达每个窗口时,通过检查这个元素是否满足某个属性来触发的。
|
||||
|
||||
就好像我在[第23讲](https://time.geekbang.org/column/article/100478)所举的例子一样,检查元素是否在窗口中到达一定的数量,然后触发计算就是数据驱动的触发器的一种,在Beam中,可以使用AfterPane.elementCountAtLeast()函数来配置。
|
||||
|
||||
4.**复合触发器**(Composite Trigger)
|
||||
|
||||
复合触发器其实就是由上面所说三种基本触发器组合而成的。在[第23讲](https://time.geekbang.org/column/article/100478)中,我举过一个触发器的例子,例子中至少要等到有两个交易数据到达后才能触发计算。
|
||||
|
||||
有同学在留言中问我,如果现实中只有一个数据到达窗口,那岂不是永远都触发不了计算了?其实,这个时候就可以定义一个复合触发器,可以定义成累积有超过两个元素落入窗口中或者是每隔一分钟触发一次计算的复合触发器。
|
||||
|
||||
而像我之前提到的,如果我们需要处理“迟到”的数据,那在Beam中又是怎么操作呢?我们可以使用withAllowedLateness这个在Window类里定义好的函数,方法签名如下:
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
public Window<T> withAllowedLateness(Duration allowedLateness);
|
||||
|
||||
```
|
||||
|
||||
这个函数接收的参数就是我们希望允许多久的“迟到”数据可以被纳入计算中。
|
||||
|
||||
最后需要说明的是累加模式。
|
||||
|
||||
在Beam中,我们可以设置两种累加模式,分别是**丢弃模式**和**累积模式**。它们可以分别通过Window类里的函数discardingFiredPanes()和accumulatingFiredPanes()来设置。
|
||||
|
||||
好了,那现在回到我们的积分排行榜问题当中。
|
||||
|
||||
虽然我们对输入数据集设定的窗口是一个窗口时长为1周的固定窗口,但是我们也需要尽可能地在近乎实时的状态下更新排行榜。所以,我们可以设置数据流水线在每5分钟更新一次。
|
||||
|
||||
那我们接受“迟到”多久的数据呢?
|
||||
|
||||
我在网上查询了一下,现在飞机航班直飞耗时最长的是新加坡飞往纽约的航班,大概需要19个小时。如果玩游戏的用户恰好也在这趟航班上,那么可能数据的延时可能就会超过19个小时了。那我们就设定允许“迟到”20个小时的数据也纳入我们的窗口计算当中。
|
||||
|
||||
一般情况下,我们可以从Pub/Sub数据流中读取实时流数据。为了简化数据流水线的逻辑,不在数据流水线中保存中间状态,我们现在假设在实际操作的时候,服务器已经判断好某一用户的分数是否是最高分,如果是最高分的话,再通过Pub/Sub将数据传入流水线。
|
||||
|
||||
这时,我们的累加模式可以定义为丢弃模式,也就是只保留最新的结果。
|
||||
|
||||
为此,我们可以写出一个Transform来设置所有上述描述的概念,分别是:
|
||||
|
||||
1. 设置窗口时长为1周的固定窗口。
|
||||
1. 每隔5分钟就会计算一次窗口内数据的结果。
|
||||
1. 允许“迟到”了20个小时的数据被重新纳入窗口中计算。
|
||||
1. 采用丢弃模式来保存最新的用户积分。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
static class ConfigUserScores extends PTransform<PCollection<UserScoreInfo>, PCollection<UserScoreInfo>> {
|
||||
private final Duration FIXED_WINDOW_SIZE = Duration.standardDays(7);
|
||||
private final Duration FIVE_MINUTES = Duration.standardMinutes(5);
|
||||
private final Duration TWENTY_HOURS = Duration.standardHours(20);
|
||||
|
||||
@Override
|
||||
public PCollection<UserScoreInfo> expand(PCollection<UserScoreInfo> infos) {
|
||||
return infos.apply(
|
||||
Window.<UserScoreInfo>into(FixedWindows.of(FIXED_WINDOW_SIZE))
|
||||
.triggering(
|
||||
AfterWatermark.pastEndOfWindow()
|
||||
.withEarlyFirings(
|
||||
AfterProcessingTime.pastFirstElementInPane().plusDelayOf(FIVE_MINUTES))
|
||||
.withLateFirings(
|
||||
AfterProcessingTime.pastFirstElementInPane().plusDelayOf(FIVE_MINUTES)))
|
||||
.withAllowedLateness(TWENTY_HOURS)
|
||||
.discardingFiredPanes());
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
有了这个Transform去设定好我们在实时流处理中如何处理数据之后,我们其实只需要修改之前批处理数据流水线中很小的一部分,就可以达到我们想要的结果了。
|
||||
|
||||
Java
|
||||
|
||||
```
|
||||
...
|
||||
pipeline.apply(
|
||||
KafkaIO.<String>read()
|
||||
.withBootstrapServers("broker_1:9092,broker_2:9092")
|
||||
.withTopic("user_scores")
|
||||
.withKeyDeserializer(StringDeserializer.class)
|
||||
.withValueDeserializer(StringDeserializer.class)
|
||||
.withLogAppendTime())
|
||||
.apply("ConvertUserScoreInfo", ParDo.of(new ConvertUserScoreInfoFn()))
|
||||
.apply("ConfigUserScores", new ConfigUserScores())
|
||||
.apply("RetrieveTop100Players", new ExtractUserAndScore())
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
如代码所示,真正做出修改的地方就是将读取输入数据集的BigTableIO改成使用KafkaIO来读取。将批处理的两个Filter Transform替换成我们自定义的ConfigUserScores Transform。
|
||||
|
||||
到此为止,我们就可以“一劳永逸”,运行一个实时流处理的数据流水线来得到游戏积分排行榜结果了。
|
||||
|
||||
## 小结
|
||||
|
||||
今天我们一起设计了一个实时流处理的数据流水线,来完成之前自定义的一个简单游戏积分排行榜。
|
||||
|
||||
这里面有不少经验是值得我们在处理现实的应用场景中借鉴的。比如,我们应该考虑数据结果的完整性有多重要、我们能接受多大的延迟、我们是否接受晚来的数据集等等。
|
||||
|
||||
这些问题其实又回到了[第23讲](https://time.geekbang.org/column/article/100478)中提到过的——我们在解决现实问题时,应该回答好的“WWWH”这四个问题。
|
||||
|
||||
## 思考题
|
||||
|
||||
今天我们一起探讨了如何利用实时流处理的方式来解决游戏积分排行榜的问题,里面涉及了配置窗口,触发器和累加模式。这些配置可能还不是最优的,你觉得我们还有什么地方可以进行优化的呢?
|
||||
|
||||
欢迎你把自己的学习体会写在留言区,与我和其他同学一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user