Spring Boot で Kafka を使うには、spring-boot-starter-kafka を入れ、送る側は KafkaTemplate、受け取る側は @KafkaListener を書きます。 接続先やシリアライザ(オブジェクトとバイト列の変換)は spring.kafka.* で設定し、Boot が KafkaTemplate とリスナーの入れ物(コンテナ)を自動で作ります。

@Service
public class OrderProducer {
    private final KafkaTemplate<String, OrderEvent> template;

    public OrderProducer(KafkaTemplate<String, OrderEvent> template) {
        this.template = template;
    }

    public void publish(OrderEvent event) {
        template.send("orders", event.customerId(), event);   // キーが同じなら同じパーティションへ
    }
}

@Component
public class OrderListener {
    @KafkaListener(topics = "orders", groupId = "order-service")
    public void onMessage(OrderEvent event) {
        // 処理。例外を投げると DefaultErrorHandler が再試行する
    }
}

つまずきやすい点は4つです。

落とし穴 何が起きるか 対処
JSON の受け取りで信頼パッケージを設定していない The class '…' is not in the trusted packages: [java.util, java.lang] spring.json.trusted.packages に自分のパッケージ
読めないメッセージが1件ある 同じメッセージで失敗し続け、先に進まない ErrorHandlingDeserializer で包む
処理で例外が出る 既定では合計10回(すぐに再試行9回)呼んでからログに出して捨てる DefaultErrorHandler に回数・間隔と DLT を設定
処理が遅い max.poll.interval.ms(既定5分)を超えるとグループから外され、再配分される 1回に読む件数(max.poll.records)を減らす

確認環境:Spring Boot 4.1.1(Spring for Apache Kafka 4.1.1、Kafka クライアント 4.2.1 は Boot の依存管理の版)。spring.kafka.* の既定値は Boot v4.1.1 の KafkaProperties、Kafka クライアントの既定値は Apache Kafka 4.2.1 の ConsumerConfig・ProducerConfig、エラー処理・JSON・テストの既定値は Spring for Apache Kafka v4.1.1 のソースとリファレンスで確かめました。再試行の間隔は Spring Framework 7.0.9 の FixedBackOff・ExponentialBackOff と Spring Kafka の ExponentialBackOffWithMaxRetries をソースからビルドして、OpenJDK 21.0.10(Linux)で出力しました。キーとパーティションの対応は Kafka 4.2.1 の Utils.murmur2 と同じ計算で求め、Kafka 互換のブローカー Tansu 0.6.0 に kafka-python 3.0.11・confluent-kafka 2.16.0(librdkafka 2.16.0)で送って、クライアントが選んだパーティションとコンシューマーグループの割り当てを記録しました(確認日 2026年10月13日)。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Spring Kafka入門|Producer・Consumerの完成コード」

Kafka の用語(最低限)

用語 意味
トピック メッセージを入れる名前付きの置き場所(orders など)
パーティション トピックを分けた単位。同じパーティションの中だけ順番が保たれる
オフセット パーティションの中のメッセージの番号(0, 1, 2…)
キー パーティションを決める値。同じキーは同じパーティションに入る
コンシューマーグループ 同じ group.id のコンシューマーの集まり。1つのパーティションはグループの中の1つにだけ割り当てられる
コミット 「ここまで処理した」というオフセットを Kafka に記録すること
リバランス コンシューマーの増減で、パーティションの割り当てをやり直すこと

Kafka 本体の起動とトピックの作成は、関連記事「Kafka入門|KRaftでトピック作成から消費まで最短手順」で扱っています。Kafka 4.0 から ZooKeeper は無くなり、KRaft だけで動きます。

依存(Boot 4)

// build.gradle.kts(Spring Boot 4.1.1)
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-kafka")
    implementation("org.springframework.boot:spring-boot-starter-jackson")    // JSON で送るなら
    testImplementation("org.springframework.boot:spring-boot-starter-kafka-test")
}
依存 中身 版(Boot 4.1.1 が決める)
spring-boot-starter-kafka spring-boot-kafka(自動設定)+spring-kafka Spring Kafka 4.1.1
org.apache.kafka:kafka-clients Kafka のクライアント 4.2.1
spring-boot-starter-kafka-test spring-kafka-test(@EmbeddedKafka)+spring-boot-starter-test 4.1.1

