Kafka キュー (シェアコンシューマー)

バージョン 4.0 以降、Spring for Apache Kafka は共有コンシューマーを介して Kafka キューをサポートし、KIP-932 (Queues for Kafka) [Apache] (英語) を実装しています。Spring for Apache Kafka 4.1 以降、Apache Kafka 4.2 を基盤として、共有コンシューマーは完全な本番環境サポートへと昇格しました。

Kafka キューは、従来のコンシューマーグループとは異なる消費モデルを実現します。各パーティションが 1 つのコンシューマーに排他的に割り当てられるパーティションベースの割り当てモデルではなく、共有コンシューマーが協力して同じパーティションから消費し、レコードは共有グループ内のコンシューマー間で分散されます。

シェアコンシューマーファクトリ

ShareConsumerFactory は共有コンシューマーインスタンスの作成を担当します。Spring と Kafka は DefaultShareConsumerFactory 実装を提供します。

構成

通常の ConsumerFactory を構成する方法と同様に、DefaultShareConsumerFactory を構成できます。

@Bean
public ShareConsumerFactory<String, String> shareConsumerFactory() {
    Map<String, Object> props = new HashMap<>();
    props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
    props.put(ConsumerConfig.GROUP_ID_CONFIG, "my-share-group");
    props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
    props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
    return new DefaultShareConsumerFactory<>(props);
}

DefaultShareConsumerFactory は KafkaResourceFactory を継承しているため、構成マップでブートストラップサーバーを固定する代わりに、setBootstrapServersSupplier() を呼び出すことで実行時にサーバーを指定できます。サプライヤーは新しい共有コンシューマーごとに参照され、構成済みの bootstrap.servers よりも優先されます。詳細については、ABSwitchCluster ヘルパーを含む Kafka への接続を参照してください。

コンストラクターオプション

DefaultShareConsumerFactory にはいくつかのコンストラクターオプションが用意されています。

// Basic configuration
new DefaultShareConsumerFactory<>(configs);

// With deserializer suppliers
new DefaultShareConsumerFactory<>(configs, keyDeserializerSupplier, valueDeserializerSupplier);

// With deserializer instances
new DefaultShareConsumerFactory<>(configs, keyDeserializer, valueDeserializer, configureDeserializers);

デシリアライザの設定

デシリアライザーはいくつかの方法で設定できます。

  1. Via Configuration Properties (単純なケースに推奨):

    props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
    props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
  2. Setter 経由 :

    factory.setKeyDeserializer(new StringDeserializer());
    factory.setValueDeserializer(new StringDeserializer());
  3. Via Suppliers (コンシューマーごとにデシリアライザーを作成する必要がある場合):

    factory.setKeyDeserializerSupplier(() -> new StringDeserializer());
    factory.setValueDeserializerSupplier(() -> new StringDeserializer());

デシリアライザーがすでに完全に構成されており、ファクトリで再構成する必要がない場合は、configureDeserializers を false に設定します。

ライフサイクルリスナー

共有コンシューマーのライフサイクルを監視するためにリスナーを追加できます。

factory.addListener(new ShareConsumerFactory.Listener<String, String>() {
    @Override
    public void consumerAdded(String id, ShareConsumer<String, String> consumer) {
        // Called when a new consumer is created
        System.out.println("Consumer added: " + id);
    }

    @Override
    public void consumerRemoved(String id, ShareConsumer<String, String> consumer) {
        // Called when a consumer is closed
        System.out.println("Consumer removed: " + id);
    }
});

メッセージリスナーコンテナーを共有する

ShareKafkaMessageListenerContainer

ShareKafkaMessageListenerContainer は、同時処理をサポートする共有コンシューマー用のコンテナーを提供します。

@Bean
public ShareKafkaMessageListenerContainer<String, String> container(
        ShareConsumerFactory<String, String> shareConsumerFactory) {

    ContainerProperties containerProps = new ContainerProperties("my-topic");
    containerProps.setGroupId("my-share-group");

    ShareKafkaMessageListenerContainer<String, String> container =
        new ShareKafkaMessageListenerContainer<>(shareConsumerFactory, containerProps);

    container.setupMessageListener(new MessageListener<String, String>() {
        @Override
        public void onMessage(ConsumerRecord<String, String> record) {
            System.out.println("Received: " + record.value());
        }
    });

    return container;
}

コンテナーのプロパティ

共有コンテナーは、通常のコンシューマーが使用できるコンテナープロパティのサブセットをサポートします。

  • topics: サブスクライブするトピック名の配列

  • groupId: 共有グループ ID

  • clientId: コンシューマーのクライアント ID

  • shareAckMode: 確認モード (EXPLICIT, MANUAL, or IMPLICIT; see 記録確認 )

  • shareAcknowledgmentTimeout: MANUAL モードで未確認レコードを検出するためのタイムアウト

  • syncShareCommits: 共有コンシューマー確認に commitSync() (デフォルト)または commitAsync() のどちらを使用するか ( 同期コミットと非同期コミットを参照してください)

  • kafkaConsumerProperties: 追加のコンシューマープロパティ

