This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,155 @@
<audio id="audio" title="40 | Kafka Streams与其他流处理平台的差异在哪里" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2f/53/2f381f477db320b11f23164dfc904f53.mp3"></audio>
你好我是胡夕。今天我要和你分享的主题是Kafka Streams与其他流处理平台的差异。
近些年来开源流处理领域涌现出了很多优秀框架。光是在Apache基金会孵化的项目关于流处理的大数据框架就有十几个之多比如早期的Apache Samza、Apache Storm以及这两年火爆的Spark以及Flink等。
应该说每个框架都有自己独特的地方也都有自己的缺陷。面对这众多的流处理框架我们应该如何选择呢今天我就来梳理几个主流的流处理平台并重点分析一下Kafka Streams与其他流处理平台的差异。
## 什么是流处理平台?
首先,我们有必要了解一下流处理平台的概念。[“Streaming Systems”](https://www.oreilly.com/library/view/streaming-systems/9781491983867/ch01.html)一书是这么定义“流处理平台”的:**流处理平台Streaming System是处理无限数据集Unbounded Dataset的数据处理引擎而流处理是与批处理Batch Processing相对应的。**
所谓的无限数据是指数据永远没有尽头。流处理平台是专门处理这种数据集的系统或框架。当然这并不是说批处理系统不能处理这种无限数据集只是通常情况下它更擅长处理有限数据集Bounded Dataset
那流处理和批处理究竟该如何区分呢?下面这张图应该能帮助你快速且直观地理解它们的区别。
<img src="https://static001.geekbang.org/resource/image/2f/b2/2f8e72ce532cf1d05306cb8b78510bb2.png" alt="">
好了,现在我来详细解释一下流处理和批处理的区别。
长期以来,流处理给人的印象通常是低延时,但是结果不准确。每来一条消息,它就能计算一次结果,但由于它处理的大多是无界数据,可能永远也不会结束,因此在流处理中,我们很难精确描述结果何时是精确的。理论上,流处理的计算结果会不断地逼近精确结果。
但是,它的竞争对手批处理则正好相反。批处理能提供准确的计算结果,但往往延时很高。
因此,业界的大神们扬长避短,将两者结合在一起使用。一方面,利用流处理快速地给出不那么精确的结果;另一方面,依托于批处理,最终实现数据一致性。这就是所谓的**Lambda架构**。
延时低是个很好的特性但如果计算结果不准确流处理是无法完全替代批处理的。所谓计算结果准确在教科书或文献中有个专属的名字叫正确性Correctness。可以这么说**目前难以实现正确性是流处理取代批处理的最大障碍**而实现正确性的基石是精确一次处理语义Exactly Once SemanticsEOS
这里的精确一次是流处理平台能提供的一类一致性保障。常见的一致性保障有三类:
- 至多一次At most once语义消息或事件对应用状态的影响最多只有一次。
- 至少一次At least once语义消息或事件对应用状态的影响最少一次。
- 精确一次Exactly once语义消息或事件对应用状态的影响有且只有一次。
注意,我这里说的都是**对应用状态的影响**。对于很多有副作用Side Effect的操作而言实现精确一次语义几乎是不可能的。举个例子假设流处理中的某个步骤是发送邮件操作当邮件发送出去后倘若后面出现问题要回滚整个流处理流程已发送的邮件是没法追回的这就是所谓的副作用。当你的流处理逻辑中存在包含副作用的操作算子时该操作算子的执行是无法保证精确一次处理的。因此我们通常只是保证这类操作对应用状态的影响精确一次罢了。后面我们会重点讨论Kafka Streams是如何实现EOS的。
我们今天讨论的流处理既包含真正的实时流处理也包含微批化Microbatch的流处理。**所谓的微批化,其实就是重复地执行批处理引擎来实现对无限数据集的处理**。典型的微批化实现平台就是**Spark Streaming**。
## Kafka Streams的特色
相比于其他流处理平台,**Kafka Streams最大的特色就是它不是一个平台**至少它不是一个具备完整功能Full-Fledged的平台比如其他框架中自带的调度器和资源管理器就是Kafka Streams不提供的。
Kafka官网明确定义Kafka Streams是一个**Java客户端库**Client Library。**你可以使用这个库来构建高伸缩性、高弹性、高容错性的分布式应用以及微服务**。
使用Kafka Streams API构建的应用就是一个普通的Java应用程序。你可以选择任何熟悉的技术或框架对其进行编译、打包、部署和上线。
在我看来这是Kafka Streams与Storm、Spark Streaming或Flink最大的区别。
Java客户端库的定位既可以说是特色也可以说是一个缺陷。目前Kafka Streams在国内推广缓慢的一个重要原因也在于此。毕竟很多公司希望它是一个功能完备的平台既能提供流处理应用API也能提供集群资源管理与调度方面的能力。所以这个定位到底是特色还是缺陷仁者见仁、智者见智吧。
## Kafka Streams与其他框架的差异
接下来我从应用部署、上下游数据源、协调方式和消息语义保障Semantic Guarantees4个方面详细分析一下Kafka Streams与其他框架的差异。
### 应用部署
首先我们从流处理应用部署方式上对Kafka Streams及其他框架进行区分。
我们刚刚提到过Kafka Streams应用需要开发人员自行打包和部署你甚至可以将Kafka Streams应用嵌入到其他Java应用中。因此作为开发者的你除了要开发代码之外还要自行管理Kafka Streams应用的生命周期要么将其打包成独立的jar包单独运行要么将流处理逻辑嵌入到微服务中开放给其他服务调用。
但不论是哪种部署方式你需要自己处理不要指望Kafka Streams帮你做这些事情。
相反地其他流处理平台则提供了完整的部署方案。我以Apache Flink为例来解释一下。在Flink中流处理应用会被建模成单个的流处理计算逻辑并封装进Flink的作业中。类似地Spark中也有作业的概念而在Storm中则叫拓扑Topology。作业的生命周期由框架来管理特别是在Flink中Flink框架自行负责管理作业包括作业的部署和更新等。这些都无需应用开发人员干预。
另外Flink这类框架都存在**资源管理器**Resource Manager的角色。一个作业所需的资源完全由框架层的资源管理器来支持。常见的资源管理器如YARN、Kubernetes、Mesos等比较新的流处理框架如Spark、Flink等都是支持的。像Spark和Flink这样的框架也支持Standalone集群的方式即不借助于任何已有的资源管理器完全由集群自己来管理资源。这些都是Kafka Streams无法提供的。
因此从应用部署方面来看Kafka Streams更倾向于将部署交给开发人员来做而不是依赖于框架自己实现。
### 上下游数据源
谈完了部署方式的差异,我们来说说连接上下游数据源方面的差异。简单来说,**Kafka Streams目前只支持从Kafka读数据以及向Kafka写数据**。在没有Kafka Connect组件的支持下Kafka Streams只能读取Kafka集群上的主题数据在完成流处理逻辑后也只能将结果写回到Kafka主题上。
反观Spark Streaming和Flink这类框架它们都集成了丰富的上下游数据源连接器Connector比如常见的连接器MySQL、ElasticSearch、HBase、HDFS、Kafka等。如果使用这些框架你可以很方便地集成这些外部框架无需二次开发。
当然由于开发Connector通常需要同时掌握流处理框架和外部框架因此在实际使用过程中Connector的质量参差不齐在具体使用的时候你可以多查查对应的**jira官网**,看看有没有明显的“坑”,然后再决定是否使用。
在这个方面我是有前车之鉴的。曾经我使用过一个Connector我发现它在读取Kafka消息向其他系统写入的时候似乎总是重复消费。费了很多周折之后我才发现这是一个已知的Bug而且早就被记录在jira官网上了。因此我推荐你多逛下jira也许能提前避开一些“坑”。
**总之目前Kafka Streams只支持与Kafka集群进行交互它没有提供开箱即用的外部数据源连接器。**
### 协调方式
在分布式协调方面Kafka Streams应用依赖于Kafka集群提供的协调功能来提供**高容错性和高伸缩性**。
**Kafka Streams应用底层使用了消费者组机制来实现任意的流处理扩缩容**。应用的每个实例或节点本质上都是相同消费者组下的独立消费者彼此互不影响。它们之间的协调工作由Kafka集群Broker上对应的协调者组件来完成。当有实例增加或退出时协调者自动感知并重新分配负载。
我画了一张图来展示每个Kafka Streams实例内部的构造从这张图中我们可以看出每个实例都由一个消费者实例、特定的流处理逻辑以及一个生产者实例组成而这些实例中的消费者实例共同构成了一个消费者组。
<img src="https://static001.geekbang.org/resource/image/4d/4d/4de6620323d74aa537127c5405bac54d.jpg" alt="">
通过这个机制Kafka Streams应用同时实现了**高伸缩性和高容错性**,而这一切都是自动提供的,不需要你手动实现。
而像Flink这样的框架它的容错性和扩展性是通过专属的主节点Master Node全局来协调控制的。
Flink支持通过ZooKeeper实现主节点的高可用性避免单点失效**某个节点出现故障会自动触发恢复操作**。**这种全局性协调模型对于流处理中的作业而言非常实用,但不太适配单独的流处理应用程序**。原因就在于它不像Kafka Streams那样轻量级应用程序必须要实现特定的API来开启检查点机制checkpointing同时还需要亲身参与到错误恢复的过程中。
应该这样说在不同的场景下Kafka Streams和Flink这种重量级的协调模型各有优劣。
### 消息语义保障
我们刚刚提到过EOS目前很多流处理框架都宣称它们实现了EOS也包括Kafka Streams本身。关于精确一次处理语义有一些地方需要澄清一下。
实际上当把Spark、Flink与Kafka结合使用时如果不使用Kafka在0.11.0.0版本引入的幂等性Producer和事务型Producer这些框架是无法实现端到端的EOS的。
因为这些框架与Kafka是相互独立的彼此之间没有任何语义保障机制。但如果使用了事务机制情况就不同了。这些外部系统利用Kafka的事务机制保障了消息从Kafka读取到计算再到写入Kafka的全流程EOS。这就是所谓的端到端精确一次处理语义。
之前Spark和Flink宣称的EOS都是在各自的框架内实现的无法实现端到端的EOS。只有使用了Kafka的事务机制它们对应的Connector才有可能支持端到端精确一次处理语义。
Spark官网上明确指出了**用户若要实现与Kafka的EOS必须自己确保幂等输出和位移保存在同一个事务中。如果你不能自己实现这套机制那么就要依赖于Kafka提供的事务机制来保证**。
而Flink在Kafka 0.11之前也宣称提供EOS不过是有前提条件的即每条消息对**Flink应用状态**的影响有且只有一次。
举个例子如果你使用Flink从Kafka读取消息然后不加任何处理直接写入到MySQL那么这个操作就是无状态的此时Flink无法保证端到端的EOS。
换句话说Flink最后写入到MySQL的Kafka消息可能有重复的。当然Flink社区自1.4版本起正式实现了端到端的EOS其基本设计思想正是基于Kafka 0.11幂等性Producer的两阶段提交机制。
两阶段提交2-Phase Commit2PC机制是一种分布式事务机制用于实现分布式系统上跨多个节点事务的原子性提交。下面这张图来自于神书“Designing Data-Intensive Applications”中关于2PC讲解的章节。它清晰地描述了一次成功2PC的过程。在这张图中两个数据库参与到分布式事务的提交过程中它们各自做了一些变更现在需要使用2PC来保证两个数据库的变更被原子性地提交。如图所示2PC被分为两个阶段Prepare阶段和Commit阶段。只有完整地执行了这两个阶段这个分布式事务才算是提交成功。
<img src="https://static001.geekbang.org/resource/image/eb/c4/eb979df60ca9a2d2bb811febbe4545c4.png" alt="">
分布式系统中的2PC常见于数据库内部实现或以XA事务的方式供各种异质系统使用。Kafka也借鉴了2PC的思想在Kafka内部实现了基于2PC的事务机制。
但是对于Kafka Streams而言情况就不同了。它天然支持端到端的EOS因为它本来就是和Kafka紧密相连的。
下图展示了一个典型的Kafka Streams应用的执行逻辑。
<img src="https://static001.geekbang.org/resource/image/3a/44/3a0cd5a5b04ea5d7c7c082ecd9d63144.jpg" alt="">
通常情况下一个Kafka Streams需要执行5个步骤
1. 读取最新处理的消息位移;
1. 读取消息数据;
1. 执行处理逻辑;
1. 将处理结果写回到Kafka
1. 保存位置信息。
这五步的执行必须是**原子性**的,否则无法实现精确一次处理语义。
在设计上Kafka Streams在底层大量使用Kafka事务机制和幂等性Producer来实现多分区的原子性写入又因为它只能读写Kafka因此Kafka Streams很容易地就实现了端到端的EOS。
总之虽然Flink自1.4版本也提供与Kafka的EOS但从适配性来考量的话应该说Kafka Streams与Kafka的适配性是最好的。
## 小结
好了我们来小结一下。今天我重点分享了Kafka Streams与其他流处理框架或平台的差异。总的来说Kafka Streams是一个轻量级的客户端库而其他流处理平台都是功能完备的流处理解决方案。这是Kafka Streams的特色所在但同时可能也是缺陷。不过我认为很多情况下我们并不需要重量级的流处理解决方案采用轻量级的库API帮助我们实现实时计算是很方便的情形我想这或许是Kafka Streams未来的破局之路吧。
在专栏后面的内容中我会详细介绍如何使用Kafka Streams API实现实时计算并跟你分享一个实际的案例希望这些能激发你对Kafka Streams的兴趣并为你以后的探索奠定基础。
<img src="https://static001.geekbang.org/resource/image/d2/0e/d2d0b922414c3776d34523946feeae0e.jpg" alt="">
## 开放讨论
知乎上有个关于Kafka Streams的“灵魂拷问”[为什么Kafka Streams没什么人用](https://www.zhihu.com/question/337923430/answer/787298849)我推荐你去看一下,并谈谈你对这个问题的理解和答案。
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。

View File

@@ -0,0 +1,259 @@
<audio id="audio" title="41 | Kafka Streams DSL开发实例" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5a/d3/5a1bc6f3e3055466d64dc0dc565db9d3.mp3"></audio>
你好我是胡夕。今天我要和你分享的主题是Kafka Streams DSL开发实例。
DSL也就是Domain Specific Language意思是领域特定语言。它提供了一组便捷的API帮助我们实现流式数据处理逻辑。今天我就来分享一些Kafka Streams中的DSL开发方法以及具体实例。
## Kafka Streams背景介绍
在上一讲中我们提到流处理平台是专门处理无限数据集的引擎。就Kafka Streams而言它仅仅是一个客户端库。所谓的Kafka Streams应用就是调用了Streams API的普通Java应用程序。只不过在Kafka Streams中流处理逻辑是用**拓扑**来表征的。
一个拓扑结构本质上是一个有向无环图DAG它由多个处理节点Node和连接节点的多条边组成如下图所示
<img src="https://static001.geekbang.org/resource/image/da/22/da3db610f959e0952a76d6b06d249c22.jpg" alt="">
图中的节点也称为处理单元或Processor它封装了具体的事件处理逻辑。Processor在其他流处理平台也被称为操作算子。常见的操作算子包括转换map、过滤filter、连接join和聚合aggregation等。后面我会详细介绍几种常见的操作算子。
大体上Kafka Streams开放了两大类API供你定义Processor逻辑。
第1类就是我刚刚提到的DSL它是声明式的函数式API使用起来感觉和SQL类似你不用操心它的底层是怎么实现的你只需要调用特定的API告诉Kafka Streams你要做什么即可。
举个简单的例子,你可以看看下面这段代码,尝试理解下它是做什么的。
```
movies.filter((title, movie) -&gt; movie.getGenre().equals(&quot;动作片&quot;)).xxx()...
```
这段代码虽然用了Java 8的Lambda表达式但从整体上来看它要做的事情应该还是很清晰的它要从所有Movie事件中过滤出影片类型是“动作片”的事件。这就是DSL声明式API的实现方式。
第2类则是命令式的低阶API称为Processor API。比起DSL这组API提供的实现方式更加灵活。你可以编写自定义的算子来实现一些DSL天然没有提供的处理逻辑。事实上DSL底层也是用Processor API实现的。
目前Kafka Streams DSL提供的API已经很丰富了基本上能够满足我们大部分的处理逻辑需求我今天重点介绍一下DSL的使用方法。
**不论是用哪组API实现所有流处理应用本质上都可以分为两类有状态的Stateful应用和无状态的Stateless应用**
有状态的应用指的是应用中使用了类似于连接、聚合或时间窗口Window的API。一旦调用了这些API你的应用就变为有状态的了也就是说你需要让Kafka Streams帮你保存应用的状态。
无状态的应用是指在这类应用中,某条消息的处理结果不会影响或依赖其他消息的处理。常见的无状态操作包括事件转换以及刚刚那个例子中的过滤等。
## 关键概念
了解了这些背景之后,你还需要掌握一些流处理领域内的关键概念,即流、表以及流表二元性,还有时间和时间窗口。
### 流表二元性
首先,我来介绍一下流处理中流和表的概念,以及它们之间的关系。
流就是一个永不停止(至少理论上是这样的)的事件序列,而表和关系型数据库中的概念类似,是一组行记录。在流处理领域,两者是有机统一的:**流在时间维度上聚合之后形成表表在时间维度上不断更新形成流这就是所谓的流表二元性Duality of Streams and Tables**。流表二元性在流处理领域内的应用是Kafka框架赖以成功的重要原因之一。
下面这张图展示了表转换成流,流再转换成表的全过程。
<img src="https://static001.geekbang.org/resource/image/3f/ee/3f056c4ba024d7169df804e161a37bee.jpg" alt="">
刚开始时表中只有一条记录“张三1”。将该条记录转成流变成了一条事件。接着表增加了新记录“李四1”。针对这个变更流中也增加了对应的新事件。之后表中张三的对应值从1更新为2流也增加了相应的更新事件。最后表中添加了新数据“王五1”流也增加了新记录。至此表转换成流的工作就完成了。
从这个过程中我们可以看出流可以看作是表的变更事件日志Changelog。与之相反的是流转换成表的过程可以说是这个过程的逆过程我们为流中的每条事件打一个快照Snapshot就形成了表。
流和表的概念在流处理领域非常关键。在Kafka Streams DSL中流用**KStream**表示,而表用**KTable**表示。
Kafka Streams还定义了**GlobalKTable**。本质上它和KTable都表征了一个表里面封装了事件变更流但是它和KTable的最大不同在于当Streams应用程序读取Kafka主题数据到GlobalKTable时它会读取主题**所有分区的数据**而对KTable而言Streams程序实例只会读取**部分分区的数据**这主要取决于Streams实例的数量。
### 时间
在流处理领域内,精确定义事件时间是非常关键的:一方面,它是决定流处理应用能否实现正确性的前提;另一方面,流处理中时间窗口等操作依赖于时间概念才能正常工作。
常见的时间概念有两类事件发生时间Event Time和事件处理时间Processing Time。理想情况下我们希望这两个时间相等即事件一旦发生就马上被处理但在实际场景中这是不可能的**Processing Time永远滞后于Event Time**而且滞后程度又是一个高度变化无法预知就像“Streaming Systems”一书中的这张图片所展示的那样
<img src="https://static001.geekbang.org/resource/image/ab/92/ab8e5206729b484b0a16f2bb9de3a792.png" alt="">
该图中的45°虚线刻画的是理想状态即Event Time等于Processing Time而粉色的曲线表征的是真实情况即Processing Time落后于Event Time而且落后的程度Lag不断变化毫无规律。
如果流处理应用要实现结果的正确性就必须要使用基于Event Time的时间窗口而不能使用基于Processing Time的时间窗口。
### 时间窗口
所谓的时间窗口机制就是将流数据沿着时间线切分的过程。常见的时间窗口包括固定时间窗口Fixed Windows、滑动时间窗口Sliding Windows和会话窗口Session Windows。Kafka Streams同时支持这三类时间窗口。在后面的例子中我会详细介绍如何使用Kafka Streams API实现时间窗口功能。
## 运行WordCount实例
好了关于Kafka Streams及其DSL的基本概念我都阐述完了下面我给出大数据处理领域的Hello World实例WordCount程序。
每个大数据处理框架第一个要实现的程序基本上都是单词计数。我们来看下Kafka Streams DSL如何实现WordCount。我先给出完整代码稍后我会详细介绍关键部分代码的含义以及运行它的方法。
```
package kafkalearn.demo.wordcount;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.common.serialization.Serdes;
import org.apache.kafka.streams.KafkaStreams;
import org.apache.kafka.streams.StreamsBuilder;
import org.apache.kafka.streams.StreamsConfig;
import org.apache.kafka.streams.kstream.KStream;
import org.apache.kafka.streams.kstream.KTable;
import org.apache.kafka.streams.kstream.Produced;
import java.util.Arrays;
import java.util.Locale;
import java.util.Properties;
import java.util.concurrent.CountDownLatch;
public final class WordCountDemo {
public static void main(final String[] args) {
final Properties props = new Properties();
props.put(StreamsConfig.APPLICATION_ID_CONFIG, &quot;wordcount-stream-demo&quot;);
props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, &quot;localhost:9092&quot;);
props.put(StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG, Serdes.String().getClass().getName());
props.put(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG, Serdes.String().getClass().getName());
props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, &quot;earliest&quot;);
final StreamsBuilder builder = new StreamsBuilder();
final KStream&lt;String, String&gt; source = builder.stream(&quot;wordcount-input-topic&quot;);
final KTable&lt;String, Long&gt; counts = source
.flatMapValues(value -&gt; Arrays.asList(value.toLowerCase(Locale.getDefault()).split(&quot; &quot;)))
.groupBy((key, value) -&gt; value)
.count();
counts.toStream().to(&quot;wordcount-output-topic&quot;, Produced.with(Serdes.String(), Serdes.Long()));
final KafkaStreams streams = new KafkaStreams(builder.build(), props);
final CountDownLatch latch = new CountDownLatch(1);
Runtime.getRuntime().addShutdownHook(new Thread(&quot;wordcount-stream-demo-jvm-hook&quot;) {
@Override
public void run() {
streams.close();
latch.countDown();
}
});
try {
streams.start();
latch.await();
} catch (final Throwable e) {
System.exit(1);
}
System.exit(0)
```
在程序开头我构造了一个Properties对象实例对Kafka Streams程序的关键参数进行了赋值比如application id、bootstrap servers和默认的KV序列化器Serializer和反序列化器Deserializer。其中application id是Kafka Streams应用的唯一标识必须要显式地指定。默认的KV序列化器、反序列化器是为消息的Key和Value进行序列化和反序列化操作的。
接着我构造了一个StreamsBuilder对象并使用该对象实例创建了一个KStream这个KStream从名为wordcount-input-topic的Kafka主题读取消息。该主题消息由一组单词组成单词间用空格分割比如zhangsan lisi wangwu。
由于我们要进行单词计数所以就需要将消息中的单词提取出来。有了前面的概念介绍你应该可以猜到KTable是很合适的存储结构因此下一步就是将刚才的这个KStream转换成KTable。
我们先对单词进行分割这里我用到了flatMapValues方法代码中的Lambda表达式实现了从消息中提取单词的逻辑。由于String.split()方法会返回多个单词因此我们使用flatMapValues而不是mapValues。原因是前者能够将多个元素“打散”成一组单词而如果使用后者我们得到的就不是一组单词而是多组单词了。
这些都做完之后程序调用groupBy方法对单词进行分组。由于是计数相同的单词必须被分到一起然后就是调用count方法对每个出现的单词进行统计计数并保存在名为counts的KTable对象中。
最后我们将统计结果写回到Kafka中。由于KTable是表是静态的数据因此这里要先将其转换成KStream然后再调用to方法写入到名为wordcount-output-topic的主题中。此时counts中事件的Key是单词而Value是统计个数因此我们在调用to方法时同时指定了Key和Value的序列化器分别是字符串序列化器和长整型序列化器。
至此Kafka Streams的流计算逻辑就编写完了接下来就是构造KafkaStreams实例并启动它了。通常来说这部分的代码都是类似的即调用start方法启动整个流处理应用以及配置一个JVM关闭钩子Shutdown Hook实现流处理应用的关闭等。
总体来说Kafka Streams DSL实现WordCount的方式还是很简单的仅仅调用几个操作算子就轻松地实现了分布式的单词计数实时处理功能。事实上现在主流的实时流处理框架越来越倾向于这样的设计思路即通过提供丰富而便捷的开箱即用操作算子简化用户的开发成本采用类似于搭积木的方式快捷地构建实时计算应用。
待启动该Java程序之后你需要创建出对应的输入和输出主题并向输入主题不断地写入符合刚才所说的格式的单词行之后你需要运行下面的命令去查看输出主题中是否正确地统计了你刚才输入的单词个数
```
bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 \
--topic wordcount-output-topic \
--from-beginning \
--formatter kafka.tools.DefaultMessageFormatter \
--property print.key=true \
--property print.value=true \
--property key.deserializer=org.apache.kafka.common.serialization.StringDeserializer \
--property value.deserializer=org.apache.kafka.common.serialization.LongDeserializer
```
## 开发API
介绍了具体的例子之后我们来看下Kafka Streams还提供了哪些功能强大的API。我们可以重点关注两个方面一个是常见的操作算子另一个是时间窗口API。
### 常见操作算子
操作算子的丰富程度和易用性是衡量流处理框架受欢迎程度的重要依据之一。Kafka Streams DSL提供了很多开箱即用的操作算子大体上分为两大类**无状态算子和有状态算子**。下面我就向你分别介绍几个经常使用的算子。
**在无状态算子中filter的出场率是极高的**。它执行的就是过滤的逻辑。依然拿WordCount为例假设我们只想统计那些以字母s开头的单词的个数我们可以在执行完flatMapValues后增加一行代码代码如下
```
.filter(((key, value) -&gt; value.startsWith(&quot;s&quot;)))
```
**另一个常见的无状态算子当属map一族了**。Streams DSL提供了很多变体比如map、mapValues、flatMap和flatMapValues。我们已经见识了flatMapValues的威力其他三个的功能也是类似的只是所有带Values的变体都只对消息体执行转换不触及消息的Key而不带Values的变体则能修改消息的Key。
举个例子假设当前消息没有Key而Value是单词本身现在我们想要将消息变更成这样的KV对即Key是单词小写而Value是单词长度那么我们可以调用map方法代码如下
```
KStream&lt;String, Integer&gt; transformed = stream.map(
(key, value) -&gt; KeyValue.pair(value.toLowerCase(), value.length()));
```
最后,我再介绍一组调试用的无状态算子:**print和peek**。Streams DSL支持你使用这两个方法查看你的消息流中的事件。这两者的区别在于print是终止操作一旦你调用了print方法后面就不能再调用任何其他方法了而peek则允许你在查看消息流的同时依然能够继续对其进行处理比如下面这两段代码所示
```
stream.print(Printed.toFile(&quot;streams.out&quot;).withLabel(&quot;debug&quot;));
```
```
stream.peek((key, value) -&gt; System.out.println(&quot;key=&quot; + key + &quot;, value=&quot; + value)).map(...);
```
常见的有状态操作算子主要涉及聚合Aggregation方面的操作比如计数、求和、求平均值、求最大最小值等。Streams DSL目前只提供了count方法用于计数其他的聚合操作需要你自行使用API实现。
假设我们有个消息流每条事件就是一个单独的整数现在我们想要对其中的偶数进行求和那么Streams DSL中的实现方法如下
```
final KTable&lt;Integer, Integer&gt; sumOfEvenNumbers = input
.filter((k, v) -&gt; v % 2 == 0)
.selectKey((k, v) -&gt; 1)
.groupByKey()
.reduce((v1, v2) -&gt; v1 + v2);
```
我简单解释一下selectKey调用。由于我们要对所有事件中的偶数进行求和因此需要把这些消息的Key都调整成相同的值因此这里我使用selectKey指定了一个Dummy Key值即上面这段代码中的数值1。它没有任何含义仅仅是让所有消息都赋值上这个Key而已。真正核心的代码在于reduce调用它是执行求和的关键逻辑。
### 时间窗口实例
前面说过Streams DSL支持3类时间窗口。前两类窗口通过**TimeWindows.of方法**来实现,会话窗口通过**SessionWindows.with**来实现。
假设在刚才的WordCount实例中我们想每一分钟统计一次单词计数那么需要在调用count之前增加下面这行代码
```
.windowedBy(TimeWindows.of(Duration.ofMinutes(1)))
```
同时你还需要修改counts的类型此时它不再是KTable&lt;String, Long&gt;而变成了KTable&lt;Windowed<string>, Long&gt;因为引入了时间窗口所以事件的Key也必须要携带时间窗口的信息。除了这两点变化WordCount其他部分代码都不需要修改。</string>
可见Streams DSL在API封装性方面还是做得很好的通常你只需要增加或删减几行代码就能实现处理逻辑的修改了。
## 小结
好了我们来小结一下。今天我跟你分享了Kafka Streams以及DSL的背景与概念然后我根据实例展示了WordCount单词计数程序以及运行方法最后针对常见的操作算子和时间窗口我给出了示例代码这些内容应该可以帮你应对大部分的流处理开发。另外我建议你经常性地查询一下[官网文档](https://kafka.apache.org/23/documentation/streams/developer-guide/dsl-api.html#aggregating),去学习一些更深入更高级的用法,比如固定时间窗口的用法。在很多场景中,我们都想知道过去一段时间内企业某个关键指标的值是多少,如果要实现这个需求,时间窗口是必然要涉及到的。
<img src="https://static001.geekbang.org/resource/image/dc/4d/dcc10168b7e3fdc43a47effdba45304d.jpg" alt="">
## 开放讨论
今天给出的WordCount例子没有调用时间窗口API我们统计的是每个单词的总数。如果现在我们想统计每5分钟内单词出现的次数应该加一行什么代码呢
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。

View File

@@ -0,0 +1,220 @@
<audio id="audio" title="42 | Kafka Streams在金融领域的应用" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/78/ca/78cfd5d0d28acda9d69fb6fe154ae0ca.mp3"></audio>
你好我是胡夕。今天我要和你分享的主题是Kafka Streams在金融领域的应用。
## 背景
金融领域囊括的内容有很多我今天分享的主要是如何利用大数据技术特别是Kafka Streams实时计算框架来帮助我们更好地做企业用户洞察。
众所周知金融领域内的获客成本是相当高的一线城市高净值白领的获客成本通常可达上千元。面对如此巨大的成本压力金融企业一方面要降低广告投放的获客成本另一方面要做好精细化运营实现客户生命周期内价值Custom Lifecycle Value, CLV的最大化。
**实现价值最大化的一个重要途径就是做好用户洞察,而用户洞察要求你要更深度地了解你的客户**即所谓的Know Your CustomerKYC真正做到以客户为中心不断地满足客户需求。
为了实现KYC传统的做法是花费大量的时间与客户见面做面对面的沟通以了解客户的情况。但是用这种方式得到的数据往往是不真实的毕竟客户内心是有潜在的自我保护意识的短时间内的面对面交流很难真正洞察到客户的真实诉求。
相反地渗透到每个人日常生活方方面面的大数据信息则代表了客户的实际需求。比如客户经常浏览哪些网站、都买过什么东西、最喜欢的视频类型是什么。这些数据看似很随意但都表征了客户最真实的想法。将这些数据汇总在一起我们就能完整地构造出客户的画像这就是所谓的用户画像User Profile技术。
## 用户画像
用户画像听起来很玄妙但实际上你应该是很熟悉的。你的很多基本信息比如性别、年龄、所属行业、工资收入和爱好等都是用户画像的一部分。举个例子我们可以这样描述一个人某某某男性28岁未婚工资水平大致在15000到20000元之间是一名大数据开发工程师居住在北京天通苑小区平时加班很多喜欢动漫或游戏。
其实这一连串的描述就是典型的用户画像。通俗点来说构建用户画像的核心工作就是给客户或用户打标签Tagging。刚刚那一连串的描述就是用户系统中的典型标签。用户画像系统通过打标签的形式把客户提供给业务人员从而实现精准营销。
## ID映射ID Mapping
用户画像的好处不言而喻而且标签打得越多越丰富就越能精确地表征一个人的方方面面。不过在打一个个具体的标签之前弄清楚“你是谁”是所有用户画像系统首要考虑的问题这个问题也被称为ID识别问题。
所谓的ID即Identification表示用户身份。在网络上能够标识用户身份信息的常见ID有5种。
- 身份证号这是最能表征身份的ID信息每个身份证号只会对应一个人。
- 手机号:手机号通常能较好地表征身份。虽然会出现同一个人有多个手机号或一个手机号在不同时期被多个人使用的情形,但大部分互联网应用使用手机号表征用户身份的做法是很流行的。
- 设备ID在移动互联网时代这主要是指手机的设备ID或Mac、iPad等移动终端设备的设备ID。特别是手机的设备ID在很多场景下具备定位和识别用户的功能。常见的设备ID有iOS端的IDFA和Android端的IMEI。
- 应用注册账号这属于比较弱的一类ID。每个人在不同的应用上可能会注册不同的账号但依然有很多人使用通用的注册账号名称因此具有一定的关联性和识别性。
- Cookie在PC时代浏览器端的Cookie信息是很重要的数据它是网络上表征用户信息的重要手段之一。只不过随着移动互联网时代的来临Cookie早已江河日下如今作为ID数据的价值也越来越小了。我个人甚至认为在构建基于移动互联网的新一代用户画像时Cookie可能要被抛弃了。
在构建用户画像系统时我们会从多个数据源上源源不断地收集各种个人用户数据。通常情况下这些数据不会全部携带以上这些ID信息。比如在读取浏览器的浏览历史时你获取的是Cookie数据而读取用户在某个App上的访问行为数据时你拿到的是用户的设备ID和注册账号信息。
倘若这些数据表征的都是一个用户的信息我们的用户画像系统如何识别出来呢换句话说你需要一种手段或技术帮你做各个ID的打通或映射。这就是用户画像领域的ID映射问题。
## 实时ID Mapping
我举个简单的例子。假设有一个金融理财用户张三他首先在苹果手机上访问了某理财产品然后在安卓手机上注册了该理财产品的账号最后在电脑上登录该账号并购买了该理财产品。ID Mapping 就是要将这些不同端或设备上的用户信息聚合起来然后找出并打通用户所关联的所有ID信息。
实时ID Mapping的要求就更高了它要求我们能够实时地分析从各个设备收集来的数据并在很短的时间内完成ID Mapping。打通用户ID身份的时间越短我们就能越快地为其打上更多的标签从而让用户画像发挥更大的价值。
从实时计算或流处理的角度来看实时ID Mapping能够转换成一个**流-表连接问题**Stream-Table Join即我们实时地将一个流和一个表进行连接。
消息流中的每个事件或每条消息包含的是一个未知用户的某种信息它可以是用户在页面的访问记录数据也可以是用户的购买行为数据。这些消息中可能会包含我们刚才提到的若干种ID信息比如页面访问信息中可能包含设备ID也可能包含注册账号而购买行为信息中可能包含身份证信息和手机号等。
连接的另一方表保存的是**用户所有的ID信息**随着连接的不断深入表中保存的ID品类会越来越丰富也就是说流中的数据会被不断地补充进表中最终实现对用户所有ID的打通。
## Kafka Streams实现
好了现在我们就来看看如何使用Kafka Streams来实现一个特定场景下的实时ID Mapping。为了方便理解我们假设ID Mapping只关心身份证号、手机号以及设备ID。下面是用Avro写成的Schema格式
```
{
&quot;namespace&quot;: &quot;kafkalearn.userprofile.idmapping&quot;,
&quot;type&quot;: &quot;record&quot;,
&quot;name&quot;: &quot;IDMapping&quot;,
&quot;fields&quot;: [
{&quot;name&quot;: &quot;deviceId&quot;, &quot;type&quot;: &quot;string&quot;},
{&quot;name&quot;: &quot;idCard&quot;, &quot;type&quot;: &quot;string&quot;},
{&quot;name&quot;: &quot;phone&quot;, &quot;type&quot;: &quot;string&quot;}
]
}
```
顺便说一下,**Avro是Java或大数据生态圈常用的序列化编码机制**比如直接使用JSON或XML保存对象。Avro能极大地节省磁盘占用空间或网络I/O传输量因此普遍应用于大数据量下的数据传输。
在这个场景下我们需要两个Kafka主题一个用于构造表另一个用于构建流。这两个主题的消息格式都是上面的IDMapping对象。
新用户在填写手机号注册App时会向第一个主题发送一条消息该用户后续在App上的所有访问记录也都会以消息的形式发送到第二个主题。值得注意的是发送到第二个主题上的消息有可能携带其他的ID信息比如手机号或设备ID等。就像我刚刚所说的这是一个典型的流-表实时连接场景连接之后我们就能够将用户的所有数据补齐实现ID Mapping的打通。
基于这个设计思路我先给出完整的Kafka Streams代码稍后我会对重点部分进行详细解释
```
package kafkalearn.userprofile.idmapping;
// omit imports……
public class IDMappingStreams {
public static void main(String[] args) throws Exception {
if (args.length &lt; 1) {
throw new IllegalArgumentException(&quot;Must specify the path for a configuration file.&quot;);
}
IDMappingStreams instance = new IDMappingStreams();
Properties envProps = instance.loadProperties(args[0]);
Properties streamProps = instance.buildStreamsProperties(envProps);
Topology topology = instance.buildTopology(envProps);
instance.createTopics(envProps);
final KafkaStreams streams = new KafkaStreams(topology, streamProps);
final CountDownLatch latch = new CountDownLatch(1);
// Attach shutdown handler to catch Control-C.
Runtime.getRuntime().addShutdownHook(new Thread(&quot;streams-shutdown-hook&quot;) {
@Override
public void run() {
streams.close();
latch.countDown();
}
});
try {
streams.start();
latch.await();
} catch (Throwable e) {
System.exit(1);
}
System.exit(0);
}
private Properties loadProperties(String propertyFilePath) throws IOException {
Properties envProps = new Properties();
try (FileInputStream input = new FileInputStream(propertyFilePath)) {
envProps.load(input);
return envProps;
}
}
private Properties buildStreamsProperties(Properties envProps) {
Properties props = new Properties();
props.put(StreamsConfig.APPLICATION_ID_CONFIG, envProps.getProperty(&quot;application.id&quot;));
props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, envProps.getProperty(&quot;bootstrap.servers&quot;));
props.put(StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG, Serdes.String().getClass());
props.put(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG, Serdes.String().getClass());
return props;
}
private void createTopics(Properties envProps) {
Map&lt;String, Object&gt; config = new HashMap&lt;&gt;();
config.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, envProps.getProperty(&quot;bootstrap.servers&quot;));
try (AdminClient client = AdminClient.create(config)) {
List&lt;NewTopic&gt; topics = new ArrayList&lt;&gt;();
topics.add(new NewTopic(
envProps.getProperty(&quot;stream.topic.name&quot;),
Integer.parseInt(envProps.getProperty(&quot;stream.topic.partitions&quot;)),
Short.parseShort(envProps.getProperty(&quot;stream.topic.replication.factor&quot;))));
topics.add(new NewTopic(
envProps.getProperty(&quot;table.topic.name&quot;),
Integer.parseInt(envProps.getProperty(&quot;table.topic.partitions&quot;)),
Short.parseShort(envProps.getProperty(&quot;table.topic.replication.factor&quot;))));
client.createTopics(topics);
}
}
private Topology buildTopology(Properties envProps) {
final StreamsBuilder builder = new StreamsBuilder();
final String streamTopic = envProps.getProperty(&quot;stream.topic.name&quot;);
final String rekeyedTopic = envProps.getProperty(&quot;rekeyed.topic.name&quot;);
final String tableTopic = envProps.getProperty(&quot;table.topic.name&quot;);
final String outputTopic = envProps.getProperty(&quot;output.topic.name&quot;);
final Gson gson = new Gson();
// 1. 构造表
KStream&lt;String, IDMapping&gt; rekeyed = builder.&lt;String, String&gt;stream(tableTopic)
.mapValues(json -&gt; gson.fromJson(json, IDMapping.class))
.filter((noKey, idMapping) -&gt; !Objects.isNull(idMapping.getPhone()))
.map((noKey, idMapping) -&gt; new KeyValue&lt;&gt;(idMapping.getPhone(), idMapping));
rekeyed.to(rekeyedTopic);
KTable&lt;String, IDMapping&gt; table = builder.table(rekeyedTopic);
// 2. 流-表连接
KStream&lt;String, String&gt; joinedStream = builder.&lt;String, String&gt;stream(streamTopic)
.mapValues(json -&gt; gson.fromJson(json, IDMapping.class))
.map((noKey, idMapping) -&gt; new KeyValue&lt;&gt;(idMapping.getPhone(), idMapping))
.leftJoin(table, (value1, value2) -&gt; IDMapping.newBuilder()
.setPhone(value2.getPhone() == null ? value1.getPhone() : value2.getPhone())
.setDeviceId(value2.getDeviceId() == null ? value1.getDeviceId() : value2.getDeviceId())
.setIdCard(value2.getIdCard() == null ? value1.getIdCard() : value2.getIdCard())
.build())
.mapValues(v -&gt; gson.toJson(v));
joinedStream.to(outputTopic);
return builder.build();
}
}
```
这个Java类代码中最重要的方法是**buildTopology函数**它构造了我们打通ID Mapping的所有逻辑。
在该方法中我们首先构造了StreamsBuilder对象实例这是构造任何Kafka Streams应用的第一步。之后我们读取配置文件获取了要读写的所有Kafka主题名。在这个例子中我们需要用到4个主题它们的作用如下
- streamTopic保存用户登录App后发生的各种行为数据格式是IDMapping对象的JSON串。你可能会问前面不是都创建Avro Schema文件了吗怎么这里又用回JSON了呢原因是这样的社区版的Kafka没有提供Avro的序列化/反序列化类支持如果我要使用Avro必须改用Confluent公司提供的Kafka但这会偏离我们专栏想要介绍Apache Kafka的初衷。所以我还是使用JSON进行说明。这里我只是用了Avro Code Generator帮我们提供IDMapping对象各个字段的set/get方法你使用Lombok也是可以的。
- rekeyedTopic这个主题是一个中间主题它将streamTopic中的手机号提取出来作为消息的Key同时维持消息体不变。
- tableTopic保存用户注册App时填写的手机号。我们要使用这个主题构造连接时要用到的表数据。
- outputTopic保存连接后的输出信息即打通了用户所有ID数据的IDMapping对象将其转换成JSON后输出。
buildTopology的第一步是构造表即KTable对象。我们修改初始的消息流以用户注册的手机号作为Key构造了一个中间流之后将这个流写入到rekeyedTopic最后直接使用builder.table方法构造出KTable。这样每当有新用户注册时该KTable都会新增一条数据。
有了表之后我们继续构造消息流来封装用户登录App之后的行为数据我们同样提取出手机号作为要连接的Key之后使用KStream的**leftJoin方法**将其与上一步的KTable对象进行关联。
在关联的过程中我们同时提取两边的信息尽可能地补充到最后生成的IDMapping对象中然后将这个生成的IDMapping实例返回到新生成的流中。最后我们将它写入到outputTopic中保存。
至此我们使用了不到200行的Java代码就简单实现了一个真实场景下的实时ID Mapping任务。理论上你可以将这个例子继续扩充扩展到任意多个ID Mapping甚至是含有其他标签的数据连接原理是相通的。在我自己的项目中我借助于Kafka Streams帮助我实现了用户画像系统的部分功能而ID Mapping就是其中的一个。
## 小结
好了我们小结一下。今天我展示了Kafka Streams在金融领域的一个应用案例重点演示了如何利用连接函数来实时关联流和表。其实Kafka Streams提供的功能远不止这些我推荐你阅读一下[官网](https://kafka.apache.org/23/documentation/streams/developer-guide/)的教程然后把自己的一些轻量级的实时计算线上任务改为使用Kafka Streams来实现。
<img src="https://static001.geekbang.org/resource/image/75/e7/75df06c2b75c3886ca3496a774730de7.jpg" alt="">
## 开放讨论
最后我们来讨论一个问题。在刚刚的这个例子中你觉得我为什么使用leftJoin方法而不是join方法呢小提示可以对比一下SQL中的left join和inner join。
欢迎写下你的思考和答案,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。