Boot 3 までは org.springframework.kafka:spring-kafka を直接書いていましたが、Boot 4 では spring-boot-starter-kafka があります(v4.1.1 の starter/spring-boot-starter-kafka)。

application.yml(完成形)

spring:
  kafka:
    bootstrap-servers: localhost:9092
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.springframework.kafka.support.serializer.JacksonJsonSerializer
      acks: all
      properties:
        linger.ms: 10
    consumer:
      group-id: order-service
      auto-offset-reset: earliest
      key-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer
      value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer
      max-poll-records: 100
      properties:
        spring.deserializer.key.delegate.class: org.apache.kafka.common.serialization.StringDeserializer
        spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JacksonJsonDeserializer
        spring.json.trusted.packages: "com.example.order.event"
        spring.json.value.default.type: com.example.order.event.OrderEvent
    listener:
      ack-mode: record
      concurrency: 3

spring.kafka.* の設定と既定値

Boot v4.1.1 の KafkaProperties では、多くの項目が null(書かなければ Kafka クライアントの既定値)です。両方を1つの表にしました。

共通・Producer

設定 既定値 決めているところ
spring.kafka.bootstrap-servers localhost:9092 Boot
spring.kafka.producer.key-serializer / value-serializer StringSerializer Boot
spring.kafka.producer.acks all(全レプリカへの書き込みを待つ) Kafka 4.2.1
spring.kafka.producer.retries Integer.MAX_VALUE(下の時間で打ち切り) Kafka 4.2.1
spring.kafka.producer.batch-size 16KB(16384 バイト) Kafka 4.2.1
spring.kafka.producer.buffer-memory 32MB Kafka 4.2.1
spring.kafka.producer.compression-type none Kafka 4.2.1
spring.kafka.producer.transaction-id-prefix なし(トランザクションを使わない) Boot
properties.linger.ms 5(ms。Kafka 4.0 から 0 → 5) Kafka 4.2.1
properties.enable.idempotence true(再送で重複させない) Kafka 4.2.1
properties.delivery.timeout.ms 120000(2分) Kafka 4.2.1
properties.max.request.size 1MB Kafka 4.2.1
properties.max.block.ms 60000(send がメタデータ取得などで待つ上限) Kafka 4.2.1

properties.xxx は spring.kafka.producer.properties.xxx の略です。Boot に専用の項目が無い設定は、ここに Kafka の名前のまま書きます。

Consumer

設定 既定値 決めているところ
spring.kafka.consumer.group-id なし(@KafkaListener の groupId か id を使う) Boot
spring.kafka.consumer.auto-offset-reset latest(新しいグループは、起動後に届いたものから読む) Kafka 4.2.1
spring.kafka.consumer.enable-auto-commit Kafka は true、Spring のコンテナは書かなければ false にする Spring Kafka 4.1.1
spring.kafka.consumer.key-deserializer / value-deserializer StringDeserializer Boot
spring.kafka.consumer.max-poll-records 500 Kafka 4.2.1
spring.kafka.consumer.max-poll-interval 300000ms(5分) Kafka 4.2.1
spring.kafka.consumer.heartbeat-interval 3000ms Kafka 4.2.1
spring.kafka.consumer.fetch-min-size / fetch-max-wait 1バイト / 500ms Kafka 4.2.1
spring.kafka.consumer.isolation-level read-uncommitted Boot
properties.session.timeout.ms 45000(45秒) Kafka 4.2.1
properties.partition.assignment.strategy RangeAssignor, CooperativeStickyAssignor Kafka 4.2.1
properties.group.protocol classic Kafka 4.2.1

enable.auto.commit は、Spring Kafka 4.1.1 の KafkaMessageListenerContainer が「設定に無ければ false にする」と決めています。コミットは Kafka に任せず、Spring のコンテナが下の ack-mode に従って行います。

Listener(コンテナ)

設定 既定値 意味
spring.kafka.listener.ack-mode batch いつコミットするか(下の表)
spring.kafka.listener.concurrency 1 1つのリスナーで動かすコンシューマーの数
spring.kafka.listener.type single batch にすると List でまとめて受け取る
spring.kafka.listener.poll-timeout 5000ms 1回の poll で待つ時間
spring.kafka.listener.ack-count / ack-time 1 / 5000ms count・time のときの区切り
spring.kafka.listener.missing-topics-fatal false トピックが無いとき起動を止めるか
spring.kafka.listener.auto-startup true 起動時にリスナーを動かすか
spring.kafka.listener.observation-enabled false Micrometer の観測(トレース)を出すか