共有コンシューマーは以下をサポートしません:

  • 明示的なパーティション割り当て (TopicPartitionOffset)

  • トピックパターン

  • 手動オフセット管理

ライフサイクルイベント

ShareKafkaMessageListenerContainer は、コンシューマーライフサイクル全体にわたって Spring アプリケーションイベントを発行します。

  • ConsumerStartingEvent: 共有コンシューマースレッドが最初に起動されたとき、ポーリングを開始する前に公開されます。

  • ConsumerStartedEvent: 株式保有者が投票を開始しようとする時点で公開されます。

  • ConsumerFailedToStartEvent: 共有コンシューマーの起動に失敗した場合に発行されます(たとえば、基盤となる ShareConsumer を作成できなかった場合など)。同じ起動試行ですでに構築された ShareConsumer インスタンスは、ポーリングループに到達しなかったため、静かに閉じられ、停止イベントや停止イベントは発行されません。

  • ShareConsumerStoppingEvent: 各共有コンシューマーが、その基盤となる ShareConsumer が閉じられる直前に発行します。ConsumerStoppingEvent とは異なり、このイベントには ShareConsumer 参照が含まれます (Consumer 参照ではありません)。これは、ShareConsumer が別のクライアント API であり、特定のパーティションに割り当てられていないためです。

  • ConsumerStoppedEvent: シェアコンシューマーが閉じられた後に公開され、reason を使用します。

    • NORMAL - コンテナーは停止した。

    • ABNORMAL - Exception によりコンシューマースレッドが終了しました。

    • ERROR - java.lang.Error がスローされました。

コンシューマースレッドが ABNORMAL または ERROR で終了した後、コンテナーが再起動されるまで isInExpectedState() は false を返します。コンテナー自体はまだ実行中なので (isRunning() は true を返します)、stop() を呼び出してから start() を呼び出して再起動します。isInExpectedState() も、stopAbnormally() が呼び出された後、コンテナーが再起動されるまで false を返します。

これらのイベントは、@EventListener (イベントの消費を参照)で利用することで、共有コンテナーを通常のコンテナーと同様の監視および再起動パターンで統合できます。

並行性

ShareKafkaMessageListenerContainer は、単一のコンテナー内に複数のコンシューマースレッドを作成することで、並行処理をサポートします。各スレッドは、同じ共有グループに参加する独自の ShareConsumer インスタンスを実行します。

従来のコンシューマーグループでは同時実行にパーティション分散が伴いますが、共有コンシューマーはブローカーにおける Kafka のレコードレベルの分散を活用します。つまり、同一コンテナー内の複数のコンシューマースレッドが共有グループの一部として連携し、Kafka ブローカーがすべてのコンシューマーインスタンスにレコードを分散します。

同時実行性はアプリケーションインスタンス間で加算される

共有グループの観点から見ると、各 ShareConsumer インスタンスは、実行場所に関係なく独立したメンバーです。単一のコンテナーに concurrency=3 を設定すると、共有グループのメンバーが 3 つ作成されます。同じ共有グループ ID を持つ複数のアプリケーションインスタンスを実行すると、それらのすべてのコンシューマースレッドが 1 つのプールに統合されます。

次に例を示します: * アプリケーションインスタンス 1: concurrency=3 → 3 つの共有グループメンバー * アプリケーションインスタンス 2: concurrency=3 → 3 人の共有グループメンバー * 合計 : ブローカーがレコードを配布できる 6 人のシェアグループメンバー

つまり、単一のコンテナーに concurrency=5 を設定することは、それぞれ concurrency=1 を持つ 5 つの個別のアプリケーションインスタンス(すべて同じ group.id を使用)を実行するのと同等の動作となります。Kafka ブローカーはすべてのコンシューマーインスタンスを等価に扱い、レコードをプール全体に分散させます。

プログラムによる同時実行の設定

@Bean
public ShareKafkaMessageListenerContainer<String, String> concurrentContainer(
        ShareConsumerFactory<String, String> shareConsumerFactory) {

    ContainerProperties containerProps = new ContainerProperties("my-topic");
    containerProps.setGroupId("my-share-group");

    ShareKafkaMessageListenerContainer<String, String> container =
        new ShareKafkaMessageListenerContainer<>(shareConsumerFactory, containerProps);

    // Set concurrency to create 5 consumer threads
    container.setConcurrency(5);

    container.setupMessageListener(new MessageListener<String, String>() {
        @Override
        public void onMessage(ConsumerRecord<String, String> record) {
            System.out.println("Received on " + Thread.currentThread().getName() + ": " + record.value());
        }
    });

    return container;
}

