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 可以共享 |
生产边界
- 教学替身 vs 真实依赖:本课所有实验跑在单机、单 vhost
/lab、默认guest用户上。生产里 vhost 是权限与配额边界,guest只允许本机登录,必须换成独立的受限用户(每类应用一个,权限按需给 configure / write / read)。 - 连接管理:每个应用进程维护一个长连接、按并发度开 channel 是常见做法;连接断了要有重连与重新声明拓扑的逻辑,否则重连后队列/交换机不存在,消息又会静默丢失。
- 上线要盯的指标:
list_connections的数量与state(是否 blocked)、list_channels数量(channel 泄漏时只增不减)、connection 的send_pend/recv_cnt。 - 失败策略:channel 级异常(如 404、406 PRECONDITION_FAILED)会关掉 channel,应用必须捕获并重建 channel;声明冲突(同名队列参数不一致)是常见的 406,参数一旦变更需要改队列名或删队列重建。
动手
- 用 pika 连到
/lab,在一条 connection 上开 4 个 channel,list_channels应看到 4 行且 connection 列相同。 - 关掉其中一个 channel,
list_connections的channels列应从 4 变 3。 - 故意声明一个
durable=True的队列,再用durable=False声明同名队列,观察 broker 返回的 406 与错误文本。 - 用
rabbitmqctl list_vhosts name tracing看清/与/lab的差异,说明 vhost 隔离的是哪一层。
自测
- 一个 connection 上能开多个 channel,为什么协议要设计这层复用?不复用会付出什么代价?
list_channels的第一列是什么?为什么三行 channel 会显示同一个值?- vhost 和权限是不是一回事?一个用户对
/lab有权限,能不能访问/? - 为什么声明队列出错会关掉整个 channel,而不是只报一条错误?这对你的启动流程有什么要求?
- 消息到了 exchange 但没有任何 binding 匹配,会发生什么?发布端能感知吗?
↓ 下一步:01 章 · 路由实测