RabbitMQ 交换机路由实战Direct、Topic、Fanout 与 Headers 的选型与陷阱1. 先看一个下单通知场景假设你负责一个电商系统的订单模块。用户下单成功后需要同时做三件事给库存服务发一条扣减指令给积分服务发一条加积分事件给风控服务发一条异步审核消息。最初代码写得很直接生产者把消息依次投递给三个队列哪条失败就重试哪条。上线没多久问题来了。库存服务扩容队列改了名字积分服务想只收“已支付”的订单事件不要“待支付”的风控服务后来又要加一个“退款审核”通道。每改一次需求生产者代码就要动一次三份投递逻辑散落在业务里很难维护。这就是需要使用交换机Exchange的典型时刻。交换机的职责很简单接收生产者发来的消息按照某种规则决定把它送到哪些队列。生产者只管把消息连同“路由信息”交给交换机具体发给谁由绑定关系和交换机类型决定。把“发消息”和“决定发给谁”这两件事拆开业务代码才能稳定下来。本文会沿着这个场景把四种常见交换机的匹配规则讲清楚再说明它们各自的代价和适用边界。读完你应该能回答一个问题我这条消息下一秒会落到哪个队列里。2. 一句话模型与整体链路先把 RabbitMQ 的核心模型压缩成一句话生产者把带路由键的消息发给交换机交换机根据自身类型和绑定规则把消息复制到匹配的队列消费者从队列里取消息。这句话里有五个角色把它们串成一次完整流转生产者 publish(exchange, routingKey, body) | v [Exchange 交换机] -- 规则来自绑定 Binding | | 按类型匹配 v [Queue 队列] -- 真正的存储与堆积点 | | 消费者拉取或推送 v 消费者 consumer 处理这里最容易误解的是消息并不是“生产者直接发给消费者”。生产者甚至不需要知道队列存在它只认识交换机。队列才是消息真正停下来等待被消费的地方交换机本身不存储消息。另一个关键点是绑定Binding。绑定就是一条“交换机到队列”的关联记录它带着一个匹配值。Direct 和 Topic 看路由键匹配Fanout 忽略匹配值Headers 看消息头。交换机类型不同绑定里那个匹配值怎么用就不同。3. Direct 交换机完全匹配最容易被低估3.1 匹配规则Direct 交换机的规则只有一条消息的路由键与绑定键完全相等才投递。注意是字符串完全相等不做前缀、不做通配、不忽略大小写。回到下单场景可以这样设计绑定order.exchange --(bindingKeystock.deduct)-- stock.queue 绑定order.exchange --(bindingKeypoint.add) -- point.queue 发送 routingKeystock.deduct - 只进 stock.queue 发送 routingKeypoint.add - 只进 point.queue 发送 routingKeystock.add - 没有匹配消息被丢弃或走备用交换机3.2 一个最小可运行示例目标验证 Direct 交换机“完全匹配才投递”。前置环境是本地启动的 RabbitMQ默认 5672 端口guest/guest。下面这段 Java 代码可以直接运行它声明交换机、两个队列、两条绑定然后发三条消息并打印各自落到哪个队列。importcom.rabbitmq.client.*;importjava.nio.charset.StandardCharsets;publicclassDirectDemo{privatestaticfinalStringEXCHANGEdemo.direct;publicstaticvoidmain(String[]args)throwsException{ConnectionFactoryfactorynewConnectionFactory();factory.setHost(localhost);factory.setUsername(guest);factory.setPassword(guest);try(Connectionconnfactory.newConnection();Channelchconn.createChannel()){ch.exchangeDeclare(EXCHANGE,BuiltinExchangeType.DIRECT,true);ch.queueDeclare(q.stock,true,false,false,null);ch.queueDeclare(q.point,true,false,false,null);ch.queueBind(q.stock,EXCHANGE,stock.deduct);ch.queueBind(q.point,EXCHANGE,point.add);publish(ch,stock.deduct,deduct-1001);publish(ch,point.add,point-1001);publish(ch,stock.add,should-be-routed-nowhere);}}privatestaticvoidpublish(Channelch,Stringkey,Stringbody)throwsException{ch.basicPublish(EXCHANGE,key,null,body.getBytes(StandardCharsets.UTF_8));System.out.println(sent routingKeykey);}}关键步骤是三次basicPublish使用的路由键不同。预期结果是stock.deduct进q.stockpoint.add进q.pointstock.add谁都不进。你可以在管理界面的 Queues 页面看到前两个队列各有 1 条 Ready 消息第三个队列仍为 0。这里容易改错的地方有两个一是忘了queueBind消息发出来却没有任何队列收到二是把绑定键写成stock.*却仍用 Direct以为它有通配能力实际它只做字符串相等。3.3 什么时候用、边界在哪当路由维度是明确的、枚举式的分类时Direct 是最省心的选择例如按业务动作分stock.deduct、point.add、按租户分tenant.a、tenant.b。它的匹配成本低行为可预测排障时一眼能看出为什么没匹配。它的边界是一旦你希望“一条消息同时进多个队列”Direct 也能做到但需要给多个队列绑同一个绑定键。这本身没问题但如果你还要按前缀批量订阅Direct 就会力不从心该换 Topic 了。4. Topic 交换机通配符的边界必须背下来4.1 匹配规则Topic 交换机的路由键和绑定键都用点号分隔成若干段支持两个通配符*匹配恰好一段#匹配零段或多段。这条规则里最容易被忽略的是“段”的概念。order.paid有两段order.paid.cn有三段order只有一段。*永远只吃一段#可以吃任意段数包括零段。绑定键 order.*.cn order.paid.cn 匹配* paid order.paid.us.cn 不匹配多了一段 绑定键 order.# order 匹配# 零段 order.paid 匹配 order.paid.cn 匹配4.2 常见误区与验证示例先看一个常被写错的绑定#.order.#。很多资料说它能匹配“包含 order 的任意位置”实际上#匹配零段或多段所以order.paid、a.order.b、order都能匹配但前提是段结构能被#吸收。真正需要警惕的是order.*与order.#的区别前者只匹配两段后者匹配一段及以上。下面这段示例用同一个 Topic 交换机声明三种绑定再发四条消息帮助你直观看到边界。importcom.rabbitmq.client.*;importjava.nio.charset.StandardCharsets;importjava.util.HashMap;importjava.util.Map;publicclassTopicBoundaryDemo{publicstaticvoidmain(String[]args)throwsException{ConnectionFactoryfactorynewConnectionFactory();factory.setHost(localhost);factory.setUsername(guest);factory.setPassword(guest);try(Connectionconnfactory.newConnection();Channelchconn.createChannel()){ch.exchangeDeclare(demo.topic,BuiltinExchangeType.TOPIC,true);ch.queueDeclare(q.exact2,true,false,false,null);ch.queueDeclare(q.all,true,false,false,null);ch.queueDeclare(q.cn,true,false,false,null);ch.queueBind(q.exact2,demo.topic,order.*);ch.queueBind(q.all,demo.topic,order.#);ch.queueBind(q.cn,demo.topic,order.*.cn);send(ch,order.paid);send(ch,order.paid.cn);send(ch,order.paid.us.cn);send(ch,order);}}privatestaticvoidsend(Channelch,Stringkey)throwsException{ch.basicPublish(demo.topic,key,null,key.getBytes(StandardCharsets.UTF_8));System.out.println(sentkey);}}预期结果整理成一张表更清楚路由键q.exact2 (order.*)q.all (order.#)q.cn (order.*.cn)order.paid是是否order.paid.cn否是是order.paid.us.cn否是否order否是否这张表说明order.#是最宽的绑定适合“我要这个前缀下所有子类型”的场景order.*只关心两段结构多一段就漏。工程上最常见的错误是两个都想覆盖结果要么收到多余消息要么漏掉关键事件。4.3 什么场景该用 Topic当路由信息天然是层级结构时Topic 非常合适例如区域.业务.事件cn.order.paid、环境.服务.级别prod.payment.error。消费者可以用通配符表达自己的订阅意图生产者不需要为每个消费者单独设计路由键。它的代价是匹配比 Direct 复杂通配符越多匹配时的字符串切分和比较就越多。更重要的是路由键设计一旦确定就变成契约中途改段结构会让所有绑定失效。建议在项目早期就把段定义写进文档例如固定三段、第一段区域、第二段业务、第三段事件。5. Fanout 交换机广播语义与它的代价5.1 匹配规则Fanout 交换机的规则最粗暴忽略路由键把消息复制到所有与它绑定的队列。不管绑定键写什么只要绑上了就都收到。回到下单场景如果你希望「下单成功」这件事通知所有下游Fanout 最直接库存、积分、风控三个队列都绑到同一个 Fanout 交换机发一次消息三个队列各得一份。fanout.exchange --(任意 bindingKey)-- stock.queue fanout.exchange --(任意 bindingKey)-- point.queue fanout.exchange --(任意 bindingKey)-- risk.queue 发送一条消息 - 三个队列各一份副本5.2 广播的代价消息复制与堆积Fanout 的语义简单但代价常被低估。你往交换机发一条消息如果它绑定了一百个队列RabbitMQ 就要把消息复制成一百份分别写入队列。这带来三个后果交换机到队列的复制成本随绑定数线性上升每个队列都会独立堆积某个消费慢的队列会一直涨磁盘占用变成原来的一百倍量级。所以 Fanout 适合“订阅者数量有限且每个都需要完整副本”的场景例如配置刷新通知、缓存失效广播、内部状态同步。它不适合作为“海量终端订阅”的通道那种场景更适合 Topic 配合按需绑定或者干脆用一类专门的流式组件。5.3 一个容易踩的坑临时队列与自动删除很多示例用queueDeclare()不传参数生成临时队列然后绑到 Fanout 上做广播订阅。注意临时队列在最后一个消费者断开后会被删除绑定的消失是自动的。生产环境如果直接用这种模式可能因为消费者短暂掉线导致队列被删、消息丢失。如果你要的是持久订阅就声明持久队列并显式管理绑定生命周期。6. Headers 交换机不看路由键但性能要付代价6.1 匹配规则Headers 交换机不看路由键它看消息头headers里的一组键值对。绑定里写一个x-match参数取值all表示所有条件都要满足取值any表示满足任意一个即可。绑定到 headers.exchange参数 x-match all format pdf type report 消息头 formatpdf, typereport - 匹配 消息头 formatpdf - 不匹配缺 type 消息头 formatpdf, typelog - 不匹配type 值不对6.2 为什么它慢什么时候才用Headers 交换机的匹配不是简单的字符串前缀比较而是要解析消息头、逐项比较键和值还要处理x-match的与或逻辑。相比 Direct 的哈希式精确匹配它的每次投递判断成本明显更高。绑定条件越多比较次数越多。那什么时候值得用当你的路由条件不是单一字符串而是多个维度的组合时例如“格式是 PDF 且来源是财务系统”用 Headers 表达比硬拼路由键更自然。但即便如此也要控制绑定数量和条件数量并且接受它比 Direct 慢这一个事实。很多团队最后会把它降级成“消息头里存少量分类字段”路由仍交给 Direct 或 Topic 完成。下面用一个最小示例演示x-matchall的效果同时注意消息头里除了业务字段还会被加上一些默认头。importcom.rabbitmq.client.*;importjava.nio.charset.StandardCharsets;importjava.util.HashMap;importjava.util.Map;publicclassHeadersDemo{publicstaticvoidmain(String[]args)throwsException{ConnectionFactoryfactorynewConnectionFactory();factory.setHost(localhost);factory.setUsername(guest);factory.setPassword(guest);try(Connectionconnfactory.newConnection();Channelchconn.createChannel()){ch.exchangeDeclare(demo.headers,BuiltinExchangeType.HEADERS,true);ch.queueDeclare(q.pdf.report,true,false,false,null);MapString,ObjectbindArgsnewHashMap();bindArgs.put(x-match,all);bindArgs.put(format,pdf);bindArgs.put(type,report);ch.queueBind(q.pdf.report,demo.headers,,bindArgs);publishWithHeaders(ch,pdf,report);publishWithHeaders(ch,pdf,log);}}privatestaticvoidpublishWithHeaders(Channelch,Stringformat,Stringtype)throwsException{MapString,ObjectheadersnewHashMap();headers.put(format,format);headers.put(type,type);AMQP.BasicPropertiespropsnewAMQP.BasicProperties.Builder().headers(headers).build();ch.basicPublish(demo.headers,,props,body.getBytes(StandardCharsets.UTF_8));System.out.println(sent formatformat, typetype);}}预期结果是只有formatpdf, typereport那条进入q.pdf.report另一条不匹配。常见错误是头里放了数字类型却用字符串去比RabbitMQ 比较的是值的实际类型类型不一致就不算相等。7. 备用交换机路由失败时的兜底前面所有类型的交换机都有一个共同风险消息发出来了但没有任何队列匹配。默认情况下这条消息会被静默丢弃生产者如果没开启发布确认根本不知道丢了。备用交换机Alternate ExchangeAE解决的就是这个问题。你给一个交换机声明alternate-exchange参数指向另一个交换机当主交换机发现消息无法路由到任何队列时会把它转给备用交换机再由备用交换机按自己的规则投递。生产者 - order.exchange主 | 无匹配 v order.ae.exchange备用常设为 Fanout | v q.unroutable -- 专门接收无法路由的消息配置方式是在声明主交换机时带上参数下面这段可以直接加到前面的示例上// 先声明备用交换机和一个兜底队列ch.exchangeDeclare(order.ae.exchange,BuiltinExchangeType.FANOUT,true);ch.queueDeclare(q.unroutable,true,false,false,null);ch.queueBind(q.unroutable,order.ae.exchange,);// 主交换机声明时指定备用交换机MapString,ObjectargsnewHashMap();args.put(alternate-exchange,order.ae.exchange);ch.exchangeDeclare(order.exchange,BuiltinExchangeType.DIRECT,true,false,args);有了它前面 Direct 示例里那条stock.add就不会凭空消失而是进入q.unroutable你可以写一个消费者专门监控这个队列发现异常路由键就告警。这比事后猜“消息去哪了”高效得多。8. 四种交换机放在一张表里对比讲完四种类型我们把判断依据收敛到一张表。选型时先问自己三个问题路由条件是不是单一字符串需不需要通配订阅要不要广播给多个下游维度DirectTopicFanoutHeaders匹配依据路由键完全相等路由键分段通配忽略路由键全部绑定队列消息头键值对通配能力无*一段、#零或多段无无靠条件组合匹配成本低中随通配符增加低但要复制到所有队列高解析并逐项比较典型场景按动作、租户精确分发层级路由、按前缀订阅广播通知、缓存失效多维度组合筛选主要风险绑定键写错导致丢消息段结构设计不当导致漏收订阅者过多导致复制和堆积绑定过多拉高路由开销是否受备用交换机保护是是一般不涉及无法路由是这张表不是让你二选一而是帮助你判断“我现在的路由条件是哪个形状”。条件形状决定类型类型决定绑定怎么写绑定写完再用备用交换机兜底。9. 完整流转一条消息从生产到消费现在让一条真实消息完整走一遍把前面的知识串起来。场景是「订单已支付」事件要求库存服务收到积分服务收到一个只关心中国区订单的报表服务收到同时无法路由的消息要能被发现。设计如下主交换机用 Topic路由键固定三段region.order.event。库存和积分绑#报表绑cn.order.#备用交换机用 Fanout 接无法路由的消息。// 主交换机ch.exchangeDeclare(order.topic,BuiltinExchangeType.TOPIC,true);// 备用交换机ch.exchangeDeclare(order.unrouted,BuiltinExchangeType.FANOUT,true);ch.queueDeclare(q.unrouted,true,false,false,null);ch.queueBind(q.unrouted,order.unrouted,);// 主营业务队列ch.queueDeclare(q.stock,true,false,false,null);ch.queueDeclare(q.point,true,false,false,null);ch.queueDeclare(q.report.cn,true,false,false,null);ch.queueBind(q.stock,order.topic,#);ch.queueBind(q.point,order.topic,#);ch.queueBind(q.report.cn,order.topic,cn.order.#);注意这里主交换机声明时没有加备用交换机参数实际项目里建议加上否则消息只会在 Topic 层被丢弃。加上之后发一条路由键为us.order.paid的消息库存和积分会收到报表不会收到如果发一条格式完全不符合三段约定的键它就会掉进q.unrouted被监控发现。一次完整的时序可以写成下面这样生产者 | publish(exchangeorder.topic, routingKeycn.order.paid) v [order.topic] --匹配 cn.order.#-- [q.report.cn] | |--匹配 #----------- [q.stock] |--匹配 #----------- [q.point] | |--无匹配时-------- [order.unrouted] -- [q.unrouted] [q.report.cn] -- 报表消费者 basicConsume [q.stock] -- 库存消费者 basicConsume [q.point] -- 积分消费者 basicConsume这个设计的好处是生产者只认识一个路由键格式消费者各自用绑定表达订阅范围无法路由的消息有地方可查。改动订阅关系时只动绑定不动生产者代码。10. 常见误区第一个误区是把 Direct 当成“精确匹配的 Topic”。Direct 没有段的概念order.paid和order.paid.cn是两个完全不同的字符串不存在包含关系。第二个误区是以为#和*可以随便组合出任意匹配。*只吃一段#吃零或多段写order.*时如果实际路由键是三段就会漏。建议在测试环境打印每个队列的绑定和实际收到的消息用数据确认边界。第三个误区是认为 Fanout 最快所以最常用。Fanout 的单次路由判断确实简单但它的复制成本随绑定数增长绑定一百个队列时发送一条消息就要写一百次。判断快不等于整体便宜。第四个误区是忽视 Headers 的性能。它在需要多条件组合时很自然但条件是运行时逐项比较的绑定数量上来后路由开销明显。能用 Direct 或 Topic 表达的条件不要为了“语义更清晰”而换成 Headers。第五个误区是没配备用交换机也没开发布确认消息丢了完全无感。路由失败是静默的这在生产环境是很危险的默认行为。11. 生产实践建议路由键尽早定契约。把段数、每段含义、大小写规则写进文档例如统一小写、固定三段。之后所有绑定都以这个契约为准避免出现Order.Paid和order.paid同时存在。交换机类型宁可选简单。能用 Direct 就不用 Topic能用 Topic 就不用 Headers。简单类型的行为更好预测排障时看路由键就能定位。给关键业务交换机加备用交换机。备用交换机通常用 Fanout绑一个专用队列再由监控消费者处理发现异常路由键立刻告警。控制 Fanout 的绑定数量。如果发现某个 Fanout 交换机绑定了几十个队列先问一句是否真的每个队列都需要完整副本能否拆成 Topic 按需订阅。绑定变更要可追踪。交换机类型和绑定关系属于基础设施配置建议用代码或配置管理不要只在管理界面手点避免不同环境不一致。12. 排障清单当你发现“消息发出去了但没进预期队列”时按下面顺序检查1. 确认消息确实到达了交换机发布确认或管理界面消息速率 2. 列出该交换机的所有绑定看清楚绑定键 3. 对比消息的实际 routingKey / headers 与绑定键 4. Topic 场景检查段数* 与 # 是否符合预期 5. Headers 场景检查 x-match 和值的类型是否一致 6. 检查队列是否存在、是否被自动删除、是否有消费者在线 7. 检查是否配置了备用交换机没有的话消息可能已被丢弃 8. 检查是否有队列因为堆积触发了流控或消费能力不足第 4 步和第 5 步是最容易出错的建议在测试环境固定一组边界用例每次改绑定都跑一遍。13. 面试或复盘问题Direct 和 Topic 的核心区别是什么如果路由键是a.b.c绑定键写a.*能匹配吗Topic 里#能否匹配零段请举一个能匹配和不能匹配的例子。Fanout 交换机收到一条消息如果有 50 个队列绑定实际会发生多少次写入Headers 交换机的x-matchany和all分别是什么语义值类型不同会怎样备用交换机在什么阶段生效没有它时无法路由的消息会怎样在什么业务条件下你会从 Topic 换成 Direct或从 Fanout 换成 Topic这些问题覆盖了匹配规则、代价和兜底机制能答清楚说明你对路由链路有整体认识。14. 总结回到最初的下单场景。交换机解决的是“发消息”和“决定发给谁”解耦的问题四种类型的差别本质上是匹配依据不同Direct 看路由键是否完全相等Topic 看路由键的分段通配Fanout 忽略匹配值做全量复制Headers 看消息头的条件组合。选型时先判断路由条件的形状再看代价条件简单精确用 Direct条件是层级且需要按前缀订阅用 Topic需要给多个下游完整副本用 Fanout条件是多维组合且能接受较高路由成本时用 Headers。无论选哪种都建议配上备用交换机让无法路由的消息有地方可查而不是静默消失。把这套判断固化成团队的绑定规范路由问题就会从“上线后救火”变成“设计时决定”。15. 参考资料RabbitMQ 官方文档AMQP 0-9-1 Model ExplainedExchange、Queue、Binding 模型RabbitMQ 官方文档ExchangesDirect、Topic、Fanout、Headers 类型说明RabbitMQ 官方文档Alternate Exchanges备用交换机RabbitMQ 官方 Java Client API 文档com.rabbitmq.client 包AMQP 0-9-1 规范Binding、Routing Key 相关定义