ファクトリによる並行性の設定

ファクトリレベルでデフォルトの同時実行性を設定できます。これは、そのファクトリによって作成されたすべてのコンテナーに適用されます。

@Bean
public ShareKafkaListenerContainerFactory<String, String> shareKafkaListenerContainerFactory(
        ShareConsumerFactory<String, String> shareConsumerFactory) {

    ShareKafkaListenerContainerFactory<String, String> factory =
        new ShareKafkaListenerContainerFactory<>(shareConsumerFactory);

    // Set default concurrency for all containers created by this factory
    factory.setConcurrency(3);

    return factory;
}

リスナーごとの同時実行

同時実行設定は、concurrency 属性を使用してリスナーごとにオーバーライドできます。

@Component
public class ConcurrentShareListener {

    @KafkaListener(
        topics = "high-throughput-topic",
        containerFactory = "shareKafkaListenerContainerFactory",
        groupId = "my-share-group",
        concurrency = "10"  // Override factory default
    )
    public void listen(ConsumerRecord<String, String> record) {
        // This listener will use 10 consumer threads
        System.out.println("Processing: " + record.value());
    }
}

同時実行に関する考慮事項

  • スレッドセーフ : 各コンシューマースレッドは独自の ShareConsumer インスタンスを持ち、独自の確認応答を独立して管理します。

  • Client IDs : 各コンシューマースレッドは、数字の接尾辞を持つ一意のクライアント ID を受け取ります。(e.g., myContainer-0, myContainer-1, etc.)

  • メトリクス : すべてのコンシューマースレッドからのメトリクスは集約され、container.metrics() 経由でアクセス可能

  • ライフサイクル : すべてのコンシューマースレッドはユニットとして同時に開始および停止します

  • Work Distribution : Kafka ブローカーは、共有グループ内のすべてのコンシューマーインスタンス間でレコードの配布を処理します。

  • 了承 : 各スレッドは、自身のレコードに対する確認応答を独立して管理します。あるスレッドで確認応答されていないレコードがあっても、他のスレッドはブロックされません。

手動確認による同時実行

並行処理は ShareAckMode.MANUAL とシームレスに連携します。各コンシューマースレッドは、それぞれ独立して自身のレコードを追跡し、確認します。

@KafkaListener(
    topics = "order-queue",
    containerFactory = "manualShareKafkaListenerContainerFactory",
    groupId = "order-processors",
    concurrency = "5"
)
public void processOrder(ConsumerRecord<String, String> record, ShareAcknowledgment acknowledgment) {
    try {
        processOrderLogic(record.value());
        acknowledgment.acknowledge(); // ACCEPT
    }
    catch (RetryableException e) {
        acknowledgment.release(); // Will be redelivered
    }
    catch (Exception e) {
        acknowledgment.reject(); // Permanent failure
    }
}

レコードの取得と配布の動作:

共有コンシューマーはプルベースのモデルを採用しており、各コンシューマースレッドは poll() を呼び出してブローカーからレコードを取得します。コンシューマーがポーリングを行うと、ブローカーの共有パーティションリーダーは次の処理を実行します。

  • 「利用可能」状態のレコードを選択します

  • 期間限定の取得ロックで「取得済み」状態に移行します (default 30 seconds, configurable via group.share.record.lock.duration.ms)

  • 効率性のために完全なレコードバッチを返すことを優先する

  • デフォルトでは max.poll.records をソフトリミットとして適用します。つまり、この値を超えても完全なレコードバッチが取得されます。

===== 取得モード (KIP-1206)

Apache Kafka 4.2 では、max.poll.records の適用範囲を厳密に制御する share.acquire.mode コンシューマープロパティが導入されます。

  • batch_optimized (default) — ソフトリミット。ブローカーは、スループット向上のため、バッチ境界に合わせて max.poll.records よりも多くのレコードを返す場合があります。

  • record_limit — 厳密な制限。ブローカーは、poll() 回の呼び出しで max.poll.records を超えるレコードを返すことはありません。メモリ使用量や処理速度を正確に制御する必要がある場合に便利です。

これはパススルー型のコンシューマープロパティです。コンシューマープロパティで直接設定してください。

Map<String, Object> props = new HashMap<>();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put("share.acquire.mode", "record_limit");
props.put("max.poll.records", 5);
// ... other properties

DefaultShareConsumerFactory<String, String> factory = new DefaultShareConsumerFactory<>(props);
Spring および Kafka に関する追加の設定は不要です。プロパティは基となる KafkaShareConsumer に直接渡されます。

