第6篇 Consumer 精讲(上):Offset 提交与幂等消费

第6篇:Consumer 精讲(上)------ Offset 提交是消费者最重要的事

系列 :Kafka × Spring Boot:参数精讲与生产落地实战
本篇关键词group.id · auto.offset.reset · enable.auto.commit · 手动提交 · 幂等消费设计


📌 本篇导读

Consumer 端的代码看起来最简单------加一个 @KafkaListener 就能消费消息。但生产上 90% 的 Kafka 问题都出在消费端,根源几乎都指向同一件事:Offset 提交时机不对

本篇解决四个核心问题:

  1. group.id 的两条铁律
  2. auto.offset.reset 的真正生效条件(很多人理解有误)
  3. 自动提交 Offset 为什么既会丢消息,也会重复消费
  4. 手动提交的正确姿势 + 幂等消费设计

一、group.id 的两条铁律

yaml 复制代码
spring:
  kafka:
    consumer:
      group-id: inventory-service  # 必须配置,不能为空

铁律一:同一 Consumer Group 内,一个 Partition 只能被一个 Consumer 消费

复制代码
Topic: order-events(3个Partition)
Consumer Group: inventory-service(3个Consumer实例)

负载均衡分配:
  Consumer A → Partition 0
  Consumer B → Partition 1
  Consumer C → Partition 2
  
每条消息只被消费一次 ✓

铁律二:不同 Consumer Group 之间完全独立,各自消费全量消息

复制代码
同一 Topic 的消息:

inventory-service Group → 消费全量(库存扣减)
notification-service Group → 消费全量(发短信通知)
analytics-service Group → 消费全量(数据统计)

三个服务互不干扰,各自独立 ✓

❌ 常见错误:多个服务用了同一个 group.id

复制代码
inventory-service 和 notification-service 都配置了 group-id=order-consumer

结果:两个服务抢同一份消息
  → 库存服务消费了 msg1,通知服务就消费不到 msg1
  → 有些订单扣了库存没发通知,有些发了通知没扣库存!

二、auto.offset.reset 的真正生效条件

含义
latest(默认) 从 Consumer 启动后的新消息开始
earliest 从 Topic 最早的消息开始
none 没有 Offset 记录时直接报错

⚠️ 最大误解:不是"每次重启都从头消费"

auto.offset.reset 只在这两种情况下生效:

  • 该 Consumer Group 第一次消费这个 Topic(从未提交过 Offset)

  • 该 Consumer Group 的 Offset 记录已过期被 Kafka 删除offsets.retention.minutes 默认 7 天)

    场景:你把 auto.offset.reset 改成 earliest,重启服务
    发现还是从上次位置继续消费,没有回到最开始

    原因:Kafka 的 __consumer_offsets 中记录了你的消费进度
    auto.offset.reset 已经完全不起作用了

    解决方案:
    ① 换一个新的 group-id(推荐)
    ② 手动重置 Offset

    手动重置到最早位置

    docker exec kafka-standalone /opt/kafka/bin/kafka-consumer-groups.sh
    --bootstrap-server localhost:9092
    --group your-group-id
    --topic your-topic
    --reset-offsets --to-earliest --execute


三、自动提交:既丢消息,又重复消费

参数 默认值
enable.auto.commit true生产建议关闭
auto.commit.interval.ms 5000(每 5 秒自动提交一次)

场景一:自动提交导致消息丢失

复制代码
时间线:
  T=0:  poll() 拉取到 msg1, msg2, msg3
  T=1:  开始处理 msg1
  T=5:  ★ 自动提交触发!Offset 提交到 msg3 后面的位置 ★
  T=6:  处理 msg2 时,业务异常,服务崩溃!
  T=7:  服务重启,从上次提交的 Offset 继续
  T=8:  从 msg4 开始消费

结果:msg2、msg3 业务没处理完,Offset 已经提交 → 永久丢失 ❌

场景二:自动提交导致重复消费

复制代码
时间线:
  T=0:  poll() 拉取到 msg1, msg2, msg3
  T=3:  msg1、msg2、msg3 全部处理完成
  T=4:  服务崩溃(还没到自动提交时间 T=5)
  T=5:  服务重启,从上次提交的 Offset 重新消费
  T=6:  msg1、msg2、msg3 再次被消费

