【消息队列】Spring Boot 集成 Kafka 消息队列:确保消费完成(手动提交偏移量+幂等判断+失败重试机制)

Spring Boot 集成 Kafka 消息队列:确保消费完成(手动提交偏移量+幂等判断+失败重试机制)

一、先明确目标:为什么必须手动提交偏移量

Kafka 消费者的偏移量(offset)提交方式决定了消息的投递语义:

提交方式 投递语义 风险
自动提交(默认) 至少一次,但提交时机不可控 消息还在处理中就定期提交 → 进程崩溃会丢消息
手动提交 由业务代码决定何时提交 提交前崩溃 → 消息重复消费

本项目要的是「不能丢单」:订单消息必须先成功写入 MySQL,才允许提交偏移量。自动提交做不到这一点,因此必须:

  • spring.kafka.consumer.enable-auto-commit: false
  • spring.kafka.listener.ack-mode: manual
  • 业务处理成功后显式调用 acknowledgment.acknowledge()

配套代价是必须解决重复消费问题(至少一次语义的必然结果),所以本项目还叠加了幂等判断。


二、整体链路

复制代码
预约端 register
   │  POST /makeOrder
   ▼
book-service(生产者)
   │  1. leftPop 扣减 Redis 库存(原子,防超卖)
   │  2. leftPush 写 Redis 订单列表(前端可立即查询的前置视图)
   │  3. kafkaTemplate.send("ORDER_FORM", json)
   ▼
Kafka 集群(ip1 / ip2 / ip3 : 9092)
   │  topic:ORDER_FORM
   ▼
consume(消费者,group-id = xxx)
   │  1. 查询当日「同手机号 + 同物资类型」是否已存在  → 判重
   │  2. bookSaleDao.insert(bookSaleDO)               → 落库 MySQL
   │  3. insert > 0 → acknowledgment.acknowledge()    → 手动提交偏移量
   ▼
MySQL:订单最终落地

三、步骤一:引入依赖

生产端与消费端都需要 spring-kafka,版本无需手写,由 Spring Boot BOM 统一管理。

xml 复制代码
<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>

说明:使用 @KafkaListener 并不强制要求 @EnableKafka ------ Spring Boot 的 KafkaAnnotationDrivenConfiguration 会自动注册监听器后置处理器。本项目未加 @EnableKafka 也能正常消费。


四、步骤二:生产者配置与发送

4.1 配置

yaml 复制代码
spring:
  kafka:
    bootstrap-servers: ip1:9092,ip2:9092,ip3:9092

只配了集群地址。因为发送的是 JSON 字符串,使用的是 Spring Boot 的默认序列化(StringSerializer),所以无需显式声明 key-serializer / value-serializer。

如果要显式配置并提高可靠性,建议补全为:

yaml 复制代码
spring:
  kafka:
    bootstrap-servers: ip1:9092,ip2:9092,ip3:9092
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.apache.kafka.common.serialization.StringSerializer
      acks: all              # 所有 ISR 副本确认,避免 leader 落盘后即返回导致丢消息
      retries: 3             # 可重试异常自动重试
      properties:
        enable.idempotence: true   # 生产者幂等,防止重试导致重复写入

注意:如果项目未配置 acks 与 retries ,走的是默认值 acks=1,即 leader 写入成功即认为发送成功,leader 在同步给副本前宕机会丢消息。

4.2 注入 KafkaTemplate 并发送

java 复制代码
@Autowired
private KafkaTemplate kafkaTemplate;

// topic 统一走常量,避免散落的魔法字符串
kafkaTemplate.send(Constants.orderFormTopic, json);

4.3 发送结果回调

send() 返回 ListenableFuture,可注册成功/失败回调,用于失败补偿:

java 复制代码
ListenableFuture listenableFuture = kafkaTemplate.send(Constants.orderFormTopic, json);

listenableFuture.addCallback(
    new SuccessCallback() {
        @Override
        public void onSuccess(Object result) { /* 发送成功,无需额外处理 */ }
    },
    new FailureCallback() {
        @Override
        public void onFailure(Throwable ex) {
            // 补偿:回滚 Redis 里的订单记录,并把扣掉的库存加回去
            del2Redis(json, phone, orderInfoPrefix);
            redisTemplate.opsForList()
                .rightPush(stockPrefix + ":" + orgType + ":" + currentTime,
                           UUID.randomUUID().timestamp());
        }
    });

补偿思路是反向操作:把已经写进 Redis 的订单删掉、把扣掉的库存补回,保证 Redis 视图与 Kafka/DB 最终一致。


五、步骤三:消费者配置(关闭自动提交 + 手动 ack)

这是手动提交偏移量的核心配置:

yaml 复制代码
spring:
  kafka:
    bootstrap-servers: ip1:9092,ip2:9092,ip3:9092
    consumer:
      # earliest:有已提交 offset 则从该位置继续;没有则从头消费
      # latest  :有已提交 offset 则从该位置继续;没有则只消费新产生的数据
      # none    :任一分区没有已提交 offset 就直接抛异常
      auto-offset-reset: latest
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      group-id: xxx
      # 关键配置 1:关闭自动提交
      enable-auto-commit: false
    listener:
      # 关键配置 2:手动确认
      ack-mode: manual

