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 的版本兼容性和网络抖动------那是另一个故事了。

相关推荐
plainGeekDev1 小时前
ProGuard → R8
android·java·kotlin
带刺的坐椅2 小时前
把 OpenAPI 接入 Agent Harness:零代码让 Agent 听懂你的 REST API
java·ai·llm·agent·solon·restapi·openapi
Geek-Chow2 小时前
微服务认证与授权:01 — 概念
java·前端·数据库
KobeSacre2 小时前
DelayQueue 源码
java·开发语言
Wang's Blog2 小时前
JavaWeb快速入门: Filter与Listener核心详解
java·filter·listener
zfoo-framework2 小时前
mongodb性能调优 1.从慢查询分析索引 2.掌握索引最左匹配原则!!!
java
AIGS0013 小时前
企业AI落地:从RAG到AgentRAG的技术跃迁
java·人工智能·人工智能ai大模型应用
2601_962341303 小时前
计算机毕业设计之jsp考研在线复习平台
java·大数据·开发语言·hadoop·python·考研·课程设计