这部分文档介绍了如何用Kafka的接口实现流式数据处理。
kafka流模式开发
1 概述
kafka Streams是一个客户端库(client library),用于处理和分析储存在Kafka中的数据,并把处理结果写回Kafka或发送到外部系统的最终输出点。它建立在一些很重要的概念上,比如事件时间和消息时间的准确区分,开窗支持,简单高效的应用状态管理。Kafka Streams的门槛很低:你可以快速编写一个小规模的原型运行在一台独立主机中;然后你只需要在其他主机主机上部署应用的实例,就可以完成到大规模生产环境的扩展。Kafka Streams利用Kafka的并行模型,可以透明处理同一应用的多实例负载均衡。
kafka Streams的特点:
*被设计为一个简单轻量级的客户端库,可以嵌入到Java应用,整合到已有的包、部署环境或者其他用户的流应用处理工具。
*除了Kafka自身做为内部消息层外,没有其他系统依赖。使用Kafka分区模型来水平扩展并保证绝对的顺序性。
*支持本地状态容错,可以执行非常快速有效的有状态操作,比如joins和windowed aggregations(窗口聚合)。
*采用“一次处理一条记录(one-record-at-a-time)”的方式达到低处理延迟,支持基于开窗操作的事件消息(event-time)。
*提供必要的流处理基础件,包括一个高级Streams DSL和一个底层处理API(Processor API)。
2 开发指南
2.1 核心概念
2.1.1 流处理过程拓扑图
*一个流(stream)是Kafka中最重要的抽象概念:它代表了一个无界,持续更新的数据集。一个流是一个有序,可重复读取,容错的不可变数据记录序列,一个数据记录被定义为一个键值对(key-value pair)。
*一个流处理应用,用Kafka Streams开发,定义了经过若干个处理拓扑(processor topologies)的计算逻辑,每个处理拓扑是一个通过流(线,edge)连接到流处理实例(点,node)的图。
*一个流处理实例(processor)是一个处理拓扑的节点;其含义是,通过从拓扑图中它的上游处理节点每次接收一条输入记录,执行一步流数据的变换,可能是请求操作流数据,也有可能随后生产若干条记录给到下游处理实例。
2.1.2 时间
流处理中一个临界面就是时间概念,以及它是怎么定义和整合的。比如,像开窗(windowing)这样的操作定义是基于时间边界的。
流中常用的消息概念有:
*事件时间————当事件或数据记录产生的时间点,最初被称为"at the source"(起源)。
*处理时间————当事件或数据记录被流处理应用开始处理的时间点,也就是记录开始被消费的时间。处理时间会比源事件时间晚若干毫秒、小时,甚至若干天。
*存储时间————当事件或者数据记录被Kafka broker储存到一个主题分区的时间。和事件时间不同的是,存储时间是发生在Kafka broker把记录添加到目标主题时,而不是记录创建时。和处理时间不同的是,处理时间发生在流处理应用处理记录时。比如,如果一个记录从来没被处理过,那它就没有处理时间的概念,但是它还是有存储时间。
选择事件时间还是存储时间,是通过Kafka配置文件确定的(不是Kafka Streams):在Kafka 0.10.x之前,时间戳会自动嵌入到Kafka消息中。通过Kafka的配置项,这些时间戳可以代表事件时间或存储时间。该项可以配置在broker级或单个topic。默认Kafka Streams中时间戳提取器会把嵌入的时间戳原样提取。所以,你应用中有效的时间含义依赖于Kafka中这些嵌入时间戳的配置。
Kafka Streams把每一个时间戳关联到每个数据记录通过接口TimestampExtractor。该接口的具体实现会检索或计算时间戳,数据记录确实产生内容的时间被当做嵌入时间戳时代表事件时间语义,或者用其他方法如当前时钟时间获取的处理时时间,会代表处理时间语义。开发者可以鉴于此依照业务需要使用不同时间概念。比如,单个记录(per-record)时间戳描述了按照时间访问流的进度(虽然流中的记录可能是无序的),然后被依赖于时间的操作(如joins)利用。
最后,无论何时一个Kafka Streams应用写记录到Kafka,都会给新记录关联一个时间戳。关联时间戳的方法依赖于context对象:
*当通过处理输入记录而产生新输出记录时,比如,用context.forward()触发process()方法调用,输出记录会直接继承输入记录的时间戳。
*当通过周期函数产生新输出记录时(如punctuate),输出记录的时间戳被定义为当前流任务的内部时间(通过context.timestamp())。
*为了聚合性,更新记录聚合的结果时间戳就是最新输入记录到达时触发的更新时间。
2.1.3 状态
某些流处理应用不需要状态,也就是一个消息处理过程不依赖于取他消息的处理过程。但是,可以保持状态会提供更多更复杂的流处理过程:你可以组合(join)输入流,分组并聚合数据记录。很多这种有状态的操作都可以通过Kafka Streams DSL得到。
Kafka Streams提供了所谓的状态存储(state stores),可以被流处理应用用于保存和查询数据。当实现有状态操作时,这是非常有用的功能。每个Kafka Streams任务会嵌入若干个状态存储,通过API访问存储的状态可以保存或查询处理过程需要的数据。这些状态存储可以保存为持久化键值对,一个内存哈希表,或者其他实用的数据结构。Kafka Streams提供了本地状态存储的容错和自动还原。
Kafka Streams允许直接只读查询(read-only query)状态存储,可以通过方法、线程、处理过程或和创建数据存储的应用无关的应用。这个功能被称为“交互式查询” (Interactive Query)。所有的存储都被命名,而且交互式查询底层实现只开放了读操作。
如前所述,一个Kafka Streams应用的计算逻辑被定义为一个处理拓扑。当前Kafka Streams提供了两组API用于定义处理拓扑。
2.2 底层处理API
2.2.1 Processor类
开发人员可以定制自己的业务处理逻辑,通过继承Process类。该接口提供了process和punctuate方法。process方法会在每条记录上执行;punctuate方法会被周期性调用。另外,processor接口可以保持当前ProcessorContext实例变量(在init方法中初始化),用context来设定punctuate调用周期(context().schedule),转发修改/新键值对到下游Processor实例(context().forwar),提交当前处理进度(context().commit),等等。
public class MyProcessor extends Processor {
private ProcessorContext context;
private KeyValueStore kvStore;
@Override
@SuppressWarnings("unchecked")
public void init(ProcessorContext context) {
this.context = context;
this.context.schedule(1000);
this.kvStore = (KeyValueStore) context.getStateStore("Counts");
}
@Override
public void process(String dummy, String line) {
String[] words = line.toLowerCase().split(" ");
for (String word : words) {
Integer oldValue = this.kvStore.get(word);
if (oldValue == null) {
this.kvStore.put(word, 1);
} else {
this.kvStore.put(word, oldValue + 1);
}
}
}
@Override
public void punctuate(long timestamp) {
KeyValueIterator iter = this.kvStore.all();
while (iter.hasNext()) {
KeyValue entry = iter.next();
context.forward(entry.key, entry.value.toString());
}
iter.close();
context.commit();
}
@Override
public void close() {
this.kvStore.close();
}
};
TopologyBuilder builder = new TopologyBuilder();
builder.addSource("SOURCE", "src-topic")
.addProcessor("PROCESS1", MyProcessor1::new /* the ProcessorSupplier that can generate MyProcessor1 */, "SOURCE")
.addProcessor("PROCESS2", MyProcessor2::new /* the ProcessorSupplier that can generate MyProcessor2 */, "PROCESS1")
.addProcessor("PROCESS3", MyProcessor3::new /* the ProcessorSupplier that can generate MyProcessor3 */, "PROCESS1")
.addSink("SINK1", "sink-topic1", "PROCESS1")
.addSink("SINK2", "sink-topic2", "PROCESS2")
.addSink("SINK3", "sink-topic3", "PROCESS3");
TopologyBuilder builder = new TopologyBuilder();
builder.addSource("SOURCE", "src-topic")
.addProcessor("PROCESS1", MyProcessor1::new, "SOURCE")
// create the in-memory state store "COUNTS" associated with processor "PROCESS1"
.addStateStore(Stores.create("COUNTS").withStringKeys().withStringValues().inMemory().build(), "PROCESS1")
.addProcessor("PROCESS2", MyProcessor3::new /* the ProcessorSupplier that can generate MyProcessor3 */, "PROCESS1")
.addProcessor("PROCESS3", MyProcessor3::new /* the ProcessorSupplier that can generate MyProcessor3 */, "PROCESS1")
// connect the state store "COUNTS" with processor "PROCESS2"
.connectProcessorAndStateStores("PROCESS2", "COUNTS");
.addSink("SINK1", "sink-topic1", "PROCESS1")
.addSink("SINK2", "sink-topic2", "PROCESS2")
.addSink("SINK3", "sink-topic3", "PROCESS3");
KStreamBuilder builder = new KStreamBuilder();
KStream source1 = builder.stream("topic1", "topic2");
KTable source2 = builder.table("topic3", "stateStoreName");
// written in Java 8+, using lambda expressions
KTable counts = source1.groupByKey().aggregate(
() -> 0L, // initial value
(aggKey, value, aggregate) -> aggregate + 1L, // aggregating value
TimeWindows.of("counts", 5000L).advanceBy(1000L), // intervals in milliseconds
Serdes.Long() // serde for aggregated value
);
KStream joined = source1.leftJoin(source2,
(record1, record2) -> record1.get("user") + "-" + record2.get("region");
);
// equivalent to
//
// joined.to("topic4");
// materialized = builder.stream("topic4");
KStream materialized = joined.through("topic4");