あるコンシューマーがレコードを取得している間は、他のコンシューマーはそれらのレコードを利用できません。取得ロックの有効期限が切れると、未確認のレコードは自動的に「利用可能」状態に戻り、他のコンシューマーに配信できるようになります。

ブローカーは、group.share.partition.max.record.locks を使用してパーティションごとに取得できるレコード数を制限します。この制限に達すると、ロックが期限切れになるまで、後続のポーリングでは一時的にレコードが返されません。

並行性への影響:

  • 各コンシューマースレッドは独立してポーリングし、ポーリングごとに異なる数のレコードを取得する可能性があります。

  • スレッド間のレコードの分配は、ポーリングのタイミングとバッチの可用性に依存します。

  • 複数のスレッドにより、レコードを取得できるコンシューマーのプールが増加する

  • メッセージ量が少ない場合やパーティションが 1 つしかない場合、レコードはより少ないスレッドに集中する可能性があります。

  • 長時間実行されるワークロードの場合、分散はより均等になる傾向がある

構成:

  • 各スレッドは独立してレコードをポーリングして処理します

  • 確認応答制約はスレッドごとに適用される (one thread’s unacknowledged records don’t block other threads)

  • 同時実行設定は 0 より大きい必要があり、コンテナーの実行中に変更することはできません。

アノテーション駆動型リスナー

@KafkaListener とシェアコンシューマー

ShareKafkaListenerContainerFactory を設定することで、@KafkaListener を共有コンシューマーで使用できます。

@Configuration
@EnableKafka
public class ShareConsumerConfig {

    @Bean
    public ShareConsumerFactory<String, String> shareConsumerFactory() {
        Map<String, Object> props = new HashMap<>();
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
        return new DefaultShareConsumerFactory<>(props);
    }

    @Bean
    public ShareKafkaListenerContainerFactory<String, String> shareKafkaListenerContainerFactory(
            ShareConsumerFactory<String, String> shareConsumerFactory) {
        return new ShareKafkaListenerContainerFactory<>(shareConsumerFactory);
    }
}

次に、リスナー内でそれを使用します。

@Component
public class ShareMessageListener {

    @KafkaListener(
        topics = "my-queue-topic",
        containerFactory = "shareKafkaListenerContainerFactory",
        groupId = "my-share-group"
    )
    public void listen(ConsumerRecord<String, String> record) {
        System.out.println("Received from queue: " + record.value());
        // Record is automatically acknowledged with ACCEPT
    }
}

共有グループオフセットリセット

通常のコンシューマーグループとは異なり、共有グループではオフセットリセット動作に異なる設定が使用されます。この設定はプログラムで構成できます。

private void configureShareGroup(String bootstrapServers, String groupId) throws Exception {
    Map<String, Object> adminProps = new HashMap<>();
    adminProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);

    try (Admin admin = Admin.create(adminProps)) {
        ConfigResource configResource = new ConfigResource(ConfigResource.Type.GROUP, groupId);
        ConfigEntry configEntry = new ConfigEntry("share.auto.offset.reset", "earliest");

        Map<ConfigResource, Collection<AlterConfigOp>> configs = Map.of(
            configResource, List.of(new AlterConfigOp(configEntry, AlterConfigOp.OpType.SET))
        );

        admin.incrementalAlterConfigs(configs).all().get();
    }
}

記録確認

Spring と Kafka は、ContainerProperties.ShareAckMode を介して 3 つの確認応答モードを公開し、処理後にレコードがどのように確認応答されるかを制御します。

EXPLICIT (デフォルト): コンテナー管理による確認応答

EXPLICIT モードでは、コンテナーが確認応答の全責任を負います。リスナーが正常に応答を返した後、コンテナーは記録のために ACCEPT を送信します。リスナーが例外を返した場合、ShareConsumerRecordRecoverer が結果を決定します(ACCEPT、RELEASE、REJECT。デフォルトは REJECT)。これは、通常のコンシューマーで auto.commit を無効にするのと最も近い類似点です。コンテナーは、リスナーの関与なしに、すべての確認応答の決定を管理します。

これはデフォルトモードです。追加の設定は不要です。

@Bean
public ShareKafkaListenerContainerFactory<String, String> shareKafkaListenerContainerFactory(
        ShareConsumerFactory<String, String> shareConsumerFactory) {
    // EXPLICIT is the default — no shareAckMode configuration needed
    return new ShareKafkaListenerContainerFactory<>(shareConsumerFactory);
}
@KafkaListener(topics = "my-queue-topic", containerFactory = "shareKafkaListenerContainerFactory",
               groupId = "my-share-group")
public void listen(ConsumerRecord<String, String> record) {
    // Container sends ACCEPT on success; recoverer decides on error (default: REJECT)
    process(record.value());
}

MANUAL: リスナー管理による確認応答