Admin・再試行トピック

設定 既定値 意味
spring.kafka.admin.auto-create true NewTopic の Bean からトピックを作る
spring.kafka.admin.fail-fast false Kafka に届かないとき起動を止めるか
spring.kafka.retry.topic.enabled false 再試行トピック(下)を Boot に作らせる
spring.kafka.retry.topic.attempts 3 最初の1回を含む回数
spring.kafka.retry.topic.backoff.delay 1s 待ち時間
spring.kafka.retry.topic.backoff.multiplier 1.0 待ち時間を何倍ずつ伸ばすか
spring.kafka.retry.topic.backoff.max-delay 30s 待ち時間の上限

Producer:KafkaTemplate

トピックを作る

@Configuration
public class KafkaTopicConfig {

    @Bean
    NewTopic orders() {
        return TopicBuilder.name("orders").partitions(3).replicas(1).build();
    }

    @Bean
    NewTopic ordersDlt() {
        // DLT は元のトピックと同じかそれ以上のパーティション数にする(下の「DLT」を参照)
        return TopicBuilder.name("orders-dlt").partitions(3).replicas(1).build();
    }
}

Boot の KafkaAdmin が、起動時に NewTopic の Bean を見て、無いトピックを作ります(spring.kafka.admin.auto-create、既定 true)。本番でトピックを別の手順で管理しているなら、auto-create: false にします。

送るコード(完成形)

package com.example.order;

import com.example.order.event.OrderEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.support.SendResult;
import org.springframework.stereotype.Service;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;

@Service
public class OrderProducer {

    private static final Logger log = LoggerFactory.getLogger(OrderProducer.class);
    private final KafkaTemplate<String, OrderEvent> template;

    public OrderProducer(KafkaTemplate<String, OrderEvent> template) {
        this.template = template;
    }

    /** 送りっぱなしにせず、結果をログに出す(非同期) */
    public CompletableFuture<SendResult<String, OrderEvent>> publish(OrderEvent event) {
        return template.send("orders", event.customerId(), event)
                .whenComplete((result, ex) -> {
                    if (ex != null) {
                        log.error("送信失敗 orderId={}", event.orderId(), ex);
                    } else {
                        var md = result.getRecordMetadata();
                        log.info("送信 orderId={} partition={} offset={}", event.orderId(), md.partition(), md.offset());
                    }
                });
    }

    /** 送れたことを確かめてから戻る(同期) */
    public void publishAndWait(OrderEvent event) throws Exception {
        template.send("orders", event.customerId(), event).get(10, TimeUnit.SECONDS);
    }
}
package com.example.order.event;

import java.math.BigDecimal;
import java.time.Instant;

public record OrderEvent(String orderId, String customerId, BigDecimal amount, Instant orderedAt) {}

send は CompletableFuture<SendResult> を返します(Spring Kafka 3.0 で ListenableFuture から変わりました)。send を呼んだだけでは、まだ届いていません。 バッファにためて linger.ms(既定 5ms)待ってから、まとめて送ります。届いたかどうかは whenComplete か get で確かめます。

キーとパーティション

キーがあるとき、Kafka の Java クライアントは「キーの murmur2 ハッシュ ÷ パーティション数の余り」でパーティションを決めます(Kafka 4.2.1 の BuiltInPartitioner)。Utils.murmur2 と同じ計算で求めた結果です。

キー 3パーティションのとき 6パーティションに増やしたとき
customer-0 1 1
customer-1 0 3
customer-2 2 2

パーティションを増やすと、同じキーでも入る先が変わります。 customer-1 の増やす前のメッセージはパーティション 0、増やした後のメッセージは 3 に入るので、増やした直後はキーごとの順番が保証されません。パーティション数は最初に余裕を持って決めます。

同じキーを、別のクライアントから3パーティションのトピックに送って、選ばれたパーティションを記録しました。

クライアント(パーティションの決め方) customer-0 customer-1 customer-2
Java(murmur2)の計算 1 0 2
kafka-python 3.0.11(既定) 1 0 2
confluent-kafka 2.16.0(既定 consistent_random) 1 0 0
confluent-kafka 2.16.0(partitioner=murmur2_random) 1 0 2