结果:重复消费 ❌(至少不丢消息,但下游业务可能被重复执行)

结论:自动提交在消息未处理完时提交 Offset(丢消息),在消息已处理但未提交时崩溃(重复消费)。生产环境必须关闭。


四、手动提交:正确姿势全解析

4.1 配置

yaml 复制代码
spring:
  kafka:
    consumer:
      enable-auto-commit: false
    listener:
      ack-mode: manual_immediate

4.2 单条消费 ------ 最基础的写法

java 复制代码
@KafkaListener(topics = "order-events", groupId = "inventory-service")
public void consume(ConsumerRecord<String, String> record, Acknowledgment ack) {
    try {
        // ① 先执行业务逻辑
        inventoryService.deductStock(record.value());

        // ② 业务成功后提交 Offset
        // ★ 顺序很重要:先处理,后提交 ★
        ack.acknowledge();

    } catch (BusinessException e) {
        // 业务异常(如库存不足):记录日志,不提交 Offset
        // 消息会被重新投递,等待业务条件满足
        log.error("业务处理失败,将重试: orderId={}, error={}",
                parseOrderId(record.value()), e.getMessage());
        // 注意:不调用 ack,消息会在下次 poll 时重新投递

    } catch (Exception e) {
        // 系统异常(如消息格式错误):跳过并提交,避免阻塞
        log.error("系统异常,跳过消息: offset={}, error={}", record.offset(), e.getMessage());
        ack.acknowledge(); // 跳过"毒药消息"
    }
}

4.3 错误示范:提交顺序反了

java 复制代码
// ❌ 错误!先提交后处理,处理失败则消息丢失
@KafkaListener(topics = "order-events")
public void wrongConsume(ConsumerRecord<String, String> record, Acknowledgment ack) {
    ack.acknowledge();             // ← 先提交了!
    inventoryService.deduct(...); // ← 如果这里抛异常,消息已提交 → 丢失!
}

4.4 ack-mode 选项

提交时机 适用场景
MANUAL_IMMEDIATE 调用 ack.acknowledge() 后立即提交 推荐,大多数场景
MANUAL 调用后在下次 poll() 前提交 批量场景可用
BATCH 每批消息全部处理后自动提交 开启批量消费时
RECORD 每条消息处理后自动提交 不推荐,性能差

五、幂等消费设计

手动提交能减少消息丢失,但无法 100% 消除重复消费 (Rebalance 时可能重复投递)。

正确做法:让消费者本身具备幂等性。

方案一:数据库唯一键(最简单,首选)

java 复制代码
@KafkaListener(topics = "order-events")
public void consume(ConsumerRecord<String, String> record, Acknowledgment ack) {
    OrderEvent event = JSON.parseObject(record.value(), OrderEvent.class);
    try {
        // 数据库建有唯一索引:UNIQUE KEY uk_order_id(order_id)
        orderMapper.insert(new Order(event.getOrderId(), event.getStatus()));
        ack.acknowledge();
    } catch (DuplicateKeyException e) {
        // 唯一键冲突 = 重复消息,直接跳过
        log.warn("重复消息,幂等跳过: orderId={}", event.getOrderId());
        ack.acknowledge();
    }
}

方案二:Redis SETNX 去重(高频消费场景)

java 复制代码
@KafkaListener(topics = "order-events")
public void consume(ConsumerRecord<String, String> record, Acknowledgment ack) {
    // 用 topic+partition+offset 作为唯一标识
    String msgId = record.topic() + "-" + record.partition() + "-" + record.offset();
    String key = "kafka:dedup:" + msgId;

    // SETNX:只有首次写入成功
    Boolean isNew = redisTemplate.opsForValue()
            .setIfAbsent(key, "1", Duration.ofDays(7));

    if (Boolean.FALSE.equals(isNew)) {
        log.warn("重复消息,Redis去重跳过: {}", msgId);
        ack.acknowledge();
        return;
    }

    try {
        processOrder(record.value());
        ack.acknowledge();
    } catch (Exception e) {
        redisTemplate.delete(key); // 处理失败,删除标记,允许重试
        throw e;
    }
}

方案三:业务状态判断(最自然,推荐状态机业务)

