Kafka Streams实战-流和状态

本文会介绍:

  • 有状态操作
  • 使用状态存储
  • 连接两个流
  • 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保存累积的积分:


 
 
  1. public class PurchaseRewardTransformer implements ValueTransformer<Purchase, RewardAccumulator> {
  2.     // 状态存储
  3.      private KeyValueStore<String, Integer> stateStore;
  4.      private final String storeName;
  5.      private ProcessorContext context;
  6.      public PurchaseRewardTransformer(String storeName) {
  7.         Objects.requireNonNull(storeName, "Store Name can't be null");
  8.          this.storeName = storeName;
  9.     }
  10.      @Override
  11.      @SuppressWarnings( "unchecked")
  12.      public void init(ProcessorContext context) {
  13.          this.context = context;
  14.          // 初始化状态存储KeyValueStore
  15.         stateStore = (KeyValueStore<String, Integer>) this.context.getStateStore(storeName);
  16.     }
  17.      @Override
  18.      public RewardAccumulator transform(Purchase value) {
  19.         // TODO
  20.         return null;
  21.     }
  22.      @Override
  23.      public void close() {
  24.     }
  25. }

下面是ValueTransformerSupplier的实现类,用于返回PurchaseRewardTransformer实例:


 
 
  1. public class PurchaseTransformerSupplier implements ValueTransformerSupplier<Purchase, RewardAccumulator> {
  2.     
  3.      private final String storeName;
  4.      private PurchaseRewardTransformer rewardTransformer;
  5.     
  6.      public PurchaseTransformerSupplier(String storeName) {
  7.         Objects.requireNonNull(storeName, "Store Name can't be null");
  8.          this.storeName = storeName;
  9.          this.rewardTransformer = new PurchaseRewardTransformer( this.storeName);
  10.     }
  11.      @Override
  12.      public ValueTransformer<Purchase, RewardAccumulator> get() {
  13.          return this.rewardTransformer;
  14.     }
  15. }

1.3 实现transform方法

实现PurchaseRewardTransformer.transform方法把Purchase对象转换为RewardAccumulator对象:


 
 
  1. @Override
  2. public RewardAccumulator transform(Purchase value) {
  3.     RewardAccumulator rewardAccumulator = RewardAccumulator.builder(value).build();
  4.      // 通过客户ID读取保存的历史积分
  5.     Integer accumulatedSoFar = stateStore.get(rewardAccumulator.getCustomerId());
  6.      // 计算总积分
  7.      if (accumulatedSoFar != null) {
  8.         rewardAccumulator.addRewardPoints(accumulatedSoFar);
  9.     }
  10.      // 更新总积分
  11.     stateStore.put(rewardAccumulator.getCustomerId(), rewardAccumulator.getTotalRewardPoints());
  12.      return rewardAccumulator;
  13. }

需要注意的是,在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()方法更方便。下面是使用了默认分区器的示例代码:


 
 
  1. KStream<String, Purchase> transByCustomerStream = purchaseKStream.through( "customer_transactions",
  2.     // 使用默认分区器DefaultPartitioner
  3.     Produced.with(stringSerde, purchaseSerde));

1.6 使用StreamPartitioner

如果不想使用默认的分区器,可以自定义化,只要实现接口StreamPartitioner:


 
 
  1. public class RewardsStreamPartitioner implements StreamPartitioner<String, Purchase> {
  2.      @Override
  3.      public Integer partition(String topic, String key, Purchase value, int numPartitions) {
  4.          // 使用客户ID作为分区策略,以便具有相同客户ID的数据会在同一个分区
  5.          return value.getCustomerId().hashCode() % numPartitions;
  6.     }
  7. }

然后更新代码使用该自定义分区器:


 
 
  1. RewardsStreamPartitioner streamPartitioner = new RewardsStreamPartitioner();
  2. KStream<String, Purchase> transByCustomerStream = purchaseKStream.through( "customer_transactions",
  3.      // 使用自定义分区器
  4.     Produced.with(stringSerde, purchaseSerde, streamPartitioner));

1.7 更新处理拓扑

