- Spring
Spring Kafka入門|Producer・Consumerの完成コード
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期連続の黒字決算。