java 复制代码
@KafkaListener(topics = "order-paid-events")
public void handleOrderPaid(ConsumerRecord<String, String> record, Acknowledgment ack) {
    OrderPaidEvent event = parseEvent(record.value());
    Order order = orderRepo.findById(event.getOrderId());

    // 已经是目标状态,说明已处理过,直接跳过
    if (OrderStatus.PAID.equals(order.getStatus())) {
        log.info("幂等跳过,订单已是PAID状态: orderId={}", event.getOrderId());
        ack.acknowledge();
        return;
    }

    order.setStatus(OrderStatus.PAID);
    order.setPaidAt(LocalDateTime.now());
    orderRepo.save(order);
    ack.acknowledge();
}

三种方案对比:

方案 额外依赖 复杂度 推荐场景
数据库唯一键 必须写 DB 的操作
Redis SETNX Redis 高频消费,性能敏感
业务状态判断 状态机类业务(最推荐

六、踩坑记录

❌ 坑1:配置了手动提交,消息却无限重复消费

复制代码
原因:ack-mode=MANUAL_IMMEDIATE,但方法签名没有 Acknowledgment 参数
     Offset 永远不提交,每次 poll 都重新消费相同的消息

// 错误写法
@KafkaListener(topics = "xxx")
public void consume(String message) { // ← 缺少 Acknowledgment!
    process(message);
    // 谁来提交 Offset?没人!
}

// 正确写法
@KafkaListener(topics = "xxx")
public void consume(ConsumerRecord<String, String> record, Acknowledgment ack) {
    process(record.value());
    ack.acknowledge(); // ← 必须显式调用
}

❌ 坑2:多个服务误用了同一个 group.id

复制代码
症状:订单消息有时触发了库存扣减,有时没有;有时发了通知,有时没有
根因:inventory-service 和 notification-service 都用了 group-id=order-group
     两个服务在抢同一份消息

解决:每个微服务使用独立的 group-id
  inventory-service  → group-id=inventory-service
  notification-service → group-id=notification-service

❌ 坑3:在异步线程中调用 ack.acknowledge() 导致顺序混乱

java 复制代码
// ❌ 危险:异步线程中提交 Offset,可能导致乱序提交
@KafkaListener(topics = "xxx")
public void consume(ConsumerRecord<String, String> record, Acknowledgment ack) {
    CompletableFuture.runAsync(() -> {
        process(record.value());
        ack.acknowledge(); // ← 在其他线程调用,有并发风险
    });
    // 方法立即返回,此时 Offset 还没提交
}

📝 本篇小结

知识点 核心结论
group.id 不同服务配不同 group.id,实现广播消费
auto.offset.reset 只在首次消费或 Offset 过期时生效,不是重启必从头的开关
自动提交 既会丢消息也会重复消费,生产必须关闭
手动提交 先业务,后提交,这个顺序雷打不动
幂等消费 重复消费是正常现象,用幂等设计消除影响

下篇预告:第7篇《Consumer 精讲(中)------批量消费、@KafkaListener 高级用法与动态启停》。

相关推荐
Devin~Y2 小时前
大厂 Java 面试实录:Spring Boot/Cloud、Kafka、Redis、JVM、K8s、RAG 一条龙(小Y翻车版)
java·jvm·spring boot·redis·spring cloud·kafka·kubernetes
代码漫谈2 小时前
Spring Boot日志配置全攻略:打造高效、可靠的日志系统
java·spring boot·log4j·日志
yangminlei2 小时前
Spring Boot Starter自定义开发 构建企业级组件库
java·spring boot·后端
接着奏乐接着舞2 小时前
springboot 常用注解
spring boot·后端·python
IT策士2 小时前
Python 中间件系列:kafka学习
python·中间件·kafka
哆啦A梦158816 小时前
20, Springboot3+vue3实现前台轮播图和详情页的设计
javascript·数据库·spring boot·mybatis·vue3
伏加特遇上西柚19 小时前
Loki+Alloy+Grafana日志采集部署
java·linux·服务器·spring boot·grafana·prometheus
庞轩px20 小时前
第四篇:SpringBoot自动配置——约定大于配置的底层原理
java·spring boot·后端·spring·自动配置·注解开发
桃花键神1 天前
【2026精品项目】基于SpringBoot3+Vue3的旧物置换系统(包含源码+项目文档+SQL脚本+部署教程)
数据库·spring boot·sql·vue