RabbitMQ 路由、确认与恢复:exchange、confirm、prefetch 与死信
Kafka 一课用一条订单事件追了七个成功点;换成 RabbitMQ,成功点会变成另一组问题:消息有没有到达某个队列?broker 确认了什么?消费者确认之后崩溃会怎样?一条永远失败的消息最终去了哪里?这四个问题分别由路由、publisher confirm/return、consumer ack/prefetch、死信回答。本页用本机真实 RabbitMQ 4.4.0 跑 11 个场景,把每个确认的边界说清。
本页是 U58(#95)的 C10 权威正文:exchange/路由/队列类型、confirm/return、ack/prefetch、重投/死信/复制恢复边界。Kafka 的分区/副本/ISR/事务见 MQ 生产存储消费可靠性;重试分类、退避公式与 DLQ 状态设计见重试、死信、延迟消息与消息积压;消费者幂等与 Inbox 见重复消费、幂等与顺序消息。本页只讲 RabbitMQ 的机制与它自己的确认边界。
三种确认,三个不同的参与者
RabbitMQ 里"确认"至少有三种,参与者不同、失败后果也不同:
| 确认 | 参与者 | 它说明什么 | 它不说明什么 |
|---|---|---|---|
| publisher confirm | 生产者 ↔ broker | broker 已对消息负责(按队列类型入盘/入日志/入内存) | 消息是否投递给了消费者、业务是否处理 |
| mandatory return | broker → 生产者 | 消息不可路由,没有匹配的队列 | 有队列收到时不会有 return |
| consumer ack | 消费者 ↔ broker | 消费者声明已处理完这条消息 | 业务效果是否已提交(先 acks 后业务失败照样丢) |
三者互相独立:一条消息可以 confirm 成功但被 return(没有队列),也可以被消费者收到但业务提交失败后再 nack 重投。排障时必须先问"缺的是哪一种确认",而不是笼统说"消息丢了"。
版本、工程与观察入口
固定 RabbitMQ 4.4.0(官方 generic-unix 发行包,仓库外缓存)、com.rabbitmq:amqp-client 5.37.0、Erlang 29.1.2(Homebrew)、BellSoft Liberica JDK 21.0.11+11-LTS、Maven 3.9.9、macOS 27.2 arm64。查询与运行日期 2026-10-28。
公开入口:
python3 labs/rabbitmq-topology/run.py脚本启动本机独立 node(NODENAME=u58@localhost、端口 5679、mnesia/log 在临时目录),运行 labs/rabbitmq-topology 的 11 个测试,结束后停止 node;完整命令、退出码与 14 条观察行见运行证据,工程边界见实验说明。不触碰用户其他 RabbitMQ 实例:启动前检查端口占用,被占用即拒绝启动。
每个测试的队列与交换机都使用 u58. 前缀命名空间,结束自行删除;默认交换机 "" 不允许 delete(broker 回 403 ACCESS_REFUSED),只清队列。
拓扑与路由
exchange 类型与匹配规则
| 类型 | routing key 的作用 | 典型用途 |
|---|---|---|
| direct | 精确相等 | 按事件类型分发(order.created / order.paid) |
| fanout | 忽略 | 广播给所有绑定队列 |
| topic | * 匹配一段、# 匹配多段 | 按层级通配(order.*.created、order.#) |
| headers | 按消息头匹配 | 本实验未运行 |
实验三组对照:
- direct:
order.created只进绑定该 key 的队列,order.paid队列为 0。 - fanout:一条消息两个绑定队列各收到 1 条。
- topic:
order.*.created只匹配三段(1 条),order.#匹配全部(2 条)。
# 可以匹配零段或多段,order.# 能收到 order.cn.created 与 order.cn.paid.retry。这与 MQTT 的 # 语义一致,但和很多人的直觉("# 只匹配一层")相反。
不可路由的两种处理
消息发出但没有任何绑定命中时,默认被 broker 静默丢弃。两种机制可以让发送方或系统感知:
- mandatory + return:发送时
mandatory=true,broker 回一条 basic.return,replyCode 312(NO_ROUTE)。实验验证:向无绑定的 direct exchange 发送 mandatory 消息,return listener 收到312:no.such.key。 - alternate-exchange:在 exchange 上声明
alternate-exchange参数,未命中的消息转投到备用 exchange。实验验证:备用 fanout 的队列收到 1 条。
两者的区别:return 让生产者知道,alternate-exchange 让系统兜底(生产者可能根本不在线)。生产里常见组合是"mandatory 用于同步感知 + alternate-exchange 用于审计兜底"。
队列声明的三个参数
queueDeclare(name, durable, exclusive, autoDelete, args) 的四个参数决定生命周期:
| 参数 | 含义 | 常见错误 |
|---|---|---|
| durable | 队列元数据在 broker 重启后保留 | 只 durable 队列不 durable exchange,重启后绑定丢失 |
| exclusive | 只允许声明它的连接使用,连接断开自动删除 | 拿 exclusive 队列做共享工作队列,第二个消费者连不上 |
| autoDelete | 最后一个消费者断开后删除 | 临时队列忘了 autoDelete,队列泄漏 |
| args | x-queue-type、DLX、TTL、长度限制等 | quorum 与 classic 的参数不能混用 |
发布确认:confirm 与 return
publisher confirm 说明什么
confirmSelect() 之后,broker 对每条 publish 回 basic.ack 或 basic.nack。实验发送 persistent 消息并 waitForConfirms(5000),返回 true。
confirm 只说明 broker 接下了这条消息,语义取决于队列类型与持久化设置:classic 非持久队列的 confirm 可能只表示在内存里;persistent + durable 才可能在重启后还在;quorum 队列的 confirm 表示多数派(≥2 节点)已写入日志——本实验是单节点,没有多数派可失,这一点必须标清。
return 先于 confirm
当 mandatory 消息不可路由时,broker 的行为是:先发 basic.return,再发 confirm(ack)。所以看到 confirm ack 不代表有队列收到——return 已经在前面告诉你了。实验的 return 312 与 confirm 是两个独立观察,正文不合并成一个"发送成功"。
持久化三件套
一条消息要在 broker 重启后仍在,需要同时满足:
- exchange durable;
- queue durable;
- 消息 deliveryMode=2(persistent)。
缺一不可。只 durable 队列但消息 transient,重启后消息没了;只消息 persistent 但队列 transient,队列本身没了。这不是配置细节,是恢复承诺的前提。本实验验证了 persistent 消息的 confirm 与投递,但broker 重启恢复未运行(会作为未运行项标注)。
消费确认与 prefetch
ack / nack / reject
| 方法 | 语义 | 消息去向 |
|---|---|---|
| basicAck(deliveryTag, multiple) | 确认 | 从 unacked 集合移除 |
| basicNack(deliveryTag, multiple, requeue) | 拒绝 | requeue=true 回队列;false 进 DLX(若配置) |
| basicReject(deliveryTag, requeue) | 单条拒绝 | 同上,不支持 multiple |
实验验证 nack+requeue:第一次投递 redelivered=false,nack 后重取 redelivered=true。redelivered 标志是"这条消息曾经投递过"的提示,和 DUP 一样不是业务去重凭据——消费者重投后仍要按业务身份去重。
prefetch 是流量控制,不是并发上限
basicQos(n) 限制每个 consumer(global=false 时)未确认的投递数。客户端实现就是发一个 Basic.Qos RPC:
}
/** Public API - {@inheritDoc} */
@Override
public void basicQos(int prefetchCount, boolean global)
throws IOException
{
basicQos(0, prefetchCount, global);
}
/** Public API - {@inheritDoc} */
@Override
public void basicQos(int prefetchCount)单参数版本调用 basicQos(0, prefetchCount, false):prefetchSize=0(不限制字节)、global=false(per-consumer)。实验:prefetch=2 时队列有 5 条消息,消费者未 ack 前只收到 2 条;ack 一条后补投第 3 条。
prefetch 不控制并发线程数。它只限制"在途未确认"的数量;消费者拿到 2 条后可以用 1 个线程顺序处理,也可以扔进 10 个线程的池——后者会让"未确认"很快变成"处理中",prefetch 形同虚设。要限并发,需要消费端自己的信号量或线程池。
unacked 消息的归属
channel 关闭或连接断开后,该 channel 上未 ack 的消息自动回队列,redelivered=true。实验:另一个 channel 取走一条消息不 ack 就关闭,主 channel 立即能重新取到,且 redelivered=true。这正是"至少一次"的来源:消费者崩溃不会丢消息,但会重复投递。
ack 时机决定失败语义
| 顺序 | 崩溃点 | 后果 |
|---|---|---|
| 先 ack 再业务 | 业务失败 | 消息已确认、业务未做——丢消息 |
| 先业务再 ack | ack 前崩溃 | 业务已做、消息未确认——重投、可能重复 |
| 业务与 ack 同一事务(不可行) | — | RabbitMQ 的 ack 不在数据库事务里 |
正确做法是"业务提交后 ack",并让业务效果幂等(Inbox)。ack 不是"确认收到",是"确认处理完,可以删除"。
死信与队列类型
DLX 的三个触发条件
消息进入死信队列(DLX)的条件:
basicReject/basicNack且 requeue=false;- 消息 TTL 过期(队列或消息级
x-message-ttl/expiration); - 队列长度超过
x-max-length(溢出策略决定丢哪条)。
实验验证第 1 种:工作队列声明 x-dead-letter-exchange 与 x-dead-letter-routing-key,消费者 reject(requeue=false) 后,死信队列收到同一条消息(work=0 dead=1)。
DLX 是 exchange,不是队列:它需要自己的 binding 才能把死信投到目标队列;只声明 x-dead-letter-exchange 而不绑定,死信会被丢弃。
x-death:死信的排障证据
死信消息头里的 x-death 数组记录:原因(rejected/expired/maxlen)、原队列、原 exchange、次数、时间。排查"为什么这条进了死信"应该读 x-death,而不是从业务日志猜。本实验的 reject 场景 x-death 原因为 rejected(header 结构未在断言中展开,属源码/文档依据)。
队列类型:classic、quorum、stream
| 类型 | 存储 | 复制 | 适用 |
|---|---|---|---|
| classic | 单节点磁盘/内存 | 无(镜像队列已弃用) | 低价值、可重建 |
| quorum | Raft 日志 | 多数派复制 | 需要持久、不丢的队列 |
| stream | 追加日志 | 复制 | 重放、大吞吐(本实验未运行) |
实验在单节点声明 quorum 队列并投递成功。单节点 quorum 没有多数派可失:它能声明、能投递,但"节点宕机后恢复"需要至少 3 节点,本实验未运行——正文不能宣称 quorum 恢复已验证。
观测、排障与恢复顺序
RabbitMQ 消息"不见了"的排查顺序:
- 先看 return:mandatory 是否开启?不可路由消息被 return 了吗?没有 return 说明消息进了至少一个队列。
- 再看队列深度:
queueDeclarePassive或管理插件看 messages、messages_unacknowledged、messages_ready。三者比例决定是"没投递"还是"消费者压着"。 - 看 prefetch 与 unacked:unacked 长期不降,说明消费者拿走了不 ack 或处理卡住。
- 看 DLX 与原队列:原队列为 0 且死信队列有值,说明 reject/过期/溢出发生了;读 x-death 看原因。
- 看 confirm:confirm nack 说明 broker 拒收(磁盘满、队列长度限制、镜像不可用等),查 broker 日志。
- 看持久化三件套:重启后消息消失,逐项核对 exchange/queue/message 的 durable/persistent。
恢复顺序:先恢复路由(否则重发也不到),再恢复消费者与 prefetch(否则队列继续堆积),再处理死信(按 x-death 分类),最后核对持久化承诺(重启后的损失比例)。反过来做等于用新错误掩盖旧错误。
实验读法
python3 labs/rabbitmq-topology/run.py 输出 14 条观察行(11 个测试 + broker 启停 + 计数):
| 观察行 | 含义 |
|---|---|
U58 brokerStart=PASS / brokerStop=PASS | 独立 node 的启停 |
U58 tests=PASS count=11 | 全部测试通过 |
U58 direct=PASS / fanout=PASS / topic=PASS | 三种 exchange 路由 |
U58 mandatoryReturn=PASS 312:... | 不可路由被 return |
U58 alternateExchange=PASS | 备用 exchange 兜底 |
U58 publisherConfirm=PASS | persistent 消息 confirm |
U58 nackRequeue=PASS | nack+requeue 的 redelivered |
U58 prefetch=PASS beforeAck=2 afterAck=3 | prefetch 限制与补投 |
U58 deadLetter=PASS | DLX 收死信 |
U58 unackedRecovery=PASS | channel 关闭后消息回队列 |
U58 quorumQueue=PASS | 单节点 quorum 队列 |
闭卷验收
本页 24 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。
未运行与不能由本页证明的
- 集群、quorum 多数派、镜像/仲裁队列跨节点恢复、脑裂与重选未运行;单节点 quorum 只验证声明与投递。
- broker 崩溃/断电后持久化恢复未运行;持久化三件套是机制说明,不是本实验的重启验证。
- TLS、SASL 外部认证(LDAP/OAuth)、vhost 权限、federation/shovel、stream 插件、headers exchange、优先级队列、惰性队列未运行。
- 多消费者公平分发、prefetch global=true、consumer 取消与重新投递的竞态未专项运行。
- 本实验不重讲 Kafka 的 ISR/HW/事务,也不替代通用重试与幂等方法论。
- 教材完成不等于学习者掌握;未运行条件不得由"配置写好"或"测试通过"推出。