Spring Boot 集成 Kafka:先跑通 Demo,再避开那 80% 的人踩过的坑

Spring Boot 集成 Kafka:先跑通 Demo,再避开那 80% 的人踩过的坑

1. Kafka 到底是什么(以及为什么它不"推"消息)

很多人把 Kafka 当消息队列用,觉得 RabbitMQ 能做的事 Kafka 也能做------这个认知恰恰是后面所有问题的根源。Kafka 本质上是一个分布式日志,不是队列。

队列的特点是:一条消息被消费后就删除了。日志的特点是:数据写入后一直存在,谁想读谁读,读到哪儿自己说了算。这就是 Kafka 能做到百万 TPS、支持消息回放、能保证分区内顺序的根本原因------它不会因为"这条消息被消费了"就把数据丢掉,只会根据配置的保留策略清理。

这也解释了一个困扰无数新手的点:Kafka 不"推"消息,而是消费者"拉"消息。传统 MQ 会把消息推给消费者,Kafka 却让消费者自己轮询拉取。这意味着如果你的业务逻辑阻塞了拉取循环,Kafka 消息的积压和消费延迟会瞬间爆炸------第二部分的 Demo 就会手把手让你踩到这个坑。

2. 第一个 Demo:30 秒跑通(但埋了两个雷)

先用 Spring Initializr 创建一个项目,依赖选择 spring-kafkaspring-webapplication.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

最后给一张可以直接抄的清单:

  • 序列化 :已配置 JsonSerializerJsonDeserializer,且 spring.json.value.default.type 指向正确的 DTO
  • 幂等生产者enable.idempotence=truetransaction-id-prefix 已设置
  • 消费者幂等:消息携带唯一 ID,业务层实现去重机制(Redis SetIfAbsent 或数据库唯一索引)
  • 消费线程隔离:监听器方法中无同步 IO 阻塞,使用异步线程池或虚拟线程
  • 错误重试策略 :配置了 @RetryableTopic 和死信队列,避免无限重试撑爆 Kafka
  • 关键监控 :监控 records-lag-max(最大积压)、request-latency-avg(请求延迟)、消费者 active-count(活跃消费者数)

这份清单能帮你躲过 80% 的线上事故。剩下的 20%,Kafka 的版本兼容性和网络抖动------那是另一个故事了。

相关推荐
小Ti客栈1 天前
Spring Boot 集成 Springdoc-OpenAPI 与 Knife4j实现接口文档与可视化调试
java·spring boot·后端
Ai拆代码的曹操1 天前
Spring 事务 REQUIRES_NEW 嵌套调用:连接池翻倍的秘密
java·后端·spring
动恰客流统计1 天前
ReID边缘计算视觉统计:餐饮店客流增长的数字化破局路径
java·大数据·运维·人工智能
Ivanqhz1 天前
Rust &‘static str浅析
java·前端·javascript·rust
weixin_419658311 天前
Docker 搭建 Jenkins 服务
java·docker·jenkins
captain3761 天前
多线程线程安全问题
java·java-ee
CoderYanger1 天前
A.每日一题:1979. 找出数组的最大公约数
java·程序人生·算法·leetcode·面试·职场和发展·学习方法
灵极海1 天前
LangChain4j RAG 实战完整指南:从入门到踩坑
java·langchain
yaoxin5211231 天前
476. Java 反射 - 调用方法
java·开发语言
Ivanqhz1 天前
Rust Lazy浅析
java·javascript·rust