librdkafka を使うクライアント(confluent-kafka、Confluent の Go・.NET のクライアントなど)は、既定のパーティションの決め方が Java と違います。 Java と Python など複数の言語から同じトピックに送り、キーごとの順番をそろえたいときは、librdkafka 側に partitioner=murmur2_random を設定します。

キーを null にすると、Java クライアントは一定量を送るごとにパーティションを切り替えながら送ります(Kafka 4.2.1 の BuiltInPartitioner)。順番が必要なメッセージには、注文 ID や顧客 ID などのキーを付けます。

Consumer:@KafkaListener

受け取るコード(完成形)

package com.example.order;

import com.example.order.event.OrderEvent;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.support.KafkaHeaders;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.stereotype.Component;

@Component
public class OrderListener {

    private static final Logger log = LoggerFactory.getLogger(OrderListener.class);
    private final OrderService orderService;

    public OrderListener(OrderService orderService) {
        this.orderService = orderService;
    }

    // 値だけ受け取り、ヘッダーから場所を取る
    @KafkaListener(id = "order-listener", topics = "orders", groupId = "order-service", concurrency = "3")
    public void onOrder(@Payload OrderEvent event,
                        @Header(KafkaHeaders.RECEIVED_KEY) String key,
                        @Header(KafkaHeaders.RECEIVED_PARTITION) int partition,
                        @Header(KafkaHeaders.OFFSET) long offset) {
        log.info("受信 key={} partition={} offset={} orderId={}", key, partition, offset, event.orderId());
        orderService.accept(event);   // 例外を投げると DefaultErrorHandler へ
    }

    // ConsumerRecord ごと受け取る書き方
    @KafkaListener(topics = "orders-audit", groupId = "audit")
    public void onAudit(ConsumerRecord<String, String> record) {
        log.info("{}-{}@{} {}", record.topic(), record.partition(), record.offset(), record.value());
    }
}
@KafkaListener の属性 意味
topics / topicPattern 読むトピック(名前・正規表現)
groupId コンシューマーグループ(spring.kafka.consumer.group-id より優先)
id コンテナの ID。groupId が無いときはグループ名にも使われる
concurrency このリスナーのコンシューマーの数
batch "true" で List にまとめて受け取る
ackMode このリスナーだけコミットのしかたを変える(Spring Kafka 4.1 から)
properties このリスナーだけ Kafka の設定を変える("max.poll.records=10" など)
containerFactory 使うコンテナファクトリーの Bean 名

concurrency とパーティション

concurrency = "3" は、コンシューマーを3つ動かすという意味です。1つのパーティションはグループの中の1つのコンシューマーにしか割り当てられないので、パーティション数より多くしても、余ったコンシューマーは何もしません。

3パーティションのトピックに、同じグループのコンシューマーを1つずつ増やしたときの割り当てです(kafka-python 3.0.11、割り当て方式 range)。

状態 c1 c2 c3 c4
1つ 0, 1, 2 ― ― ―
2つ 0, 1 2 ― ―
3つ 0 1 2 ―
4つ 0 1 2 なし
c2 を止めた 0 ― 1 2

c4 は割り当てが無く、c2 が抜けたときに代わりに入りました。concurrency × アプリの台数が、パーティション数を超えないように決めます。

JSON で送る・受け取る(信頼パッケージ)

Spring Kafka 4 の JSON クラス

Spring Kafka 4.0 で、Jackson 3 用のクラスが加わりました。Boot 4 は Jackson 3 を使うので、こちらを使います。

Jackson 3 用(4.0 から) Jackson 2 用
JacksonJsonSerializer JsonSerializer(4.0 で非推奨、削除予定)
JacksonJsonDeserializer JsonDeserializer(同上)
JacksonJsonSerde JsonSerde
JacksonJsonMessageConverter JsonMessageConverter

信頼パッケージ(trusted packages)

JacksonJsonSerializer は、送るときにヘッダー __TypeId__ にクラス名(com.example.order.event.OrderEvent)を入れます。受け取る側の JacksonJsonDeserializer は、そのクラス名でオブジェクトを作ります。どんなクラスでも作れてしまうと危ないので、作ってよいパッケージ(信頼パッケージ)を決めています。 既定は java.util と java.lang だけです(v4.1.1 の DefaultJacksonJavaTypeMapper)。