MANUAL モードでは、リスナーがレコードごとの確認応答の決定をすべて行います。各レコードには、リスナーが終端操作(acknowledge()、release()、reject())で一度だけ呼び出す必要がある、null 以外の ShareAcknowledgment インスタンスが付属しています。前回のポーリングで受信したすべてのレコードが確認されるまで、以降のポーリングはブロックされます。

ビジネスロジックによってレコードごとに確認応答の結果が決定される場合は、MANUAL モードを使用してください。

@Bean
public ShareKafkaListenerContainerFactory<String, String> manualShareKafkaListenerContainerFactory(
        ShareConsumerFactory<String, String> shareConsumerFactory) {
    ShareKafkaListenerContainerFactory<String, String> factory =
        new ShareKafkaListenerContainerFactory<>(shareConsumerFactory);
    factory.getContainerProperties().setShareAckMode(ContainerProperties.ShareAckMode.MANUAL);
    return factory;
}

リスナーは ShareAcknowledgment パラメーターを受け入れる必要があります(つまり、AcknowledgingShareConsumerAwareMessageListener を実装する必要があります)。

@KafkaListener(topics = "order-queue", containerFactory = "manualShareKafkaListenerContainerFactory")
public void processOrder(ConsumerRecord<String, String> record, ShareAcknowledgment acknowledgment) {
    try {
        processOrderLogic(record.value());
        acknowledgment.acknowledge(); // ACCEPT — success
    }
    catch (TransientException e) {
        acknowledgment.release();     // RELEASE — redelivery
    }
    catch (Exception e) {
        acknowledgment.reject();      // REJECT — permanent failure
    }
}

ShareAckMode.MANUAL では、リスナーが ShareAcknowledgment パラメーターを受け入れること(つまり、AcknowledgingShareConsumerAwareMessageListener を実装すること)が求められます。コンテナーは起動時にこれを検証し、単純な MessageListener が使用された場合は IllegalStateException 例外をスローします。

IMPLICIT: Kafka クライアント暗黙モード

IMPLICIT モードでは、確認応答は完全に Kafka ブローカーに委譲され、処理結果に関わらず、取得したすべてのレコードが自動的に受け入れられます。レコードごとの確認応答 API は利用できません。ShareAcknowledgment 引数は常に null です。これは、Kafka クライアント構成で share.acknowledgement.mode=implicit を設定することに直接対応します。

@Bean
public ShareKafkaListenerContainerFactory<String, String> implicitShareKafkaListenerContainerFactory(
        ShareConsumerFactory<String, String> shareConsumerFactory) {
    ShareKafkaListenerContainerFactory<String, String> factory =
        new ShareKafkaListenerContainerFactory<>(shareConsumerFactory);
    factory.getContainerProperties().setShareAckMode(ContainerProperties.ShareAckMode.IMPLICIT);
    return factory;
}

IMPLICIT モードでは、ShareConsumerRecordRecoverer は参照されません。処理エラーはブローカー側から見て確認なしで吸収され、すべてのレコードは常に ACCEPT されます。このモードは、レコードごとの配信保証が不要な場合にのみ使用してください。

確認応答モードの比較

モード 誰が認めるか 成功について リスナーエラー発生時

EXPLICIT (default)

Container

Container sends ACCEPT

回復者が決定する (デフォルトで REJECT )

MANUAL

リスナーコード

リスナーが acknowledge() を呼び出す

リスナーが release() または reject() を呼び出す

IMPLICIT

Kafka ブローカー

ブローカーによる自動承認

ブローカーによる自動承認 (no recovery)

4.0 からの移行

4.0 では、共有コンシューマーの確認は setExplicitShareAcknowledgment(boolean) によって制御されていました。4.1 では、これは setShareAckMode(ShareAckMode) に置き換えられています。非推奨のメソッドは引き続き機能します。true は MANUAL に、false は EXPLICIT にマッピングされます。

Default behavior is unchanged。4.0 のデフォルト(setExplicitShareAcknowledgment(false))はコンテナー管理による確認応答であり、これは ShareAckMode.EXPLICIT が行うことと全く同じです。デフォルトを使用するアプリケーションでは、特に何もする必要はありません。

If you used setExplicitShareAcknowledgment(true) を置き換えてください。

// Before (4.0)
factory.getContainerProperties().setExplicitShareAcknowledgment(true);

// After (4.1)
factory.getContainerProperties().setShareAckMode(ContainerProperties.ShareAckMode.MANUAL);

If you set share.acknowledgement.mode=implicit in the factory configuration (ConsumerConfig.SHARE_ACKNOWLEDGEMENT_MODE_CONFIG 経由) では、4.0 ではコンテナーが常に consumer.acknowledge() を呼び出すため、この設定は効果がありませんでした。4.1 では、コンテナーはこの競合を検出し、警告をログに記録し、明示モードで提供時の設定を上書きします。Kafka クライアントの暗黙モードを使用するには、コンテナーで明示的に設定してください。

