Appearance
消息不丢失与不重复
看在前面
「怎么保证消息不丢?」是 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。两个坑:
- 先提交后处理:poll 拉了 500 条,还没处理完自动提交了,进程崩了。这 500 条里没处理的全丢。
- 提交位移大于处理进度本质是「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 补全)。