設定していないと、受け取りのときに次の例外になります(文は v4.1.1 のソース)。

The class 'com.example.order.event.OrderEvent' is not in the trusted packages: [java.util, java.lang]. If you believe this class is safe to deserialize, please provide its name. If the serialization is only done by a trusted source, you can also enable trust all (*).
設定(consumer の properties. の後ろ) 意味
spring.json.trusted.packages 信頼するパッケージ(カンマ区切り)。* ですべて
spring.json.value.default.type ヘッダーが無いときに使うクラス
spring.json.use.type.headers false でヘッダーを見ずに default.type を使う
spring.json.type.mapping order:com.example.OrderEvent のように短い名前とクラスを対応させる
spring.json.add.type.headers(producer 側) false で __TypeId__ を付けない

*(すべて信頼)は、送る側を自分たちが管理しているときだけにします。送る側と受け取る側でパッケージ名が違う(別のサービス)ときは、spring.json.type.mapping を両方に書くか、use.type.headers: false と value.default.type で受け取るクラスを固定します。

読めないメッセージで止まらないようにする

JSON が壊れたメッセージが1件あると、Kafka クライアントの poll が次の例外を投げ、同じメッセージを何度読んでも先に進めません(文は Kafka 4.2.1 の CompletedFetch)。

Error deserializing VALUE for partition orders-0 at offset 5. If needed, please seek past the record to continue consumption.

ErrorHandlingDeserializer で包むと、変換の失敗を DeserializationException としてリスナーの手前で受け取れます。上の application.yml の value-deserializer と spring.deserializer.value.delegate.class がその設定です。DeserializationException は再試行しても直らない例外として扱われ(下の表)、すぐに DLT へ送るかログに出して次へ進みます。

エラー処理:DefaultErrorHandler・再試行・DLT

既定の動き

リスナーで例外が出ると、Spring Kafka の DefaultErrorHandler が受け取ります。既定の再試行は SeekUtils.DEFAULT_BACK_OFF = FixedBackOff(0, 9) です。Spring Framework 7.0.9 の FixedBackOff をソースからビルドして、間隔を出力しました。

設定 再試行 待ち時間 尽きたときの BackOffExecution
既定 FixedBackOff(0, 9) 9回(合計10回呼ぶ) 0ms×9 FixedBackOffExecution[interval=0, currentAttempts=10, maxAttempts=9]
FixedBackOff(1000, 2) 2回 1000ms, 1000ms FixedBackOffExecution[interval=1000, currentAttempts=3, maxAttempts=2]
ExponentialBackOffWithMaxRetries(4)、1s・2倍・上限5s 4回 1000, 2000, 4000, 5000ms ExponentialBackOffExecution[currentInterval=5000ms, multiplier=2.0, attempts=4]

回数が尽きると、既定ではエラーのログを出してそのメッセージを捨て(コミットして)次へ進みます。ログの形は v4.1.1 の FailedRecordTracker で次のとおりです(orders-0@5 は「トピック-パーティション@オフセット」)。

Backoff FixedBackOffExecution[interval=0, currentAttempts=10, maxAttempts=9] exhausted for orders-0@5

再試行の間はコンシューマーがそのパーティションで止まります。 間隔を長くするほど、その間ほかのメッセージも待たされます。数秒より長く待ちたいときは、下の「再試行トピック」を使います。

再試行しない例外

次の例外は、既定で再試行せずにすぐ回復処理(ログ・DLT)へ回ります(v4.1.1 の ExceptionClassifier.defaultFatalExceptionsList)。

例外 起きる場面
DeserializationException ErrorHandlingDeserializer が変換に失敗した
MessageConversionException メッセージを引数の型に変換できない
ConversionException 変換の失敗
MethodArgumentResolutionException 引数を決められない(@Valid の失敗など)
NoSuchMethodException 対応するメソッドが無い
ClassCastException 型の取り違え

自分の例外を足すときは addNotRetryableExceptions(…) を使います。

DLT(デッドレタートピック)へ送る(完成形)

package com.example.order;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.listener.DeadLetterPublishingRecoverer;
import org.springframework.kafka.listener.DefaultErrorHandler;
import org.springframework.kafka.support.ExponentialBackOffWithMaxRetries;

@Configuration
public class KafkaErrorConfig {