到目前为止,我们已经创建了一个新的处理节点负责把消费数据按照客户ID分区,这是为了确保对相同客户的所有消费数据都写入同一分区。因此,对相同客户的所有消费数据都会保存在相同的状态存储中。下图是更新的处理拓扑,在Masking节点和Rewards处理器之间使用新的through处理器:

下面是更新的代码:


 
 
  1. String rewardsStateStoreName = "rewardsPointsStore";
  2. KStream<String, RewardAccumulator> statefulRewardAccumulator = transByCustomerStream
  3.      // 使用新的状态转换器
  4.     .transformValues( new PurchaseTransformerSupplier(rewardsStateStoreName), rewardsStateStoreName);
  5. 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来自定义状态存储:


 
 
  1. KeyValueBytesStoreSupplier storeSupplier = Stores.inMemoryKeyValueStore(rewardsStateStoreName);
  2. StoreBuilder<KeyValueStore<String, Integer>> storeBuilder = Stores.keyValueStoreBuilder(storeSupplier,
  3.     Serdes.String(), Serdes.Integer());
  4. 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:


 
 
  1. Map<String, String> changeLogConfigs = new HashMap<String, String>();
  2. changeLogConfigs.put( "log.retention.hours", "48");
  3. changeLogConfigs.put( "log.retention.bytes", "10000000000");
  4. changeLogConfigs.put( "log.cleanup.policy", "compact,delete");
  5. KeyValueBytesStoreSupplier storeSupplier = Stores.inMemoryKeyValueStore( "foo");
  6. StoreBuilder<KeyValueStore<String, Integer>> storeBuilder = Stores.keyValueStoreBuilder(storeSupplier,
  7.     Serdes.String(), Serdes.String());
  8. // 使用StoreBuilder
  9. storeBuilder.withLoggingEnabled(changeLogConfigs);
  10. // 使用Materialized
  11. Materialized.as(storeSupplier);

3. 连接两个流

现在ZMart他们希望通过赠送咖啡店的优惠券来保持电子商店的客流量(希望增加的客流量能提高销售量)。他们希望能识别在某段时间内同时购买咖啡和电子产品的顾客,并在第二次的消费后马上赠送优惠券,见下图:

3.1 生成包含客户ID的key值

要确定何时赠送优惠券,需要连接咖啡店和电子商店的数据流。而为了连接它们,需要生成连接的key(这里使用客户ID)和拆分咖啡店和电子商店的数据:


 
 
  1. // 使用客户ID重新生成分区key
  2. KStream<String, Purchase> kstreamByKey = purchaseKStream.selectKey((key, purchase) -> purchase.getCustomerId());
  3. // 拆分咖啡店和电子商店的数据
  4. @SuppressWarnings( "unchecked")
  5. KStream<String, Purchase>[] branchesStream = kstreamByKey.branch(
  6.         (key, purchase) -> purchase.getDepartment().equalsIgnoreCase( "coffee"),
  7.         (key, purchase) -> purchase.getDepartment().equalsIgnoreCase( "electronics"));
  8.         
  9. KStream<String, Purchase> coffeeStream = branchesStream[ 0];
  10. KStream<String, Purchase> electronicsStream = branchesStream[ 1];

注意KStream.selectKey方法会触发数据重新分区。下图是更新的处理拓扑:

3.2 创建连接器

内连接两个流可以使用KStream.join方法,它的第二个参数是ValueJoiner的一个实例,所以要先创建一个连接器,实现其接口方法apply:


 
 
  1. public class PurchaseJoiner implements ValueJoiner<Purchase, Purchase, CorrelatedPurchase> {
  2.      @Override
  3.      public CorrelatedPurchase apply(Purchase purchase, Purchase otherPurchase) {
  4.         CorrelatedPurchase.Builder builder = CorrelatedPurchase.newBuilder();
  5.         Date purchaseDate = purchase != null ? purchase.getPurchaseDate() : null;
  6.         Double price = purchase != null ? purchase.getPrice() : 0.0;
  7.         String itemPurchased = purchase != null ? purchase.getItemPurchased() : null;
  8.         Date otherPurchaseDate = otherPurchase != null ? otherPurchase.getPurchaseDate() : null;
  9.         Double otherPrice = otherPurchase != null ? otherPurchase.getPrice() : 0.0;
  10.         String otherItemPurchased = otherPurchase != null ? otherPurchase.getItemPurchased() : null;
  11.         List<String> purchasedItems = new ArrayList<String>();
  12.          if (itemPurchased != null) {
  13.             purchasedItems.add(itemPurchased);
  14.         }
  15.          if (otherItemPurchased != null) {
  16.             purchasedItems.add(otherItemPurchased);
  17.         }
  18.         String customerId = purchase != null ? purchase.getCustomerId() : null;
  19.         String otherCustomerId = otherPurchase != null ? otherPurchase.getCustomerId() : null;
  20.         builder.withCustomerId(customerId != null ? customerId : otherCustomerId)
  21.                 .withFirstPurchaseDate(purchaseDate)
  22.                 .withSecondPurchaseDate(otherPurchaseDate)
  23.                 .withItemsPurchased(purchasedItems)
  24.                 .withTotalAmount(price + otherPrice);
  25.          return builder.build();
  26.     }
  27. }

