主题
界面
主题
《消息队列基础与选型》讲了"为什么要 MQ";本节讲 RabbitMQ 的灵魂:路由模型:
生产者 → Exchange(交换机)→ (Binding + Routing Key) → Queue → 消费者
↑
所有路由逻辑都在这里★ 把 Exchange 搞懂,RabbitMQ 就懂了一半 ✓✓
* 与 # 的区别 ★★★;先看一个真实排障案例 ✗:
现象:下单后积分没加、短信也没发,但订单服务日志显示"消息发送成功" ✗
排查:rabbitmqctl list_bindings 一看 ——
**交换机的绑定关系写错了 routing key**(order.created vs order.create)
根因:RabbitMQ 在「没有队列接收」时**不报错,直接丢弃** ✗✗★ 本节的实测会复现这个行为 ✓:
发送 rk=order.paid → 投递到 [queue.order.paid, queue.order.paid.urgent]
发送 rk=order.shipped → 投递到 [queue.order.shipped]
发送 rk=order.cancelled → **没有队列接收,消息被丢弃** ✗★ 注意最后一行:生产者的 publish 调用是成功的、没有任何异常 ✗✗
| 概念 | 职责 | 类比 |
|---|---|---|
| Exchange ★★★ | 决定"这条消息该去哪些队列" | 邮局分拣中心 ✓ |
| Binding ★★★ | 把 Exchange 和 Queue 连起来的规则 | 分拣规则表 ✓ |
| Routing Key ★★ | 生产者给消息贴的"地址标签" | 信封上的地址 ✓ |
| Queue ★★★ | 真正存消息的地方 | 收件人的信箱 ✓ |
★ 最关键的一步认识 ✓✓:
生产者从不直接把消息发给队列 ✓
它只发给 Exchange,并带一个 routing key ——由 Exchange 按绑定规则决定投递给谁 ✓✓
★ 这与 Kafka 完全不同 ✓:
| Kafka | RabbitMQ | |
|---|---|---|
| 发送目标 | 直接发到 topic 的某个分区 | 发给 Exchange,由它路由 ✓ |
| 消息留存 | 存下来,谁要谁来读(可重放) ✓ | 投递出去就没了(除非声明了持久化队列)✗ |
| 没人接收时 | 留在分区里 | 直接丢弃 ✗✗ |
实测 ✓:
exchange: order.direct(类型 direct)
绑定:
order.paid → queue.order.paid
order.shipped → queue.order.shipped
order.paid → queue.order.paid.urgent ← 同一个 key 绑了多个队列
发送 rk=order.paid → [queue.order.paid, queue.order.paid.urgent] (一份消息,两方消费)
发送 rk=order.shipped → [queue.order.shipped]
发送 rk=order.cancelled → **没有队列接收,消息被丢弃** ✗★ 两个要点 ✓✓:
| 要点 | 说明 |
|---|---|
| 同一个 routing key 可以绑多个队列 ★★ | 一份消息被多方消费(这实现了"发布订阅")✓ |
| 匹配不上就丢弃 ★★★ | 不报错、不重试、不落盘 ✗✗ |
★ 后者是 RabbitMQ 最容易出的事故 ✓ ——生产环境的标准做法:
给交换机配 alternate-exchange(备用交换机):
没有队列接收的消息会被转发到备用交换机 → 落到一个"未路由"队列里 → 有人监控 ✓✓实测 ✓:
exchange: order.fanout(类型 fanout)
绑定:三个队列(其中 app 被绑了两次)
发送 rk=任意key → [queue.notify.sms, queue.notify.email, queue.notify.app, queue.notify.app]★ 注意两点 ✓:
| 现象 | 原因 |
|---|---|
| routing key 是"任意key"也照样投递 ✓ | fanout 完全忽略 routing key ✓ |
| app 队列在列表里出现了两次 ⚠️ | 但实际只收到一条消息 —— binding 是集合语义,重复绑定不产生重复投递 ✓ |
★ fanout 的典型用途 ✓✓:
一个事件、多个下游各自处理 ✓
如「订单支付成功」要通知:短信、邮件、App 推送、积分、数据仓库 ✓
注意:这与"工作队列"不同 ——fanout 是每个下游都处理一遍 ✓✓
实测(这张表精确验证了 * 与 # 的语义) ✓✓:
绑定:
*.*.error → queue.log.all-error (任意服务、任意模块的 error)
order.# → queue.log.order-* (order 下的所有日志)
order.*.error → queue.log.order-error (order 下任意模块的 error)
路由结果:
order.pay.error → all-error, order-*, order-error (三个都命中)
order.pay.info → order-* (不是 error)
order.stock.timeout.error → order-* ← ★ 关键
user.login.error → all-error (别的服务)★★ 第三行是本节最重要的证据 ✓✓:
order.*.error没有匹配order.stock.timeout.error✓因为
*恰好匹配一个词 ——而order.stock.timeout.error在 order 与 error 之间有两个词 ✓✓
★ 两条规则(面试常考) ✓✓:
| 通配符 | 含义 | 例子 |
|---|---|---|
* ★★★ | 恰好匹配一个词 | order.*.error 匹配 order.pay.error,不匹配 order.stock.timeout.error ✓ |
# ★★★ | 匹配零到多个词 | order.# 连 order 本身都能匹配 ✓ |
★ 实践建议 ✓:
用一套 routing key 编码业务维度:
服务.模块.级别(如order.pay.error)✓消费方按需订阅 ——消费方完全不需要知道生产者是谁 ✓✓
代价:topic 路由比 direct 慢(要逐词匹配)—— 但通常不是瓶颈 ⚠️
实测(3 个消费者抢同一个队列的 12 条任务) ✓:
消费者1 收到 10 条:[任务-1, 任务-4, 任务-5, ... 任务-12]
消费者2 收到 2 条:[任务-2, 任务-3]
消费者3 收到 0 条:[]
队列深度:0 | 总交付:12 条(不重复不遗漏)✓★ 两条结论 ✓✓:
| 结论 | 说明 |
|---|---|
| 不重复不遗漏 ✓ | 竞争消费保证一条消息只被处理一次 ✓ |
| 但分配可能极不均匀 ✗✗ | 3 个消费者收到 10 / 2 / 0 ——完全不是平均分配 ✓ |
★★ 为什么不均 ✓✓:
谁先抢到谁处理 ——消费者 1 恰好跑得快(或调度器一直给它时间片) ✓
于是它一直抢,其他两个饿着 ✓
★★★ 这正是 basic.qos(prefetchCount) 要解决的问题 ✓✓:
// ★ 关键配置:一个消费者最多同时持有 1 条未确认的消息
channel.basicQos(1);| 配置 | 行为 |
|---|---|
prefetchCount = 0(默认) ✗ | 不限制 ——快消费者会把消息全抢光 ✗✗ |
prefetchCount = 1 ★★★ | 处理完一条才给下一条 → 慢消费者也能分到 ✓✓ |
prefetchCount = N ✓ | 兼顾吞吐与公平(N 通常取 10~100) |
★ 但它有个代价 ✓:
prefetch 越小,吞吐越低(消费者经常在等消息)✗
所以是"公平 vs 吞吐"的取舍 ——默认值 0 在"所有消费者性能一致"时反而最优 ✓✓
| 模式 | 用哪种 exchange | 一条消息被处理几次 |
|---|---|---|
| 简单队列 ★ | (默认交换机,直接发队列) | 1 次 |
| 工作队列(竞争消费) ★★ | (默认交换机) | 1 次(多个消费者分摊)✓ |
| 发布订阅 ★★ | fanout | N 次(每个队列一次)✓ |
| 路由模式 ★★ | direct | 按 key 决定 |
| 主题模式 ★★★ | topic | 按通配符决定 ✓ |
★ 最容易搞混的是第 2 和第 3 行 ✓✓:
想让"一条消息只被处理一次"→ 工作队列(多消费者同一队列) ✓
想让"一条消息被多方各处理一次"→ fanout(多队列) ✓
判断依据不是"有几个消费者",而是"有几个队列" ✓✓
★ 一条排障经验 ✓✓:
收到消息但没进队列 → 先用
list_bindings查绑定与 routing key ✓这是 RabbitMQ 最常见的问题(绑定写错、key 大小写不一致、exchange 类型选错)✓✓
★ 常用运维命令 ✓:
rabbitmqctl list_exchanges name type # 看所有交换机
rabbitmqctl list_queues name messages consumers # ★ 看队列深度与消费者数
rabbitmqctl list_bindings # ★ 看绑定关系
rabbitmqctl list_connections name state # 看连接
rabbitmq-plugins enable rabbitmq_management # 开 Web 管理台(15672)★ 三个必须监控的指标 ✓:
| 指标 | 含义 | 异常表现 |
|---|---|---|
| 队列深度(messages) ★★★ | 积压量 | 持续上涨 = 消费跟不上了 ✗ |
| 消费者数(consumers) ★★ | 有几个在消费 | 掉到 0 = 消费者全挂了 ✗✗ |
| 未确认消息数(unacked) ★★ | 投出去但没 ACK 的 | 很多 = 消费者卡住了 ⚠️ |
Channel channel = connection.createChannel();
// 声明交换机与队列(幂等操作,重复声明无害)
channel.exchangeDeclare("order.direct", BuiltinExchangeType.DIRECT, true);
channel.queueDeclare("queue.order.paid", true, false, false, null);
// 绑定:routing key = order.paid 的消息进这个队列
channel.queueBind("queue.order.paid", "order.direct", "order.paid");
// 发送
channel.basicPublish("order.direct", "order.paid", null, body);// 交换机用 fanout,每个下游一个队列
channel.exchangeDeclare("order.paid.fanout", BuiltinExchangeType.FANOUT, true);
for (String consumer : List.of("points", "sms", "email", "warehouse")) {
channel.queueDeclare("queue." + consumer, true, false, false, null);
channel.queueBind("queue." + consumer, "order.paid.fanout", "");
}★ 好处 ✓:加一个下游只加队列与绑定,生产者的代码一行不改 ✓✓(呼应 《SOLID 原则》 的开闭原则)
✗ key = "orderPaid"(没有分隔),却绑定了 "order.*"
✓ key = "order.paid"(用 . 分隔,便于按维度订阅)✓✓代码:labs/distributed-lab/src/main/java/cn/kb/learn/dist/mq/RabbitMqModelLesson.java
运行:
cd labs
.\mvnw.cmd -pl distributed-lab compile
java "-Dstdout.encoding=UTF-8" -cp distributed-lab/target/classes cn.kb.learn.dist.mq.RabbitMqModelLesson实测输出(关键部分):
===== 1. direct exchange =====
发送 rk=order.paid → 投递到 [queue.order.paid, queue.order.paid.urgent]
发送 rk=order.cancelled → **没有队列接收,消息被丢弃** ✗
===== 3. topic exchange =====
order.pay.error → all-error, order-*, order-error
order.pay.info → order-*
order.stock.timeout.error → order-*
user.login.error → all-error
===== 4. 工作队列模式 =====
消费者1 收到 10 条:[任务-1, 任务-4, ...]
消费者2 收到 2 条:[任务-2, 任务-3]
消费者3 收到 0 条:[]★ 三条可落地的结论 ✓:
| # | 结论 | 依据 |
|---|---|---|
| ① | 匹配不上的消息会被静默丢弃 ——必须配备用交换机或监控 | order.cancelled 被丢弃 ✗ |
| ② | * 只匹配一个词 | order.*.error 没匹配 order.stock.timeout.error ✓ |
| ③ | 竞争消费可能极不均匀,要靠 basicQos 纠正 | 10 / 2 / 0 ✗ |
order.*.error 改成 order.#.error:观察 order.stock.timeout.error 也被匹配 ✓✓;basicQos(1) 去掉:观察快消费者抢光消息 ✗。| # | 错误 | 说明 |
|---|---|---|
| 1 | 以为匹配不上会报错 ✗✗ | 静默丢弃(5.2)✗✗ |
| 2 | 把 * 当成"任意多词" ✗✗ | * 是一个词,# 才是零到多 ✓ |
| 3 | 以为"多个消费者同一队列"= 发布订阅 ✗✗ | 那是竞争消费(一条只被处理一次)✓ |
| 4 | 忘了配 basicQos ✗ | 快消费者抢光,慢的饿着 ✓ |
| 5 | routing key 不用分隔符 ✗ | 无法按维度订阅 ✓ |
| 6 | 不监控队列深度与消费者数 ✗✗ | 积压/消费者全挂都不知道 ✓ |
| 7 | 以为 RabbitMQ 能重放消息 ✗✗ | 它是"投递即出队"(Kafka 才能重放)✓ |
目标:用路由表把"一个事件、多方消费"配出来。
步骤:
RabbitMqModelLesson,确认三种 exchange 的路由结果 ✓;basicQos(1),观察分配是否变均匀 ✓。验收方式:能说出"你这个系统里哪些事件适合 fanout、哪些适合 topic"。
扩展任务:设计一个"延迟队列"(RabbitMQ 原生不支持,要想办法):
rabbitmq_delayed_message_exchange ✓;* 匹配 ______ 个词,# 匹配 ______ 个词。routing key = order.stock.timeout.error
绑定之一:order.*.error会匹配吗?为什么? 5. (设计)给"订单状态变更"设计路由方案:短信服务只关心已支付,仓配只关心已发货,数据仓库关心全部。
* 只匹配一个词,而中间有两个词(stock、timeout) ✓ —— 要用 order.#.error 或 order.stock.*.error ✓★order.paid / order.shipped ✓; 短信绑 order.paid、仓配绑 order.shipped、数仓绑 order.# ✓✓*(一个词)与 #(零到多词) ★★★;basicQos 的作用 ★★★;《消息可靠性、幂等与顺序》:丢不丢、重不重、乱不乱。