Kafka 消息重复消费的核心原因是 “消息消费与 Offset 提交的原子性未保证” (如消息已处理但 Offset 未提交,重启后重新拉取),或 “生产端重试导致消息重复写入” 。解决思路是从「生产端去重、Broker 端防重、消费端幂等处理、业务端兜底」四个层面层层防护。
一、核心前提:理解重复消费的本质场景
重复消费的常见触发场景:
- 消费端:消息处理完成前崩溃 / 重启,Offset 未提交,重启后重新拉取相同消息;
- 生产端:发送消息时网络抖动,未收到 Broker 确认(ACK),生产者重试导致消息重复写入;
- Rebalance:消费组重平衡时,分区分配给新消费者,旧消费者已处理的消息未提交 Offset,新消费者重新拉取;
- 手动操作:误操作导致 Offset 回滚(如
seek()到历史 Offset),重新消费已处理消息。
因此,去重的核心是:确保 “消息写入” 和 “消息消费” 的幂等性(即重复操作不影响最终结果)。
二、生产端:避免重复写入(从源头减少重复)
生产端的目标是:即使重试,也只让 Broker 存储一条相同消息,核心依赖 Kafka 的「幂等性生产者」和「事务消息」。
1. 开启幂等性生产者(最常用)
-
原理:Kafka 为每个幂等生产者分配唯一的
Producer ID (PID),并为每个分区的消息分配递增的序列号(Sequence Number);Broker 会缓存 <PID, 分区,序列号> 映射,若收到相同 PID + 分区 + 序列号的消息,直接丢弃,避免重复写入。 -
配置要求(必须满足):
Properties props = new Properties(); // 1. 开启幂等性(默认 false) props.put(ProducerConfig.ENABLE_IDEMPOTENCE, true); // 2. ACK 必须设为 "all"(确保消息被所有副本确认,避免副本同步导致的序列号错乱) props.put(ProducerConfig.ACKS, "all"); // 3. 重试次数 ≥1(默认 2147483647,确保网络抖动时自动重试) props.put(ProducerConfig.RETRIES_CONFIG, 10); // 4. 单连接最大未确认请求数 ≤5(默认 5,幂等性要求,避免并发导致序列号混乱) props.put(ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION, 5); -
适用场景:单分区或多分区的独立消息(无需跨分区原子性),性能开销低,推荐优先使用。
2. 事务消息(跨分区原子性场景)
-
原理:若需要 “多条消息(可能跨分区)要么同时成功写入,要么同时失败”(如订单创建 + 库存扣减消息),可使用事务消息,避免部分消息重试导致的重复。
-
核心逻辑:
- 生产者开启事务(
initTransactions()),通过beginTransaction()开始事务,发送消息后用commitTransaction()提交,失败则abortTransaction()回滚; - Broker 会为事务消息标记状态,消费端需配置
isolation.level过滤未提交的事务消息,避免脏读和重复。
- 生产者开启事务(
-
配置示例:
// 生产端 props.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG, "order-transaction-1"); // 唯一事务ID KafkaProducer<String, String> producer = new KafkaProducer<>(props); producer.initTransactions(); // 初始化事务 try { producer.beginTransaction(); // 开始事务 // 发送多条跨分区消息 producer.send(new ProducerRecord<>("topic1", "order1", "create")); producer.send(new ProducerRecord<>("topic2", "stock1", "deduct")); producer.commitTransaction(); // 提交事务 } catch (Exception e) { producer.abortTransaction(); // 回滚事务 } // 消费端(需配置隔离级别) props.put(ConsumerConfig.ISOLATION_LEVEL_CONFIG, "read_committed"); // 只消费已提交的事务消息 -
适用场景:跨分区 / 跨 Topic 的原子性消息发送,性能开销略高于幂等性生产者,按需使用。
3. 业务层生成唯一消息 ID(兜底)
若无法使用幂等性 / 事务(如旧版本 Kafka),可在生产端为每条消息生成唯一 ID(如 UUID、雪花 ID),写入消息体或 headers 中,Broker 端不处理,但消费端可通过该 ID 去重。
-
示例:
String msgId = UUID.randomUUID().toString(); ProducerRecord<String, String> record = new ProducerRecord<>("topic", "key", "value"); record.headers().add("msg-id", msgId.getBytes(StandardCharsets.UTF_8)); // 消息头携带唯一ID producer.send(record);
三、Broker 端:减少重复的辅助防护
Broker 端本身不主动去重(依赖生产端幂等性),但可通过配置避免因 Broker 故障导致的重复写入:
-
合理配置副本数和 ISR:
- 设
min.insync.replicas ≥2(与ACK=all配合),确保消息被多数副本确认后才返回成功,避免 Leader 故障后数据丢失,减少生产者不必要的重试; - 示例:
topic配置min.insync.replicas=2,acks=all时,需至少 2 个副本同步消息才算发送成功。
- 设
-
禁用日志清理导致的重复:
- 避免使用
log.cleanup.policy=delete时过早删除消息(需确保消费端处理速度快于日志清理速度),否则可能导致消费端重新拉取时消息已丢失,触发生产者重试重复。
- 避免使用
四、消费端:避免重复处理(核心防护)
消费端是重复消费的 “重灾区”,核心原则是 “消息处理完成后,再提交 Offset” ,并确保处理逻辑幂等。
1. 禁用自动提交 Offset,改为手动提交
-
原因:自动提交(默认 5 秒)可能导致 “消息未处理完,但 Offset 已提交”(崩溃后不会重复),或 “消息处理完,但 Offset 未提交”(崩溃后重复消费),手动提交可精准控制提交时机。
-
配置与代码示例:
Properties props = new Properties(); // 禁用自动提交(默认 true) props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false); // 每次拉取的最大消息数(避免拉取过多导致处理超时) props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 100); // 拉取超时时间(需大于单次消息处理时间) props.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, 300000); // 5分钟 KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props); consumer.subscribe(Collections.singletonList("topic")); while (true) { ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100)); for (TopicPartition partition : records.partitions()) { List<ConsumerRecord<String, String>> partitionRecords = records.records(partition); boolean allProcessed = true; for (ConsumerRecord<String, String> record : partitionRecords) { try { // 1. 业务处理(必须确保幂等) processMessage(record); } catch (Exception e) { allProcessed = false; handleFailure(record); // 失败处理(如放入死信队列) break; // 停止处理该分区后续消息,避免部分成功 } } // 2. 所有消息处理完成后,手动提交 Offset(关键) if (allProcessed) { long lastOffset = partitionRecords.get(partitionRecords.size() - 1).offset(); // 提交到下一条要消费的 Offset(当前最后一条 Offset +1) consumer.commitSync(Collections.singletonMap(partition, new OffsetAndMetadata(lastOffset + 1))); } } } -
提交方式选择:
commitSync():同步提交,阻塞直到成功,适合对一致性要求高的场景;commitAsync():异步提交,不阻塞,但需处理回调失败(如重试提交),适合高吞吐量场景。
2. 控制 Rebalance 导致的重复
Rebalance 时,分区会从旧消费者转移到新消费者,若旧消费者已处理消息但未提交 Offset,新消费者会重复消费。解决方案:
-
- 延长
session.timeout.ms和heartbeat.interval.ms:
session.timeout.ms(默认 10 秒):消费者心跳超时时间,超过则被认为死亡,触发 Rebalance;heartbeat.interval.ms(默认 3 秒):消费者发送心跳的间隔,建议设为session.timeout.ms的 1/3;- 配置示例:
props.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, 30000);(延长到 30 秒),减少不必要的 Rebalance。
- 延长
-
- 使用
ConsumerRebalanceListener监听 Rebalance:
-
Rebalance 触发前,旧消费者提交已处理的 Offset,避免新消费者重复消费:
consumer.subscribe(Collections.singletonList("topic"), new ConsumerRebalanceListener() { @Override public void onPartitionsRevoked(Collection<TopicPartition> partitions) { // 分区被回收前,提交Offset consumer.commitSync(); } @Override public void onPartitionsAssigned(Collection<TopicPartition> partitions) { // 新分区分配后,可重置Offset(如从最新开始) } });
- 使用
-
- 避免消费者长时间阻塞:
- 若消费者处理消息耗时过长(超过
max.poll.interval.ms),会被认为无响应,触发 Rebalance; - 解决方案:拆分长任务(如异步处理,但需确保 Offset 提交与异步处理的一致性),或增大
max.poll.interval.ms。
3. 消费端幂等处理(关键兜底)
即使生产端和 Broker 端做了去重,极端情况下仍可能出现重复消息(如网络分区导致的幂等性失效),消费端必须保证 业务处理逻辑幂等(重复处理不影响结果)。
常见幂等处理方案:
-
方案 1:基于唯一消息 ID 去重(推荐)
-
生产端已生成唯一
msg-id(如 UUID、雪花 ID),消费端处理前先检查该 ID 是否已处理:-
存储介质:用 Redis(缓存已处理的 msg-id,设置过期时间)、数据库(如 MySQL 唯一索引);
-
示例:
String msgId = new String(record.headers().lastHeader("msg-id").value()); // 用 Redis 检查是否已处理(原子操作) Boolean isProcessed = redisTemplate.opsForValue().setIfAbsent(msgId, "1", 24, TimeUnit.HOURS); if (Boolean.TRUE.equals(isProcessed)) { processMessage(record); // 未处理过,执行业务逻辑 } else { // 已处理过,直接跳过 log.info("消息已重复,msgId: {}", msgId); }
-
-
-
方案 2:基于业务唯一键去重
-
若消息无全局唯一 ID,可用业务字段组合唯一键(如订单 ID + 操作类型),通过数据库唯一索引约束:
- 示例:订单支付消息,用
order_id作为唯一键,插入数据库时执行INSERT IGNORE INTO order_pay (order_id, amount) VALUES (?, ?),重复插入会被忽略。
- 示例:订单支付消息,用
-
-
方案 3:状态机校验
-
业务数据存在状态流转(如订单:待支付 → 支付中 → 已支付),重复消息触发时,通过状态机判断是否允许重复处理:
- 示例:已支付的订单,再次收到支付消息时,直接返回成功,不执行扣钱逻辑。
-
五、业务端:最终兜底方案
分布式系统中,任何技术层面的去重都无法 100% 避免极端情况,业务端需设计 “容错机制”:
- 定期对账:对核心业务(如金融交易、支付),定期与上游 / 下游系统对账,修正重复处理导致的数据不一致;
- 死信队列(DLQ):无法处理的重复消息(如多次重试仍失败),转发到死信 Topic,人工介入排查,避免阻塞正常消费;
- 日志审计:记录每条消息的消费日志(msg-id、处理时间、结果),便于排查重复问题。
六、常见避坑点
- 滥用自动提交 Offset:消息未处理完就提交,崩溃后不会重复,但可能丢失消息;未处理完就崩溃,会重复消费,需根据业务选择提交时机;
- 手动提交 Offset 过早:在消息处理前提交 Offset,处理失败后无法重试,导致消息丢失;
- 幂等性生产者配置不全:未设置
acks=all或retries≥1,导致幂等性失效; - 消费端并发处理未加锁:多线程处理同一个分区的消息时,去重逻辑(如 Redis 检查)未加锁,导致重复处理;
- 消息 ID 过期:Redis 缓存的 msg-id 过期时间过短,导致旧消息重新消费时无法识别重复。
七、总结:去重方案优先级
- 优先开启生产端幂等性(简单、低开销),避免重复写入;
- 消费端禁用自动提交,手动提交 Offset(确保 “处理完成” 与 “提交 Offset” 原子性);
- 消费端实现业务幂等处理(核心兜底,无论是否重复,处理结果一致);
- 按需使用事务消息(跨分区原子性场景)和 Rebalance 监听(减少 Rebalance 导致的重复)。