    @Bean
    DefaultErrorHandler errorHandler(KafkaTemplate<Object, Object> template) {
        // 失敗したメッセージを "<元のトピック>-dlt" の同じパーティションへ送る
        var recoverer = new DeadLetterPublishingRecoverer(template);

        var backOff = new ExponentialBackOffWithMaxRetries(4);   // 再試行4回
        backOff.setInitialInterval(1_000);
        backOff.setMultiplier(2.0);
        backOff.setMaxInterval(5_000);

        var handler = new DefaultErrorHandler(recoverer, backOff);
        handler.addNotRetryableExceptions(InvalidOrderException.class);   // 業務上の誤りは再試行しない
        return handler;
    }
}

Boot 4.1.1 の KafkaAnnotationDrivenConfiguration は、CommonErrorHandler の Bean が1つあれば、自動で作るリスナーのコンテナに設定します。Bean を定義するだけで効きます。

項目 既定(v4.1.1 のソース)
送り先のトピック 元のトピック名+-dlt(orders-dlt)
送り先のパーティション 元と同じ番号
付くヘッダー kafka_dlt-exception-fqcn・kafka_dlt-exception-message・kafka_dlt-original-offset など

DLT のパーティション数が元より少ないと、同じ番号のパーティションが無いので送れません。 DLT は元と同じかそれ以上のパーティション数で作ります。Spring Kafka 3.3 で、名前の付け方が -dlt にそろえられました(それ以前の DeadLetterPublishingRecoverer は .DLT)。3.2 以前から上げたアプリは、送り先のトピック名を確かめます。

DLT のメッセージは、別のリスナーで受けて記録・調査します。

@KafkaListener(topics = "orders-dlt", groupId = "order-dlt")
public void onDlt(ConsumerRecord<String, byte[]> record,
                  @Header(KafkaHeaders.DLT_EXCEPTION_MESSAGE) String reason) {
    log.warn("DLT {}@{} reason={}", record.partition(), record.offset(), reason);
}

再試行トピック(@RetryableTopic)

DefaultErrorHandler の再試行はそのパーティションを止めます。止めずに、時間をおいて再試行したいときは @RetryableTopic を使います。失敗したメッセージを別の再試行用トピックに移し、時間が来たら読み直します。

@RetryableTopic(attempts = "4", backOff = @BackOff(delay = 1000, multiplier = 2.0, maxDelay = 10000))
@KafkaListener(topics = "payments", groupId = "payment-service")
public void onPayment(PaymentEvent event) {
    paymentService.charge(event);
}

@DltHandler
public void onPaymentDlt(PaymentEvent event, @Header(KafkaHeaders.EXCEPTION_MESSAGE) String reason) {
    log.error("決済の再試行が尽きた {} {}", event.paymentId(), reason);
}
@RetryableTopic の属性 既定値(v4.1.1)
attempts "3"(最初の1回を含む)
backOff @BackOff(1000ms 固定)
retryTopicSuffix -retry
dltTopicSuffix -dlt
autoCreateTopics / numPartitions "true" / "1"
sameIntervalTopicReuseStrategy SINGLE_TOPIC
方式 パーティションを止めるか 順番 向いている場面
DefaultErrorHandler の再試行 止める 保たれる 数秒以内に直る失敗、順番が大事な処理
@RetryableTopic 止めない 崩れる(失敗したものが後回し) 外部 API の一時的な失敗、順番が要らない処理

再試行の考え方(再試行してよい処理・よくない処理)は、関連記事「Spring Retry・Resilience4jで再試行|@Retryable設定」で扱っています。

手動コミット

ack-mode の一覧

ack-mode コミットするタイミング
batch(既定) poll で受け取った分をすべて処理したあと
record 1件処理するごと
time / count / count_time 時間(ack-time)・件数(ack-count)ごと
manual Acknowledgment.acknowledge() を呼んだもの。poll の分が終わったらまとめて
manual_immediate acknowledge() を呼んだときにすぐ(リスナーのスレッドで呼んだ場合)

コード(完成形)

spring:
  kafka:
    listener:
      ack-mode: manual_immediate
@KafkaListener(topics = "orders", groupId = "order-service")
public void onOrder(OrderEvent event, Acknowledgment ack) {
    try {
        orderService.accept(event);
        ack.acknowledge();                          // DB への保存が終わってからコミット
    } catch (TemporaryException e) {
        ack.nack(Duration.ofSeconds(5));             // コミットせず、5秒後にこのメッセージから読み直す
    }
}