连接返回的对象是CorrelatedPurchase:


 
 
  1. import java.util.Date;
  2. import java.util.List;
  3. public class CorrelatedPurchase {
  4.      private String customerId;
  5.      private List<String> itemsPurchased;
  6.      private double totalAmount;
  7.      private Date firstPurchaseTime;
  8.      private Date secondPurchaseTime;
  9.      private CorrelatedPurchase(Builder builder) {
  10.         customerId = builder.customerId;
  11.         itemsPurchased = builder.itemsPurchased;
  12.         totalAmount = builder.totalAmount;
  13.         firstPurchaseTime = builder.firstPurchasedItem;
  14.         secondPurchaseTime = builder.secondPurchasedItem;
  15.     }
  16.      public static Builder newBuilder() {
  17.          return new Builder();
  18.     }
  19.      // 省略get方法
  20.      @Override
  21.      public String toString() {
  22.          return "CorrelatedPurchase{" + "customerId='" + customerId + '\'' + ", itemsPurchased=" + itemsPurchased
  23.                 + ", totalAmount=" + totalAmount + ", firstPurchaseTime=" + firstPurchaseTime + ", secondPurchaseTime="
  24.                 + secondPurchaseTime + '}';
  25.     }
  26.      public static final class Builder {
  27.          private String customerId;
  28.          private List<String> itemsPurchased;
  29.          private double totalAmount;
  30.          private Date firstPurchasedItem;
  31.          private Date secondPurchasedItem;
  32.          private Builder() {
  33.         }
  34.          public Builder withCustomerId(String val) {
  35.             customerId = val;
  36.              return this;
  37.         }
  38.          public Builder withItemsPurchased(List<String> val) {
  39.             itemsPurchased = val;
  40.              return this;
  41.         }
  42.          public Builder withTotalAmount(double val) {
  43.             totalAmount = val;
  44.              return this;
  45.         }
  46.          public Builder withFirstPurchaseDate(Date val) {
  47.             firstPurchasedItem = val;
  48.              return this;
  49.         }
  50.          public Builder withSecondPurchaseDate(Date val) {
  51.             secondPurchasedItem = val;
  52.              return this;
  53.         }
  54.          public CorrelatedPurchase build() {
  55.              return new CorrelatedPurchase( this);
  56.         }
  57.     }
  58. }

3.3 内连接两个流

这样我们就可以调用KStream.join方法,内连接咖啡店和电子商店的数据流。下面是更新的拓扑:

连接代码:


 
 
  1. // 20分钟连接窗口
  2. JoinWindows twentyMinuteWindow = JoinWindows.of( 60 * 1000 * 20);
  3. KStream<String, CorrelatedPurchase> joinedKStream = coffeeStream.join(electronicsStream, new PurchaseJoiner(), twentyMinuteWindow,
  4.         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,下面是示例代码,使用了购买的时间:


 
 
  1. public class TransactionTimestampExtractor implements TimestampExtractor {
  2.      @Override
  3.      public long extract(ConsumerRecord<Object, Object> record, long previousTimestamp) {
  4.         Purchase purchasePurchaseTransaction = (Purchase) record.value();
  5.          return purchasePurchaseTransaction.getPurchaseDate().getTime();
  6.     }
  7. }

注意:日志保留和滚动是基于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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值