前置阅读:02 消息模型篇——本文的代码基于工作队列和 direct 交换机扩展。
上一篇文章留了个尾巴:basicConsume 的自动 ack 会丢消息。这篇把消息丢失这个问题彻底讲透——它是 MQ 面试和生产的头号问题。
一、消息会在哪丢?
把链路摊开看,消息丢失只发生在三个环节:
1 | 生产者 --①网络/路由失败--> Broker --②宕机内存丢--> 队列 --③消费失败--> 消费者 |
- 生产者 → Broker:网络抖动发送失败;或消息路由不到任何队列(交换机没绑定),被 Broker 默默丢弃
- Broker 内部:RabbitMQ 的消息默认在内存里,Broker 重启就没了
- Broker → 消费者:消费者刚拿到消息还没处理完就崩了,但用的是自动 ack——Broker 认为已消费,消息丢了
每个环节都有对应的解法,全链路示意:
二、环节一:生产者确认(Publisher Confirm)
confirm 确认到达
开启 confirm 后,Broker 收到消息会异步回执一个 ack(或路由失败/异常时 nack):
1 | Channel channel = conn.createChannel(); |
return 回退不可路由
confirm 只保证”消息到了交换机”,不保证路由到了队列。如果 routing key 没有匹配的绑定,消息照样丢(01 篇讲过的头号新手坑)。mandatory + ReturnListener 专门兜这个底:
1 | channel.addReturnListener(reply -> { |
三、环节二:三重持久化
Broker 侧的持久化要三层全部声明,缺一不可:
1 | // 1. 交换机持久化:Broker 重启后交换机还在 |
只做队列持久化、消息没持久化 → 重启后队列还在但消息没了;只做消息持久化、队列没持久化 → 队列都没了消息无处安放。三者是与的关系。
注意:已存在的队列不能修改持久化属性(会报 PRECONDITION_FAILED),要改只能删了重建——所以建队列前就想好。
另外一个诚实的提醒:持久化消息也是先写进缓存再刷盘的,极端情况(刷盘前宕机)仍可能丢极少量消息,要绝对不丢需要配合官方的 quorum 队列 + 磁盘刷盘策略,见第 04 篇。
四、环节三:消费者手动 ack
自动 ack 的语义是”消息一递出去就算消费成功”。改成手动 ack,处理完业务再确认,崩了消息还能回来:
1 | channel.basicQos(1); |
三种应答:
| 方法 | 含义 | 消息去向 |
|---|---|---|
basicAck |
处理成功 | 从队列删除 |
basicNack(requeue=true) |
暂时处理不了 | 重回队首,稍后再投(慎用:异常会死循环) |
basicNack(requeue=false) |
处理失败且放弃 | 进死信队列(配了 DLX 的话) |
requeue=true 配合 basicQos(1) 要特别小心:如果消息本身是”毒消息”(每次处理都抛异常),它会被无限投递,消费者 CPU 直接打满。正确姿势是有限次重试 + 超次进死信。
五、防重:幂等消费
做到”不丢”之后要面对它的副作用——重试必然带来重复:ack 回执丢了、消费者处理完但没来得及 ack 就崩了,Broker 都会把消息再投一次。所以消费端必须幂等(同一条消息处理一次和处理多次结果一样)。
常用三招:
1 | // 招式一:数据库唯一约束——消息带全局业务号,重复插入直接被数据库拦下 |
一句话总结:可靠性 = 生产端 confirm/return + 存储端三重持久化 + 消费端手动 ack + 幂等兜底。
六、死信队列(DLX):消息的”收容所”
死信(Dead Letter)是三种”走投无路”的消息:
- 被消费者
basicNack/reject且requeue=false - 消息 TTL 过期
- 队列超过
x-max-length长度限制,最老的消息被挤出
死信队列不是一种新队列,而是给普通队列配一个”死后去处”:声明队列时通过参数绑定一个死信交换机,死信会被自动转投到它上面。
1 | // 死信交换机 + 死信队列(就是个普通 direct 交换机) |
之后 basicNack(tag, false, false) 的消息、过期消息、挤出的消息,都会带着原始内容自动流进 pay.dlx.queue,由专门的消费者做人工处理或告警。线上没有死信队列的业务队列等于裸奔。
七、延迟队列:TTL + 死信的经典组合
场景:订单下单 30 分钟未支付自动取消。不能起定时任务每秒扫表,用”TTL + DLX”组合出延迟队列:
原理是给队列设置消息存活时间(x-message-ttl),消息过期就成了死信,正好被死信交换机转投到真正的消费队列——过期的那一刻才被消费,天然就是延迟执行。
1 | // 1. 延迟队列:消息进来 30 秒后过期,死信转投 pay.dlx |
队头阻塞:这个方案的坑
RabbitMQ 判断消息过期只看队头。发一条 TTL=10 分钟的消息,再发一条 TTL=10 秒的消息,第二条必须等第一条过期出队后才有机会被检查——短延迟消息被长延迟消息堵死。
解法:
- 按 TTL 分档建队列:TTL 30s / 5min / 30min 各一个延迟队列,同档内不会互相堵
- 官方延迟插件:
rabbitmq_delayed_message_exchange,交换机层面实现任意毫秒级延迟,没有队头阻塞问题,但插件消息存磁盘(RabbitMQ 3.13 起部分场景内存化),超大量使用前要压测
小结
- 消息丢失的三个环节:生产→Broker(confirm/return)、Broker 存储(三重持久化)、Broker→消费(手动 ack)
- 重试必然带来重复,消费端幂等是可靠性的另一半
- 死信三来源:被拒绝、过期、超长;死信队列是线上必备的”收容所”
- 延迟队列 = TTL + DLX,注意队头阻塞,分档或延迟插件解决
最后一篇:Spring Boot 怎么优雅整合这一切,以及 Broker 自己挂了怎么办——集群与仲裁队列。