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。上线一周后收到三种反馈:

三种现象,三个不同的失效点。下面按链路顺序逐个拆。

二、上行链路的四个丢失点

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 才看得见

生产边界

动手

  1. 关掉 confirms,用 AE 兜底,投一个不可路由的 key,确认消息进了 AE 队列但发布端没有任何异常。
  2. 打开 confirms,同样的 key,确认 pika 抛 UnroutableError;再改回能路由的 key,确认不抛。
  3. 建一个 x-max-length=3, x-overflow=reject-publish 的队列,开 confirms 连投 10 条,统计 ack 与 nack 的条数,并核对队列深度。
  4. 用 durable 队列各投 100 条 delivery_mode=2 与 =1,stop_app / start_app 后重新计数,验证官方文档那句话。

自测

  1. publisher confirms 补的是哪一段?它和 mandatory 解决的是不是同一个问题?
  2. 为什么说队列不 durable 时,消息设 delivery_mode=2 也没用?
  3. basic.return 和 basic.nack 分别在什么情况下出现?两者的触发条件有什么区别?
  4. 非持久化消息为什么会出现在磁盘上?这对「用非持久化省磁盘」的想法意味着什么?
  5. x-overflow 的 drop-head 和 reject-publish 各丢哪种消息?做队列保护时你会选哪个,为什么?

↓ 下一步:03 章 · 下行可靠性

进入 keel 阅读