在 Spring Boot 中发送和接收 Kafka 消息
1. 概览
Apache Kafka 是一个流行的事件流平台,用于服务之间的异步通信。Spring Boot 通过 Spring Kafka 库支持 Kafka,帮助你无需自己管理 KafkaProducer 和 poll() 循环。
在本文中,你将构建一个小示例:订单服务发送 OrderCreatedEvent 事件,consumer 接收该事件以在仓库中保留库存。读完本文后,你将了解如何配置、发送和接收 JSON 格式的消息,通过 Dead Letter Topic 处理错误,以及用 Embedded Kafka 进行测试。
2. 准备工作
2.1. Maven 依赖
你需要 spring-kafka。Spring Boot 已经管理了版本,所以无需指定版本号:
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<!-- 用于测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<scope>test</scope>
</dependency>
spring-kafka-test 提供了在内存中运行的 Kafka broker 用于测试。Awaitility 帮助你等待异步处理,无需使用 Thread.sleep()。
2.2. 本地运行 Kafka
最快的方式是使用 Apache Kafka 的官方镜像,采用 KRaft 模式(不需要 ZooKeeper):
docker run -d --name kafka -p 9092:9092 apache/kafka:latest
broker 将在 localhost:9092 监听。
3. 数据模型和配置
要发送的事件是一个简单的 record:
package com.nonla.shop.order;
import java.math.BigDecimal;
public record OrderCreatedEvent(String orderId, String customerId, BigDecimal amount) {
}
接下来在 application.yml 中配置:
spring:
kafka:
bootstrap-servers: localhost:9092
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
acks: all
consumer:
group-id: inventory-service
auto-offset-reset: earliest
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
properties:
spring.json.trusted.packages: "com.nonla.shop.order"
各部分的含义:
bootstrap-servers是 broker 地址。- Producer 对 key 使用
StringSerializer,对 value 使用JsonSerializer。JsonSerializer用 Jackson 将对象转换为 JSON。 acks: all强制 broker 只在所有副本都同步写入后才确认。这种方式更安全但稍微慢一些。group-id确定 consumer group。同一 group 中的各个实例会共享 partition。auto-offset-reset: earliest当 group 还没有任何 offset 提交时,consumer 从 topic 的开头读取。spring.json.trusted.packages声明JsonDeserializer被允许创建对象的包。如果缺少这一行,你会遇到错误“The class … is not in the trusted packages”。
3.1. 自动创建 topic
Spring Boot 自动配置了一个 KafkaAdmin。这个 bean 会创建在 context 中声明的所有 NewTopic:
package com.nonla.shop.config;
import org.apache.kafka.clients.admin.NewTopic;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.config.TopicBuilder;
@Configuration
public class KafkaTopicConfig {
public static final String ORDERS_TOPIC = "orders";
@Bean
public NewTopic ordersTopic() {
return TopicBuilder.name(ORDERS_TOPIC)
.partitions(3)
.replicas(1)
.build();
}
}
应用启动时,如果 topic orders 不存在,将使用 3 个 partition 创建它。如果 topic 已经存在,KafkaAdmin 会跳过而不覆盖现有配置。
4. 用 KafkaTemplate 发送消息
Spring Boot 根据 producer 配置自动创建 KafkaTemplate bean。你只需注入它:
package com.nonla.shop.order;
import com.nonla.shop.config.KafkaTopicConfig;
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;
@Service
public class OrderEventPublisher {
private static final Logger log = LoggerFactory.getLogger(OrderEventPublisher.class);
private final KafkaTemplate<String, OrderCreatedEvent> kafkaTemplate;
public OrderEventPublisher(KafkaTemplate<String, OrderCreatedEvent> kafkaTemplate) {
this.kafkaTemplate = kafkaTemplate;
}
public CompletableFuture<SendResult<String, OrderCreatedEvent>> publish(OrderCreatedEvent event) {
return kafkaTemplate.send(KafkaTopicConfig.ORDERS_TOPIC, event.orderId(), event)
.whenComplete((result, ex) -> {
if (ex != null) {
log.error("发送订单 {} 失败", event.orderId(), ex);
return;
}
var metadata = result.getRecordMetadata();
log.info("已发送订单 {} 至 partition {} offset {}",
event.orderId(), metadata.partition(), metadata.offset());
});
}
}
需要注意的几点:
send(topic, key, value)使用orderId作为 key。Kafka 对 key 进行哈希以选择 partition,因此同一订单的所有消息都会进入同一 partition。这样可以保证它们之间的顺序。- 从 Spring Kafka 3.0 开始,
send()返回CompletableFuture<SendResult>而不是已被弃用的ListenableFuture。 send()是异步的。调用立即返回,whenComplete中的回调在 broker 响应时运行。
发送成功时,日志会类似于:
INFO c.n.s.order.OrderEventPublisher : 已发送订单 ORD-1001 至 partition 2 offset 0
如果需要阻塞线程直到发送完成,例如在批处理任务中,你可以调用 publish(event).get(5, TimeUnit.SECONDS)。但在高流量请求处理线程中不要这样做,因为每次调用都会让一个线程等待 broker。
注意: 在同一个方法中先写入数据库再发送 Kafka 不是 一个原子事务。如果数据库 commit 成功但 Kafka 发送失败,数据将不一致。对于重要数据,你应该考虑使用 Transactional Outbox 模式。
5. 用 @KafkaListener 接收消息
consumer 端只需要一个带 @KafkaListener 注解的方法:
package com.nonla.shop.inventory;
import com.nonla.shop.config.KafkaTopicConfig;
import com.nonla.shop.order.OrderCreatedEvent;
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 OrderCreatedListener {
private static final Logger log = LoggerFactory.getLogger(OrderCreatedListener.class);
private final InventoryService inventoryService;
public OrderCreatedListener(InventoryService inventoryService) {
this.inventoryService = inventoryService;
}
@KafkaListener(topics = KafkaTopicConfig.ORDERS_TOPIC, concurrency = "3")
public void onOrderCreated(@Payload OrderCreatedEvent event,
@Header(KafkaHeaders.RECEIVED_PARTITION) int partition,
@Header(KafkaHeaders.OFFSET) long offset) {
log.info("收到订单 {} 来自 partition {} offset {}", event.orderId(), partition, offset);
inventoryService.reserve(event);
}
}
InventoryService 只是一个普通的 service:
package com.nonla.shop.inventory;
import com.nonla.shop.order.OrderCreatedEvent;
import org.springframework.stereotype.Service;
@Service
public class InventoryService {
public void reserve(OrderCreatedEvent event) {
if (event.amount().signum() <= 0) {
throw new IllegalArgumentException("金额无效:" + event.amount());
}
// 在仓库中保留库存...
}
}
解释:
topics指定要监听的 topic。groupId取自spring.kafka.consumer.group-id,或者你可以直接在注解中覆盖。concurrency = "3"创建 3 个 consumer 线程,每个线程处理一个 partition。值大于 partition 数量是无用的,因为多余的线程不会接收任何东西。@Payload接收已反序列化的对象。@Header获取元数据如 partition 和 offset。- 默认情况下,当方法运行完成且没有抛出异常时 offset 被提交(
AckMode.BATCH)。因此,如果应用在中途崩溃,消息将被重新处理。这是 at-least-once 语义,所以你的逻辑需要幂等:处理同一个orderId两次不应该保留库存两次。
如果你需要 key、timestamp 或其他任意 header,你可以接收原始的 ConsumerRecord<String, OrderCreatedEvent> 作为参数。
6. 错误处理和 Dead Letter Topic
当 listener 抛出异常时,默认的 DefaultErrorHandler 会重试 9 次,然后记录日志并跳过消息。在实际应用中,你通常希望将出错的消息转移到单独的 topic 以供后续检查:
package com.nonla.shop.config;
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.util.backoff.FixedBackOff;
@Configuration
public class KafkaErrorConfig {
@Bean
public DefaultErrorHandler errorHandler(KafkaTemplate<Object, Object> kafkaTemplate) {
var recoverer = new DeadLetterPublishingRecoverer(kafkaTemplate);
var handler = new DefaultErrorHandler(recoverer, new FixedBackOff(1000L, 2));
handler.addNotRetryableExceptions(IllegalArgumentException.class);
return handler;
}
}
Spring Boot 自动将这个 CommonErrorHandler bean 附加到默认的 container factory。上面的配置工作如下:
FixedBackOff(1000L, 2)最多重试 2 次,每次间隔 1 秒。- 重试用尽时,
DeadLetterPublishingRecoverer将消息发送到orders.DLTtopic。这个名称是原始名称加上.DLT后缀,消息保留原始 partition。DLT topic 因此需要至少与原始 topic 相同数量的 partition。 IllegalArgumentException是数据错误,重试也无济于事。handler 会直接将消息转移到 DLT。
一条 amount = -5 的消息会产生类似的日志:
ERROR o.s.k.l.DefaultErrorHandler : Backoff none exhausted for orders-1@4
INFO c.n.s.inventory.DltListener : DLT 中的错误消息:ORD-1002
注意: 发生在反序列化期间的错误,例如 JSON 损坏,不会到达 listener。如果不处理,consumer 可能会永久卡在那条消息上,这称为“poison pill”。请用
ErrorHandlingDeserializer包装反序列化器:spring: kafka: consumer: value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer properties: spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JsonDeserializer使用这个配置,反序列化错误会被传递给
DefaultErrorHandler,错误消息也会进入 DLT。
7. 用 Embedded Kafka 测试
@EmbeddedKafka 启动内存中的 broker,所以运行测试时你不需要 Docker。下面的示例测试完整的流程:通过 OrderEventPublisher 发送消息,然后验证 InventoryService 正确接收了事件。
package com.nonla.shop.order;
import com.nonla.shop.inventory.InventoryService;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
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.Duration;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.mockito.Mockito.verify;
@SpringBootTest(properties = "spring.kafka.bootstrap-servers=${spring.embedded.kafka.brokers}")
@EmbeddedKafka(partitions = 3, topics = "orders")
class OrderKafkaIntegrationTest {
@Autowired
private OrderEventPublisher publisher;
@MockitoBean
private InventoryService inventoryService;
@Test
void whenOrderPublished_thenListenerReservesInventory() throws Exception {
var event = new OrderCreatedEvent("ORD-1001", "CUS-42", new BigDecimal("250000"));
var result = publisher.publish(event).get();
assertThat(result.getRecordMetadata().topic()).isEqualTo("orders");
var captor = ArgumentCaptor.forClass(OrderCreatedEvent.class);
await().atMost(Duration.ofSeconds(10))
.untilAsserted(() -> verify(inventoryService).reserve(captor.capture()));
assertThat(captor.getValue()).isEqualTo(event);
}
}
各部分的作用:
${spring.embedded.kafka.brokers}是由@EmbeddedKafka提供的属性。它包含嵌入式 broker 的地址,我们用它来覆盖bootstrap-servers。@MockitoBean(Spring Boot 3.4 中引入)用 mock 替换真实的InventoryService,以便于验证。对于较旧的版本,使用@MockBean。publish(event).get()等待 broker 确认,这样测试可以确保消息已被写入。- Listener 在另一个线程上运行,所以
await().untilAsserted(...)会在最多 10 秒内重试verify检查。 - 由于
OrderCreatedEvent是record,equals()按值比较。assertThat(...).isEqualTo(event)因此验证 JSON 被正确序列化和反序列化。
配置中的 auto-offset-reset: earliest 帮助测试保持稳定。即使 consumer 在消息已发送之后才加入 group,它仍然可以读到该消息。
8. 常见错误和何时不应使用
你容易遇到的一些错误:
- 缺少
trusted.packages:JsonDeserializer会拒绝创建未知的类。请正确声明包名。只在完全控制所有 producer 时才使用"*"。 - Producer 和 consumer 在不同的包中:
JsonSerializer默认将完全限定类名写入__TypeId__头。如果 consumer 服务有同名类但在不同包中,反序列化会失败。此时,在 consumer 端设置spring.json.use.type.headers: false和spring.json.value.default.type: com.nonla.inventory.OrderCreatedEvent。 - Listener 运行时间长:如果一次
poll()处理超过max.poll.interval.ms(默认 5 分钟),broker 会认为 consumer 已死亡并重新平衡。结果是消息被重复处理。请减少max.poll.records或优化你的逻辑。 concurrency大于 partition 数量:多余的线程会闲置。要增加吞吐量,你必须先增加 partition 数量。- 发送
nullkey:消息会均匀分布在各 partition 上,你会失去按实体保证顺序的能力。
Kafka 也不是在所有情况下都是好选择:
- 你需要同步的请求/响应并立即得到结果。此时使用 REST 或 gRPC。
- 系统很小,流量低,不需要保留事件历史。运行 Kafka 集群就成了浪费,RabbitMQ 或数据库中的队列表可能足够。
- 你需要复杂的按消息路由、或需要针对不同消息的延迟或优先级设置。这是传统消息 broker 的优势。
9. 总结
在本文中,你配置了 Spring Kafka 使用 JSON 序列化器并用 NewTopic 创建 topic。你通过 KafkaTemplate 以 key 发送消息来保证顺序,用带 concurrency 的 @KafkaListener 接收消息。你还用 DefaultErrorHandler 结合 Dead Letter Topic 处理错误,然后用 @EmbeddedKafka 和 Awaitility 编写了集成测试。
部署到生产环境时,请记住三点:listener 必须是幂等的,必须用 ErrorHandlingDeserializer 处理反序列化错误,发送事件必须与数据库数据保持一致。