读完这篇,你能做什么
能手画一条消息的路由路径,并给定绑定推算哪些队列收到消息。
学习理由、准备要求与知识点
为什么学它
消息路由模型是后续可靠发布与消费的前提,交换机与队列混淆会直接造成消息走错路径。
先读这些内容
知识点清单
- Producer
- Exchange
- Queue
- Binding
- Routing Key
- direct
- topic 通配符
- fanout
运行前先确认协议主线为 AMQP 0-9-1;资料对应 RabbitMQ 4.3 文档与 Java Client 5.25.0。独立算法使用 Java 17;教学模型不启动 Broker,也不是 RabbitMQ 的 Erlang 路由内核。
01先弄懂它是什么
订单创建后,库存、通知和审计都可能要处理。若订单服务依次等待它们,慢的一方会拖长用户请求,也要求订单服务知道所有接收方。消息系统让生产者先把事件交给 Broker,其他服务按自己的速度处理。这样能分开处理时间,但结果不会立刻全部可见,还要管理积压、重复和事件版本;异步把等待放进了消息队列,并没有消除等待。
RabbitMQ 的 AMQP 0-9-1 中,交换机负责决定发给谁,绑定描述路由关系,队列保存等待交付的消息。生产者给本次消息附上路由键,绑定键则是匹配规则,二者不总要相同。direct 精确匹配,fanout 发给所有绑定队列,topic 按点分词匹配。先画出交换机和队列之间的连接,再写配置,容易发现消息为什么没到目标队列。
以 order.created 为例,审计队列需要所有订单事件,绑定 order.#;通知队列只要创建事件,绑定 order.created。一次发布可以进入两个队列,它们的消费进度互不代替。若两个通知实例读同一个队列,通常是协作分担任务,而不是每个实例都拿到一份。独立业务职责用独立队列,扩容同一个职责则增加消费者,这是理解广播与负载分担最关键的区分。
文中这几个词,先认一认
- Exchange
- 接收发布并按规则决定消息去向的交换机,不能当成保存积压消息的普通队列。
- Binding
- 交换机到目标队列或另一交换机的连接关系,可以带匹配条件。
- Routing key
- 生产者给本次消息附带的路由分类;topic 模式按点分成多个词来匹配。
- Virtual host
- 隔开名称和权限的一层逻辑范围,不同 vhost 里的同名队列仍是不同对象。
02看看它怎么工作
路由的输入是交换机名称、路由键、消息属性和负载。Broker 先找到相应 vhost 中的交换机,再按其类型评估绑定。direct 的比较对象是整个键;topic 中 * 消耗恰好一个词,# 消耗零个或多个词。
因此 order.* 匹配 order.created,却不匹配 order.eu.created;order.# 两者都匹配,也匹配单独的 order。通配符只在绑定模式中起作用,把星号放进生产者的普通业务键并不会自动广播。
路由产生的是目标队列集合,而不是“命中的绑定条数”。同一队列存在两个匹配绑定时,一次发布不应被推理成必须收到两份;重复业务消息仍可能来自重试等其它环节。默认交换机名称为空字符串,它是预先存在的 direct 交换机,队列创建时会有按队列名匹配的关系。用户感觉是在“直接发队列”,协议上仍是在向这个特殊交换机发布。不要把默认交换机和 fanout 默认广播混淆。
如果交换机不存在,这是发布目标错误,可能关闭 channel;如果交换机存在而没有匹配队列,则是不可路由。mandatory 标志及 return 回调让应用观察后一类结果,publisher confirm 又负责另一层确认,二者不能互相替代。
教学图故意把不可路由画成单独分支:一次 basicPublish 没抛异常,只能说明调用当时没有同步暴露错误,不能替代最终路由结果,更不能证明接收者已完成业务。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart TD
P["生产者:order.created"] --> E["交换机:study.orders"]
E --> B["按绑定规则计算目标队列集合"]
B --> A["审计队列:order.#"]
B --> N["通知队列:order.created"]
B --> X["没有匹配队列"]
A --> C["审计消费者"]
N --> D["通知消费者组内分担"]
X --> R["mandatory 与 return 或备用路由"]按顺序看,每一步做了什么
- 先确认连接使用的 vhost,以及声明权限和名称。
- 声明交换机与队列,已有实体的关键属性必须兼容。
- 建立绑定;把消息键逐词代入规则,手工算出预期目标集合。
- 注册 return 与确认处理,再发布带稳定
eventId的事件。 - 核对路由与消费结果,分别记录无匹配、积压和业务失败。
03对照实现代码
rabbitmq-java-client v5.25.0 · ChannelN#basicPublish
下面是原创局部 Java 模型,表现客户端打包发布参数和确认登记的职责;不是实际协议编码器,也不实现 Broker 端路由。真实源码链接中还包含观察器、指标、异常和连接层处理。
import java.util.*;
import java.util.function.Consumer;
final class PublishModel {
record Command(String exchange, String routingKey, boolean mandatory,
byte[] body) {}
private long sequence = 1; // 本模型假设已启用确认模式。
private final Set<Long> pending = new HashSet<>();
long publish(String exchange, String key, byte[] body,
Consumer<Command> transport) {
long id = sequence++; // 序号属于当前 channel 的发布序列。
pending.add(id); // 先登记,再交给传输层;否则快速回调可能无记录可查。
transport.accept(new Command(exchange, key, true, body.clone()));
// 返回的是本地关联编号,不是消费者的业务结果。
return id;
}
}观察 basicPublish 的参数,可以看到交换机名和路由键分别传入,客户端没有在此遍历队列绑定。这是客户端与 Broker 职责分离的证据。应用不需要持有全部订阅者清单,接收者可以调整绑定;相应地,发布调用本身不能告诉你每一个接收业务处理到哪一步,结果需要另外设计。
源码在启用确认序列时维护未确认记录,然后组装 Basic.Publish 方法及消息属性、正文交给传输层。教学模型把协议命令写成 record,便于看到哪些字段一起离开进程;这里克隆字节数组仅为了防止调用者随后修改模型负载,不应据此推断真实客户端的全部缓冲复制行为。
先登记序号再交给可能异步执行的传输逻辑,是一个普遍的并发设计问题:回调可能比主线程继续执行得更早。如果先发送后建表,确认回来时应用找不到关联事件,会造成错误重试。真实客户端提供协议侧编号管理,应用仍要把事件标识、业务内容与结果记录对应起来,重连后不能把新 channel 的序号当成旧 channel 的延续。
模型中的 pending 只记录还在等待结果的发送,不能凭它判断业务已经成功。传输报错时,消息可能还没写出去,也可能已经被服务端接收,只是结果没回来;直接删掉内存记录,会丢失这次仍需查明的发送。可靠发布专题会继续讨论持久待发记录、确认、退回和防重复处理。读本段源码时,应先分清发布调用已经保证了什么、还需要后续哪些证据。
代码先调用谁,再调用谁
- 应用准备事件及路由信息
ChannelN.basicPublish重载归并参数- 构造 AMQP
Basic.Publish与消息内容 - 传输层发送协议命令
- Broker 解析命令并执行交换机路由;确认或退回异步返回
04动手写一个例子
Topic 绑定模式匹配:用动态规划处理 * 与 #
给定点分词模式 pattern 与消息键 key,判断是否匹配。* 匹配一个词,# 匹配零到多个词。教学接口允许空键表示零个词,拒绝连续点产生的空词;它用于推导匹配规则,不是 Broker 的完整字符串校验器。
- 输入 1
pattern=order.*,key=order.created- 输出 1
true
- 输入 2
pattern=order.*,key=order.eu.created- 输出 2
false
- 输入 3
pattern=order.#,key=order- 输出 3
true
先考虑只含普通词的情况:两个词相等才继续比较后缀。加入 * 后,仍只消耗一个词;加入 # 后,它可以不消耗当前词,也可以消耗一个词但继续停留在自身。直接递归尝试会反复访问相同的“模式位置、消息位置”,带有多个 # 时分支数量容易膨胀,所以应把重复子问题存成状态。
令 dp[i][j] 表示模式前 i 个词是否匹配键的前 j 个词。初始 dp[0][0] 为真,空模式不匹配非空键。普通词与 * 依赖 dp[i-1][j-1];# 则依赖 dp[i-1][j] 或 dp[i][j-1],分别表示匹配零词和多消耗一词。当前行依赖左侧,所以 j 必须从小到大推进,不能随意倒序。
对 order.# 与 order,第一行匹配 order 后状态落在 dp[1][1];第二行的 # 可以取零词分支 dp[1][1],因此结果为真。对 order.* 与 order,同一位置没有可供星号消耗的词,结果是假。代码使用滚动数组保留上一行和当前行,进一步把空间从二维表压缩为键长度的线性空间,仍保留可解释的状态转移。
为什么这样写是对的计算 cur[j] 时,prev[j] 已代表少一个模式词的正确结果,cur[j-1] 已代表当前模式匹配更短键的正确结果;所有转移都覆盖且只覆盖允许的末步消耗方式。
P、K 是模式和键的词数,L 为一次词比较的最大字符数。状态数为 P×K,普通词的 String.equals 不能无条件视为常数;滚动数组占 O(K),切词数组和字符串占输入字符级空间。若词已经驻留或整数编码,状态转移可简化为 O(PK)。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.util.*;
public class Main {
static String[] words(String text) {
Objects.requireNonNull(text, "text");
if (text.isEmpty()) return new String[0]; // 本题约定空串是零词。
String[] result = text.split("\\.", -1); // 保留尾部空词以便校验。
for (String word : result)
if (word.isEmpty()) throw new IllegalArgumentException("empty word");
return result;
}
static boolean matches(String pattern, String key) {
String[] p = words(pattern), k = words(key);
boolean[] prev = new boolean[k.length + 1];
prev[0] = true; // 空模式只匹配空键。
for (String token : p) {
boolean[] cur = new boolean[k.length + 1];
cur[0] = token.equals("#") && prev[0];
for (int j = 1; j <= k.length; j++) {
if (token.equals("#"))
cur[j] = prev[j] || cur[j - 1]; // 零词,或继续吞一个词。
else
cur[j] = prev[j - 1]
&& (token.equals("*") || token.equals(k[j - 1]));
}
prev = cur; // 下一轮只需要刚完成的一行。
}
return prev[k.length];
}
static void check(boolean expected, String pattern, String key) {
if (matches(pattern, key) != expected) throw new AssertionError(pattern);
}
public static void main(String[] args) {
check(true, "order.*", "order.created");
check(false, "order.*", "order.eu.created");
check(true, "order.#", "order");
check(true, "#.#", "");
check(false, "*", "");
check(true, "", "");
check(false, "", "order");
boolean bad = false;
try { matches("a..b", "a.b"); }
catch (IllegalArgumentException e) { bad = true; }
if (!bad) throw new AssertionError("empty token accepted");
System.out.println("PASS 8");
}
}这些特殊情况也要试一试
- # 可以匹配零词,* 不可以。
- 连续多个 # 仍须正确处理,不能把它们变成必须消耗多个词。
- 空模式、空键与非法空词是不同输入。
- 模式量与单键长度需要限制,避免匹配成本成为路由攻击面。
进阶选读 · 大量绑定下的共享前缀 Trie 与状态集合
场景十万个 topic 绑定逐个做动态规划会重复比较相同前缀,例如大量 order.eu 开头的订阅。
做法把模式词组成 Trie,普通词、星号和井号作为不同边。沿输入推进一组可达状态,井号引入零消耗闭包与自保持消耗;用状态与词位置去重,防止重复搜索。命中末端节点后再按队列标识去重。动态删除绑定要维护节点引用与并发快照。
复杂度设 Trie 节点数 V,键词数 K,可达状态的保守上界为 O(VK),不是存在通配符时也固定 O(K);结果输出还有至少 O(命中队列数) 成本。
好处与代价Trie 共享前缀减少重复工作,但索引更新、通配符状态和并发一致性更复杂。应对真实绑定分布做基准,不能只用无通配符小样本宣称稳定提升。
05项目里怎么用
配置应围绕业务职责命名并可重复声明,例如订单审计和订单通知分开管理。声明已有队列时改变 durable 等不兼容属性,不会神奇地原地迁移,可能报通道级错误;需要明确迁移顺序和回滚策略。发布前存在绑定还是发布后才补绑定,会直接影响事件能否到达目标;普通队列的绑定不是历史事件订阅查询。
消息体应带上稳定的 eventId、事件类型和 schemaVersion,后者用来标明数据格式版本;路由键用于粗分类,不要塞入敏感信息。修改格式时,先让消费者能读懂新旧字段,再更新生产者,最后移除旧格式。遇到未知版本,要记录并单独处理,不能当成解析成功。消息能送到队列,与消费者能否正确理解内容,是两件都要检查的事。
排障按链条收集证据:发布速率、不可路由计数、队列 ready 与 unacked、消费处理成功率。如果 ready 很高而 unacked 很低,可能消费者不足或不在线;如果 unacked 持续升高,可能处理慢或确认未执行。单看队列深度下降无法排除提前 ack 或拒绝丢弃。应把事件 ID 贯穿发布、处理和业务结果,才能证明这条事件究竟去了哪里。
容易踩的坑
- 把交换机当成持久消息仓库,忽略目标队列是否存在。
- 把同一队列的多个消费者当成每个业务各收一份。
- 依赖 mandatory 却没有注册并处理 return 回调。
- 在路由键中滥用高基数用户信息,导致绑定规模难管理。
- 用同名但不同 vhost 的实体排查,看到的是另一套拓扑。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
| direct | 精确规则直观,便于审计与测试。 | 分类扩展需增加绑定。 | 事件类型固定,职责边界明确。 |
| fanout | 所有绑定队列得到事件,生产者无需选择细分类。 | 接收者可能收到大量无关事件。 | 确实需要相同广播语义的少量独立业务。 |
| topic | 按层级表达订阅,共享前缀便于扩展。 | 通配符容易过宽,绑定规模和匹配成本要观察。 | 事件分类有稳定层级且订阅条件确实需要模式匹配。 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01交换机收到发布后是否必然能留下可消费消息?
展开参考答案与推导
不必然。没有匹配队列、队列策略或存储故障都可能改变结果;应区分路由结果和可靠接收确认。
02order.# 为什么能匹配 order?
展开参考答案与推导
井号允许消耗零个词;已匹配 order 后通过零消耗转移就能结束。
03两个绑定都命中同一队列,应该按绑定数计算业务份数吗?
展开参考答案与推导
不应。路由目标按队列理解;独立业务需要独立队列,重投递造成的重复再由幂等规则处理。
换个情况,还会处理吗
01发布没有抛异常,通知队列却为空,按什么顺序排查?
展开参考答案与推导
核对 vhost、交换机及绑定,再查不可路由与 return;确认消费者是否瞬间取走消息,最后按 eventId 查业务结果。空队列可能是未入队,也可能已消费。
02审计与通知互相抢消息,怎样改拓扑?
展开参考答案与推导
它们若共用队列会竞争;为两个职责建立独立队列并绑定相同事件,随后考虑已有积压迁移,不能只改消费组名称期待历史消息自动复制。
03上线新绑定后为何没有昨天的消息?
展开参考答案与推导
普通交换机不保存全部历史供新绑定补发;需要从业务事件档案或可重放日志按范围补偿,设计 eventId 幂等并限制重放流量。
写代码试一试
01为匹配器加入跨两词的反例。
- 输入 / 条件
pattern=a.*.c,key=a.b.x.c- 输出 / 预期
false
提示 星号只允许一个词。
展开参考答案与推导
按三段模式与四段键比较,最后 c 的位置无法同时满足;添加 a.b.c 为 true 的对照用例。
02求一条键匹配到的去重队列集合。
- 输入 / 条件
Q1:a.#,Q1:a.*,Q2:b.#;key=a.x- 输出 / 预期
[Q1]
提示 匹配规则后使用 Set 保存队列名。
展开参考答案与推导
遍历绑定调用 matches,将命中队列加入集合;成本为各次匹配成本之和加结果集合空间。
03将滚动数组版本改成可打印推导表。
- 输入 / 条件
pattern=#.b,key=a.b- 输出 / 预期
true,并打印 dp 的三行三列
提示 先写出 dp[0][0]。
展开参考答案与推导
使用二维 boolean 表保持相同转移,验证 dp[1] 全为真,第二个模式词在 j=2 时从左上成功;注意空间变为 O(PK)。
读完之后,独立完成
课后作业:自己做一遍
本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能手画一条消息的路由路径,并给定绑定推算哪些队列收到消息。
本篇重点加练
队列生命周期与绑定变更决定消息保留和交付范围,必须超越静态拓扑图。
- Queue
- Binding
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01原理问答
生产端将订单支付与取消事件发往同一 Exchange。解释连接、发布地址、业务事件类型之间的关系,并标出哪些配置应由生产端知道。
- 提交内容
- 组件图与责任表
- 怎样算完成
- 明确 Producer 和 Exchange 职责
- 不把交换机名当作消费队列名
练到的知识点Producer · Exchange
- 作业 02代码推演
为邮件、积分、审计三种处理声明队列和绑定,其中积分只接收支付。手工列出新增与删除一条 Binding 对后续消息路径的影响。
- 提交内容
- 拓扑图与事件路由表
- 怎样算完成
- 区分队列身份与绑定规则
- 覆盖无匹配队列情形
练到的知识点Queue · Binding
- 作业 03代码推演
为库存事件拟定三段 Routing Key,并为区域订阅者和全量审计者分别选择 topic 或 fanout。用自拟反例审查星号、井号和空段的匹配约定。
- 提交内容
- 匹配矩阵与选型说明
- 怎样算完成
- 至少含六种事件键
- 逐条说明需求是否被实际拓扑覆盖
练到的知识点topic 通配符 · fanout
- 作业 04代码推演
两个临时客户端先后声明同名队列但使用不同参数,随后其中一个断开。列出声明结果和队列寿命需要核查的配置,先给预测再查实际行为。
- 提交内容
- 声明协议与预测表
- 怎样算完成
- 注明队列类型和属性
- 区分连接退出与队列删除
练到的知识点Queue · Binding
综合应用
- 作业 05编程实践
写 Java direct 路由小模拟器,输入交换机绑定和 Routing Key,输出实际目标队列集合及判定记录;构造包含大小写、空串和重复绑定的数据。
- 提交内容
- 程序、输入和运行记录
- 怎样算完成
- 明确字符串匹配约定
- 验证目标去重与非法配置处理
练到的知识点Routing Key · direct
- 作业 06排错实验
在本地
RabbitMQ实例建立三类交换机,逐条发布带唯一 ID 的事件并检查各队列内容;演示一次绑定拼写错误,记录定位步骤。- 提交内容
- 声明脚本、事件清单与观测记录
- 怎样算完成
- 实验资源独立命名
- 区分客户端模拟结果和真实 Broker 观察
练到的知识点Producer · Exchange · Queue · Binding · Routing Key · direct · topic 通配符 · fanout
- 作业 07设计评审
新通知队列在促销开始后才绑定到交换机,业务要求能补查促销开始以来的通知。评审拓扑本身能提供与不能提供的信息。
- 提交内容
- 补查需求及数据来源
- 怎样算完成
- 区分消息保留和绑定生效范围
- 说明历史证据的责任方
练到的知识点Queue · Binding
- 作业 08排错实验
在本地
RabbitMQ对独立临时队列记录声明、绑定、取消绑定和重新绑定期间的逐事件去向,队列声明参数固定并写入实验报告。- 提交内容
- 脚本与逐事件报告
- 怎样算完成
- 每次操作有时间及唯一事件身份
- 实验资源可安全清理
练到的知识点Queue · Binding
进阶挑战 · 掌握前置后选做
- 作业 09设计评审
团队要把 Routing Key 从地区两级改成三级且不中断旧消费者。设计兼容期路由方案与撤除旧绑定的验收标准。
- 提交内容
- 迁移计划和重复风险清单
- 怎样算完成
- 覆盖旧生产者仍运行的时间段
- 记录事件身份与投递份数的核验方法
练到的知识点Exchange · Queue · Binding · topic 通配符
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 10思维拓展 · 条件变化
如果一个审计消费者要求可证明“某时刻之后应收到的消息一个不漏”,为绑定切换定义所需同步点和证据;讨论发布者无法暂停时的限制。
- 提交内容
- 完整性声明与反例计划
- 怎样算完成
- 明确声明覆盖的时间区间
- 不以队列非空作为完整性证明
练到的知识点Queue · Binding
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读教程英文
RabbitMQ:Exchanges (新标签页)↗
阅读 direct、topic、fanout 和绑定规则,手画路由结果后用本地队列验证。
- 实践实践指南英文
RabbitMQ Java Tutorial:Routing (新标签页)↗
阅读 Java 发布与绑定片段,区分交换机名、路由键和队列名。
- 深入参考文档英文
RabbitMQ:Consumer Acknowledgements and Publisher Confirms (新标签页)↗
阅读生产确认、消费确认和重投递,识别发送成功与业务成功之间的空窗。
- 深入参考文档英文
RabbitMQ:Java Topic 教程 (新标签页)↗
运行点分词与通配符示例,观察绑定变化。
- 深入参考文档英文
RabbitMQ:发布者指南 (新标签页)↗
阅读不可路由、并发 Channel 使用与发布指标。
- 深入参考文档英文
Java Client 5.25.0 ChannelN (新标签页)↗
定位
basicPublish的参数归并、命令组装和发送边界。