factory.getContainerProperties().setShareAckMode(ContainerProperties.ShareAckMode.IMPLICIT);

謝辞の種類

コンシューマーが支持する 4 つの承認型:

  • ACCEPT — 記録は正常に処理されました。完了としてマークしてください。

  • RELEASE — 一時的な障害です。この記録を、この顧客または別の顧客に再配信できるようにしてください。

  • REJECT — 永久的な障害です。記録をアーカイブし、再送信しないでください。

  • RENEW — 取得ロックを延長します(ターミナル以外)。処理時間がブローカーのロック期間(group.share.record.lock.duration.ms、デフォルトは 30 秒)を超える可能性がある場合は、renew() を使用します。ターミナルからの確認応答(acknowledge()、release()、reject())は引き続き必要です。

ShareAcknowledgment API

ShareAcknowledgment インターフェースは、ShareAckMode.MANUAL モードでのみ非 null になります。

public interface ShareAcknowledgment {
    void acknowledge(); // ACCEPT — record successfully processed
    void release();     // RELEASE — redelivery on transient failure
    void reject();      // REJECT — permanent failure, do not retry
    void renew();       // RENEW (non-terminal) — extend acquisition lock
}

リスナーインターフェース

基本メッセージリスナー

Use the standard MessageListener in EXPLICIT (default) or IMPLICIT mode:

@KafkaListener(topics = "my-topic", containerFactory = "shareKafkaListenerContainerFactory")
public void listen(ConsumerRecord<String, String> record) {
    // In EXPLICIT mode (default): container sends ACCEPT on success, REJECT on error
    process(record.value());
}

AcknowledgingShareConsumerAwareMessageListener

This interface provides access to the ShareConsumer instance and, in MANUAL mode, to the ShareAcknowledgment. The acknowledgment parameter is non-null only in MANUAL mode; it is null in EXPLICIT and IMPLICIT modes.

EXPLICIT Mode (デフォルト— acknowledgment is null)
@KafkaListener(
    topics = "my-topic",
    containerFactory = "shareKafkaListenerContainerFactory"  // EXPLICIT by default
)
public void listen(ConsumerRecord<String, String> record,
                  @Nullable ShareAcknowledgment acknowledgment,
                  ShareConsumer<?, ?> consumer) {

    // acknowledgment is null in EXPLICIT mode — container handles ACCEPT/REJECT automatically
    process(record.value());

    // Access consumer metrics if needed
    Map<MetricName, ? extends Metric> metrics = consumer.metrics();
}
MANUAL Mode (acknowledgment is non-null)
@KafkaListener(
    topics = "my-topic",
    containerFactory = "manualShareKafkaListenerContainerFactory" // ShareAckMode.MANUAL
)
public void listen(ConsumerRecord<String, String> record,
                  @Nullable ShareAcknowledgment acknowledgment,
                  ShareConsumer<?, ?> consumer) {

    // acknowledgment is non-null in MANUAL mode
    try {
        processRecord(record);
        acknowledgment.acknowledge(); // ACCEPT
    }
    catch (RetryableException e) {
        acknowledgment.release(); // Will be redelivered
    }
    catch (Exception e) {
        acknowledgment.reject(); // Permanent failure
    }
}

承認に関する制約 (MANUAL Mode)

In ShareAckMode.MANUAL, the container enforces the following constraints:

  • Poll Blocking : Subsequent polls are blocked until all records from the previous poll are acknowledged.

  • Terminal Acknowledgment : Each record must receive exactly one terminal acknowledgment (acknowledge(), release(), or reject()). You may call renew() zero or more times before that to extend the acquisition lock.

  • エラー処理 : If processing throws an exception, the outcome is determined by the ShareConsumerRecordRecoverer (default: REJECT).

In ShareAckMode.MANUAL, failing to acknowledge records will block further message processing. Always ensure records are acknowledged in all code paths, including exception handlers.

確認応答タイムアウト検出

To help identify missing acknowledgments, Spring Kafka provides configurable timeout detection in MANUAL mode. When a record is not acknowledged within the specified timeout, a warning is logged with details about the unacknowledged record. Calling renew() restarts the timeout for that record, so records whose lock is renewed during long processing are not reported.

@Bean
public ShareKafkaListenerContainerFactory<String, String> manualShareKafkaListenerContainerFactory(
    ShareConsumerFactory<String, String> shareConsumerFactory) {
    ShareKafkaListenerContainerFactory<String, String> factory =
        new ShareKafkaListenerContainerFactory<>(shareConsumerFactory);
    factory.getContainerProperties().setShareAckMode(ContainerProperties.ShareAckMode.MANUAL);

    // Set acknowledgment timeout (default is 30 seconds)
    factory.getContainerProperties().setShareAcknowledgmentTimeout(Duration.ofSeconds(30));

    return factory;
}

