Spring-Kafka がデータを失わずにメッセージのバッチ消費を実装する方法
まず答えを教えてください:
// 批量消费配置: 1批量, 2手动提交
factory.setBatchListener(true);
factory.getContainerProperties().setAckMode(AbstractMessageListenerContainer.AckMode.MANUAL_IMMEDIATE);
// 调大fetch的相关参数, 以便于提升吞吐量, 但会增大延时
// 一次poll操作最大获取的记录数量
propsMap.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, maxPollRecords); // max.poll.records, 缺省是500
// 一次fetch操作最小的字节数, 如果低于这个字节数, 就会等待, 直到超时后才返回给消费者. 这里给100kB, 缺省是1B
propsMap.put(ConsumerConfig.FETCH_MIN_BYTES_CONFIG, 1024 * 100); // fetch.min.bytes
// 一次fetch操作的最大等待时间,“最大等待时间”与“最小字节”任何一个先满足了就立即返回给消费者
// 需要注意:“最大等待时间”不能超过 session.timeout.ms 和 request.timeout.ms
propsMap.put(ConsumerConfig.FETCH_MAX_WAIT_MS_CONFIG, 10000); // fetch.max.wait.ms, 缺省是500
// 在消费者方法中注入acknowledgment并在执行完业务逻辑后手动调用确认方法
acknowledgment.acknowledge();
1. 背景:
特定のビジネス オブジェクトは複数のテーブルに関連付けられています。オブジェクトを作成するには、複数のテーブルにデータを挿入する必要があります。canal に基づく監視にはオブジェクトの複数の変更レコードが含まれ、Kafka は使用時に同じ変更を複数回処理します。オブジェクト (テーブルは異なりますが、同じオブジェクトの異なる部分) の場合、元の Kafka コンシューマーは一度に 1 つのオブジェクトを処理するため、同じオブジェクトの繰り返し処理が発生します。実際、すべてのテーブルを挿入した後でオブジェクトを処理する必要があるのは 1 回だけです。
2. 既存の技術アーキテクチャ:
mysql --> canal --> Kafka --> Spring-Kafka コンシューマ --> ダウンストリーム インターフェイス
3. 解決策:
Spring-Kafka コンシューマを最適化し、単一処理から複数処理に変更し、コンシューマ内で同じオブジェクトをマージすることで、オブジェクトを 1 回 (最大 2 回) だけ処理するという目的を達成します。なぜ 2 回処理できるのでしょうか? バッチ処理するときに、同じオブジェクトの複数のメッセージを上位バッチと下位バッチに分割するのが簡単なので、このオブジェクトは 2 回処理されることになりますが、なぜ 3 回処理できないのでしょうか? 実際、前回のような状況も作成できます。つまり、バッチサイズが小さい場合、簡単に 3 回、さらにはそれ以上発生します。したがって、通常はバッチ サイズをテーブル数よりも大きく設定します。
たとえば、挿入されたメッセージを含む 10 個のテーブルを含むオブジェクトが作成され、バッチ サイズが 4 に設定されます。このとき、10 個のメッセージはバッチ サイズ 4 の 3 つのバッチに分割され、オブジェクトは各バッチで 1 回処理されます。バッチを実行すると、オブジェクトは 3 回処理されます。したがって、バッチサイズは重要なパラメータであり、その値は通常大きな値に設定されますが、どんなに大きくても必然的に 2 つのバッチに分割されます。
4. 導入手順
Spring-kafka はバージョン 1.1 からバッチ消費をサポートしています。バッチ内の最大レコード数を制御するには、 ContainerFactory でbatchListener
=を設定しtrue
、コンシューマ パラメータを設定する必要があります。このパラメータのデフォルト値は です。max.poll.records
500
AbstractKafkaListenerContainerFactory
クラスのソースコードは次のとおりです。
/**
* Set to true if this endpoint should create a batch listener.
* @param batchListener true for a batch listener.
* @since 1.1
*/
public void setBatchListener(Boolean batchListener) {
this.batchListener = batchListener;
}
javaDoc を見ると、@since
この関数はバージョン 1.1 から存在しており、Spring-Kafka のバージョンが 1.1 より高い限り、バッチ消費関数がサポートされていることがわかります。このパラメータは@KafkaListener
組み合わせて使用されます。単回消費の書き方は以下の通りです。
@KafkaListener(id = "", groupId = "", topics = {
})
public void listen(ConsumerRecord<String, String> data) {
}
上記では一度に 1 つのメッセージしか処理できず、複数のメッセージをまとめて処理することはできないため、次のバッチ消費書き込み方法が使用されます。
@KafkaListener(id = "", groupId = "", topics = {
}, containerFactory = "")
public void listen(List<ConsumerRecord<String, String>> datas) {
}
上記のコードからわかるように、バッチ使用ではパラメータのConsumerRecord
タイプを変更するだけで済みますList<ConsumerRecord>
。
これで十分ですか?バッチが大きくなると、このデータ バッチの処理時間が長くなり、データ損失が発生しやすくなります。シナリオは次のとおりです。1
. 自動オフセット送信をオンにします。2
. オフセットを送信する時間間隔は 1 秒です。3
. このデータ バッチの処理には 2 秒かかります。
メッセージが失われるには、次の条件も発生する必要があります:
1. 「自動オフセット送信時刻」が到来し、オフセット送信が正常に実行された場合
2. プログラム内でほぼ同時に重大なエラーが発生し、終了するプロセス (この時点ではコンシューマーのコード ロジックに注意してください。実行されていません) の場合、
Kafka がオフセットの送信を受信したため、メッセージは失われます。そのため、Kafka は、このメッセージのバッチが正常に処理されたと考えます。プログラムは実際には正常に処理されていません。次回プログラムが開始されると、Kafka から記録されます。オフセットの消費が開始され、「記録されたオフセット」は、例外が発生して終了する前の「送信されたオフセット」になります。したがって、最後の異常終了時のメッセージのバッチは失われ、再度使用されることはありません。
例:
このバッチでコンシューマによって消費されるメッセージのオフセット エンコーディング リストが 5、6、および 7 であると仮定します。オフセットを自動的に送信すると、7 が Kafka に送信され、次のメッセージが 8 から消費されることを示します。ただし、これら 3 つのメッセージ 567 は、コンシューマ プロセスが処理される前に予期せず終了しました。エラーが手動で処理されてプログラムが再起動されると、消費は 8 から始まります。これは、Kafka は 567 が処理されたと考えるためですが、実際には 567 は正常に処理されていないため、567 メッセージのバッチは失われます。
さらに、メッセージの損失を防ぐにはどうすればよいでしょうか? 答えは、オフセットを手動で送信することです。Spring-Kafka もサポートを提供しています。実際、Spring-Kafka はネイティブ Kafka の単なるラッパーです。重要なのは、ネイティブ Kafka がオフセットを手動で送信する機能をサポートしていることです。
乾いた話になりますが、Spring-Kafka には非常に便利なクラスがあります。AcknowledgingMessageListener
このクラスは手動の ACK メッセージ、つまり手動のオフセット送信をサポートしますが、Spring は「オフセットの手動送信」の概念を「確認メッセージ」にパッケージ化しています。 "、次の方法があります。
/**
* Invoked with data from kafka.
* @param data the data to be processed.
* @param acknowledgment the acknowledgment.
*/
@Override
void onMessage(ConsumerRecord<K, V> data, Acknowledgment acknowledgment);
このクラスは、オフセットを手動で送信できるように実装するように設計されていますが、簡素化することもできます。前のメソッドと組み合わせて、メソッドにパラメータを置き、Spring がオブジェクトを渡すことができるため、それを@KafkaListener
制御できます「メッセージを承認」するタイミング。もちろん、このステップだけでは十分ではなく、簡単な設定を通じて、オフセットを手動で送信する必要があることを Spring-Kafka に伝える必要もあります。Acknowledgment acknowledgment
@KafkaListener
Acknowledgment
@Bean(name = "batch_and_manual_ack_ContainerFactory")
public KafkaListenerContainerFactory<ConcurrentMessageListenerContainer<String, String>> batch_and_manual_ack_ContainerFactory() {
ConcurrentKafkaListenerContainerFactory<String, String> factory = new ConcurrentKafkaListenerContainerFactory<>();
factory.setConsumerFactory(consumerFactory());
// 批量消费配置: 1批量, 2手动提交
factory.setBatchListener(true);
factory.getContainerProperties().setAckMode(AbstractMessageListenerContainer.AckMode.MANUAL_IMMEDIATE);
return factory;
}
最も重要な文は次のとおりです。.setAckMode(AckMode.MANUAL_IMMEDIATE);
この構成のデフォルト値は ですAckMode.BATCH
。ContainerProperties
デフォルト値は、クラスのソース コードから見つけることができます。
/**
* The ack mode to use when auto ack (in the configuration properties) is false.
* <ul>
* <li>RECORD: Ack after each record has been passed to the listener.</li>
* <li>BATCH: Ack after each batch of records received from the consumer has been
* passed to the listener</li>
* <li>TIME: Ack after this number of milliseconds; (should be greater than
* {@code #setPollTimeout(long) pollTimeout}.</li>
* <li>COUNT: Ack after at least this number of records have been received</li>
* <li>MANUAL: Listener is responsible for acking - use a
* {@link org.springframework.kafka.listener.AcknowledgingMessageListener}.
* </ul>
*/
private AbstractMessageListenerContainer.AckMode ackMode = AckMode.BATCH;
スループットを向上させるには、いくつかのパラメータを設定する必要があります。
-
max.poll.records
1 回のポーリング操作で取得されるレコードの最大数。デフォルトは 500 です。値が大きいほどスループットは高くなりますが、コンシューマはタイムアウトなしですべてのメッセージを処理できる必要があります。 -
fetch.min.bytes は
、フェッチ操作の最小バイト数です。このバイト数より小さい場合、タイムアウトになるまで待機し、コンシューマーに戻ります。デフォルトは 1B です。 -
fetch.max.wait.ms は
、フェッチ操作の最大待機時間です。「最大待機時間」または「最小バイト数」のいずれかが最初に満たされると、すぐにコンシューマに返されます。デフォルトは 500 です。
注: 「最大待機時間」は session.timeout.ms および request.timeout.ms を超えることはできません。
5. すべてのコード:
@Bean(name = "batch_and_manual_ack_ContainerFactory")
public KafkaListenerContainerFactory<ConcurrentMessageListenerContainer<String, String>> batch_and_manual_ack_ContainerFactory() {
ConcurrentKafkaListenerContainerFactory<String, String> factory = new ConcurrentKafkaListenerContainerFactory<>();
factory.setConsumerFactory(consumerFactory());
factory.setConcurrency(concurrency);
factory.getContainerProperties().setPollTimeout(1500);
// 批量消费配置: 1批量, 2手动提交
factory.setBatchListener(true);
factory.getContainerProperties().setAckMode(AbstractMessageListenerContainer.AckMode.MANUAL_IMMEDIATE);
return factory;
}
public ConsumerFactory<String, String> consumerFactory() {
return new DefaultKafkaConsumerFactory<String, String>(consumerConfigs());
}
public Map<String, Object> consumerConfigs() {
Map<String, Object> propsMap = new HashMap<String, Object>();
propsMap.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, servers);
propsMap.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, enableAutoCommit);
propsMap.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, autoCommitInterval);
propsMap.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, sessionTimeout);
propsMap.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
propsMap.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
propsMap.put(ConsumerConfig.GROUP_ID_CONFIG, groupId);
propsMap.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, autoOffsetReset);
// 调大fetch的相关参数, 以便于提升吞吐量, 但会增大延时
// 一次poll操作最大获取的记录数量
propsMap.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, maxPollRecords); // max.poll.records, 缺省是500
// 一次fetch操作最小的字节数, 如果低于这个字节数, 就会等待, 直到超时后才返回给消费者. 这里给100kB, 缺省是1B
propsMap.put(ConsumerConfig.FETCH_MIN_BYTES_CONFIG, 1024 * 100); // fetch.min.bytes
// 一次fetch操作的最大等待时间,“最大等待时间”与“最小字节”任何一个先满足了就立即返回给消费者
// 需要注意:“最大等待时间”不能超过 session.timeout.ms 和 request.timeout.ms
propsMap.put(ConsumerConfig.FETCH_MAX_WAIT_MS_CONFIG, 10000); // fetch.max.wait.ms, 缺省是500
return propsMap;
}
@KafkaListener(groupId = "junit-test-group", containerFactory = "batch_and_manual_ack_ContainerFactory", topics = {
"test"})
public void test_batchConsume(List<ConsumerRecord<String, String>> datas, Acknowledgment acknowledgment) {
System.out.println(new Date() + " datas = " + datas.size());
System.out.println(new Date() + " collect = " + datas.stream().map(t -> t.offset()).collect(Collectors.toList()));
// 最后一定要提交进度 (用于持久化进度到Kafka)
acknowledgment.acknowledge();
}
上記のコードを Spring クラスに配置し、それを使用するように構成を変更します。