在Wingify,我们已经在不同的团队和项目中使用Kafka,解决了大量的用例。因此,当我们需要为新的数据平台实现VWO会话记录功能时,Kafka是一个合理的选择,Kafka流框架做了所有与使用Kafka消费者API有关的繁重工作,使我们能够专注于数据处理部分。这篇博文介绍了我们在开发基于Kafka流的解决方案时遇到的问题,以及我们如何找到解决这些问题的方法。
问题所在
对数据库进行分批写操作可以显著提高写的吞吐量。在你必须遵守配额和限制的情况下,它也可能成为一种必要。在我们之前的一篇博文中,我们讨论了Kafka流的窗口化和聚合功能是如何让我们在一个时间间隔内聚合事件并减少对数据库的更新操作。我们想为录音功能做的事情也很类似。我们想把某个客户的数据批量更新到外部水槽的方法是,在以下两种情况下启动更新:
- 该客户的事件数量超过了某个阈值。
- 或者,距离上次更新已经过去了一定的时间。
实施
我们想要实现的批处理策略类似于Apache Beam等框架通过窗口和触发器的概念提供的功能。Kafka Streams提供了基于时间的窗口的功能,但缺乏触发器的概念。尽管如此,由于在生产中拥有几乎相同架构的应用程序运行良好,我们开始着手制定解决方案。
使用draw.io生成的Kafka Streams应用程序的架构图
解决方案--第一次尝试
我们的第一个解决方案是使用Kafka Streams DSL groupByKey()
和 reduce()
操作符,在固定间隔的时间窗口上进行聚合。
Visitor
Java类代表输入的Kafka消息并有JSON表示:
{
"customerId": 1234,
"visitorId": "42F77D2D52A343F487C313BC77A312D0",
"action": "click"
}
复制代码
VisitorAggregated
Java类用于批处理更新,具有JSON表示:
{
"customerId": 1234,
"events": [
{
"visitorId": "42F77D2D52A343F487C313BC77A312D0",
"action": "click"
},
{
"visitorId": "198CCCA1A0F74BF19FDC80F282F21A5C",
"action": "scroll"
}
]
}
复制代码
下面的代码片断描述了该方法的代码
Serde<Visitor> visitorSerde = Serdes.serdeFrom(
new JsonSerializer<>(Visitor.class),
new JsonDeserializer<>(Visitor.class));
Serde<VisitorAggregated> visitorAggregatedSerde = Serdes.serdeFrom(
new JsonSerializer<>(VisitorAggregated.class),
new JsonDeserializer<>(VisitorAggregated.class));
StreamsBuilder streamsBuilder = new StreamsBuilder();
Duration windowDuration = Duration.ofMillis(10000);
TimeWindows window = TimeWindows.of(windowDuration).advanceBy(windowDuration);
streamsBuilder
.stream(KAFKA_TOPIC, Consumed.with(Serdes.String(), visitorSerde)) (1)
.filter((k, v) -> v != null)
.map((k, v) ->
KeyValue.pair(v.getCustomerId(), new VisitorAggregated(v))) (2)
.groupByKey(Grouped.with((Serdes.Integer()), visitorAggregatedSerde))
.windowedBy(window.grace(Duration.ZERO))
.reduce(VisitorAggregated::merge) (3)
.suppress( (4)
Suppressed.untilTimeLimit(windowDuration,
Suppressed.BufferConfig.unbounded()))
.toStream()
.foreach((k, v) -> writeToSink(k.toString(), v)); (5)
KafkaStreams kafkaStreams = new KafkaStreams(streamsBuilder.build(), streamsConfig);
kafkaStreams.start();
复制代码
上述代码片段的简要概述:
stream()
从 主题读取Kafka消息,并使用JSON反序列化器将其转换为Java对象。KAFKA_TOPIC
- 为了执行基于customerId的聚合,
map()
,执行一个改变键的操作,并重新映射所有以customerId为键的键值对。 groupByKey()
reduce()``merge()
是 类的一个静态方法,其中定义了聚合逻辑。VisitorAggregated
reduce()
的输出是一个KTable
对象,suppress()
确保聚合结果只在窗口过期后转发,抑制所有中间结果。forEach()
transform通过 ,将聚合结果写到外部汇中。writeToSink()
理论上,一切看起来都很好,而且现有的Kafka流应用程序具有几乎相同的逻辑,在生产中运行良好,增加了我们对这个解决方案的信心。然而,会话记录功能的一个重大偏差是有效载荷的大小和延迟要求。VWO会话记录捕捉了所有访客与网站的互动,而Kafka消息的有效载荷大小明显高于我们其他使用Kafka的应用。另外,我们希望更新是接近实时的。
groupByKey()
和reduce()
DSL运算符方法的问题
通过很少的负载测试运行,我们观察到某些值得关注的地方
-
在进行
groupByKey()
转换之前,我们需要进行一个改变键的操作(上面代码片断的第2步)。因此,Kafka流框架被迫执行一个 重新分区操作(类似于Map/Reduce范式中的shuffle步骤)。这涉及到创建一个内部主题,其分区数量与源主题相同,并将具有相同键的记录写入相同的分区中。在具有相同键的记录被共同定位到同一分区后,进行聚合,结果被发送到下游的处理器节点。由于重新分区,最初是一个单一的 拓扑结构,现在被分成了两个子结构,并且引入了写入和读出Kafka主题的处理开销,以及源主题数据的重复。这种开销意味着已经具有较高有效载荷大小的消息将在Kafka代理上留下更多的足迹。
-
聚合步骤的结果是一个
KTable
对象,并通过压缩的Kafka changelog主题进行持久化和复制以实现容错。同时,KTable对象会定期刷新到磁盘上。在消费者重新平衡的情况下,新的/现有的Kafka流应用实例从这个变化日志主题中读取所有的消息,并确保它赶上了所有的状态更新/计算,而早期的消费者正在处理这些分区的消息。与分区主题一样,变更日志主题是由Kafka流框架本身创建的一个内部主题。一个额外的变更日志主题和一个持久的KeyValue存储意味着在分区主题的基础上有更多的存储开销,而且应用程序的启动时间也更慢,因为他们必须从这个主题读取。通过变更日志主题进行的状态存储复制对于需要持久化状态的流媒体用例非常有用,但对于我们的聚合用例来说,这是不需要的,因为我们没有持久化状态。
Kafka Streams创建的内部repartition和chang-log topics
-
我们对基于窗口的聚合的期望是,对于每个键,我们将严格在窗口到期后在下游的处理器节点上收到结果。然而,存储在
KTable
对象中的聚合结果会从缓存中刷出,并在提交时间间隔过后或达到max-cache
大小时转发到下游。这意味着我们缺乏对某一特定客户的聚合结果何时被转发的精细控制。此外,对于传入消息率较低的键来说,聚合结果可能需要很长时间才能被转发并反映在数据库中。 尽管对配置参数进行了调整,并使用Kafka流结构,如 提交时间间隔, 缓存刷新,和KTable#supress()
我们无法确保所有的更新都以有时限的方式发送到外部水槽。
解决方案--处理器API
我们在基于时间的窗口化和groupByKey()
+reduce()
的方法中面临的挑战表明,这不是我们用例中最理想的方法。我们需要一些高于Kafka Streams DSL运营商提供的东西。经过一番研究,我们发现了Processor API。
处理器API
Processor API是一个低级别的KafkaStreams结构,它允许:
- 将KeyValue存储附加到KafkaStreams处理器节点,并执行读/写操作。每个分区都会创建一个状态存储实例,可以是持久性的,也可以是仅在内存中。
- 安排行动在严格的定期间隔(壁钟时间)发生,并获得对记录转发到特定处理器节点的完全控制。
变换器接口
使用Processor API需要手动创建流拓扑,当使用标准DSL操作符(如map()
、filter()
、reduce()
等)时,这一过程被抽象出来。该接口 Transformer
接口在使用Kafka Streams DSL操作符的便捷性和低级别的Processor API的能力之间取得了很好的平衡。
Transformer接口用于将输入记录有状态地映射到0、1或多个新的输出记录(键和值的类型都可以任意改变)。 这是一个有状态的逐条记录的操作,即transform(Object, Object)是为流中的每条记录单独调用的,可以访问和修改transform(Object, Object)的单个调用以外的状态。此外,这个Transformer可以安排一个方法在所提供的上下文中被定期调用。
实施
Transformer
接口可以访问一个键值存储,并且能够以固定的时间间隔安排任务,这意味着我们可以实现我们想要的批处理策略。下面是使用transform()
操作符的代码片段。
使用kafka-streams-viz 生成的Kafka流拓扑结构
StreamsBuilder streamsBuilder = new StreamsBuilder();
streamsBuilder.addStateStore( (1)
Stores.keyValueStoreBuilder(
Stores.inMemoryKeyValueStore(AGGREGATE_KV_STORE_ID),
Serdes.Integer(), visitorAggregatedSerde
).withLoggingDisabled().withCachingDisabled());
streamsBuilder.addStateStore(
Stores.keyValueStoreBuilder(
Stores.inMemoryKeyValueStore(COUNT_KV_STORE_ID),
Serdes.Integer(), Serdes.Integer()
).withLoggingDisabled().withCachingDisabled());
streamsBuilder
.stream(KAFKA_TOPIC, Consumed.with(Serdes.String(), visitorSerde))
.filter((k, v) -> v != null)
.mapValues(VisitorAggregated::new)
.transform(() -> (2)
new VisitorProcessor(AGGREGATE_THRESHOLD, AGGREGATE_DURATION),
AGGREGATE_KV_STORE_ID,
COUNT_KV_STORE_ID)
.foreach((k, v) -> writeToSink(k, v));
KafkaStreams kafkaStreams = new KafkaStreams(streamsBuilder.build(), streamsConfig);
Runtime.getRuntime().addShutdownHook(new Thread(kafkaStreams::close)); (3)
kafkaStreams.start();
复制代码
- 我们使用内存中的键值存储来存储聚合结果,并且关闭了状态存储的基于变更日志的主题备份。我们可以这样做,因为聚合结果在被转发后不需要被持久化。如果键值存储的更新必须被持久化,建议启用磁盘
caching()
和 changelog topiclogging()
。 - 在
transform
处理器中,我们附加了状态存储AGGREGATE_KV_STORE_ID
、COUNT_KV_STORE_ID
,并提供了一个VisitorProcessor
类的实例,它实现了Transformer
接口。AGGREGATE_THRESHOLD
和AGGREGATE_DURATION
指定了批处理配置参数。 - 一个后台线程监听终止信号,并通过
close()
,确保Kafka流应用程序的优雅关闭。
VisitorProcessor
实现 、 和 方法的 和 接口。init()
transform()
punctuate()
Transformer
Punctuator
public class VisitorProcessor implements Transformer<String, VisitorAggregated>, Punctuator {
private Duration interval;
private ProcessorContext ctx;
private KeyValueStore<Integer, VisitorAggregated> aggregateStore;
private KeyValueStore<Integer, Integer> countStore;
private Integer threshold;
public VisitorProcessor(Integer threshold, Duration interval) {
this.threshold = threshold;
this.interval = interval;
}
@Override
public void init(ProcessorContext context) {
this.ctx = context;
this.aggregateStore = (KeyValueStore) context.getStateStore(Main.AGGREGATE_KV_STORE_ID);
this.countStore = (KeyValueStore) context.getStateStore(Main.COUNT_KV_STORE_ID);
this.ctx.schedule(interval, PunctuationType.WALL_CLOCK_TIME, this::punctuate);
}
@Override
public KeyValue<String, VisitorAggregated> transform(String key, VisitorAggregated visitor) {
KeyValue<String, VisitorAggregated> toForward = null;
Integer stateStoreKey = visitor.getCustomerId();
countStore.putIfAbsent(stateStoreKey, 0);
aggregateStore.putIfAbsent(stateStoreKey, new VisitorAggregated(visitor.getCustomerId()));
Integer aggregateCount = countStore.get(stateStoreKey) + 1;
VisitorAggregated visitorAggregated = visitor.merge(aggregateStore.get(stateStoreKey));
aggregateStore.put(stateStoreKey, visitorAggregated);
countStore.put(stateStoreKey, aggregateCount);
if (aggregateCount >= threshold) {
toForward = KeyValue.pair(key, visitorAggregated);
countStore.delete(stateStoreKey);
aggregateStore.delete(stateStoreKey);
}
return toForward;
}
private void forwardAll() {
KeyValueIterator<Integer, VisitorAggregated> it = aggregateStore.all();
while (it.hasNext()) {
KeyValue<Integer, VisitorAggregated> entry = it.next();
ctx.forward(entry.key, entry.value);
aggregateStore.delete(entry.key);
countStore.delete(entry.key);
}
it.close();
}
@Override
public void punctuate(long timestamp) {
forwardAll();
}
@Override
public void close() {
forwardAll();
}
}
复制代码
init()
初始化状态存储,并安排 ,在每个固定的时间间隔内严格执行一次。一个状态存储维护每个客户的事件计数,另一个状态存储维护聚合的结果。punctuate()
transform()
对于从上游处理器节点收到的所有记录,我们都会调用状态存储,并返回一个键值对,由下游节点处理。在收到一条记录时,我们对键(customerId)进行增量操作。如果计数增加超过阈值,在 方法调用定时器之前,聚合结果被转发,并且在两个状态存储中为特定的键刷新状态。从这个方法返回的空值会被下游的处理器节点所忽略。punctuate()
punctuate()
调用 ,该方法遍历聚合状态存储中的所有键值对,在聚合间隔结束时将所有键值对转发给下游Processor节点,并清除两个状态存储。 ,调用 ,确保每当Kafka流应用程序收到关闭信号时,在进程退出前转发内存存储中的所有记录。forwardAll()
close()
forwardAll()
结语
使用状态存储和Processor API,我们能够以一种可预测和有时限的方式进行批量更新,而不需要重新分区的开销。另外,使用内存键值存储意味着Kafka流应用在Kafka集群上留下的足迹最小。你可以在这里找到完整的工作代码。如果你对本文有任何反馈或疑问,你可以通过评论来分享它们。