Spring Boot 集成 Kafka 消息队列:确保消费完成(手动提交偏移量+幂等判断+失败重试机制)
一、先明确目标:为什么必须手动提交偏移量
Kafka 消费者的偏移量(offset)提交方式决定了消息的投递语义:
| 提交方式 | 投递语义 | 风险 |
|---|---|---|
| 自动提交(默认) | 至少一次,但提交时机不可控 | 消息还在处理中就定期提交 → 进程崩溃会丢消息 |
| 手动提交 | 由业务代码决定何时提交 | 提交前崩溃 → 消息重复消费 |
本项目要的是「不能丢单」:订单消息必须先成功写入 MySQL,才允许提交偏移量。自动提交做不到这一点,因此必须:
spring.kafka.consumer.enable-auto-commit: falsespring.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。
如果要显式配置并提高可靠性,建议补全为:
yamlspring: 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
三条配置缺一不可:
enable-auto-commit: false------ 不允许 Kafka 客户端后台定时提交偏移量ack-mode: manual------ 告诉容器「偏移量由监听器自己确认」- 监听方法注入
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));
十一、小结
保证「消息消费完成」这条链路,本项目的做法可以概括为:
- 生产者 :扣库存 → 写 Redis 前置视图 → 发 Kafka,失败则回滚两者(
ParamSetServiceImpl) - 中间件 :Kafka 三节点集群,topic
Constants.orderFormTopic - 消费者 :
enable-auto-commit=false+ack-mode=manual,落库成功后 才acknowledge()(KafkaReceiver) - 幂等:按「手机号 + 物资类型 + 当日」去重,重复消息只打标记
- 代价:至少一次语义下必须容忍重复投递,因此第四步不是可选项
配置层面最容易出错的两点:忘关 enable-auto-commit(ack-mode 直接失效),以及过早调用 acknowledge()(在业务成功之前提交,相当于退化成自动提交,会丢消息)。