Skip to content

消息不丢失与不重复 ​

看在前面 ​

「怎么保证消息不丢?」是 MQ 面试出现率最高的题目,没有之一。它的高明之处在于:看似背参数,实际考你有没有链路思维。消息从生产到消费要经过生产端、broker 集群、消费端三个环节。任何一个环节的默认配置都可能丢。正确的答法是三个环节逐个设防,最后再谈不重复与 Exactly-Once。

前置阅读:Kafka架构与高性能原理(ISR、HW 的机制在那里)。

先定位:消息会在哪丢 ​

text
 Producer                 Broker 集群                    Consumer
 ┌─────────┐   ①发送丢失   ┌───────────────┐   ③消费丢失   ┌──────────┐
 │ 发送后不 │ ──────────► │ 副本未同步完成 │ ──────────► │ 先提交    │
 │ 等确认   │  网络抖动/    │ 就宕机/       │  offset再   │ offset后  │
 │ 异步fire │  ACK前挂     │ 刷盘前掉电     │  处理失败    │ 异步处理   │
 │ -and-   │             │ unclean选举    │             │ 提前提交   │
 │ -forget │             │ ②存储丢失      │             │ 进程重启   │
 └─────────┘             └───────────────┘             └──────────┘
环节丢失根源设防手段
生产端fire-and-forget、acks=0/1、重试失败未感知acks=all + retries + 幂等 producer + 回调检查
broker 端副本数不足、ISR 收缩、脏选举多副本 + min.insync.replicas + 禁 unclean 选举
消费端自动提交 + 异步处理、先提交后处理关自动提交、处理后手动提交、幂等消费

生产端:把「发出去」变成「发成功」 ​

acks 三个值的真实含义 ​

  • acks=0:发完就忘,网络一抖就丢。
  • acks=1:leader 写入本地日志(进 page cache)就 ACK,leader 落 ACK 后立刻宕机、follower 还没同步——丢。
  • acks=all:ISR 中所有副本都写入才 ACK。注意是「ISR 中」而不是「所有副本」,所以必须配合 min.insync.replicas,否则 ISR 收缩到只剩 leader 自己时 acks=all 退化成 acks=1。
properties
# 生产者
acks=all
retries=Integer.MAX_VALUE          # 重试兜底
delivery.timeout.ms=120000         # 重试的总超时,超过才算真失败
enable.idempotence=true            # 幂等(隐含 acks=all + max.in.flight<=5)
max.in.flight.requests.per.connection=5   # 幂等开启下 5 以内仍保证顺序

回调里必须检查失败,发现不可重试错误(消息过大、鉴权失败)要落补偿表而不是打日志了事:

java
producer.send(new ProducerRecord<>(topic, key, value), (metadata, e) -> {
    if (e != null) {
        // 可重试异常交给 retries;不可重试异常必须落库/告警,进入补偿重发
        if (!(e instanceof RetriableException)) {
            saveToRetryTable(record, e);   // 本地表 + 定时任务重投
        }
    }
});

幂等 producer 原理一句话:broker 给每个 producer 分配 PID,每条消息带 <PID, partition, seqNum>。broker 内存里记每个 PID+分区的最近 5 个批次序号,重复序号直接拒收——重试不产生重复。但只保证单分区单会话,跨会话、跨分区要靠事务。

broker 端:副本机制兜住单机故障 ​

properties
# topic 级别
replication.factor=3            # 3 副本
min.insync.replicas=2           # ISR 至少 2 个才允许 acks=all 写入
# 集群级别
unclean.leader.election.enable=false   # 禁止 OSR 落后副本当选 leader

为什么 min.insync.replicas=2 是关键:两台 follower 宕机后 ISR 只剩 leader。此时 acks=all 的生产者写入会直接报 NotEnoughReplicasException 拒绝写入——用可用性换一致性,宁可阻塞不能丢。这是面试要点:acks=all 单独用是不够的。

unclean.leader.election=false 则是另一道闸:leader 挂了只能从 ISR 里选新 leader,绝不把落后副本(OSR)扶正。否则已 ACK 的消息被截断——丢数据的经典事故源。

刷盘策略提一句:Kafka 默认异步刷盘(依赖 page cache + 副本)。追求单机落盘再 ACK 可以调 log.flush.interval.messages,但吞吐大幅下降。Kafka 的答案是用副本冗余替代 fsync。RocketMQ 默认也是异步刷盘但可配同步刷盘(SYNC_FLUSH),对比记忆。

消费端:先处理,后提交,再幂等 ​

自动提交的两个坑 ​

enable.auto.commit=true 时每 5 秒(auto.commit.interval.ms)由 poll 线程自动提交当前已拉取的最大 offset。两个坑:

  1. 先提交后处理:poll 拉了 500 条,还没处理完自动提交了,进程崩了。这 500 条里没处理的全丢。
  2. 提交位移大于处理进度本质是「at-most-once」,要 at-least-once 必须手动。

手动提交的姿势 ​

