第9篇 消息不丢:三端协同防丢失方案

第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篇《消息不重 + 不乱------幂等消费、顺序性保证与死信队列》。

相关推荐
Devin~Y1 小时前
大厂Java面试实录:Spring Boot/WebFlux、JVM调优、Redis/Kafka、Spring Cloud 与 RAG/Agent 追问
java·jvm·spring boot·maven·mybatis·jpa·spring webflux
一轮弯弯的明月1 小时前
Spring AOP编程
java·开发语言·spring boot·笔记·spring aop·学习心得
Boop_wu1 小时前
[Java项目] Spring Boot + WebSocket 实现网页在线聊天室|完整项目架构与实战讲解
spring boot·websocket·java-ee·mybatis
Ting-yu11 小时前
SpringCloud快速入门(7)---- 数据隔离
spring boot·spring·spring cloud
明明跟你说过14 小时前
Kafka 与 Elasticsearch 的集成应用案例深度解析
大数据·elk·elasticsearch·kafka·big data·bigdata
无人不xiao15 小时前
springBoot 实现 接口进度条
java·spring boot·后端
smileNicky15 小时前
Docker 部署 SpringBoot 项目超详细教程
spring boot·docker·容器
HLAIA光子16 小时前
这些Spring Boot写法已经过时了!
spring boot·后端
i220818 Faiz Ul16 小时前
宠物猫之猫咖管理系统|基于java + vue宠物猫之猫咖管理系统(源码+数据库+文档)
java·数据库·vue.js·spring boot·论文·毕设·宠物猫之猫咖管理系统