本文会介绍:
- 有状态操作
- 使用状态存储
- 连接两个流
- Kafka Streams的timestamps
1. 有状态操作
1.1 转换处理器
KStream.transformValues是最基本的有状态方法,下图展示了它工作的原理:
此方法在语义上与KStream.mapValues方法相同,但主要的区别是transformValues可以访问状态存储实例来完成其任务。
1.2 初始化转换器
在上一篇的开发入门讲述的ZMart应用程序里面,Rewards节点使用KStream.mapValues方法把Purchase对象映射为RewardAccumulator对象,用于计算积分奖励。但为了计算累计积分,需要保存每次的消费积分。KStream.transformValues方法的第一个参数是一个接口ValueTransformerSupplier<? super V, ? extends VR>,需要实现它创建一个ValueTransformer<V, VR>转换器的实例。下面是示例代码,使用状态存储KeyValueStore保存累积的积分:
-
public
class PurchaseRewardTransformer implements ValueTransformer<Purchase, RewardAccumulator> {
-
-
// 状态存储
-
private KeyValueStore<String, Integer> stateStore;
-
private
final String storeName;
-
private ProcessorContext context;
-
-
public PurchaseRewardTransformer(String storeName) {
-
Objects.requireNonNull(storeName,
"Store Name can't be null");
-
this.storeName = storeName;
-
}
-
-
@Override
-
@SuppressWarnings(
"unchecked")
-
public void init(ProcessorContext context) {
-
this.context = context;
-
// 初始化状态存储KeyValueStore
-
stateStore = (KeyValueStore<String, Integer>)
this.context.getStateStore(storeName);
-
}
-
-
@Override
-
public RewardAccumulator transform(Purchase value) {
-
// TODO
-
return
null;
-
}
-
-
@Override
-
public void close() {
-
}
-
-
}
下面是ValueTransformerSupplier的实现类,用于返回PurchaseRewardTransformer实例:
-
public
class PurchaseTransformerSupplier implements ValueTransformerSupplier<Purchase, RewardAccumulator> {
-
-
private
final String storeName;
-
private PurchaseRewardTransformer rewardTransformer;
-
-
public PurchaseTransformerSupplier(String storeName) {
-
Objects.requireNonNull(storeName,
"Store Name can't be null");
-
this.storeName = storeName;
-
this.rewardTransformer =
new PurchaseRewardTransformer(
this.storeName);
-
}
-
-
@Override
-
public ValueTransformer<Purchase, RewardAccumulator> get() {
-
return
this.rewardTransformer;
-
}
-
-
}
1.3 实现transform方法
实现PurchaseRewardTransformer.transform方法把Purchase对象转换为RewardAccumulator对象:
-
@Override
-
public RewardAccumulator transform(Purchase value) {
-
RewardAccumulator rewardAccumulator = RewardAccumulator.builder(value).build();
-
// 通过客户ID读取保存的历史积分
-
Integer accumulatedSoFar = stateStore.get(rewardAccumulator.getCustomerId());
-
// 计算总积分
-
if (accumulatedSoFar !=
null) {
-
rewardAccumulator.addRewardPoints(accumulatedSoFar);
-
}
-
// 更新总积分
-
stateStore.put(rewardAccumulator.getCustomerId(), rewardAccumulator.getTotalRewardPoints());
-
return rewardAccumulator;
-
}
需要注意的是,在Kafka集群模式下,消费数据在没有指定key的情况下是按照round-robin模式分配到不同的分区,所以具有相同客户ID的数据不会全部在同一个分区。如下图所示:
因为分区是通过StreamTask管理的,而每个StreamTask都有自己的状态存储。因此把具有相同客户ID的数据分配到相同的分区是非常重要的,以便它们可以被保存在同一个状态存储里。为了解决此问题,我们需要按客户ID重新分区数据。
1.4 重新分区数据
要重新分区数据,可以修改原来数据的key值,然后把数据写入一个新的topic。如下图所示:
在这个简单的例子中,我们使用了一个具体的key值替换了null,但重新分区不必总是修改key值。通过使用StreamPartitioner应用你可以想到的任何分区策略,例如对值或部分值进行分区。
1.5 在Kafka Streams中重新分区
使用KStream.through()方法可以容易地在Kafka Streams中实现重新分区,如下图所示。该方法创建了一个中间topic,当前的KStream实例会把数据写入这个中间topic。KStream.through()方法返回的新KStream实例会从这个中间topic读取数据,这样,数据就可以无缝地重新分区。
该方法的内部实现是创建了一个sink和source节点,sink节点是KStream实例的子处理器,而新的KStream实例使用新的source节点作为其数据源。你可以使用DSL创建相同类型的子拓扑,但使用KStream.through()方法更方便。下面是使用了默认分区器的示例代码:
-
KStream<String, Purchase> transByCustomerStream = purchaseKStream.through(
"customer_transactions",
-
// 使用默认分区器DefaultPartitioner
-
Produced.with(stringSerde, purchaseSerde));
1.6 使用StreamPartitioner
如果不想使用默认的分区器,可以自定义化,只要实现接口StreamPartitioner:
-
public
class RewardsStreamPartitioner implements StreamPartitioner<String, Purchase> {
-
-
@Override
-
public Integer partition(String topic, String key, Purchase value, int numPartitions) {
-
// 使用客户ID作为分区策略,以便具有相同客户ID的数据会在同一个分区
-
return value.getCustomerId().hashCode() % numPartitions;
-
}
-
-
}
然后更新代码使用该自定义分区器:
-
RewardsStreamPartitioner streamPartitioner =
new RewardsStreamPartitioner();
-
KStream<String, Purchase> transByCustomerStream = purchaseKStream.through(
"customer_transactions",
-
// 使用自定义分区器
-
Produced.with(stringSerde, purchaseSerde, streamPartitioner));
1.7 更新处理拓扑
到目前为止,我们已经创建了一个新的处理节点负责把消费数据按照客户ID分区,这是为了确保对相同客户的所有消费数据都写入同一分区。因此,对相同客户的所有消费数据都会保存在相同的状态存储中。下图是更新的处理拓扑,在Masking节点和Rewards处理器之间使用新的through处理器:
下面是更新的代码:
-
String rewardsStateStoreName =
"rewardsPointsStore";
-
KStream<String, RewardAccumulator> statefulRewardAccumulator = transByCustomerStream
-
// 使用新的状态转换器
-
.transformValues(
new PurchaseTransformerSupplier(rewardsStateStoreName), rewardsStateStoreName);
-
statefulRewardAccumulator.to(
"rewards", Produced.with(stringSerde, rewardAccumulatorSerde));
2. 使用状态存储
2.1 数据局部性
数据局部性对性能是至关重要的。虽然通常利用key查找数据是非常快的,但是当数据达到一定规模时,使用远程存储带来的延时通常会是一个瓶颈。下图说明了数据局部性的重要性,虚线表示从远程数据库获取数据,实线表示从同一个服务器上的内存数据存储读取数据,后者比前者更有效。
数据局部性还意味着存储是每个处理节点的本地存储,不存在跨进程或线程的共享。这样,如果一个进程故障,它不应该对其它流处理进程或线程产生影响。
2.2 故障恢复和容错
应用程序故障是不可避免的,特别是涉及分布式应用程序。我们需要把注意力放在如何迅速恢复故障,而不是防止故障。下图说明了数据局部性和容错的原理,每个处理器都有其本地数据存储和一个用于备份状态存储的changelog topic。使用topic备份状态存储看起来成本比较高,但这是为了满足容错的需求,一旦进程故障或重启,可以从该topic读取数据进行快速恢复。
2.3 使用状态存储
添加状态存储是非常简单的,就是使用Stores类中的一个静态工厂方法创建StoreSupplier实例。还有两个用于自定义状态存储的类:Materialized和StoreBuilder类,使用哪一个取决于把存储添加到拓扑中的方式。如果使用high-level的DSL,通常会使用Materialized类;如果使用lower-level的Processor API,则通常会使用StoreBuilder。
即使当前的例子使用了high-level的DSL,但由于在上面的转换器使用了状态存储,实际上是使用了lower-level的Processor API,所以这里会使用StoreBuilder来自定义状态存储:
KeyValueBytesStoreSupplier storeSupplier = Stores.inMemoryKeyValueStore(rewardsStateStoreName); StoreBuilder<KeyValueStore<String, Integer>> storeBuilder = Stores.keyValueStoreBuilder(storeSupplier, Serdes.String(), Serdes.Integer()); streamsBuilder.addStateStore(storeBuilder);
这样,上面的PurchaseRewardTransformer转换器就可以使用这个内存key-value存储。
2.4 其它key/value存储供应商
除了Stores.inMemoryKeyValueStore方法之外,还可以使用下面这些静态工厂方法来生成存储供应商:
- Stores.persistentKeyValueStore
- Stores.lruMap
- Stores.persistentWindowStore
- Stores.persistentSessionStore
值得注意的是,所有持久化的StateStore实例都使用RocksDB提供本地存储。
2.5 StateStore容错
所有StateStoreSupplier类型都默认启用日志记录,它是作为changelog的一个Kafka的topic,用于备份存储中的值和提供容错功能。例如,假设有一台运行Kafka Streams的服务器故障,当恢复和重启Kafka Streams应用程序后,该实例的状态存储将恢复为原始内容(故障前在changelog最后提交的offset)。该日志记录功能可以使用StoreBuilder.withLoggingDisabled()方法禁用,但不建议使用。
2.6 配置changelog topics
Kafka Streams会自动创建changelog的topic,它是一个compacted的topic。如果想从状态存储删除数据,可以使用put(key, null)方法,把需要删除的值设为null。数据保留的默认设置是一个星期,且大小不受限制,默认清除的策略是delete。
下面让我们看看如何配置changelog的topic,使其保留数据大小为10GB,保留时间为2天,清除策略是先compact再delete:
-
Map<String, String> changeLogConfigs =
new HashMap<String, String>();
-
changeLogConfigs.put(
"log.retention.hours",
"48");
-
changeLogConfigs.put(
"log.retention.bytes",
"10000000000");
-
changeLogConfigs.put(
"log.cleanup.policy",
"compact,delete");
-
-
KeyValueBytesStoreSupplier storeSupplier = Stores.inMemoryKeyValueStore(
"foo");
-
StoreBuilder<KeyValueStore<String, Integer>> storeBuilder = Stores.keyValueStoreBuilder(storeSupplier,
-
Serdes.String(), Serdes.String());
-
// 使用StoreBuilder
-
storeBuilder.withLoggingEnabled(changeLogConfigs);
-
// 使用Materialized
-
Materialized.as(storeSupplier);
3. 连接两个流
现在ZMart他们希望通过赠送咖啡店的优惠券来保持电子商店的客流量(希望增加的客流量能提高销售量)。他们希望能识别在某段时间内同时购买咖啡和电子产品的顾客,并在第二次的消费后马上赠送优惠券,见下图:
3.1 生成包含客户ID的key值
要确定何时赠送优惠券,需要连接咖啡店和电子商店的数据流。而为了连接它们,需要生成连接的key(这里使用客户ID)和拆分咖啡店和电子商店的数据:
-
// 使用客户ID重新生成分区key
-
KStream<String, Purchase> kstreamByKey = purchaseKStream.selectKey((key, purchase) -> purchase.getCustomerId());
-
-
// 拆分咖啡店和电子商店的数据
-
@SuppressWarnings(
"unchecked")
-
KStream<String, Purchase>[] branchesStream = kstreamByKey.branch(
-
(key, purchase) -> purchase.getDepartment().equalsIgnoreCase(
"coffee"),
-
(key, purchase) -> purchase.getDepartment().equalsIgnoreCase(
"electronics"));
-
-
KStream<String, Purchase> coffeeStream = branchesStream[
0];
-
KStream<String, Purchase> electronicsStream = branchesStream[
1];
注意KStream.selectKey方法会触发数据重新分区。下图是更新的处理拓扑:
3.2 创建连接器
内连接两个流可以使用KStream.join方法,它的第二个参数是ValueJoiner的一个实例,所以要先创建一个连接器,实现其接口方法apply:
-
public
class PurchaseJoiner implements ValueJoiner<Purchase, Purchase, CorrelatedPurchase> {
-
-
@Override
-
public CorrelatedPurchase apply(Purchase purchase, Purchase otherPurchase) {
-
CorrelatedPurchase.Builder builder = CorrelatedPurchase.newBuilder();
-
-
Date purchaseDate = purchase !=
null ? purchase.getPurchaseDate() :
null;
-
Double price = purchase !=
null ? purchase.getPrice() :
0.0;
-
String itemPurchased = purchase !=
null ? purchase.getItemPurchased() :
null;
-
-
Date otherPurchaseDate = otherPurchase !=
null ? otherPurchase.getPurchaseDate() :
null;
-
Double otherPrice = otherPurchase !=
null ? otherPurchase.getPrice() :
0.0;
-
String otherItemPurchased = otherPurchase !=
null ? otherPurchase.getItemPurchased() :
null;
-
-
List<String> purchasedItems =
new ArrayList<String>();
-
-
if (itemPurchased !=
null) {
-
purchasedItems.add(itemPurchased);
-
}
-
-
if (otherItemPurchased !=
null) {
-
purchasedItems.add(otherItemPurchased);
-
}
-
-
String customerId = purchase !=
null ? purchase.getCustomerId() :
null;
-
String otherCustomerId = otherPurchase !=
null ? otherPurchase.getCustomerId() :
null;
-
-
builder.withCustomerId(customerId !=
null ? customerId : otherCustomerId)
-
.withFirstPurchaseDate(purchaseDate)
-
.withSecondPurchaseDate(otherPurchaseDate)
-
.withItemsPurchased(purchasedItems)
-
.withTotalAmount(price + otherPrice);
-
-
return builder.build();
-
}
-
-
}
连接返回的对象是CorrelatedPurchase:
-
import java.util.Date;
-
import java.util.List;
-
-
public
class CorrelatedPurchase {
-
-
private String customerId;
-
private List<String> itemsPurchased;
-
private
double totalAmount;
-
private Date firstPurchaseTime;
-
private Date secondPurchaseTime;
-
-
private CorrelatedPurchase(Builder builder) {
-
customerId = builder.customerId;
-
itemsPurchased = builder.itemsPurchased;
-
totalAmount = builder.totalAmount;
-
firstPurchaseTime = builder.firstPurchasedItem;
-
secondPurchaseTime = builder.secondPurchasedItem;
-
}
-
-
public static Builder newBuilder() {
-
return
new Builder();
-
}
-
-
// 省略get方法
-
-
@Override
-
public String toString() {
-
return
"CorrelatedPurchase{" +
"customerId='" + customerId +
'\'' +
", itemsPurchased=" + itemsPurchased
-
+
", totalAmount=" + totalAmount +
", firstPurchaseTime=" + firstPurchaseTime +
", secondPurchaseTime="
-
+ secondPurchaseTime +
'}';
-
}
-
-
public
static
final
class Builder {
-
private String customerId;
-
private List<String> itemsPurchased;
-
private
double totalAmount;
-
private Date firstPurchasedItem;
-
private Date secondPurchasedItem;
-
-
private Builder() {
-
}
-
-
public Builder withCustomerId(String val) {
-
customerId = val;
-
return
this;
-
}
-
-
public Builder withItemsPurchased(List<String> val) {
-
itemsPurchased = val;
-
return
this;
-
}
-
-
public Builder withTotalAmount(double val) {
-
totalAmount = val;
-
return
this;
-
}
-
-
public Builder withFirstPurchaseDate(Date val) {
-
firstPurchasedItem = val;
-
return
this;
-
}
-
-
public Builder withSecondPurchaseDate(Date val) {
-
secondPurchasedItem = val;
-
return
this;
-
}
-
-
public CorrelatedPurchase build() {
-
return
new CorrelatedPurchase(
this);
-
}
-
}
-
-
}
3.3 内连接两个流
这样我们就可以调用KStream.join方法,内连接咖啡店和电子商店的数据流。下面是更新的拓扑:
连接代码:
-
// 20分钟连接窗口
-
JoinWindows twentyMinuteWindow = JoinWindows.of(
60 *
1000 *
20);
-
KStream<String, CorrelatedPurchase> joinedKStream = coffeeStream.join(electronicsStream,
new PurchaseJoiner(), twentyMinuteWindow,
-
Joined.with(stringSerde, purchaseSerde, purchaseSerde));
本例使用20分钟的连接窗口,时间发生先后没有限制,只要两者数据的timestamp相差在20分钟以内。另外还有两个指定发生先后的连接窗口:
- JoinWindows.after:连接的数据发生在之后N毫秒内
- JoinWindows.before:连接的数据发生在之前N毫秒内
注意:在执行连接之前,你需要确保所有连接的分区都是co-partitioned,也就是它们要有相同数量的分区和使用相同类型的分区key。因此,当调用join()方法时,两个KStream的实例会被检查是否需要重新分区。(当连接GlobalKTable实例时不需要重新分区)
在上述3.1示例代码的purchaseKStream调用了selectKey()方法,并且在返回的KStreams马上创建分支。因为selectKey()方法修改了分区key,所以coffeeStream和electronicsStream都需要重新分区。值得重复的是,重新分区是必要的,因为需要确保具有相同key的数据会被写入同一个分区,这种重新分区是自动处理的。此外,当启动Kafka Streams应用程序时,会检查连接中涉及的topics以确保它们有相同数量的分区,如果发现数量不同会抛出TopologyBuilderException异常。开发人员有责任确保连接中涉及的key是同一类型的。
在写入Kafka Streams源主题时,Co-partitioning还要求所有Kafka生产者使用相同的分区类。同样地,你需要对通过KStream.to()方法写入sink topics的任何操作使用相同的StreamPartitioner。如果使用默认的分区策略,则就无需担心这个问题。
3.4 外连接
如果想使用外连接,可以使用:
coffeeStream.outerJoin(electronicsStream, ...)
下图说明了外连接的三种可能结果:
3.5 左连接
如果想使用左连接,可以使用:
coffeeStream.leftJoin(electronicsStream, ...)
下图说明了左连接的三种可能结果:
4. Kafka Streams的timestamps
Timestamps在Kafka Streams以下功能发挥了关键的作用:
- 连接流
- 更新一个changelog (KTable API)
- 决定Punctuator.punctuate()方法什么时候被触发 (Processor API)
(本文暂不介绍KTable和Processor的API) 在流处理系统中,timestamps可以分为以下3种时间概念:
- Event time:事件被创建的时间
- Ingestion time:事件被保存在Kafka broker的时间
- Processing time:流处理应用程序接收事件的时间
注意:到目前为止,我们都是假定客户和brokers在同一个时区,但实际情况并非总是如此。当使用timestamps时,使用UTC时区规范化时间是最安全的,这样可以避免brokers和客户的时区差异。
4.1 内置TimestampExtractor的实现
几乎所有内置TimestampExtractor的实现都使用生产者或broker设置在消息metadata的timestamps。默认的timestamp配置(broker配置log.message.timestamp.type或topic配置message.timestamp.type)是CreateTime,可以修改为LogAppendTime。ExtractRecordMetadataTimestamp是一个抽象类,它提供从ConsumerRecord对象读取metadata timestamp的extract方法。大多数的实现类都是继承这个类,重写其onInvalidTimestamp这个抽象方法来处理无效的timestamps(当timestamps小于0)。
下面是继承ExtractRecordMetadataTimestamp的类列表:
- FailOnInvalidTimestamp:如果timestamp是无效的,抛出StreamsException异常
- LogAndSkipOnInvalidTimestamp:如果timestamp是无效的,返回这个无效的timestamp并打印“由于timestamp无效而将丢弃该消息的警告信息”
- UsePreviousTimeOnInvalidTimestamp:如果timestamp是无效的,返回最后一个有效的timestamp
4.2 WallclockTimestampExtractor
该实现类返回调用System.currentTimeMillis()方法的结果。
4.3 自定义TimestampExtractor
自定义TimestampExtractor只需要实现该接口和方法extract,下面是示例代码,使用了购买的时间:
-
public
class TransactionTimestampExtractor implements TimestampExtractor {
-
-
@Override
-
public long extract(ConsumerRecord<Object, Object> record, long previousTimestamp) {
-
Purchase purchasePurchaseTransaction = (Purchase) record.value();
-
return purchasePurchaseTransaction.getPurchaseDate().getTime();
-
}
-
-
}
注意:日志保留和滚动是基于timestamp的,还有自定义的TimestampExtractor返回的timestamp可能成为changelogs和下游输出topics使用的消息timestamp。
4.4 指定TimestampExtractor
指定TimestampExtractor有两种选项,第一种选项是在设置Kafka Streams应用程序时在属性中指定,这是全局的设置,默认设置是FailOnInvalidTimestamp。例如:
props.put(StreamsConfig.DEFAULT_TIMESTAMP_EXTRACTOR_CLASS_CONFIG, TransactionTimestampExtractor.class);
第二种选项是通过Consumed对象指定,例如:
Consumed.with(stringSerde, purchaseSerde).withTimestampExtractor(new TransactionTimestampExtractor());
这样做的好处是每个输入源都有一个TimestampExtractor,而第一种选项是使用一个TimestampExtractor处理来自不同topics的消息。
END O(∩_∩)O