使用Kafka Streams和Quarkus构建数据流管道
在典型的数据仓库系统中,数据首先被积累,然后被处理。但随着新技术的出现,现在可以在数据到达时对其进行处理。我们称之为实时数据处理。在实时处理中,通过管道的数据流,即从一个系统移动到另一个系统。数据从静态源(如数据库)或实时系统(如事务应用程序)生成,然后过滤、转换并最终存储在数据库中,或推送到其他几个系统进行进一步处理。然后其他系统可以遵循相同的循环,即过滤、转换、存储或推送到其他系统。
在本文中,我们将构建一个Quarkus应用程序,该应用程序使用Kafka流实时流化和处理数据。通过这个示例,您将了解如何应用Kafka概念,如连接、窗口、处理器、状态存储、标点符号和交互式查询。在本文结束时,您将拥有Quarkus中真实数据流管道的体系结构。
传统的消息传递系统
作为开发人员,我们的任务是更新最初使用关系数据库和传统消息代理构建的消息处理系统。以下是消息传递系统的数据流:
1.来自两个不同系统的数据到达两个不同的消息队列。一个队列中的每个记录在另一个队列中都有相应的记录。每个记录都有一个唯一的键。
当数据记录到达其中一个消息队列时,系统使用该记录的唯一键来确定数据库是否已经有该记录的条目。如果找不到具有该唯一键的记录,系统会将该记录插入数据库进行处理。
2.如果相同的数据记录在几秒钟内到达第二个队列,应用程序将触发相同的逻辑。它检查数据库中是否存在具有相同关键字的记录。如果记录存在,应用程序将检索数据并处理这两个数据对象。
3.如果数据记录在到达第一个队列后的50秒内没有到达第二个队列,那么另一个应用程序将处理数据库中的记录。
4.正如您可能想象的那样,在数据流出现之前,这个场景工作得很好,但在今天却不太好。
数据流管道
我们的任务是建立一个新的消息系统,用Kafka执行数据流操作。这种类型的应用程序能够实时处理数据,并且无需为未处理的记录维护数据库。图1说明了新应用程序的数据流:
图1:数据流管道的体系结构。
在下一节中,我们将介绍在Quarkus中使用Kafka流构建数据流管道的过程。您可以从本文的GitHub存储库获得完整的源代码。在开始编写体系结构之前,让我们讨论一下Kafka流中的连接和窗口。
Kafka流中的连接和窗口
Kafka允许你加入两个不同主题的记录。您可能熟悉关系数据库中的联接概念,其中的数据是静态的,可以在两个表中使用。在Kafka中,连接的工作方式不同,因为数据总是流式传输的。
稍后我们将讨论连接的类型,但首先要注意的是,连接发生在一段时间内收集的数据上。kafka称这种类型的收集窗口。kafka有各种类型的窗户。对于我们的示例,我们将使用翻滚窗口。
内部连接
现在,让我们考虑一下内部连接是如何工作的。假设两个独立的数据流到达两个不同的Kafka主题,我们称之为左主题和右主题。到达一个主题的记录有另一个也到达另一个主题的相关记录(具有相同的键但值不同)。第二条记录在短暂的时间延迟后到达。如图2所示,我们为每个主题创建一个Kafka流。
两个主题的内部连接图。
图2:内部连接的示意图。
左流和右流上的内部连接将创建一个新的数据流。当它在左流和右流上找到匹配的记录(具有相同的键)时,Kafka在新流中的时间t2发出一个新记录。因为B记录没有在指定的时间窗口内到达正确的流,所以Kafka流不会为B发出新的记录。
外部连接
接下来,让我们看看外连接是如何工作的。图3显示了我们示例中外部连接的数据流:
外部连接的示意图。
图3:外部连接的示意图。
如果在Kafka流中连接两个流时不使用“groupby”子句,那么连接操作将发出三条记录。Kafka中的流不会等待整个窗口;相反,只要外部连接的条件为真,它们就会开始发出记录。因此,当左流上的记录A在时间t1到达时,join操作立即发出一个新记录。在时间t2,outerjoin Kafka流从右流接收数据。join操作立即发出另一条记录,其中包含来自左记录和右记录的值。
如果在这些Kafka流上使用groupBy和reduce函数,您将看到不同的输出。在这种情况下,流将等待窗口完成持续时间,执行连接,然后发出数据,如图3所示。
了解Kafka流中内部和外部连接的工作方式有助于我们找到实现所需数据流的最佳方法。在这种情况下,很明显我们需要执行一个外部连接。这种类型的连接允许我们检索出现在左侧和右侧主题中的记录,以及只出现在其中一个主题中的记录。
有了这个背景,让我们开始构建基于Kafka的数据流管道。
注意:我们可以使用Quarkus extensions for Spring Web和Spring DI(依赖注入)使用基于Spring的注释以Spring引导样式进行编码。
步骤1:执行外部连接
要执行外部联接,首先创建一个名为KafkaStreaming的类,然后添加函数startStreamStreamOuterJoin():
@RestController
public class KafkaStreaming {
private KafkaStreams streamsOuterJoin;
private final String LEFT_STREAM_TOPIC = "left-stream-topic";
private final String RIGHT_STREAM_TOPIC = "right-stream-topic";
private final String OUTER_JOIN_STREAM_OUT_TOPIC = "stream-stream-outerjoin";
private final String PROCESSED_STREAM_OUT_TOPIC = "processed-topic";
private final String KAFKA_APP_ID = "outerjoin";
private final String KAFKA_SERVER_NAME = "localhost:9092";
@RequestMapping("/startstream/")
public void startStreamStreamOuterJoin() {
Properties props = new Properties();
props.put(StreamsConfig.APPLICATION_ID_CONFIG, KAFKA_APP_ID);
props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, KAFKA_SERVER_NAME);
props.put(StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG, Serdes.String().getClass());
props.put(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG, Serdes.String().getClass());
final StreamsBuilder builder = new StreamsBuilder();
KStream<String, String> leftSource = builder.stream(LEFT_STREAM_TOPIC);
KStream<String, String> rightSource = builder.stream(RIGHT_STREAM_TOPIC);
// TODO 1 - Add state store
// do the outer join
// change the value to be a mix of both streams value
// have a moving window of 5 seconds
// output the last value received for a specific key during the window
// push the data to OUTER_JOIN_STREAM_OUT_TOPIC topic
leftSource.outerJoin(rightSource,
(leftValue, rightValue) -> "left=" + leftValue + ", right=" + rightValue,
JoinWindows.of(Duration.ofSeconds(5)))
.groupByKey()
.reduce(((key, lastValue) -> lastValue))
.toStream()
.to(OUTER_JOIN_STREAM_OUT_TOPIC);
// build the streams topology
final Topology topology = builder.build();
// TODO - 2: Add processor code later
streamsOuterJoin = new KafkaStreams(topology, props);
streamsOuterJoin.start();
}
}
当我们进行连接时,我们会创建一个新的值来组合左右主题中的数据。如果左侧或右侧主题中缺少任何带键的记录,则新值将使用字符串null作为缺少记录的值。另外,Kafka Stream reduce函数返回所有键的最后一个聚合值。
注意:TODO 1-Add state store和TODO-2:Add processor code later注释是我们将在下一节中添加的代码的占位符。
到目前为止的数据流
图4说明了以下数据流:
当键为a且值为V1的记录在时间t1进入左流时,Kafka流应用外部连接操作。此时,应用程序将创建一个新记录,其键为a,值left=V1,right=null。
当键为a且值为V2的记录到达正确的主题时,Kafka Streams再次应用外部连接操作。这将创建一个键为a的新记录,其值left=V1,right=V2。
在持续时间窗口结束时计算reduce函数时,Kafka Streams API会根据唯一记录键发出最后计算的值。在这种情况下,它向新的流中发出一个带有键a和值left=V1,right=V2的记录。
新的流将记录推送到outerjoin主题。
接下来,我们将添加状态存储和处理器代码。
步骤2:添加Kafka流处理器
我们需要处理由outerjoin操作推送到outerjoin主题的记录。Kafka Streams提供了一个处理器API,我们可以使用它编写用于记录处理的自定义逻辑。首先,我们定义一个自定义处理器DataProcessor,并将其添加到KafkaStreaming类中的streams拓扑中:
public class DataProcessor implements Processor<String, String>{
private ProcessorContext context;
@Override
public void init(ProcessorContext context) {
this.context = context;
}
@Override
public void process(String key, String value) {
if(value.contains("null")) {
// TODO 3: - let's process later
} else {
processRecord(key, value);
//forward the processed data to processed-topic topic
context.forward(key, value);
}
context.commit();
}
@Override
public void close() {
}
private void processRecord (String key, String value) {
// your own custom logic. I just print
System.out.println("==== Record Processed ==== key: "+key+" and value: "+value);
}
}
将处理该记录,如果该值不包含空字符串,则将其转发到接收器主题(即已处理的主题)。在下面KafkaStreaming类的粗体部分中,我们连接拓扑以定义源主题(即outerjoin主题),添加处理器,最后添加接收器(即处理过的主题)。完成后,我们可以将这段代码添加到KafkaStreaming类后面的TODO-2:添加处理器代码部分:
// add another stream that reads data from OUTER_JOIN_STREAM_OUT_TOPIC topic
topology.addSource("Source", OUTER_JOIN_STREAM_OUT_TOPIC);
// add a processor to the stream so that each record is processed
topology.addProcessor("StateProcessor",
new ProcessorSupplier<String, String>()
{ public Processor<String, String> get() {
return new DataProcessor();
}},
"Source");
topology.addSink("Sink", PROCESSED_STREAM_OUT_TOPIC, "StateProcessor");
请注意,我们所做的只是定义源主题(outerjoin主题),添加自定义处理器类的实例,然后添加sink主题(processed主题)。这个上下文转发()方法将记录发送到接收器主题。
图5显示了我们迄今为止构建的体系结构。
图5:添加了Kafka流处理器的体系结构。
步骤3:添加标点符号和状态存储
如果仔细观察DataProcessor类,您可能会注意到我们只处理同时具有所需(左流和右流)键值的记录。我们还需要处理只有一个值的记录,但是我们希望在处理这些记录之前引入一个延迟。在某些情况下,另一个值将在稍后的时间窗口中到达,我们不希望过早地处理记录。
状态存储
为了延迟处理,我们需要将传入的记录保存在某种存储中,而不是保存在外部数据库中。Kafka Streams允许我们在状态存储中存储数据。我们可以使用这种类型的存储来保存最近收到的输入记录、跟踪滚动聚合、消除重复的输入记录等等。
标点符号
一旦我们开始在状态存储中保存任何主题中缺少值的记录,我们就可以使用标点符号来处理它们。例如,我们可以向processorcontext.schedule进程()方法。我们可以设置调用标点()方法的时间表。
添加状态存储
将以下代码添加到KafkaStreaming类中会添加一个状态存储。将此代码放置在KafkaStreaming类中的TODO 1-Add state store注释处:
// build the state store that will eventually store all unprocessed items
Map<String, String> changelogConfig = newHashMap<>();
StoreBuilder<KeyValueStore<String, String>> stateStore = Stores.keyValueStoreBuilder(
Stores.persistentKeyValueStore(STORE_NAME),
Serdes.String(),
Serdes.String())
.withLoggingEnabled(changelogConfig);
.....
.....
.....
.....
// add the state store in the topology builder
topology.addStateStore(stateStore, "StateProcessor");
我们定义了一个状态存储,它将键和值存储为字符串。我们还启用了日志记录,这在应用程序停止并重新启动时非常有用。在这种情况下,状态存储不会丢失数据。
我们将修改处理器的process(),将任何主题中缺少值的记录放在状态存储中,以供以后处理。将下面的代码放在您看到注释TODO 3的位置-让我们稍后在KafkaStreaming类中处理:
if(value.contains("null")) {
if (kvStore.get(key) != null) {
// this means that the other value arrived first
// you have both the values now and can process the record
String newvalue = value.concat(" ").concat(kvStore.get(key));
process(key, newvalue);
// remove the entry from the statestore (if any left or right record came first as an event)
kvStore.delete(key);
context.forward(key, newvalue);
} else {
// add to state store as either left or right data is missing
System.out.println("Incomplete value: "+value+" detected. Putting into statestore for later processing");
kvStore.put(key, value);
}
}
添加标点符号
接下来,我们将标点符号添加到刚刚创建的自定义处理器中。为此,我们将DataProcessor的init()方法更新为:
private KeyValueStore<String, String> kvStore;
@Override
public void init(ProcessorContext context) {
this.context = context;
kvStore = (KeyValueStore) context.getStateStore(STORE_NAME);
// schedule a punctuate() method every 50 seconds based on stream-time
this.context.schedule(Duration.ofSeconds(50), PunctuationType.WALL_CLOCK_TIME,
new Punctuator(){
@Override
public void punctuate(long timestamp) {
System.out.println("Scheduled punctuator called at "+timestamp);
KeyValueIterator<String, String> iter = kvStore.all();
while (iter.hasNext()) {
KeyValue<String, String> entry = iter.next();
System.out.println(" Processed key: "+entry.key+" and value: "+entry.value+" and sending to processed-topic topic");
context.forward(entry.key, entry.value.toString());
kvStore.put(entry.key, null);
}
iter.close();
// commit the current processing progress
context.commit();
}
}
);
}
我们将标点逻辑设置为每50秒调用一次。代码检索状态存储中的条目并对其进行处理。然后,forward()函数将处理过的记录发送到处理过的主题。最后,我们从状态存储中删除记录。
图6显示了完整的数据流体系结构:
图6:完整的数据流管道。
交互式查询
我们已经完成了基本的数据流管道,但是如果我们希望能够查询状态存储呢?在这种情况下,我们可以使用Kafka Streams API中的交互式查询来使应用程序可查询。有关Kafka流中交互式查询的更多信息,请参阅本文的GitHub存储库。
总结
您可以使用我们在本文中开发的流媒体管道执行以下任何操作:
实时处理记录。
存储数据而不依赖于数据库或缓存。
构建一个现代的、事件驱动的体系结构。
我希望示例应用程序和说明将帮助您构建和处理数据流管道。您可以从本文的GitHub存储库获取示例应用程序的源代码。