三条配置缺一不可:

  1. enable-auto-commit: false ------ 不允许 Kafka 客户端后台定时提交偏移量
  2. ack-mode: manual ------ 告诉容器「偏移量由监听器自己确认」
  3. 监听方法注入 Acknowledgment 参数 ------ 提供确认入口

只有 enable-auto-commit: false 时 ack-mode 才有意义;两者同时配置为自动提交时,Spring Kafka 会给出警告且 ack-mode 不生效。


六、步骤四:手动确认的消费者代码

java 复制代码
@Component
@Slf4j
public class KafkaReceiver {

    @Autowired
    private BookSaleDao bookSaleDao;

    @Autowired
    private RedisTemplate redisTemplate;

    @KafkaListener(topics = {"ORDER_FORM"})
    public void listen(ConsumerRecord<?, ?> record, Acknowledgment acknowledgment) throws Exception {
        Optional<?> kafkaMessage = Optional.ofNullable(record.value());
        if (kafkaMessage.isPresent()) {
            String message = kafkaMessage.get().toString();
            ObjectMapper objectMapper = new ObjectMapper();
            BookSaleDO bookSaleDO = objectMapper.readValue(message, BookSaleDO.class);

            Date gmtCreate = bookSaleDO.getGmtCreate();
            String phone = bookSaleDO.getPhone();
            String date = TimeUtil.formatDate(gmtCreate, TimeUtil.YMD);
            String startTime = date + " 00:00:00";
            String endTime = date + " 23:59:59";
            Integer type = bookSaleDO.getType();

            // 1. 幂等判重:当日同手机号 + 同物资类型 是否已存在
            QueryWrapper<BookSaleDO> queryWrapper = new QueryWrapper<>();
            queryWrapper.gt("GMT_CREATE", startTime);
            queryWrapper.lt("GMT_CREATE", endTime);
            queryWrapper.eq("TYPE", type);
            queryWrapper.eq("PHONE", phone);
            List<BookSaleDO> list = bookSaleDao.selectList(queryWrapper);
            if (list.size() > 0) {
                // 重复消息:打标记,替换掉 Redis 中的旧记录
                bookSaleDO.setRepeatNum(1);
                up2Redis(bookSaleDO, message);
            }

            // 2. 落库
            int insert = bookSaleDao.insert(bookSaleDO);

            // 3. 落库成功才提交偏移量
            if (insert > 0) {
                acknowledgment.acknowledge();
            }
        }
    }
}

关键点逐条说明:

位置 作用
Acknowledgment acknowledgment 入参 手动确认的唯一入口,由容器注入
判重查询 + repeatNum = 1 幂等兜底:重复消费时不产生第二条订单,只做标记和 Redis 替换
insert > 0 才 acknowledge() 「确保消费完成」的核心:数据库没写成功就绝不提交偏移量
落库失败时不 ack 偏移量不前进,消息会被重新投递(重启后重新消费)

七、保证「消费完成」的三个要素

手动提交只是手段,要真正做到不丢单、不错单,需要三者配齐:

复制代码
① 手动提交偏移量  ──► 保证「没处理完就不算已消费」
        │
② 消费幂等        ──► 保证「重复投递不会产生脏数据」
        │
③ 失败补偿/重试    ──► 保证「失败的消息有归宿」

① 手动提交偏移量

  • 配置:enable-auto-commit: false + ack-mode: manual
  • 代码:业务成功后调用 acknowledgment.acknowledge()
  • ack-mode 的五种取值差异见下一节,选错会直接影响可靠性

② 消费幂等

手动提交带来的是「至少一次」投递,重复消费无法避免(应用重启、rebalance、提交前崩溃都会触发)。本项目的做法是业务键去重:

  • 判重维度:PHONE + TYPE + 当日
  • 命中重复:repeatNum = 1 打标,并替换 Redis 中该手机号的订单记录
  • 消费者侧 up2Redis 用 remove 精确匹配旧 JSON 再 leftPush 新 JSON,避免重复堆积

更强的做法(建议补充):在 PHONE + TYPE + 日期 上建数据库唯一索引,或落库前用 Redis setIfAbsent 做分布式去重键,做到真正「重复消息只写一次」。

③ 失败补偿

生产侧:Kafka 发送失败时删除 Redis 订单、回补库存。

消费侧:落库失败不 ack,由容器重试;超过重试次数应配置死信队列。


八、ack-mode 五种取值对照

取值 提交时机 适用场景
RECORD 每条记录处理完提交一次 单条处理成本低、可容忍频繁提交
BATCH(默认) 一次 poll() 返回的整批处理完提交 吞吐优先,允许少量重复
TIME 距上次提交超过 ackTime 提交 按时间窗口批量提交
COUNT 累计 ackCount 条后提交 按条数批量提交
MANUAL 监听器调用 acknowledge() 后,在整批处理结束时统一提交 本项目采用
MANUAL_IMMEDIATE 监听器每次调用 acknowledge(),立即提交 要求单条精确提交

