Spring Boot 集成 Kafka:先跑通 Demo,再避开那 80% 的人踩过的坑
1. Kafka 到底是什么(以及为什么它不"推"消息)
很多人把 Kafka 当消息队列用,觉得 RabbitMQ 能做的事 Kafka 也能做------这个认知恰恰是后面所有问题的根源。Kafka 本质上是一个分布式日志,不是队列。
队列的特点是:一条消息被消费后就删除了。日志的特点是:数据写入后一直存在,谁想读谁读,读到哪儿自己说了算。这就是 Kafka 能做到百万 TPS、支持消息回放、能保证分区内顺序的根本原因------它不会因为"这条消息被消费了"就把数据丢掉,只会根据配置的保留策略清理。
这也解释了一个困扰无数新手的点:Kafka 不"推"消息,而是消费者"拉"消息。传统 MQ 会把消息推给消费者,Kafka 却让消费者自己轮询拉取。这意味着如果你的业务逻辑阻塞了拉取循环,Kafka 消息的积压和消费延迟会瞬间爆炸------第二部分的 Demo 就会手把手让你踩到这个坑。
2. 第一个 Demo:30 秒跑通(但埋了两个雷)
先用 Spring Initializr 创建一个项目,依赖选择 spring-kafka 和 spring-web。application.yml 配置如下:
yaml
spring:
kafka:
bootstrap-servers: localhost:9092
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.apache.kafka.common.serialization.StringSerializer
consumer:
group-id: demo-group
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
auto-offset-reset: earliest
然后写一个发送端点和一个监听器:
java
@RestController
@RequestMapping("/kafka")
public class KafkaController {
@Autowired
private KafkaTemplate<String, String> kafkaTemplate;
@PostMapping("/send")
public String send(@RequestParam String message) {
kafkaTemplate.send("demo-topic", message);
return "Sent: " + message;
}
}
@Component
public class DemoConsumer {
@KafkaListener(topics = "demo-topic")
public void listen(String message) {
System.out.println("收到消息: " + message);
}
}
启动、发一条 Hello Kafka,控制台完美打印。看起来一切正常对吧?这两个雷分别是:序列化会在发对象时炸 ,阻塞操作会让消费者停摆。往下看。
3. 踩坑一:序列化------你以为发的是 JSON,实际是 Java 对象
产品经理说"我要在消息里传用户信息",你写了个 User 类,然后这样发:
java
kafkaTemplate.send("user-topic", new User("张三", 25));
紧接着消费者监听器里收到的是------org.apache.kafka.common.errors.SerializationException: can't deserialize to User。
问题出在 Spring Boot 默认的 StringSerializer 根本不知道怎么序列化对象。你看到的是 Java 对象,Kafka 收到的是乱码。更坑的是,如果你直接用 JsonSerializer,它默认写入 __typeId__ 头信息,记录完整的 Java 类名。生产者写的 User,消费者如果没用同一个包路径,或者改了字段名------TypeIdException 直接炸。
正确的做法是显式配置,且在生产者和消费者之间约定清晰的类型映射:
yaml
spring:
kafka:
producer:
value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
consumer:
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
properties:
spring.json.trusted.packages: "*" // 修正:生产环境建议限定具体包名
spring.json.value.default.type: com.example.demo.User // 修正:显式指定类型
消费者也要加泛型:
java
@KafkaListener(topics = "user-topic")
public void listenUser(User user) {
System.out.println("收到用户: " + user.getName());
}
这还没完------建议永远不要在 @KafkaListener 方法参数里用泛型解析 List<User>,直接消费字符串然后用 ObjectMapper 反序列化。因为 Kafka 的泛型反序列化在不同版本的 Spring Kafka 里行为不一致,生产环境踩过这个坑的人不少。
4. 踩坑二:消息重复------幂等性不是可选项,是必选项
Kafka 默认是"至少一次"语义。这意味着如果生产者发送消息后没收到 ACK(网络抖了一下),它会重试,而重试的消息会被 Kafka 写入两次。同时,消费者在处理完消息后提交偏移量之前挂了,重启后会重新消费这条消息。
一个订单扣款场景:用户下单 100 元,消费者收到消息后执行扣款,结果扣了两次。这种 Bug 上线后修复成本是巨大的。
解决方案分两层。第一层开启生产者幂等性:
yaml
spring:
kafka:
producer:
enable-idempotence: true // 修正:关键参数
transaction-id-prefix: tx- // 修正:开启事务(幂等性依赖于事务)
生产者幂等性通过 producerId 和序列号,确保哪怕重试,Kafka 也只接受一次写入,吞吐量反而能提升 10%-30%。第二层是消费者侧做幂等处理:
java
@Component
public class OrderConsumer {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@KafkaListener(topics = "order-topic")
public void processOrder(OrderMessage order) {
// 1. 先检查消息是否已经处理过
String messageId = order.getMessageId(); // 消息携带唯一 ID
if (redisTemplate.opsForValue().setIfAbsent("order:" + messageId, "1")) {
// 2. 执行业务(扣款、发货等)
orderService.deductBalance(order.getUserId(), order.getAmount());
// 3. 业务成功后提交偏移量(Spring 默认自动提交)
} else {
log.info("重复消息跳过: {}", messageId);
}
}
}
记住:消息系统保证"不丢"容易,保证"不重复"需要业务层配合。幂等性是架构底线,不是锦上添花。
5. 踩坑三:消费阻塞------poll 循环一停,整个消费者就罢工
回到 Kafka 的"拉模式"。@KafkaListener 底层的本质是一个无限循环:
java
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
// 这里执行你的监听方法
}
}
这个循环和 @KafkaListener 是同一个线程。如果你在监听方法里写了同步 HTTP 调用:
java
@KafkaListener(topics = "order-topic")
public void handleOrder(OrderMessage order) {
// 同步调用外部支付接口(可能耗时 5 秒)
paymentService.callExternalApi(order);
}
这 5 秒里,这个消费者线程的 poll 循环也被阻塞了。如果一个分区有 1000 条消息积压,每个处理 5 秒,50 秒后这批消息才消费完。更可怕的是,如果外部接口超时 30 秒,你这个消费者的消费间隔会超过 max.poll.interval.ms(默认 5 分钟),Kafka 直接认为你挂了,触发 rebalance------其他消费者接手你分区的任务,但它们也要重新 poll,整体吞吐量断崖式下跌。
解决方案:一定要异步处理,别让 IO 阻塞 poll 循环。
java
@KafkaListener(topics = "order-topic")
public void handleOrderAsync(OrderMessage order) {
// 使用虚拟线程或者自定义线程池异步处理
Thread.ofVirtual().start(() -> {
paymentService.callExternalApi(order);
});
// poll 循环立即返回,处理下一批消息
}
另一个高级方案是直接消费原始 ConsumerRecords,手动控制并发粒度------不推荐新手这么干,但要知道有这个选项。
6. 生产级 Checklist
最后给一张可以直接抄的清单:
- 序列化 :已配置
JsonSerializer和JsonDeserializer,且spring.json.value.default.type指向正确的 DTO - 幂等生产者 :
enable.idempotence=true,transaction-id-prefix已设置 - 消费者幂等:消息携带唯一 ID,业务层实现去重机制(Redis SetIfAbsent 或数据库唯一索引)
- 消费线程隔离:监听器方法中无同步 IO 阻塞,使用异步线程池或虚拟线程
- 错误重试策略 :配置了
@RetryableTopic和死信队列,避免无限重试撑爆 Kafka - 关键监控 :监控
records-lag-max(最大积压)、request-latency-avg(请求延迟)、消费者active-count(活跃消费者数)
这份清单能帮你躲过 80% 的线上事故。剩下的 20%,Kafka 的版本兼容性和网络抖动------那是另一个故事了。