When a record exceeds the timeout, you’ll see a warning like:

WARN: Record not acknowledged within timeout (30 seconds).
In ShareAckMode.MANUAL you must call ack.acknowledge(), ack.release(),
or ack.reject() for every record (call ack.renew() to extend the lock; a terminal ack is still required).
Unacknowledged record: topic='my-topic', partition=0, offset=42

This feature helps identify missing acknowledgment calls before they cause "no new records consumed" symptoms due to blocked polls.

同期コミットと非同期コミット

By default, the share container uses commitSync() to ensure acknowledgments are durable before proceeding. Set syncShareCommits to false to use commitAsync() when slightly relaxed ack-durability is acceptable in exchange for higher throughput.

@Bean
public ShareKafkaListenerContainerFactory<String, String> asyncCommitFactory(
        ShareConsumerFactory<String, String> consumerFactory) {
    ShareKafkaListenerContainerFactory<String, String> factory =
        new ShareKafkaListenerContainerFactory<>(consumerFactory);
    factory.getContainerProperties().setSyncShareCommits(false); // use commitAsync()
    return factory;
}
プロパティ値 Kafka API calledTrade-off

true (default)

consumer.commitSync()

Durable — acknowledgments are guaranteed persisted before the next poll

false

consumer.commitAsync()

Higher throughput — slight risk of redelivery if the consumer crashes before the async commit completes

エラー処理

The share container handles errors at two levels: during poll() (poll-level) and when the listener throws (listener-level).

Poll-Level Error Handling

If consumer.poll() throws an exception, the container handles it so the consumer thread does not stop:

  • RecordDeserializationException : Thrown when a record cannot be deserialized (for example, invalid UTF-8 for StringDeserializer). The container catches it, logs a warning, and continues polling. In EXPLICIT and MANUAL modes, the container REJECTs the affected record using the topic, partition, and offset from the exception. In IMPLICIT mode no explicit acknowledgment call is made — the broker auto-accepts the record. Subsequent records are processed normally.

  • CorruptRecordException : Thrown when CRC validation fails (for example, with check.crcs enabled). The Kafka client automatically rejects the corrupt batch. The container catches the exception, logs it, and continues polling.

Without this handling, a single bad record would terminate the consumer thread.

Listener-Level Error Handling: ShareConsumerRecordRecoverer

When the listener throws an exception, the container delegates to a ShareConsumerRecordRecoverer to decide whether to ACCEPT, RELEASE, or REJECT the failed record. The default is ShareConsumerRecordRecoverer.REJECTING (log and REJECT); ShareConsumerRecordRecoverer.RELEASING is also available (log and RELEASE for redelivery).

You can provide a custom recoverer to RELEASE records for transient failures (for example, downstream timeouts) so they can be redelivered to another consumer, while REJECTing permanent failures.

Configuring a Custom Recoverer

Set the recoverer on the container or on the factory so it applies to all containers created by that factory:

@Bean
public ShareKafkaListenerContainerFactory<String, String> shareKafkaListenerContainerFactory(
        ShareConsumerFactory<String, String> shareConsumerFactory) {

    ShareKafkaListenerContainerFactory<String, String> factory =
        new ShareKafkaListenerContainerFactory<>(shareConsumerFactory);

    factory.setShareConsumerRecordRecoverer((record, ex) -> {
        if (ex instanceof TransientException || ex.getCause() instanceof TimeoutException) {
            return AcknowledgeType.RELEASE;
        }
        return AcknowledgeType.REJECT;
    });

    return factory;
}

You can also set the recoverer on an individual container via container.setShareConsumerRecordRecoverer(recoverer).

Recoverer Behavior
  • The recoverer must not return RENEW; the container treats it as REJECT.

  • If the recoverer itself throws, the container falls back to REJECT for that record.

  • The default recoverer logs at ERROR and returns REJECT for every exception.

Relation to Poison Message Protection

Broker-side delivery count (see Poison Message Protection and Delivery Count ) limits how many times a record can be acquired. Even if your recoverer always returns RELEASE, the broker will eventually archive the record after the configured limit (default 5), so poison messages cannot loop forever.

Difference from Traditional Container Error Handling

Share consumers do not use CommonErrorHandler. That interface is designed for partition-based consumers (seek, remaining records, offset commit). Share consumers use acknowledgment-based recovery: the recoverer only decides ACCEPT, RELEASE, or REJECT; the container performs the acknowledgment. There is no seek or dead-letter topic integration in the share container.

Acknowledgment Examples (MANUAL Mode)

Mixed Acknowledgment Patterns