本项目的选择与影响:

  • 选择 manual,偏移量提交发生在整批处理完之后
  • 如果一批里前几条已 ack、应用在处理到一半时崩溃,这批的偏移量还没提交 → 整批会重新投递,前面已处理的记录被重复消费
  • 这正是必须做幂等的原因
  • 若希望每次 acknowledge() 立即落定、把重复范围压到最小,可改为 manual_immediate(代价是提交更频繁、吞吐略降)

九、验证与排查

目的 方式
确认消费组与积压 kafka-consumer-groups.sh --bootstrap-server ip1:9092 --describe --group xxx
观察提交的偏移量是否随消费推进 上条命令的 CURRENT-OFFSET 列
确认手动提交是否生效 把消费者停掉再启动,观察是否重复消费(repeatNum 被打成 1)
确认消息是否发送成功 查看 KafkaReceiver 中的 log.info 输出(查询条件日志)
验证幂等 人为重放同一条消息,检查 REPEAT_NUM 字段是否变为 1 且订单表无第二条

十、可复用的最小实现模板

把上面的步骤收敛成一份可直接套用的骨架:

消费端配置

yaml 复制代码
spring:
  kafka:
    bootstrap-servers: ${KAFKA_ADDR}
    consumer:
      group-id: ${GROUP_ID}
      auto-offset-reset: latest
      enable-auto-commit: false
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
    listener:
      ack-mode: manual

消费端代码

java 复制代码
@Component
@Slf4j
public class OrderConsumer {

    @KafkaListener(topics = "${app.kafka.topic.order}")
    public void onMessage(ConsumerRecord<String, String> record, Acknowledgment ack) {
        try {
            // 1. 幂等去重
            if (isDuplicated(record.value())) {
                ack.acknowledge();      // 重复消息直接确认,避免反复投递
                return;
            }
            // 2. 业务处理(写库)
            doBusiness(record.value());
            // 3. 业务成功,才提交偏移量
            ack.acknowledge();
        } catch (Exception e) {
            // 不 ack:偏移量不前进,消息会被重新投递
            log.error("消费失败, topic={}, partition={}, offset={}",
                      record.topic(), record.partition(), record.offset(), e);
            throw e;
        }
    }
}

生产端发送

java 复制代码
ListenableFuture<SendResult<String, String>> future =
        kafkaTemplate.send(topic, JSON.toJSONString(payload));

future.addCallback(
    result -> log.info("发送成功, topic={}, offset={}",
                       result.getRecordMetadata().topic(),
                       result.getRecordMetadata().offset()),
    ex -> log.error("发送失败,进入补偿逻辑", ex));

十一、小结

保证「消息消费完成」这条链路,本项目的做法可以概括为:

  1. 生产者 :扣库存 → 写 Redis 前置视图 → 发 Kafka,失败则回滚两者(ParamSetServiceImpl)
  2. 中间件 :Kafka 三节点集群,topic Constants.orderFormTopic
  3. 消费者 :enable-auto-commit=false + ack-mode=manual,落库成功后 才 acknowledge()(KafkaReceiver)
  4. 幂等:按「手机号 + 物资类型 + 当日」去重,重复消息只打标记
  5. 代价:至少一次语义下必须容忍重复投递,因此第四步不是可选项

配置层面最容易出错的两点:忘关 enable-auto-commit(ack-mode 直接失效),以及过早调用 acknowledge()(在业务成功之前提交,相当于退化成自动提交,会丢消息)。

相关推荐
计算机毕设定制辅导-无忧学长2 小时前
《校园食堂在线订餐小程序的设计与实现》
java·vue.js·spring boot·微信小程序·uniapp·校园食堂在线订餐小程序
IT研究室2 小时前
最新计算机毕业设计选题推荐-基于spring boot的食品安全信息管理系统-网站-文档指导-Java-springboot
java·spring boot·课程设计
code_slave(码畜)4 小时前
微服务架构落地:消息队列架构设计(上篇)——异步解耦、削峰填谷,看懂业务事件流转本质
spring boot·spring cloud·微服务·云原生·架构
小蒜学长5 小时前
基于Java的公司采购系统的设计与实现(代码+数据库+LW)
java·数据库·spring boot·后端·公司采购系统
小坏讲微服务5 小时前
Spring Boot 4 新特性全解析:从上手到生产实战
java·spring boot·后端·架构·springboot4
huaweichenai7 小时前
spring boot 实现file文件上传
java·spring boot·后端
VX_bysjlw9857 小时前
数码设备销售网站设计与实现39138-计算机毕设原创(免费领源码+带部署教程)
java·vue.js·spring boot·mysql·tomcat·mybatis·idea
code_slave(码畜)8 小时前
微服务架构落地:消息队列架构设计(下篇)——消息堆积、死信治理与集群监控告警实战
spring boot·spring cloud·微服务·云原生·中间件·架构