KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

00 · AMQP 0-9-1 模型全景 — keel 龙骨

排查「消息没到」之前,先要能说清一条消息从 basic_publish 到消费回调,在 broker 里到底经过哪几个对象。这些对象不是同一层的东西:connection 是 TCP,channel 是它上面的复用槽位,vhost 是命名空间,exchange 是路由表,queue 才是真正存消息的地方。把它们混成一坨,后面每一章的失败现象都会看错。本篇的所有输出来自本机 RabbitMQ 4.3.6,节点名已做脱敏,统一写作 rabb

排查「消息没到」之前,先要能说清一条消息从 basic_publish 到消费回调,在 broker 里到底经过哪几个对象。这些对象不是同一层的东西:connection 是 TCP,channel 是它上面的复用槽位,vhost 是命名空间,exchange 是路由表,queue 才是真正存消息的地方。把它们混成一坨,后面每一章的失败现象都会看错。本篇的所有输出来自本机 RabbitMQ 4.3.6,节点名已做脱敏,统一写作 rabbit@node1。

一、现场:消息发出去就没影了

日志服务往 lab.ex.topic 发消息,routing key 形如 order.created。有两个消费者:lab.t.hash 绑 order.#,lab.t.hashall 绑 #。上线后发现 lab.t.hashall 什么都能收到,lab.t.hash 时好时坏——order.created 能收到,但测试同学改发的 order.pay 收不到,order.create.success 又收到了。

发布端没有任何报错,basic_publish 正常返回。这就是 RabbitMQ 最容易让人误判的地方:publish 成功只代表这一段消息被 broker 收到了,不代表它被投进了任何队列。 要判断这一点,得先知道消息在 broker 里要穿过哪些对象、在哪一步可能断掉。

二、一条消息在 broker 里的链路

先看全貌,再看每一跳。

flowchart TD
    subgraph CLIENT["发布端进程(pika)"]
        P["业务代码 basic_publish"] --> C1["Connection<br/>1 条 TCP 到 127.0.0.1:5672"]
        C1 --> CH1["Channel 1"]
    end

    subgraph BROKER["broker 节点 rabbit@node1"]
        subgraph VH["vhost /lab"]
            EX["exchange lab.ex.topic<br/>type=topic"]
            B1["binding<br/>lab.t.hash <- order.#"]
            B2["binding<br/>lab.t.hashall <- #"]
            Q1["queue lab.t.hash"]
            Q2["queue lab.t.hashall"]
        end
    end

    subgraph CONSUMER["消费端进程"]
        CO["basic_consume 回调"]
    end

    CH1 -->|"① publish rk=order.created"| EX
    EX -->|"② 匹配 order.#"| B1
    EX -->|"③ 匹配 #"| B2
    B1 --> Q1
    B2 --> Q2
    Q1 -->|"④ deliver 到消费者"| CO
    EX -.->|"⑤ 没有任何 binding 匹配"| DROP["消息直接丢弃,发布端无感"]
    CH1 -.->|"⑥ exchange 名写错"| ERR["channel 级异常 404 NOT_FOUND"]

    style CLIENT fill:#e3f2fd,color:#0d3b66
    style BROKER fill:#fff8e1,color:#8a4b00
    style VH fill:#fff3e0,color:#8a4b00
    style CONSUMER fill:#f3e5f5,color:#4a148c
    style EX fill:#ffe0b2,color:#8a4b00
    style B1 fill:#f1f8e9,color:#33691e
    style B2 fill:#f1f8e9,color:#33691e
    style Q1 fill:#e8f5e9,color:#1b5e20
    style Q2 fill:#e8f5e9,color:#1b5e20
    style P fill:#e3f2fd,color:#0d3b66
    style C1 fill:#e3f2fd,color:#0d3b66
    style CH1 fill:#e3f2fd,color:#0d3b66
    style CO fill:#f3e5f5,color:#4a148c
    style DROP fill:#ffebee,color:#b71c1c
    style ERR fill:#ffebee,color:#b71c1c

三、逐跳解读:六个对象各自的边界

① connection 是 TCP 层的东西。 它管的是「一条到 broker 的网络连接」:认证(用户名密码)、心跳、以及这个连接属于哪个 vhost。在 vhost 上做的大部分事情,都是在这个连接上开的 channel 里发生的。connection 建立时会完成 AMQP 0-9-1 的协议握手(协商 channel-max、frame-max、心跳间隔),失败就在这一步。

② channel 是连接上的复用单元。 一条 TCP 连接的建立成本(握手、TLS、认证)不便宜,AMQP 的解法是让多条逻辑通道共用一条 TCP。channel 在协议里有一个整数编号,从 1 开始,由客户端在 channel.open 时指定。声明队列、发布、确认、消费回调,全部挂在某个 channel 上。channel 之间的隔离是逻辑隔离,不是进程隔离——同一个 channel 上一条命令出错(比如声明了冲突的队列参数),broker 会关掉整个 channel,而不是只拒绝那一条。

③ vhost 是命名空间。 它隔离的是「对象名字的可见范围」:不同 vhost 里可以各有一个叫 orders 的队列,互不冲突。权限模型是「用户 × vhost」的组合——一个用户对某个 vhost 有 configure / write / read 三类权限。默认 vhost 是 /,本课所有实验都在 /lab 里做,避免和你已有的东西串台。

