消息凭空消失排查记:MQ 可靠投递实测——三种配置丢消息率 0.62%、0.49%、0
适用读者:用 MQ 做过异步解耦,但说不清"消息到底会不会丢、丢在哪"的后端工程师
技术栈:Python 纯标准库模拟链路 + 实测数据;原理与语言无关,RabbitMQ / RocketMQ / Kafka / Redis Stream 通用
阅读时间:约 24 分钟 | 难度:中高级 | 收获:丢消息的完整归因链路 + 三种配置的实测对比 + 上线 Checklist
前言:一次"订单消息凭空消失"的事故
风控同学找过来:过去一个月有 600 多笔订单,状态已变更为"已支付",但下游的积分系统
没有发放积分。两边对账,中间就是隔着一个 MQ。
排查日志的过程很煎熬:生产者日志里有"发送成功",消费端却完全没有这条消息的消费记录——
消息在中间凭空消失了。
最后定位到三个因素叠加:
- 生产者发送时开了
auto_ack 类似的"发完就算",网络抖动丢了没人知道;
- Broker 消息没开持久化,恰好重启了一次,内存队列被清空;
- 消费者是"取到即确认",处理到一半进程被 OOM Kill,消息已经从队列里移除了。
这三个环节,
任何一个单独出问题的概率都不高,但乘起来就是每月几百笔订单。
这篇文章不背"如何保证消息不丢失"的面试答案,而是把一条消息的完整旅程拆开,
逐跳分析它能丢在哪、用什么机制兜住,并真的写一个链路模拟器跑一遍,用数据对比三种典型配置的丢消息率。
目录
- 消息到底能丢在哪:一条消息的完整旅程
- 生产端:发送确认与本地消息表
- Broker 端:持久化、刷盘与副本
- 消费端:自动 ACK 是最大的坑
- 重复不可怕:幂等消费兜底
- 实测:三种配置的丢消息率对比
- 重试、退避与死信队列
- 积压处理与延迟消息
- 上线 Checklist + 高频 FAQ
1. 消息到底能丢在哪:一条消息的完整旅程
一条消息从生产到生效,要经过三跳:
生产者 ──①──> Broker ──②──> 存储 ──③──> 消费者
每一跳都有独立的丢失原因:
一个关键的认知:
可靠性是"最弱一跳"决定的。三跳里任何一跳是 fire-and-forget,整体就是 fire-and-forget。上面事故里三个环节各丢一部分,比例叠加起来正好对上对账差异。
还有一条贯穿全文的原则:
可靠性不是免费的。每加一层兜底,都会付出吞吐、延迟或复杂度的代价。所以正确的姿势不是"无脑全开",而是
按消息的业务价值分级:
2. 生产端:发送确认与本地消息表
2.1 "发送成功"的两种含义
大部分消息丢失事故的起点,是混淆了两种"发送成功":
- API 调用成功:消息写进了客户端的网络缓冲区,
send() 没抛异常。
- Broker 落盘确认:Broker 明确回复"我收到了,并且已持久化"。
RabbitMQ 的 Publisher Confirm、RocketMQ 的同步发送 + Broker ACK、Kafka 的
acks=all,解决的都是同一件事:把"成功"的定义从①升级到②。
Kafka 的
acks 参数是个很好的度量衡:
2.2 重试解决不了的问题:本地消息表
发送确认 + 失败重试能覆盖绝大多数瞬时故障,但有一个它永远解决不了的场景:
本地事务和发消息的原子性。
经典场景:下单接口先写订单表,再发"订单已创建"消息。两步之间服务崩溃,订单存在、消息永远发不出去。反过来先发消息再写库,则是用户收到了"下单成功"的推送,订单却是空的。
这不是重试能解决的,因为
第一步成功、第二步失败时,进程可能已经死了,没有任何代码能再重试。
解法是把两步变成一个事务——在同一个数据库事务里写业务表 + 写一张
outbox(本地消息表):
BEGIN;
INSERT INTO orders (...) VALUES (...); -- 业务数据
INSERT INTO outbox (topic, payload, status) -- 消息先落库
VALUES ('order.created', '...', 'pending');
COMMIT;
然后由一个独立的
投递任务轮询
outbox,把
pending 的消息发出去,收到 Broker 确认后标记为
sent:
def deliver_outbox():
rows = db.query(
"SELECT id, topic, payload FROM outbox "
"WHERE status='pending' ORDER BY id LIMIT 100"
)
for row in rows:
try:
broker.publish(row.topic, row.payload) # 带发送确认
db.execute("UPDATE outbox SET status='sent' WHERE id=%s", row.id)
except Exception:
continue # 下轮重试
这个模式叫
Transactional Outbox,它的精髓是:
把"发消息"这个网络操作从事务里挪出去,事务里只做本地写。本地写是原子的,消息只要落了库就永远不会丢——最多是晚一点被投递出去。
代价也明确:投递有轮询延迟(可加
SELECT ... FOR UPDATE SKIP LOCKED 或 CDC 如 Debezium 优化),且 outbox 表需要清理策略。但对比"订单存在、消息消失",这点代价几乎总是值得的。
3. Broker 端:持久化、刷盘与副本
消息到了 Broker 还没完——
内存里的消息不是你的消息,落盘的才是。
三个层次的可靠性,逐层递进:
第一层:持久化标记。 RabbitMQ 要求 exchange、queue、message 三者都标记 durable/persistent,任何一个漏了,重启后照样丢。Kafka 天然写磁盘,这一层默认达标。
第二层:刷盘时机。 写到操作系统的 page cache 不等于落盘,机器掉电时 page cache 会丢。RocketMQ 提供了明确的选择:
- 异步刷盘:写 page cache 即返回,吞吐高,掉电丢窗口内的消息;
- 同步刷盘:等磁盘写完才返回 ACK,吞吐低,不丢。
第三层:副本。 单机磁盘坏了,持久化救不了你。Kafka 的 ISR 机制、RocketMQ 的 Dledger / 主从同步,本质都是用
多机器冗余换单点故障的免疫。
一个容易被忽略的细节:
持久化是异步的,确认和刷盘之间有时间窗。RabbitMQ 的 persistent 消息被 Confirm 时通常已落盘,但批量场景下要留意
basic.consume 的 prefetch 与刷盘节奏;Kafka 的
acks=all 只保证副本内存同步,配合
flush.messages /
log.flush.interval.messages 才能控制掉电窗口。理解这个窗口,才能解释"我都配了持久化为什么还丢了几条"。
4. 消费端:自动 ACK 是最大的坑
三跳里,
生产事故最集中的是第三跳,而元凶几乎总是同一个:自动 ACK。
自动 ACK 的语义是:
消息一送达消费者就从队列里移除——注意是"送达",不是"处理完"。于是:
- 消费者处理到一半崩溃 → 消息已删除,业务永远没执行;
- 处理逻辑抛异常 → 消息已删除,异常吞掉就是静默丢单。
手动 ACK 把确认时机从"送达"推迟到"业务完成":
def on_message(ch, method, properties, body):
try:
handle(body) # 真正的业务逻辑
ch.basic_ack(method.delivery_tag)
except TransientError:
ch.basic_nack(method.delivery_tag, requeue=False) # 进重试/死信
except PermanentError:
ch.basic_nack(method.delivery_tag, requeue=False) # 不可恢复,也进死信
注意两个细节:
- 崩溃在 ACK 之前,Broker 会把消息重投给其他消费者——消息不丢,但会被处理多次(见下一节);
- NACK 不要无脑 requeue=True。一条消息格式损坏,requeue 后立刻被取出、再次 NACK、再入队——无限循环,CPU 打满,整个队列堵死。正确姿势是重试有限次后进死信队列(DLQ)。
5. 重复不可怕:幂等消费兜底
到这里一个结论已经浮出水面:
"不丢"和"不重"是物理上不可兼得的。
想不丢,就必须"先处理、后确认"(或 Broker 重投);而"先处理后确认"意味着确认之前崩溃就会重投——同一条消息被处理两次。反过来"先确认后处理"保证不重,但崩溃即丢。这本质上就是分布式系统 CAP 之外的经典两难,只能二选一。
工程上的标准答案是:
选择 at-least-once(可能重复),用幂等消费消灭重复的影响。
幂等的具体手段在我之前那篇《API 幂等性深度解析》里有完整展开——唯一约束、状态机、
dedup_key 去重表。落到 MQ 消费场景,最常用的是
消费去重表:
CREATE TABLE consumed_messages (
message_key VARCHAR(64) PRIMARY KEY, -- 业务唯一键:订单号/消息ID
consumed_at DATETIME NOT NULL
);
BEGIN;
INSERT IGNORE INTO consumed_messages (message_key, consumed_at)
VALUES ('order:20260916:0001', NOW());
-- 受影响行数为 0 => 已消费过,直接 ACK 跳过
UPDATE points SET balance = balance + 100 WHERE order_id = ...;
COMMIT;
两个实践要点:
- 去重键要用业务键,不要用消息 ID。Broker 重投后 message_id 通常不变,但生产端重发(本地消息表场景)会生成新 ID;只有"订单号 + 事件类型"这种业务键才真正标识语义。
- 去重记录要有清理策略,否则表无限膨胀。按业务容忍窗口清理即可——对账系统跑过 7 天前的数据后,更早的去重记录就可以删了。
6. 实测:三种配置的丢消息率对比
写了 24 分钟原理,是时候用数据验证了。我写了一个链路模拟器(完整代码在仓库
tools/bench_mq_delivery.py,纯标准库、结果可复现):模拟 10 万条消息经过"生产者 → Broker → 消费者"完整链路,注入三类故障——
- 网络丢包率 0.1%(生产者 → Broker 方向)
- Broker 半路宕机一次(5 万条消息时,模拟重启)
- 消费者处理消息时 0.5% 概率崩溃(模拟 OOM Kill)
对比三种典型配置:
实测结果(10 万条消息):
三个数据点,三条结论:
结论一:网络确认是性价比最高的一层。 partial 对比 fire-and-forget,生产端加了确认重试,丢了 127 条(网络丢包 + Broker 崩溃窗口那部分全被兜住了)。只需要一行配置。
结论二:丢消息的最后一米在消费端。 partial 明明生产端和 Broker 端都配好了,还是丢了 488 条——全部来自"自动 ACK 后消费者崩溃"。这 488 条就是文章开头事故里最隐蔽的那部分:日志显示"消费到了",但业务没执行。
结论三:full 模式用 85 次重复换 0 丢失。 注意 full 配置多了 85 条重复 + 85 次额外发送——这正是"先处理后确认"的重投代价。代价是
确定性的、有界的(0.085%),且被幂等消费完全消化;而丢消息的代价是不确定的、无界的(可能是积分,也可能是退款)。
一句话总结:
用可预算的小成本(重复 + 幂等表),消灭不可预算的大风险(丢消息)。
7. 重试、退避与死信队列
消息兜住了,还有一类问题在等着:
下游一直失败怎么办。
无脑重试的代价可以用数据量化。模拟消费者宕机 10 秒,期间重试线程不断投递:
差了 14 倍。下游越拥塞,固定间隔重试打得越密,恢复越慢——这就是重试风暴。指数退避的标准姿势:
delay = min(base * (2 ** attempt) + random.uniform(0, base), max_delay)
末尾的
random.uniform 是
抖动(jitter):没有它,同一批失败的重试会在完全相同的时刻再次打出波峰,退避了个寂寞。
重试策略要配合
分层队列:
主队列 ──NACK──> 重试队列 L1 (延迟 5s) ──NACK──> 重试队列 L2 (延迟 30s)
│ 超过 3 次
v
死信队列 DLQ ──> 人工介入/告警
- L1/L2 延迟队列:给下游喘息时间,避免热消息高频空转;
- DLQ:重试穷尽后的最终归宿。关键不是把消息扔进去,而是必须有监控和人工处理流程——DLQ 里躺了消息没人看,等于换了个地方丢消息。
判断要不要重试要区分异常类型:网络超时、下游 503 这类
瞬时异常值得退避重试;参数校验失败、消息反序列化失败这类
永久异常,重试一万次也是失败,直接进 DLQ。
8. 积压处理与延迟消息
可靠性之外,MQ 的日常运维还有两件高频事。
积压处理。积压的根因通常是消费速度 < 生产速度。先看能否
水平扩容消费者——注意消费者数量受限于队列分区/分片数(Kafka 的 consumer 数 ≤ partition 数,超出的实例只能空转)。扩容来不及时的经典组合拳:写一个临时程序把积压消息
批量搬运到一个 10 倍分区的临时 topic,再起 10 倍消费者消化,处理完删掉临时链路。
预防手段是给消费逻辑做
批量化:单条
UPDATE 攒成批量
INSERT ... ON DUPLICATE KEY UPDATE,消费吞吐通常能上一个数量级。
延迟消息。订单 30 分钟未支付自动取消、优惠券到期提醒,都需要"消息在未来的某时刻才被消费"。三种实现对比:
注意 TTL + DLQ 方案的队头陷阱:队列是 FIFO 的,第一条消息 TTL 10 分钟,后面 TTL 30 秒的消息也得等它出队才检查——
延迟值差异大的场景不要用。
9. 上线 Checklist + 高频 FAQ
上线 Checklist
生产端:
- [ ] 关键消息禁用 fire-and-forget,开启发送确认(Confirm /
acks=all / 同步发送)
- [ ] 发送失败有重试,重试上限 + 告警
- [ ] 本地事务与发消息的原子性:有本地消息表(Transactional Outbox)
- [ ] 消息体里带业务唯一键(供消费端幂等与对账使用)
Broker 端:
- [ ] queue/exchange/message 全部持久化(RabbitMQ 三者缺一不可)
- [ ] 多副本或主从,
min.insync.replicas ≥ 2(Kafka)
- [ ] 明确刷盘模式,知道掉电丢失窗口是多大并接受
消费端:
- [ ] 手动 ACK,业务成功后才确认
- [ ] 消费逻辑幂等:去重表或状态机,去重键用业务键
- [ ] NACK 不无限 requeue,重试有限次后进 DLQ
- [ ] DLQ 有监控告警和人工处理流程
运维:
- [ ] 队列深度、消费延迟(lag)有监控告警
- [ ] 有对账任务兜底——MQ 不是最可靠的系统,对账才是最后防线
- [ ] 消息按业务价值分级配置,不是所有 topic 都上全套
高频 FAQ
Q:怎么选 MQ? 中小规模、已有 Redis:Redis Stream 够用(记得配
maxlen 和消费组)。通用场景 RabbitMQ(易运维)或 RocketMQ(功能全,事务消息/延迟消息原生支持)。大数据流、日志管道:Kafka。选型差异远小于配置差异——一个配置错误的 Kafka 丢起消息来比配置正确的 RabbitMQ 惨得多。
Q:Redis 做队列可靠吗? 默认配置不可靠(AOF everysec 有 1 秒丢失窗口、主从切换可能丢),
WAIT 命令 + appendfsync always 可以逼近可靠但吞吐骤降。小规模 + 接受小概率丢失可用;支付类消息请上专业 MQ。
Q:事务消息(RocketMQ half message)和本地消息表选哪个? 事务消息省去轮询表,但绑定特定 MQ 且仍有回查机制要实现。本地消息表与 MQ 无关、便于排查、还能顺便做消息审计。多数团队本地消息表更实用,MQ 本身支持事务消息(如 RocketMQ)时可以省一层。
Q:消费很慢,先加消费者还是先优化逻辑? 先看瓶颈:CPU 密集优化逻辑/批量化,IO 密集加并发(asyncio / 线程池 + 调大 prefetch)。逻辑有批量优化空间时优先批量化,再扩分区和消费者——盲目扩容受 partition 数限制还白花钱。
写在最后
回到开头那 600 笔丢失的订单。修复方案没有引入任何新组件:生产端补上发送确认和本地消息表,Broker 开持久化,消费端换手动 ACK + 去重表,DLQ 加上告警。上线后对账差异归零,重复消费被去重表消化,月均重复量不到万分之九。
MQ 可靠性这件事,知识本身不复杂,复杂的是
三跳里任何一跳的默认配置都不面向可靠——默认 auto-ack、默认异步刷盘、默认 fire-and-forget。工程上的功课,就是知道每一跳的默认值是什么、哪些业务承受得起、承受不起的怎么换掉。
如果这篇文章对你有帮助,欢迎在评论区聊聊你遇到过的"消息消失"事故。