Spring Kafka 4.1 からは、@KafkaListener(ackMode = "MANUAL_IMMEDIATE") のようにリスナーごとに変えられます。

Kafka の配信は「少なくとも1回」です。 処理のあとコミットの前にアプリが落ちると、同じメッセージがもう一度届きます。DB に保存する処理は、注文 ID を一意キーにするなど、2回届いても結果が変わらない(冪等な)作りにします。

テスト:EmbeddedKafka

package com.example.order;

import com.example.order.event.OrderEvent;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.kafka.test.context.EmbeddedKafka;
import org.springframework.test.context.bean.override.mockito.MockitoBean;

import java.math.BigDecimal;
import java.time.Instant;

import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.verify;
import static org.mockito.ArgumentMatchers.argThat;

@SpringBootTest(properties = "spring.kafka.consumer.auto-offset-reset=earliest")
@EmbeddedKafka(partitions = 3, topics = {"orders", "orders-dlt"})
class OrderFlowTest {

    @Autowired
    OrderProducer producer;

    @MockitoBean
    OrderService orderService;

    @Test
    void 送ったメッセージをリスナーが受け取る() throws Exception {
        var event = new OrderEvent("o-1", "customer-1", new BigDecimal("3980"), Instant.now());
        producer.publishAndWait(event);

        verify(orderService, timeout(10_000)).accept(argThat(e -> e.orderId().equals("o-1")));
    }
}
@EmbeddedKafka の属性 既定値(v4.1.1)
count 1(ブローカーの数)
partitions 2
topics なし
ports {0}(空いているポート)
bootstrapServersProperty spring.kafka.bootstrap-servers

bootstrapServersProperty の既定が spring.kafka.bootstrap-servers なので、Boot のテストでは、起動したテスト用ブローカーの接続先が自動で入ります。 Spring Kafka 4.0 で ZooKeeper の部品(EmbeddedKafkaZKBroker・zookeeperPort など)と JUnit 4 の EmbeddedKafkaRule は削除され、KRaft の EmbeddedKafkaKraftBroker だけになりました。

テスト用のコンシューマーで直接読むときは KafkaTestUtils を使います。

Map<String, Object> props = KafkaTestUtils.consumerProps(broker, "test-group", false);
try (Consumer<String, String> c = new DefaultKafkaConsumerFactory<String, String>(props).createConsumer()) {
    broker.consumeFromAnEmbeddedTopic(c, "orders-audit");
    ConsumerRecord<String, String> r = KafkaTestUtils.getSingleRecord(c, "orders-audit");
}

コンテナ(本物の Kafka)でテストする方法は、関連記事「Testcontainersの使い方|Spring BootでPostgreSQL」で扱っています(Kafka のコンテナも同じ考え方で使えます)。

よくあるエラーと対処

エラー・症状 原因 対処
… is not in the trusted packages: [java.util, java.lang] JSON の信頼パッケージが無い spring.json.trusted.packages
Error deserializing VALUE for partition … If needed, please seek past the record… が繰り返される 読めないメッセージで止まっている ErrorHandlingDeserializer で包む
Backoff … exhausted for orders-0@5 既定の再試行(合計10回)が尽きた 原因の例外を直す。DLT を設定する
consumer poll timeout has expired. This means the time between subsequent calls to poll() was longer than the configured max.poll.interval.ms… 1回の poll 分の処理が5分を超えた max-poll-records を減らす、処理を速くする
Commit cannot be completed since the group has already rebalanced and assigned the partitions to another member… 上と同じ理由でグループから外れたあとにコミットした 同上
Connection to node -1 (localhost/127.0.0.1:9092) could not be established. Node may not be available. 接続先が違う・Kafka が動いていない bootstrap-servers、ブローカーの advertised.listeners
The message is … bytes when serialized which is larger than 1048576, which is the value of the max.request.size configuration. 1件が 1MB を超えた max.request.size(ブローカー・トピック側の上限も合わせる)
リスナーが何も受け取らない 新しいグループで auto-offset-reset が latest(既定) earliest にする
一部のコンシューマーが何もしない concurrency × 台数 がパーティション数を超えた パーティションを増やすか、数を減らす
同じメッセージが2回処理される コミット前に落ちた・リバランスが起きた 処理を冪等にする
DLT に送れない DLT のパーティション数が元より少ない 同じかそれ以上にする