④ exchange 是路由表,它自己不存消息。 一个刚声明出来的 exchange 是空的,谁都不认识。消息到了 exchange,它按 exchange 类型 + binding 的规则算出「该投给哪几个队列」,投完就不管了。exchange 不存在时,publish 会触发 channel 级异常(404 NOT_FOUND)——这是少数几种 publish 会立刻报错的情况。

⑤ queue 是唯一真正存消息的对象。 消息最终落在队列里,消费者也从队列里取。队列的存在和 exchange 无关,一个没有任何 binding 的队列就是收不到东西。

⑥ binding 是 exchange 到 queue 的规则。 它由「exchange + queue + binding key + 可选 arguments」组成。direct 用它做精确匹配,topic 用它做通配符匹配,fanout 直接忽略它,headers 用 arguments 做匹配。回到开头的现场:order.pay 收不到,是因为 order.# 里 # 匹配「零个或多个词」,而 order.pay 的第二段是 pay,order.# 要求第一段必须是 order——问题出在绑定错了模式,不在 broker。

四、证据:一个连接上开 3 个 channel

跑一个最小脚本:连到 /lab,在同一个 connection 上开 3 个 channel,每个 channel 声明一个队列,然后在连接存活期间用 rabbitmqctl 从 broker 那侧看。

conn = pika.BlockingConnection(params)      # virtual_host="/lab"
channels = [conn.channel() for _ in range(3)]
for i, ch in enumerate(channels):
    ch.queue_declare(queue=f"lab.ch{i}", durable=False, exclusive=True, auto_delete=True)

broker 侧的连接视图:

$ rabbitmqctl list_connections name user vhost channels
name                            user   vhost   channels
127.0.0.1:20731 -> 127.0.0.1:5672  guest  /lab    3

三个 channel 各自一行,但属于同一个 connection 名:

$ rabbitmqctl list_channels connection number
connection                   number
<rabbit@node1.1791179995.991.0>  1
<rabbit@node1.1791179995.991.0>  2
<rabbit@node1.1791179995.991.0>  3

list_channels 的第一列就是 connection 的内部 ID,三行完全相同——这行输出直接证明了「channel 是复用的单元,不是新连接」。把第 3 个 channel 关掉再查:

$ rabbitmqctl list_connections name vhost channels
127.0.0.1:20731 -> 127.0.0.1:5672  /lab    2

channels 从 3 变成 2,connection 本身还在。等整个 connection 关掉,list_connections 里这一行就整条消失了。三个对象的层级关系到这里就没有歧义了:vhost 是作用域,connection 是 TCP,channel 挂在 connection 上。

五、失败注入:4.x 拒绝非持久非独占队列

上面那段代码我第一次写错了,queue_declare 只传了 durable=False, auto_delete=True,没加 exclusive=True,结果不是「收到一个告警」,而是整个连接被 broker 关掉:

ConnectionClosedByBroker: (541, 'INTERNAL_ERROR - Feature `transient_nonexcl_queues` is deprecated.
By default, this feature is not permitted anymore.
The feature will be removed from a future major RabbitMQ version, regardless of the configuration; actual version to be determined.')

transient_nonexcl_queues(既非持久化、又非独占的队列)在 2021 年 8 月就被标记弃用,4.x 默认直接拒绝。加上 exclusive=True 后同一条声明立刻成功。这件事说明两个边界:一是声明类命令出错会连坐整个 channel / connection,所以生产代码里队列声明要么幂等、要么在启动阶段一次性完成;二是队列的 durable 和消息的 delivery_mode 是两件事,前者说的是「队列定义能不能扛过重启」,后者说的是「消息能不能扛过重启」,第 02 章会分别验证。

六、三个常见误判

常见误解 对着哪条证据核对 结论
publish 成功就等于消息到了队列 本篇第 02 章的 mandatory 实验:不可路由时发布端毫无感知 publish 只确认 broker 收到;是否入队是另一段
connection 数量越多吞吐越高 list_channels 三行共用同一个 connection 该扩的是 channel,不是 connection;connection 受 TCP 和文件描述符约束
channel 可以多线程共用 RabbitMQ 官方客户端文档明确 channel 非线程安全 一个线程一个 channel,connection 可以共享

生产边界

动手

  1. 用 pika 连到 /lab,在一条 connection 上开 4 个 channel,list_channels 应看到 4 行且 connection 列相同。
  2. 关掉其中一个 channel,list_connections 的 channels 列应从 4 变 3。
  3. 故意声明一个 durable=True 的队列,再用 durable=False 声明同名队列,观察 broker 返回的 406 与错误文本。
  4. 用 rabbitmqctl list_vhosts name tracing 看清 / 与 /lab 的差异,说明 vhost 隔离的是哪一层。

自测

  1. 一个 connection 上能开多个 channel,为什么协议要设计这层复用?不复用会付出什么代价?
  2. list_channels 的第一列是什么?为什么三行 channel 会显示同一个值?
  3. vhost 和权限是不是一回事?一个用户对 /lab 有权限,能不能访问 /?
  4. 为什么声明队列出错会关掉整个 channel,而不是只报一条错误?这对你的启动流程有什么要求?
  5. 消息到了 exchange 但没有任何 binding 匹配,会发生什么?发布端能感知吗?

↓ 下一步:01 章 · 路由实测

进入 keel 阅读