KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
02 · 上行可靠性:publish 的四个丢失点 — keel 龙骨
「消息发出去了」是个含糊的说法。从 basicpublish 返回,到消息真的躺在某个队列里,中间有四段可能断:帧没送到 broker、到了 exchange 没人接、到了队列被拒收、broker 重启后消失。这四段各有一件对应的工具——publisher confirms、mandatory/alternate exchange、basic.nack、durable + deliverymode=2——但它们不是互相替代的,各补各的一
「消息发出去了」是个含糊的说法。从
basic_publish返回,到消息真的躺在某个队列里,中间有四段可能断:帧没送到 broker、到了 exchange 没人接、到了队列被拒收、broker 重启后消失。这四段各有一件对应的工具——publisher confirms、mandatory/alternate exchange、basic.nack、durable +delivery_mode=2——但它们不是互相替代的,各补各的一段。本章每一段都用本机 4.3.6 的真实输出验证,实验脚本见lab/evidence/rabbitmq-engineering/。
一、现场:三条消息,三种「丢法」
一个订单服务往 lab.ex.main 发消息,routing key 是 order.create。上线一周后收到三种反馈:
- 有客户说下单没扣库存——翻日志发现那次 publish 前 broker 所在的机器正好在重启;
- 有报表说某些事件完全查不到——查绑定才发现那些 key 从来没被任何队列绑过,消息被静默丢了;
- 有一次队列积压了很久,运维临时把队列长度设了个上限,之后几条消息发布端说「发了」但队列里没有。
三种现象,三个不同的失效点。下面按链路顺序逐个拆。
二、上行链路的四个丢失点
flowchart TD
P["应用 basic_publish(exchange, rk, mandatory)"] --> SEND["AMQP 帧写入 TCP 缓冲"]
SEND --> EX["exchange lab.ex.main"]
EX --> R{"有 binding 匹配?"}
R -->|"是"| Q["queue lab.main.q"]
R -->|"否"| M{"mandatory=True?"}
M -->|"否"| SILENT["静默丢弃:发布端毫不知情"]
M -->|"是"| RET["basic.return schema 312 NO_ROUTE"]
R -.->|"exchange 配了 alternate-exchange"| AE["lab.ex.alt (fanout)"]
AE --> AEQ["queue lab.alt.q 兜底收下"]
Q --> D{"queue durable 且 delivery_mode=2?"}
D -->|"是"| SURV["broker 重启后消息仍在"]
D -->|"否"| LOST["broker 重启后消息消失"]
SEND -.->|"confirm_delivery 开启时"| ACK{"broker 回什么?"}
ACK -->|"basic.ack"| OK["确认已落队"]
ACK -->|"basic.nack"| NACK["拒收,如 x-max-length reject-publish"]
style P fill:#e3f2fd,color:#0d3b66
style SEND fill:#e3f2fd,color:#0d3b66
style EX fill:#ffe0b2,color:#8a4b00
style R fill:#fff3e0,color:#8a4b00
style M fill:#fff3e0,color:#8a4b00
style D fill:#fff3e0,color:#8a4b00
style ACK fill:#fff3e0,color:#8a4b00
style Q fill:#e8f5e9,color:#1b5e20
style AEQ fill:#e8f5e9,color:#1b5e20
style AE fill:#f1f8e9,color:#33691e
style OK fill:#e8f5e9,color:#1b5e20
style SURV fill:#e8f5e9,color:#1b5e20
style SILENT fill:#ffebee,color:#b71c1c
style LOST fill:#ffebee,color:#b71c1c
style RET fill:#fff8e1,color:#8a4b00
style NACK fill:#ffebee,color:#b71c1c
三、第一段:帧有没有到 broker —— publisher confirms
basic_publish 默认是「发射后不管」:消息写进 TCP 发送缓冲就返回,broker 有没有收到、有没有入队,客户端一概不知。连接在半路断掉、broker 内存告警把连接 block 住,发布端都看不到异常。
打开 channel.confirm_delivery() 后,broker 会对每条消息回一个 basic.ack 或 basic.nack,pika 会把它转成同步语义。实测(04-publisher-confirms.txt):
confirms=off + mandatory=True 发不可路由 → 未抛异常;basic.return 收到 1 条,key=['miss']
confirms=on + mandatory=True 发不可路由 → 抛出 UnroutableError
注意这两行是同一个不可路由场景:不开 confirms 时不可路由只会走 basic.return 回调(异步,容易被忽略);开了 confirms 后 pika 直接把它升级成异常 UnroutableError。所以 confirms 不只是「确认送达」,它把之前靠回调才能感知的失败变成了可以 try/except 的控制流。
四、第二段:到了 exchange 没人接 —— mandatory 与 alternate exchange
先看不开 mandatory 时丢得有多彻底(03-unroutable-mandatory-ae.txt):
情形A:普通 publish(routing_key=miss),无 mandatory
basic.return 回调收到 0 条
情形A 后 lab.main.q 消息数 → 列表里 lab.main.q 为 0,其余实验队列也为 0
miss 这个 key 没有任何 binding,消息被静默丢掉,lab.main.q 没有增长,basic.return 也没触发。这就是本篇开头「报表查不到」那类问题的成因。
打开 mandatory=True 后,消息被退回,客户端能拿到退回收据:
情形B:mandatory=True publish(routing_key=miss)
basic.return 回调收到 1 条:
{'reply_code': 312, 'reply_text': 'NO_ROUTE', 'exchange': 'lab.ex.main',
'routing_key': 'miss', 'body': 'B-mandatory-不可路由'}
reply_code=312 就是 AMQP 的 NO_ROUTE。有一组对照能说明 mandatory 只在「不可路由」时触发:同样 mandatory=True 但 key 改成能路由的 hit 时,return 回调新增 0 条。mandatory 是「路由层面的告警」,不关心队列后面有没有消费者。
mandatory 要求发布端自己处理退回的消息(重投、落盘、告警)。如果不想让发布端承担这个逻辑,可以在 exchange 上挂一个 alternate exchange:任何不可路由的消息自动转到 AE。
情形C:主 exchange 带 alternate-exchange=lab.ex.alt,routing_key=miss
lab.alt.q 收到 → messages 列里 lab.alt.q 为 1
从 lab.alt.q 取回的消息 body=C-落入alternate-exchange
AE 是个「兜底队列」的思路:主 exchange 路由失败 → AE(这里是 fanout lab.ex.alt)→ 绑到 AE 的 lab.alt.q 全部收下。代价是这些消息会堆在兜底队列里,需要有人定期消费或审计,否则只是换了个地方堆积。
五、第三段:到了队列被拒收 —— basic.nack
前面两段都假设「只要路由到了队列就会进去」。不成立。给队列设 x-max-length 加 x-overflow=reject-publish 后,超出上限的发布会被 broker 拒收。开启 confirms 时,这个拒收会以 basic.nack 的形式回到客户端(04-publisher-confirms.txt):
x-max-length=2 + reject-publish,连发 5 条
broker 确认 ok=2,basic.nack 拒绝=3
lab.maxlen.q 最终深度 method.message_count=2
留在队列里的消息 msg-0 / msg-1
5 条里前 2 条 ack、后 3 条 nack,队列里剩下 msg-0、msg-1——数字对得上。这件事说明 confirms 的价值不只是「确认成功」,它还确认失败:如果没有 confirms,这 3 条被拒的消息发布端完全不知道,日志里也不会出现异常。配合 x-max-length 做队列保护时,必须同时开 confirms,否则保护队列的动作本身就在丢消息。
顺带说一个语义细节:x-overflow 默认是 drop-head(丢队列里最老的消息给新消息腾位置,不 nack 发布端),只有设成 reject-publish 才会把压力回推给发布端。选哪个是「丢老消息还是丢新消息」的取舍,不是有无问题。
六、第四段:broker 重启后还在不在 —— durable + delivery_mode
前面三段都在讲「这一次投递有没有成功」。这一段是时间维度:消息进了队列,但 broker 进程重启后它还在不在。
先看队列计数。两个 durable 队列,各投 20000 条 1KiB 消息,一个 delivery_mode=2、一个 delivery_mode=1:
name messages messages_ram messages_persistent
lab.volatile.q 20000 1 0
lab.persist.q 20000 1 20000
messages_persistent 这一列把两者的差别写得很直白:lab.persist.q 20000 条全是持久的,lab.volatile.q 是 0。真正决定命运的验证是重启。各投 100 条后执行 stop_app / start_app:
重启前 lab.volatile.q 100 0 lab.persist.q 100 100
重启后 lab.volatile.q 0 0 lab.persist.q 100 100
非持久化的 100 条重启后归零,持久化的 100 条一条不少。
这里有个我先没预料到的结果:非持久化消息也会落盘。 两个队列各投 20000 条后,消息存储目录 msg_stores/vhosts 的体积分别增长了约 23.5 MiB 和 23.2 MiB(09-persistence-disk.txt),messages_ram 只剩 1 条。也就是说,delivery_mode=1 并不是「只在内存里过一遍」,量一大,classic 队列会把消息写进磁盘的 message store。RabbitMQ 官方文档(Queues,检索于 2026-10-05)对这一点写得很明确:Transient messages will still be stored on disk but will be discarded during the next node restart. 持久化与非持久化的差别只在于重启后是否恢复,不在于「有没有落盘」。
所以 delivery_mode 的选择依据是「能不能承受重启丢失」:能丢的(实时性通知、可重算的指标)用 1 省一次 fsync;不能丢的(订单、扣款)必须 2,并且队列必须 durable——队列不 durable 的话,broker 重启连队列本身都没了,消息持久化毫无意义。
七、常见误判
| 常见误解 | 对着哪条输出核对 | 结论 |
|---|---|---|
开了 delivery_mode=2 就万无一失 |
重启实验:lab.persist.q 100→100,lab.volatile.q 100→0 |
该结论只对「队列也 durable」成立;队列非 durable 时消息再持久也随队列一起没 |
| 非持久化消息不进磁盘、更快也更省磁盘 | 09-persistence-disk.txt:非持久化那一步同样涨了约 23 MiB |
量大会被 paging 到磁盘;省的是重启恢复能力,不是磁盘 IO |
| mandatory 能保证消息被消费 | hit 对照:能路由就不触发 return,即使没人消费 |
mandatory 只管「是否路由到队列」,不管「有没有消费者」 |
队列设了 x-max-length 只是丢老消息 |
reject-publish 下 5 投 5 结果 ok=2/nack=3 |
默认 drop-head 丢老消息;reject-publish 是把消息退回发布端,必须配合 confirms 才看得见 |
生产边界
- 教学替身 vs 真实依赖:实验用单条连接、同步发布。生产里发布端通常是「连接池 + confirms + 有限重试」,并且要给重试设上限和退避;无限重试在队列持续拒收时会变成雪崩。
- 上线要盯的指标:confirm 的
basic.nack比例、basic.return数量、发布端待确认队列长度(pika 里是未完成的 confirm futures);队列侧看messages是否与你预期入队速率一致。 - 失败策略:
mandatory退回的消息要有明确去处(告警 + 落盘重投),不能只打条日志;AE 兜底队列必须有消费者或至少有人监控其深度;delivery_mode按「能否承受重启丢失」分级,不要一刀切全 2(会明显增加磁盘 IO 和写入延迟)。
动手
- 关掉 confirms,用 AE 兜底,投一个不可路由的 key,确认消息进了 AE 队列但发布端没有任何异常。
- 打开 confirms,同样的 key,确认 pika 抛
UnroutableError;再改回能路由的 key,确认不抛。 - 建一个
x-max-length=3, x-overflow=reject-publish的队列,开 confirms 连投 10 条,统计 ack 与 nack 的条数,并核对队列深度。 - 用 durable 队列各投 100 条
delivery_mode=2与=1,stop_app/start_app后重新计数,验证官方文档那句话。
自测
- publisher confirms 补的是哪一段?它和 mandatory 解决的是不是同一个问题?
- 为什么说队列不 durable 时,消息设
delivery_mode=2也没用? basic.return和basic.nack分别在什么情况下出现?两者的触发条件有什么区别?- 非持久化消息为什么会出现在磁盘上?这对「用非持久化省磁盘」的想法意味着什么?
x-overflow的drop-head和reject-publish各丢哪种消息?做队列保护时你会选哪个,为什么?
↓ 下一步:03 章 · 下行可靠性