KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
01 · 路由实测:四种 exchange 各收到什么 — keel 龙骨
exchange 选型和 routing key 设计是 RabbitMQ 里最容易埋雷的一步,因为配错了不会报错,只会静默地少发。这一章不背规则表,直接把同一批 routing key 分别投给四种 exchange,把每个队列实际收到的东西打印出来,用输出说话。实验环境是本机 RabbitMQ 4.3.6,vhost /lab,节点名已脱敏为 rabbit@node1。
exchange 选型和 routing key 设计是 RabbitMQ 里最容易埋雷的一步,因为配错了不会报错,只会静默地少发。这一章不背规则表,直接把同一批 routing key 分别投给四种 exchange,把每个队列实际收到的东西打印出来,用输出说话。实验环境是本机 RabbitMQ 4.3.6,vhost
/lab,节点名已脱敏为rabbit@node1。
一、现场:routing key 写错一位,丢的是一类消息
承接上一章那个例子。运维同学给日志定了规范 业务.组件.级别,比如 order.pay.error。有人把消费端绑定写成了 order.*.error,觉得「星号反正匹配任意东西」。结果 order.pay.error 收到了,order.pay.retry.error 收不到。两种 key 都是「订单的报错」,但通配符只匹配固定段数。
这类 bug 的可怕之处在于:发布端正常、broker 正常、消费者也正常,只是部分 key 永远不匹配任何 binding。要提前发现,唯一可靠的办法是知道四种 exchange 各自怎么算匹配。
二、四种 exchange 的路由决策
flowchart TD
MSG["publish(exchange, routing_key, headers)"] --> T{"exchange 类型?"}
T -->|"direct"| D["routing key 与 binding key<br/>必须完全相等"]
T -->|"topic"| TP["按 . 分词<br/>* 匹配恰好 1 段,# 匹配 0 段或多段"]
T -->|"fanout"| F["忽略 routing key<br/>投给所有绑定队列"]
T -->|"headers"| H["忽略 routing key<br/>按 x-match=any/all 匹配消息头"]
D --> D1["lab.d.info / lab.d.warn / lab.d.error<br/>命中即投,可多队列同 key"]
TP --> TP1["lab.t.star 绑 a.*.c"]
TP --> TP2["lab.t.hash 绑 a.#"]
TP --> TP3["lab.t.hashall 绑 #"]
TP --> TP4["lab.t.one 绑 a.*"]
F --> F1["lab.f.1 / lab.f.2 / lab.f.3 全收到"]
H --> H1["lab.h.any 绑 x-match=any"]
H --> H2["lab.h.all 绑 x-match=all"]
D -.->|"key=debug 无 binding"| DROP["丢弃:没人收,发布端无感"]
TP -.->|"a.*.c 不匹配 a.c"| DROP
TP -.->|"a.* 不匹配 a.b.c"| DROP
H -.->|"只命中一条但 x-match=all"| DROP
style MSG fill:#e3f2fd,color:#0d3b66
style T fill:#fff3e0,color:#8a4b00
style D fill:#ffe0b2,color:#8a4b00
style TP fill:#ffe0b2,color:#8a4b00
style F fill:#ffe0b2,color:#8a4b00
style H fill:#ffe0b2,color:#8a4b00
style D1 fill:#e8f5e9,color:#1b5e20
style TP1 fill:#e8f5e9,color:#1b5e20
style TP2 fill:#e8f5e9,color:#1b5e20
style TP3 fill:#e8f5e9,color:#1b5e20
style TP4 fill:#e8f5e9,color:#1b5e20
style F1 fill:#e8f5e9,color:#1b5e20
style H1 fill:#e8f5e9,color:#1b5e20
style H2 fill:#e8f5e9,color:#1b5e20
style DROP fill:#ffebee,color:#b71c1c
三、direct:精确相等,一个 key 可绑多个队列
lab.ex.direct 绑了三条:info → lab.d.info、warn → lab.d.warn、warn → lab.d.warn.mirror、error → lab.d.error。投四个 key(多投一个没人绑的 debug)后,每个队列实际收到:
direct 队列 lab.d.info 收到 [('info', 'direct:info')]
direct 队列 lab.d.warn 收到 [('warn', 'direct:warn')]
direct 队列 lab.d.warn.mirror 收到 [('warn', 'direct:warn')]
direct 队列 lab.d.error 收到 [('error', 'direct:error')]
两个结论直接写在输出里:info 只进了 lab.d.info,一个都没多;warn 同时进了两个队列,因为两条 binding 的 key 都是 warn。debug 没有出现在任何队列里——因为没有任何 binding 用 debug,它被丢了。direct 的判断是字符串完全相等,Info 和 info 是两个不同的 key。
四、topic:* 匹配一段,# 匹配零段或多段
这是最需要实测的一块。四个队列分别绑 a.*.c、a.#、#、a.*,然后投 5 个 key:a.b.c、a.c、a.b.b.c、a.b.d、x.y。每个队列实际收到:
绑定[a.*.c] 的队列 lab.t.star 收到
[('a.b.c', ...)] # 只收 a.b.c
绑定[a.#] 的队列 lab.t.hash 收到
[('a.b.c', ...), ('a.c', ...), ('a.b.b.c', ...), ('a.b.d', ...)]
绑定[#] 的队列 lab.t.hashall 收到
[('a.b.c', ...), ('a.c', ...), ('a.b.b.c', ...), ('a.b.d', ...), ('x.y', ...)]
绑定[a.*] 的队列 lab.t.one 收到
[('a.c', ...)] # 只收 a.c
对着这四行把规则钉死:
a.*.c要求恰好三段且以a开头、以c结尾。所以a.b.c命中;a.c只有两段,不命中;a.b.b.c有四段,不命中。*只顶一段,顶不了多段,也顶不了零段。a.#要求第一段是a,后面零段或多段都行。所以a.c(后面一段)、a.b.c(两段)、a.b.b.c(三段)、a.b.d全命中;x.y第一段不是a,不命中。#单用匹配任意 key,效果等于 fanout;x.y也进了。a.*要求恰好两段且第一段是a,所以只有a.c命中。
RabbitMQ 官方文档(Exchanges,4.2 版本文档,检索于 2026-10-05)给的规则和实测一致:* substitutes exactly one segment,# substitutes zero or more segments,routing key 用 . 分词。文档里专门举了 regions.na.cities.* 不匹配 regions.na.cities 的例子,就是上面 a.*.c 不匹配 a.c 的同一个边界。
五、fanout:忽略 routing key,投给所有绑定
lab.ex.fanout 上挂了 lab.f.1 / lab.f.2 / lab.f.3,绑定时不传 key,投递时随手传了个 任意key:
fanout 队列 lab.f.1 收到 [('任意key', 'fanout:hello')]
fanout 队列 lab.f.2 收到 [('任意key', 'fanout:hello')]
fanout 队列 lab.f.3 收到 [('任意key', 'fanout:hello')]
三个队列全都收到,且消息上带的 routing key 原样保留下去了(第一列还是 任意key),只是 fanout 在路由时不用它。所以想让某个队列不收,只能解绑,不能靠改 routing key 过滤。fanout 的适用面比看起来窄:它适合「所有订阅者都要同一份」的广播(配置变更通知、缓存失效),不适合需要按维度筛选的场景。
六、headers:不看 routing key,看消息头
lab.ex.headers 上两条 binding,lab.h.any 用 x-match=any,lab.h.all 用 x-match=all,两边都声明 type=report, format=pdf。发三种消息头:
用例[两条都命中] headers={'type': 'report', 'format': 'pdf'}
用例[只命中一条] headers={'type': 'report', 'format': 'zip'}
用例[都不命中] headers={'type': 'log', 'format': 'zip'}
headers 队列 lab.h.any 收到 [('', 'headers:两条都命中'), ('', 'headers:只命中一条')]
headers 队列 lab.h.all 收到 [('', 'headers:两条都命中')]
x-match=any 只要消息头命中任意一条绑定头就投;x-match=all 要求全部命中。所以「只命中一条」的消息进了 any 队列、没进 all 队列。注意 routing key 那一列是空字符串——headers exchange 完全不看 key。headers 的代价是每条消息都要带足够多的头,且匹配逻辑不如 topic 直观,实际项目里用得比 topic 少。
七、三个常见误判
| 常见误解 | 对着哪条输出核对 | 结论 |
|---|---|---|
* 和 # 都能匹配多段 |
a.*.c 只收到 a.b.c,a.b.b.c 没进 |
* 只顶一段;要多段用 # |
topic 能匹配「任意前缀」,a.# 应匹配 x.y |
x.y 没出现在 lab.t.hash 里 |
# 只管段数,不管内容;第一段 x 和 a 不相等就不匹配 |
| fanout 绑定时传的 key 没用,所以投递时也随便传 | lab.f.1 收到的第一列是 任意key |
routing key 会原样保留在消息上,只是路由时不参与判断 |
生产边界
- 教学替身 vs 真实依赖:实验里 binding 是手工声明的一次性拓扑。生产里拓扑(exchange / queue / binding)应当由部署脚本或应用启动时幂等声明,并且纳入版本管理——routing key 的命名规范一旦变化,绑定和发布端必须同步改,否则就是本篇开头那种静默丢消息。
- 上线要盯的指标:
list_exchanges与list_bindings的数量;更关键的是发布速率与各队列入队速率的比值——两者长期不等,说明有消息在路由阶段被丢(或走了 alternate exchange)。 - 失败策略:对「必须有人收」的消息,用
mandatory+ alternate exchange 兜底(第 02 章);对路由规则,宁可多绑几个明确的 key,也不要用过于宽泛的#通配符去「兜所有」,宽通配符会把不属于你的消息也吸进来。
动手
- 建
lab.ex.topic.demo,绑定shop.order.#和shop.*.paid,投shop.order.paid、shop.order.refund.paid、shop.cart.paid,把每个队列收到的 key 列出来,逐条说明为什么。 - 把某个 topic 绑定的模式从
order.*.error改成order.#.error,重投一批order.pay.error与order.pay.retry.error,对比两次收到的集合。 - 用
rabbitmqctl list_bindings -p /lab把lab.ex.topic上的每条 binding 的 source / destination / routing_key 打出来,核对与代码里声明的是否一致。 - 故意用 headers exchange 的
x-match=all绑两个头,只投其中一个,确认消息没有进任何队列。
自测
a.*.c为什么不匹配a.c?换成a.#.c会匹配a.c吗,为什么?- fanout 投递时传的 routing key 去哪了?它还能在哪些地方被用到?
- headers exchange 的
x-match=any和x-match=all分别是什么语义?只命中一条时两者行为差在哪? - direct 上两个队列绑同一个 key,一条消息投过去会发生什么?和 fanout 的结果有何不同?
- 为什么说「没有任何 binding 匹配」是静默丢消息?用什么手段能让它变得不静默?
↓ 下一步:02 章 · 上行可靠性