第9篇:消息不丢 ------ 三端协同,构建零丢失的消息通道
系列 :Kafka × Spring Boot:参数精讲与生产落地实战
本篇关键词 :消息丢失 ·acks=all·min.insync.replicas· 手动提交 · 发送失败补偿
📌 本篇导读
"Kafka 会不会丢消息?"几乎是每个用 Kafka 的工程师都被问到过的问题。
标准答案是:看你怎么配置。
默认配置下,消息可能在三个环节丢失:Producer 端、Broker 端、Consumer 端。本篇逐一拆解,给出完整的防丢方案,并在最后画出一张"防丢失全景图"。
一、先搞清楚:Kafka 的消息投递语义
| 语义 | 含义 | 如何实现 |
|---|---|---|
| At Most Once(最多一次) | 可能丢消息,绝不重复 | 自动提交 + acks=0 |
| At Least Once(至少一次) | 不丢消息,可能重复 | 手动提交 + acks=all |
| Exactly Once(恰好一次) | 不丢不重 | 事务消息(高成本,复杂) |
生产最佳实践:At Least Once + 消费者幂等 ≈ 事实上的 Exactly Once
二、Producer 端:三种丢消息场景与解法
场景一:acks=1(默认),Leader 宕机
时间线:
T1: Producer → Leader 接收并写入 → 返回 ACK(Producer 认为成功!)
T2: Follower 还在同步中,还没有这条消息
T3: Leader 突然宕机
T4: Follower 成为新 Leader(但它没有 T1 的那条消息)
结果:消息永久丢失,Producer 毫不知情 ❌
解法:
props.put(ProducerConfig.ACKS_CONFIG, "all");
// 等所有 ISR 副本确认,任何副本宕机都有其他副本保存数据 ✓
场景二:重试耗尽,彻底放弃
网络抖动 → 发送失败 → 触发重试
重试次数耗尽 / delivery.timeout.ms 到期 → 彻底失败
但 whenComplete 中没有处理失败情况 → 消息悄无声息地丢失 ❌
解法:发送失败必须有补偿机制
场景三:缓冲区打满,抛异常
生产速度 >> Broker 处理速度
→ RecordAccumulator 缓冲区满了
→ max.block.ms 超时后抛出 BufferExhaustedException
→ 调用方未捕获 → 消息丢失 ❌
解法:
1. 调大 buffer.memory
2. 捕获异常并补偿
Producer 端的完整防丢配置
java
@Bean
public ProducerFactory<String, String> reliableProducerFactory() {
Map<String, Object> props = new HashMap<>();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
// ① 等所有 ISR 副本确认(关键!)
props.put(ProducerConfig.ACKS_CONFIG, "all");
// ② 幂等,防止重试导致重复写入(开启后自动设置 retries 为合理值)
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
// ③ 总超时 5 分钟内无限重试,超时后彻底失败
props.put(ProducerConfig.RETRIES_CONFIG, Integer.MAX_VALUE);
props.put(ProducerConfig.DELIVERY_TIMEOUT_MS_CONFIG, 300000);
return new DefaultKafkaProducerFactory<>(props);
}
发送失败必须有补偿
java
@Service
@Slf4j
public class ReliableProducer {
@Autowired
private KafkaTemplate<String, String> kafkaTemplate;
@Autowired
private FailedMessageRepository failedMessageRepo;
public void send(String topic, String key, String value) {
kafkaTemplate.send(topic, key, value)
.whenComplete((result, ex) -> {
if (ex == null) {
log.debug("发送成功: topic={}, key={}, offset={}",
topic, key, result.getRecordMetadata().offset());
} else {
// delivery.timeout.ms 内无限重试后,最终失败
log.error("【告警】消息发送最终失败!topic={}, key={}, error={}",
topic, key, ex.getMessage());
// 补偿1:持久化到数据库,供定时任务重发
failedMessageRepo.save(FailedMessage.builder()
.topic(topic).key(key).value(value)
.failedAt(LocalDateTime.now())
.errorMsg(ex.getMessage())
.retryCount(0).build());
// 补偿2:告警通知(钉钉/企业微信/短信)
alertService.sendCritical(
"Kafka消息发送失败\ntopic=" + topic + "\nkey=" + key);
}
});
}
}
定时补发失败消息
java
@Component
@Slf4j
public class FailedMessageRetryTask {
@Autowired
private FailedMessageRepository repo;
@Autowired
private KafkaTemplate<String, String> kafkaTemplate;
@Scheduled(fixedDelay = 300000) // 每5分钟执行一次
public void retry() {
List<FailedMessage> failedList = repo.findByRetryCountLessThan(5)
.stream()
.filter(m -> m.getNextRetryAt().isBefore(LocalDateTime.now()))
.collect(Collectors.toList());
for (FailedMessage msg : failedList) {
try {
kafkaTemplate.send(msg.getTopic(), msg.getKey(), msg.getValue())
.get(30, TimeUnit.SECONDS); // 同步等待,确认成功
repo.delete(msg);
log.info("补发成功: key={}", msg.getKey());
} catch (Exception e) {
msg.setRetryCount(msg.getRetryCount() + 1);
msg.setNextRetryAt(LocalDateTime.now().plusMinutes(
(long) Math.pow(2, msg.getRetryCount()))); // 指数退避
repo.save(msg);
log.warn("补发失败: key={}, retryCount={}", msg.getKey(), msg.getRetryCount());
}
}
}
}
三、Broker 端:数据存进去了,还是可能丢
场景一:单副本,Broker 宕机
Topic 只有 1 个副本(replication.factor=1)
该 Broker 磁盘损坏 → 数据永久丢失 ❌
解法:生产环境副本数必须 >= 3
场景二:min.insync.replicas 配置不合理
acks=all,但 min.insync.replicas=1(默认值!)
含义:ISR 中至少有 1 个副本确认即可
当 ISR 只剩 Leader 一个副本时,acks=all = acks=1 ❌
解法:Broker 端配置 min.insync.replicas=2
含义:ISR 中至少 2 个副本确认,才响应 Producer
若 ISR 不足 2 个 → 拒绝写入(返回 NOT_ENOUGH_REPLICAS)
Producer 重试,不会丢消息 ✓(代价:暂时不可写)
场景三:unclean.leader.election.enable=true
ISR 中所有副本都下线了,但有 ISR 外的落后副本还活着
若 unclean.leader.election.enable=true:
允许这个落后副本成为 Leader
代价:该副本没有同步的那部分消息 → 永久丢失 ❌
Kafka 默认值:false(安全)
生产环境:绝对不要改成 true!
Broker 端防丢失配置
properties
# server.properties(需要 Kafka 运维配合)
# 副本数(建议3副本)
default.replication.factor=3
# 至少2个ISR副本确认才响应
min.insync.replicas=2
# 禁止不在ISR中的落后副本成为Leader(宁可停服,也不丢数据)
unclean.leader.election.enable=false
# (可选)强化刷盘,减少 Page Cache 丢失的概率
# 注意:过于频繁会严重影响性能,谨慎评估
# log.flush.interval.messages=10000
# log.flush.interval.ms=1000
副本数与 min.insync.replicas 搭配原则
推荐搭配(3副本,容忍1个宕机):
replication.factor = 3
min.insync.replicas = 2
acks = all
效果:任意1个副本宕机,系统仍可读写 ✓
2个副本同时宕机,拒绝写入(不丢数据)
过于严格(3副本,任意宕机即停服):
min.insync.replicas = 3
效果:任意1个副本宕机 → 写入失败(可用性太低,不推荐)
过于宽松(等同于acks=1):
min.insync.replicas = 1
效果:无额外保障,acks=all 形同虚设(不推荐)
四、Consumer 端:拉到了,还是可能丢
场景一:自动提交
(已在第6篇详细讲解,此处简要回顾)
T=0: poll() 拉取 msg1、msg2、msg3
T=5: 自动提交触发,Offset 提交到 msg4
T=6: 处理 msg2 时服务崩溃
T=7: 重启后从 msg4 开始 → msg2、msg3 丢失 ❌
解法:关闭自动提交,手动提交
场景二:先提交后处理
java
// ❌ 错误顺序!先提交,后处理
@KafkaListener(topics = "order-events")
public void wrong(ConsumerRecord<String, String> record, Acknowledgment ack) {
ack.acknowledge(); // ← 先提交
orderService.process(...); // ← 后处理,若此处异常 → 消息已提交但未处理 → 丢失!
}
// ✅ 正确顺序:先处理,后提交
@KafkaListener(topics = "order-events")
public void correct(ConsumerRecord<String, String> record, Acknowledgment ack) {
orderService.process(record.value()); // ← 先处理
ack.acknowledge(); // ← 成功后提交
}
五、防丢失全景图
┌─────────────────────────────────────────────────────────────────────────┐
│ Kafka 消息防丢失全景图 │
├──────────────────┬──────────────────────┬───────────────────────────────┤
│ Producer 端 │ Broker 端 │ Consumer 端 │
├──────────────────┼──────────────────────┼───────────────────────────────┤
│ acks=all │ replication.factor=3 │ enable.auto.commit=false │
│ │ │ │
│ enable.idempotence│ min.insync.replicas=2│ ack-mode=MANUAL_IMMEDIATE │
│ =true │ │ │
│ │ unclean.leader. │ 先业务,后 ack.acknowledge() │
│ delivery.timeout │ election.enable=false│ │
│ .ms=300000 │ │ 异常时不 ack,触发重投递 │
│ │ │ │
│ whenComplete 捕获 │ │ 幂等消费设计(防止重复执行) │
│ 异常 + 存库补偿 │ │ │
└──────────────────┴──────────────────────┴───────────────────────────────┘
三端协同,缺任何一端,消息都可能丢失
六、踩坑记录
❌ 坑1:acks=all 配置了,但还是发现消息丢失
原因:没有配置 Broker 端的 min.insync.replicas
ISR 只有 Leader 一个副本
acks=all = Leader 自己确认自己 = acks=1
解决:Broker 端配置 min.insync.replicas=2
确认方法:
kafka-configs.sh --describe --entity-type topics \
--entity-name your-topic \
--bootstrap-server localhost:9092
❌ 坑2:单节点 Kafka 配置 min.insync.replicas=2 导致发送一直失败
本地开发:单 Broker,单副本
配置了 min.insync.replicas=2
→ ISR 中只有1个副本,< 2 → 所有写入失败!
解决:本地开发环境用宽松配置
spring.kafka.producer.acks=1
# 或在 Broker 配置 min.insync.replicas=1
❌ 坑3:补偿任务重发消息,下游出现重复处理
原因:Producer 重发了原来那条消息(幂等 Producer 的 ProducerID 已变化)
Broker 当作新消息写入,Consumer 重复消费
解决:
1. 重发时在消息中携带原始消息 ID(messageId)
2. Consumer 端基于 messageId 做幂等判断
3. 数据库唯一键 / Redis 去重(见第6篇)
📝 本篇小结
| 环节 | 主要风险 | 解决方案 |
|---|---|---|
| Producer | acks=1 + Leader 宕机 | acks=all + min.insync.replicas=2 |
| Producer | 发送失败无感知 | whenComplete 捕获异常 + 存库补偿 |
| Broker | 单副本 | replication.factor=3 |
| Broker | 不洁选举 | unclean.leader.election.enable=false |
| Consumer | 自动提交 | enable.auto.commit=false + 手动 ack |
| Consumer | 先提交后处理 | 严格保证"先处理,后 ack"的顺序 |
核心记忆:消息不丢 = 三端协同。Producer 确认 + Broker 多副本 + Consumer 手动提交,任何一端缺失都是定时炸弹。
下篇预告:第10篇《消息不重 + 不乱------幂等消费、顺序性保证与死信队列》。