java
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
while (running) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
    for (ConsumerRecord<String, String> r : records) {
        process(r);                      // ① 先处理
    }
    consumer.commitSync();               // ② 后提交(成功后再拉下一批)
    // 若要在处理中途崩溃时少重放,可按分区粒度提交 offset
}

注意 max.poll.interval.ms:处理太慢超时会被 coordinator 踢出组触发 rebalance,进而引发重复消费——所以批量别拉太多(max.poll.records 调小)。

幂等消费:at-least-once 的最后一块拼图 ​

手动提交把语义修成 at-least-once,代价是崩溃重试必然带来重复(处理完、还没来得及提交就崩了)。所以消费端必须幂等,常用手段按代价从低到高:

text
① 天然幂等        :set 操作、按主键覆盖更新、定点 delete
② 唯一约束        :消息 key 落业务唯一索引,插入冲突即忽略
③ 去重表/Redis    :SETNX msgId + 过期时间,处理前先占坑
④ 状态机          :订单 待支付→已支付→已发货,非法迁移直接丢弃
sql
-- 方案②:业务表加唯一键,重复消费插入即冲突,捕获后视为成功
INSERT INTO order_paid (msg_id, order_id, amount) VALUES (?, ?, ?);
-- DuplicateKeyException -> return SUCCESS(关键:必须返回成功,否则重试风暴)

Exactly-Once:实现与代价 ​

先纠正一个认知:端到端 Exactly-Once 只存在于「Kafka 读 → 处理 → Kafka 写」的闭环里(Kafka Streams / 事务 API),一旦下游是 MySQL/ES,就只能「at-least-once + 消费幂等」逼近。

Kafka 事务的实现(详见 事务消息与延迟消息):

java
props.put("transactional.id", "order-service-01");  // 跨会话稳定
producer.initTransactions();                        // ① 向 coordinator 注册
producer.beginTransaction();                        // ② 开启事务
producer.send(recordA);                             // ③ 写入(对外不可见)
consumer.poll(...);  // consume-transform-produce
producer.sendOffsetsToTransaction(offsets, groupMetadata);  // ④ 把消费位移也纳入事务
producer.commitTransaction();                       // ⑤ 两阶段提交,原子可见

代价必须如实回答:

  • 吞吐损失:事务提交涉及额外 RPC 与写 marker(control batch),小事务下吞吐可能降 30%+。
  • 延迟增加:transaction.timeout.ms 内的故障要等超时才能回滚,恢复慢。
  • 不覆盖外部系统:写 MySQL 不受事务保护,闭环外无 EOS。
  • 面试正确姿势:绝大多数业务选 at-least-once + 幂等消费,EOS 只在 Kafka Streams 内部流处理里默认启用。

追问链 ​

Q1:acks=all 就一定不丢吗?

不一定。ISR 可能收缩到 1(此时 acks=all 退化为 leader 单点确认),必须配 min.insync.replicas>=2。并且 unclean.leader.election.enable 必须为 false,否则宕机时脏选举截断已 ACK 数据。

Q2:为什么重试会造成乱序和重复?怎么解?

批次 1 失败重发、批次 2 先成功,就乱序。批次 1 成功但 ACK 丢了,重发就重复。解法:启用幂等(enable.idempotence=true),broker 按 seq 去重并保证 5 个 in-flight 内有序。更大范围用事务。展开见 顺序消息。

Q3:消费者先处理后提交,处理到一半崩了怎么办?

这批消息会被重放——这是 at-least-once 的固有属性,靠消费端幂等兜底。可以细化到分区粒度记录处理进度减少重放量,但语义不变。

Q4:RocketMQ 怎么保证不丢?

生产端同步发送(sync master 或 slave)、broker 同步刷盘(SYNC_FLUSH)+ 主从同步复制(SYNC_MASTER)、消费端返回 CONSUME_SUCCESS 才推进。思路同构:三环节设防,只是 Kafka 用副本换刷盘、RocketMQ 可选同步刷盘。

Q5:怎么监控「可能丢了」?

监控 ISR 收缩(UnderReplicatedPartitions)、RecordsPerSec 与下游对账(T+1 对账表 + 消息轨迹),不丢是设计出来的,不是默认的。

面试官视角小结 ​

  • 这题的标准答案结构就是本文目录:先画三环节图,再逐环节给配置。能一开口就说「消息丢失要分生产、存储、消费三段看」的候选人,已经赢了 70%。
  • 高频加分点:acks=all 与 min.insync.replicas 的成对关系。幂等 producer 的 PID+seq 机制及其局限(单分区/单会话)。Exactly-Once 的闭环边界(出了 Kafka 就没有 EOS)。
  • 报「我们用 at-least-once + 幂等消费」并说清去重表设计,比硬吹 EOS 更可信,面试官大概率追问幂等方案的三种写法。
  • 常见减分项:只说「设 acks=all」不提 ISR 收缩;只说「关自动提交」不提先处理后提交的顺序理由;把 EOS 说成万能。

2026-10 新增(P0 补全)。