第6篇:Consumer 精讲(上)------ Offset 提交是消费者最重要的事
系列 :Kafka × Spring Boot:参数精讲与生产落地实战
本篇关键词 :group.id·auto.offset.reset·enable.auto.commit· 手动提交 · 幂等消费设计
📌 本篇导读
Consumer 端的代码看起来最简单------加一个 @KafkaListener 就能消费消息。但生产上 90% 的 Kafka 问题都出在消费端,根源几乎都指向同一件事:Offset 提交时机不对。
本篇解决四个核心问题:
group.id的两条铁律auto.offset.reset的真正生效条件(很多人理解有误)- 自动提交 Offset 为什么既会丢消息,也会重复消费
- 手动提交的正确姿势 + 幂等消费设计
一、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 高级用法与动态启停》。