@KafkaListener(topics = "order-processing", containerFactory = "manualShareKafkaListenerContainerFactory")
public void processOrder(ConsumerRecord<String, String> record, ShareAcknowledgment acknowledgment) {
    String orderData = record.value();
    try {
        if (isValidOrder(orderData)) {
            if (processOrder(orderData)) {
                acknowledgment.acknowledge(); // ACCEPT — success
            }
            else {
                acknowledgment.release(); // RELEASE — temporary failure, retry later
            }
        }
        else {
            acknowledgment.reject(); // REJECT — invalid order, don't retry
        }
    }
    catch (Exception e) {
        acknowledgment.reject(); // REJECT — unexpected failure
    }
}

Conditional Acknowledgment

@KafkaListener(topics = "data-validation", containerFactory = "manualShareKafkaListenerContainerFactory")
public void validateData(ConsumerRecord<String, String> record, ShareAcknowledgment acknowledgment) {
    ValidationResult result = validator.validate(record.value());
    switch (result.getStatus()) {
        case VALID -> acknowledgment.acknowledge();        // ACCEPT
        case INVALID_RETRYABLE -> acknowledgment.release(); // RELEASE
        case INVALID_PERMANENT -> acknowledgment.reject();  // REJECT
    }
}

Poison Message Protection and Delivery Count

KIP-932 includes broker-side poison message protection to prevent unprocessable records from being endlessly redelivered.

使い方

Every time a record is acquired by a consumer in a share group, the broker increments an internal delivery count. The first acquisition sets the delivery count to 1, and each subsequent acquisition increments it. When the delivery count reaches the configured limit (default: 5), the record moves to Archived state and is not eligible for additional delivery attempts.

Delivery Count is Not Exposed to Applications The delivery count is maintained internally by the broker and is not exposed to consumer applications. This is an intentional design decision in KIP-932. The delivery count is approximate and serves as a poison message protection mechanism, not a precise redelivery counter. Applications cannot query or access this value through any API.

アプリケーションレベルの再試行ロジックには、以下の確認応答型を使用してください。

  • RELEASE - Make record available for redelivery (contributes to delivery count)

  • REJECT - Mark as permanently failed (does not cause redelivery)

  • ACCEPT - Successfully processed (does not cause redelivery)

The broker automatically prevents endless redelivery once group.share.delivery.count.limit is reached, moving the record to Archived state.

Retry Strategy Recommendations

Here is an example of how to use the various acknowledgement types based exception types.

@KafkaListener(topics = "orders", containerFactory = "manualShareKafkaListenerContainerFactory")
public void processOrder(ConsumerRecord<String, String> record, ShareAcknowledgment ack) {
    try {
        // Attempt to process the order
        orderService.process(record.value());
        ack.acknowledge(); // ACCEPT - successfully processed
    }
    catch (TransientException e) {
        // Temporary failure (network issue, service unavailable, etc.)
        // Release the record for redelivery
        // Broker will retry up to group.share.delivery.count.limit times
        logger.warn("Transient error processing order, will retry: {}", e.getMessage());
        ack.release(); // RELEASE - make available for retry
    }
    catch (ValidationException e) {
        // Permanent semantic error (invalid data format, business rule violation, etc.)
        // Do not retry - this record will never succeed
        logger.error("Invalid order data, rejecting: {}", e.getMessage());
        ack.reject(); // REJECT - permanent failure, do not retry
    }
    catch (Exception e) {
        // Unknown error - typically safer to reject to avoid infinite loops
        // But could also release if you suspect it might be transient
        logger.error("Unexpected error processing order, rejecting: {}", e.getMessage());
        ack.reject(); // REJECT - avoid poison message loops
    }
}

The broker’s poison message protection ensures that even if you always use RELEASE for errors, records won’t be retried endlessly. They will automatically be archived after reaching the delivery attempt limit.

Differences from Regular Consumers

Share consumers differ from regular consumers in several key ways:

  1. No Partition Assignment : Share consumers cannot be assigned specific partitions

  2. No Topic Patterns : Share consumers do not support subscribing to topic patterns

  3. Cooperative Consumption : Multiple consumers in the same share group can consume from the same partitions simultaneously

  4. Record-Level Acknowledgment : Supports explicit acknowledgment with ACCEPT、RELEASE、REJECT types

  5. Different Group Management : Share groups use different coordinator protocols

  6. No Batch Processing : Share consumers process records individually, not in batches

  7. Broker-Side Retry Management : Delivery count tracking and poison message protection are managed by the broker, not exposed to applications

Limitations and Considerations

Current Limitations

  • No Message Converters : Message converters are not yet supported for share consumers

  • No Batch Listeners : Batch processing is not supported with share consumers

  • Poll Constraints : In ShareAckMode.MANUAL, unacknowledged records block subsequent polls within each consumer thread