エラー文は Kafka 4.2.1(AbstractCoordinator・CommitFailedException・NetworkClient・KafkaProducer)と Spring Kafka 4.1.1 のソースにある文です(… は値が入る部分)。

よくある質問

Q. KafkaTemplate の型は <String, OrderEvent> と <Object, Object> のどちらで受け取りますか。 A. Boot 4.1.1 の KafkaAutoConfiguration が自動で作るのは、KafkaTemplate<?, ?> 型の Bean 1つです。ジェネリクスは使う側で KafkaTemplate<String, OrderEvent> と書いて受け取れます。実際に送れる型は、spring.kafka.producer.*-serializer の設定で決まります。

Q. 自分で ConsumerFactory の Bean を作ると、application.yml の設定はどうなりますか。 A. Boot の KafkaAutoConfiguration は @ConditionalOnMissingBean(ConsumerFactory.class) なので、自分で作ると Boot の ConsumerFactory は作られず、spring.kafka.consumer.* は自動では使われません。自分で作るときは、KafkaProperties#buildConsumerProperties() で設定を取り込みます。

Q. 1件ずつではなくまとめて受け取れますか。 A. spring.kafka.listener.type: batch か @KafkaListener(batch = "true") にすると、List<OrderEvent> で受け取れます。エラーのときは BatchListenerFailedException で失敗した位置を伝えると、その位置から再試行されます。

Q. メトリクスは取れますか。 A. Boot のリファレンスでは、自動で作る ConsumerFactory・ProducerFactory に Micrometer のリスナーが登録され、Kafka クライアントのメトリクスが /actuator/prometheus などに出ると説明されています。コンシューマーの遅れ(ラグ)は kafka_consumer_fetch_manager_records_lag_max などで見られます。

まとめ

  • Boot 4 の依存は spring-boot-starter-kafka(Spring Kafka 4.1.1・Kafka クライアント 4.2.1)
  • 送るのは KafkaTemplate.send(トピック, キー, 値)、受け取るのは @KafkaListener。send は CompletableFuture で結果を確かめる
  • 同じキーは同じパーティション。パーティションを増やすと入る先が変わる。librdkafka は既定の決め方が Java と違う
  • JSON は Boot 4 なら JacksonJsonSerializer・JacksonJsonDeserializer。受け取り側に spring.json.trusted.packages
  • 読めないメッセージ対策に ErrorHandlingDeserializer
  • 既定の再試行は 合計10回・待ちなしのあと捨てる。DefaultErrorHandler の Bean で回数・間隔・DLT(-dlt、同じパーティション番号)を決める
  • コミットは Spring のコンテナが行う(enable.auto.commit は書かなければ false)。手動なら manual_immediate と acknowledge()
  • テストは @EmbeddedKafka。接続先は spring.kafka.bootstrap-servers に自動で入る

参考・出典

確認日はいずれも 2026年10月13日です。

  • Spring for Apache Kafka Reference(4.1):https://docs.spring.io/spring-kafka/reference/
  • Spring for Apache Kafka「What's new?」:https://docs.spring.io/spring-kafka/reference/whats-new.html
  • Spring for Apache Kafka v4.1.1 ソース(DefaultErrorHandler・SeekUtils・DeadLetterPublishingRecoverer・ExceptionClassifier・DefaultJacksonJavaTypeMapper・ContainerProperties・EmbeddedKafka):https://github.com/spring-projects/spring-kafka/tree/v4.1.1
  • Spring Boot Reference「Apache Kafka Support」:https://docs.spring.io/spring-boot/reference/messaging/kafka.html
  • Spring Boot v4.1.1 ソース(KafkaProperties・KafkaAutoConfiguration・KafkaAnnotationDrivenConfiguration):https://github.com/spring-projects/spring-boot/tree/v4.1.1
  • Apache Kafka 4.2.1 ソース(ConsumerConfig・ProducerConfig・Utils.murmur2・BuiltInPartitioner):https://github.com/apache/kafka/tree/4.2.1
  • Apache Kafka Documentation「Configuration」:https://kafka.apache.org/documentation/#configuration
  • librdkafka「CONFIGURATION.md」(partitioner):https://github.com/confluentinc/librdkafka/blob/master/CONFIGURATION.md

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Spring Kafka入門|Producer・Consumerの完成コード」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?