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

对着这四行把规则钉死:

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 会原样保留在消息上,只是路由时不参与判断

生产边界

动手

  1. 建 lab.ex.topic.demo,绑定 shop.order.# 和 shop.*.paid,投 shop.order.paid、shop.order.refund.paid、shop.cart.paid,把每个队列收到的 key 列出来,逐条说明为什么。
  2. 把某个 topic 绑定的模式从 order.*.error 改成 order.#.error,重投一批 order.pay.error 与 order.pay.retry.error,对比两次收到的集合。
  3. 用 rabbitmqctl list_bindings -p /lab 把 lab.ex.topic 上的每条 binding 的 source / destination / routing_key 打出来,核对与代码里声明的是否一致。
  4. 故意用 headers exchange 的 x-match=all 绑两个头,只投其中一个,确认消息没有进任何队列。

自测

  1. a.*.c 为什么不匹配 a.c?换成 a.#.c 会匹配 a.c 吗,为什么?
  2. fanout 投递时传的 routing key 去哪了?它还能在哪些地方被用到?
  3. headers exchange 的 x-match=any 和 x-match=all 分别是什么语义?只命中一条时两者行为差在哪?
  4. direct 上两个队列绑同一个 key,一条消息投过去会发生什么?和 fanout 的结果有何不同?
  5. 为什么说「没有任何 binding 匹配」是静默丢消息?用什么手段能让它变得不静默?

↓ 下一步:02 章 · 上行可靠性

进入 keel 阅读