ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

RabbitMQ Exchange 四种类型详解:Direct、Fanout、Topic、Headers 路由规则与实战选型

RabbitMQ Exchange 四种类型详解:Direct、Fanout、Topic、Headers 路由规则与实战选型 聊到 RabbitMQ很多人第一道坎就是 Exchange。我刚用 RabbitMQ 的时候以为消息发到队列里是理所当然的事结果消息丢得莫名其妙管理后台里队列始终是空的排查半天才发现自己根本没搞懂 Exchange、Queue、Binding 三者的关系。RabbitMQ 里的 Exchange 就是消息进门后的第一站它决定消息该往哪个队列走而它最常见的四种类型——Direct、Fanout、Topic、Headers——就是四种完全不同的路由规则。搞懂这四种 Exchange你才算真正入了 RabbitMQ 的门。这篇文章我不打算照着官方文档念而是把我实际项目中用过的场景、踩过的坑、选型的思考过程一次讲清楚希望对正在学 RabbitMQ 或者被消息丢失折磨的朋友有帮助。1. 路由中枢先把 Exchange 和 Queue 的真实关系理清楚1.1 消息不是直接进队列而是先进 Exchange 再被转发很多新手第一次写 RabbitMQ 代码时都会有个错误预期我把消息发到队列名上消息总该到对应队列里了吧实际上生产者发布消息时指定的是 Exchange 名称和 Routing Key路由键而不是队列名称。消息到达 Exchange 后Exchange 根据自身类型、绑定关系和路由键把消息复制投递到符合条件的队列中然后由消费者从队列读取。这个机制可以类比成快递分拣中心你寄出的包裹消息先到分拣中心Exchange分拣中心根据面单上的城市、区域Routing Key和已经登记好的线路规则Binding决定包裹送上哪辆卡车Queue。如果没有匹配的线路这个包裹就只能留在分拣中心等处理而在 RabbitMQ 里默认情况下它会直接被丢弃。我在给人讲这个知识点时经常会打一个比方Exchange 是路由规则处理器Queue 才是真正装消息的仓库。所以排查消息丢失问题时第一件事不是盯着消费者代码看而是先确认消息到底有没有被 Exchange 成功转发到队列。1.2 绑定、路由键、持久化属性这三个词必须刻在脑子里要理解四种 Exchange必须先统一语言因为后面所有示例都离不开这三个概念。Queue队列真正保存消息的地方消息最终都在这里等待消费者拉取。Binding绑定把 Exchange 和 Queue 连接起来的线路规则。一条绑定关系通常包含一个队列名、一个 Exchange 名、一个用于匹配的 Routing Key部分类型不需要。Routing Key路由键生产者发布消息时附带的一个字符串标签Exchange 拿它和 Binding 上配置的键做匹配。除了这三者Exchange 本身还有几个属性需要关注否则容易埋坑。Name交换机名称在同一个 Virtual Host虚拟主机内必须唯一。Type就是你选的 direct、fanout、topic、headers 之一声明之后不能修改。Durability是否持久化。durable 的 Exchange 在 RabbitMQ 重启后还存在transient 的 Exchange 重启后消失。注意它只是交换机本身存活的属性并不代表消息不丢消息是否持久化还要看队列的 durable 设置和消息的 delivery_mode。Auto-delete当最后一个绑定它的队列解绑后这个 Exchange 是否自动删除。适合临时性路由场景。Internal设置为 true 的 Exchange 不允许生产者直接发消息到它只能通过其他 Exchange 转发进来属于比较进阶的玩法。在代码里声明一个持久化 Exchange 的写法很简单比如用 Python 的 pikaimport pika connection pika.BlockingConnection(pika.ConnectionParameters(localhost)) channel connection.channel() channel.exchange_declare( exchangeorder_events, exchange_typetopic, durableTrue, auto_deleteFalse ) connection.close()这里值得多说一句如果你之前已经用相同的名字、不同的类型声明过一个 Exchange再声明时会直接报 406 PRECONDITION_FAILED因为 RabbitMQ 不允许同名 Exchange 改类型。我见过有人图省事把原来的 Exchange 删掉重建结果所有绑定关系一并消失线上消息瞬间全部走丢。所以生产环境里Exchange 的 name 和 type 一定要当作不可变契约来对待。1.3 默认 Exchange 是一个伪装成特例的 DirectRabbitMQ 里有一个名字为空字符串的默认 Exchange它其实是一个内置的 Direct Exchange。当你不显式指定 Exchange 时消息会发到默认 Exchange 上此时 Routing Key 必须等于队列名称消息就会被精确投递到同名队列。这个机制给新手提供了极大的便利你可以用最简单的方式先把消息跑通channel.basic_publish( exchange, routing_keymy_queue, bodyhello )但它的隐患也很明显消息只能按队列名即路由键的规则走无法做到同一个消息按多种条件分发到不同队列。一旦业务开始复杂动不动就用默认 Exchange 写消息后面重构时改动面会非常大。我的建议是从第一步就用显式声明 Exchange 的方式组织代码哪怕只是单队列场景养成分而治之的习惯。2. Direct Exchange精确路由的默认选择2.1 Direct 的工作原理Routing Key 必须完全一致Direct Exchange 是四种类型里最接近查表机制的一种。它的规则简单到一句话生产者发消息时设置的 Routing Key必须和 Binding 上配置的 Routing Key 完全一致消息才会被投递到对应队列。举个例子。我做过一个订单状态同步系统里面有订单创建、订单支付、订单取消三种事件分别对应三个消费者模块订单邮件服务只关心订单创建财务系统只关心订单支付售后系统只关心订单取消用 Direct Exchange 来设计就是创建三个队列分别绑定到同一个order_events交换机上绑定键分别是order.created、order.paid、order.cancelled。生产者发布订单创建事件时Routing Key 设为order.created这条消息就只会进入订单邮件服务对应的队列其他队列收不到。这里我想强调一个新手容易误解的点Direct 并不是说一个 Exchange 只能绑定一个队列也不是说一个队列只能绑定一个 Routing Key。一个队列完全可以绑定多个 Routing Key比如售后系统既收order.cancelled又收order.refunded那它就在同一个队列上做两次绑定或者一次绑定多个键而多个队列也可以用同一个 Routing Key 绑定到同一个 Exchange实现多个消费者各自拿到完整副本这在 RabbitMQ 里是完全合法的。2.2 一个完整的 Direct Exchange 示例我用 Python 写一段能直接跑通的示例方便你体会绑定关系import pika connection pika.BlockingConnection(pika.ConnectionParameters(localhost)) channel connection.channel() channel.exchange_declare(exchangeorder_events, exchange_typedirect, durableTrue) channel.queue_declare(queueorder_email, durableTrue) channel.queue_declare(queueorder_finance, durableTrue) channel.queue_bind(queueorder_email, exchangeorder_events, routing_keyorder.created) channel.queue_bind(queueorder_finance, exchangeorder_events, routing_keyorder.paid) # 发布一条订单创建消息 channel.basic_publish( exchangeorder_events, routing_keyorder.created, bodyorder #123 created, propertiespika.BasicProperties(delivery_mode2) ) print(消息已发送) connection.close()在这段代码里消息的 Routing Key 是order.created所以只有order_email队列会收到消息order_finance队列不会。你可以把 Routing Key 改成order.paid再发一次结果正好反过来。这个完全精确匹配的特性决定了 Direct 最适合那些路由规则明确、不需要模糊匹配的业务场景例如命令消息按类型分发到不同处理单元。多个工作实例通过同一个 Routing Key 绑定同一个队列同一 Exchange实现竞争消费严格来说这里的竞争是队列和消费者层面的不是 Exchange 层面的。支付回调、短信通知等一对一或者一对少量的定向投递。2.3 我在 Direct 上踩过的一个坑绑定键的大小写和多余字符Routing Key 是区分大小写的这是我印象最深的一个低级错误。当时有个同事把一个事件的 key 定义为user.Created另一个消费服务绑定成了user.created结果消息一直神秘消失管理后台里交换机的绑定关系看着也没问题但就是没有消息进队列。后来一行行比对才发现大小写不一致。还有一个容易忽略的坑路由键末尾不能有空格点号.也参与匹配order.created和order_created是完全不同的字符串。如果你在键的命名规范上不统一这种问题会反复出现。我们后来在项目里立了一条规矩Routing Key 一律用小写英文字母、数字和点号禁止使用下划线禁止大小写混用发布端和消费端都从统一的事件定义文件里引用常量不再各自手写字符串。3. Fanout Exchange广播出去的每条消息都要被所有队列收到3.1 Fanout 的核心逻辑完全忽略 Routing KeyFanout Exchange 是四种类型里最无脑的一种它根本不看 Routing Key只要队列绑定了这个 Exchange消息就会被复制投递给每一个绑定队列每个队列都会收到一份完整消息。如果你需要一条消息同时触发多个模块这种广播语义Fanout 就是最直接的选择。还是用我经历过的例子。我们在微服务架构里做一个用户资料变更通知用户修改头像后可能需要同时触发消息通知服务给用户发站内信。搜索服务更新用户索引。推荐服务刷新用户画像缓存。这三个模块对事件的处理逻辑不同但都需要拿到同一份完整的用户资料变更事件。如果用 Direct就要为每个模块单独发送三次消息或者定义多套路由键用 Fanout 就简单了三套队列分别绑定到同一个user_profile_changed交换机上生产者只发布一条消息三个队列各自收到副本互不干扰。更妙的是Fanout 的消费方之间没有任何竞争关系。每个队列里的副本是独立的消费者处理速度完全不同也不会相互影响。这跟多个消费者共享同一个队列是完全不同的语义共享队列是消息在多个消费者之间瓜分一条消息只被一个消费者消费而 Fanout 绑定多个队列是每个队列都得到一条完整副本每个队列对应的消费者群体再各自内部竞争。3.2 Fanout 的声明与绑定其实比想象中还要简单因为不需要 Routing KeyFanout 的绑定代码比 Direct 更简洁import pika connection pika.BlockingConnection(pika.ConnectionParameters(localhost)) channel connection.channel() channel.exchange_declare(exchangebroadcast_events, exchange_typefanout, durableTrue) channel.queue_declare(queuenotify_queue, durableTrue) channel.queue_declare(queuesearch_queue, durableTrue) channel.queue_declare(queuecache_queue, durableTrue) # fanout 绑定不需要 routing key传空字符串即可 channel.queue_bind(queuenotify_queue, exchangebroadcast_events) channel.queue_bind(queuesearch_queue, exchangebroadcast_events) channel.queue_bind(queuecache_queue, exchangebroadcast_events) channel.basic_publish( exchangebroadcast_events, routing_key, # 即使传了任何值fanout 也会忽略 bodyuser_profile_changed, propertiespika.BasicProperties(delivery_mode2) ) print(广播消息已发送) connection.close()注意queue_bind里没传 routing_key或者传了一个任意字符串效果都一样。我之前见过有人在 Fanout 绑定里研究路由键该怎么填才能只发给一部分队列这是对 Fanout 的误解。Fanout 的定位就是无差别广播如果只想发给一部分队列应该换 Topic 或 Direct而不是在 Fanout 上找技巧。3.3 Fanout 搭配临时队列临时订阅的推荐姿势在实际项目中Fanout 还有一个非常经典的用法配合 Auto-delete 的临时队列实现运行期动态订阅。比如监控平台需要临时监听一段时间的全量事件我们可以让订阅方声明一个随机的、排他的临时队列绑定到 Fanout 交换机上用完即删。pika 里声明临时队列可以这样result channel.queue_declare(queue, exclusiveTrue) queue_name result.method.queue channel.queue_bind(queuequeue_name, exchangebroadcast_events)queue时 RabbitMQ 会生成一个随机名称的队列exclusiveTrue表示这个队列只对当前连接可见连接关闭后自动删除。这样就不会在 RabbitMQ 里留下永远没人清理的僵尸队列。不过我也要提醒一句临时队列名是随机生成的出了当前进程就没法预知所以这种做法只适合同一进程内的消费逻辑不适合需要多个服务共享队列名称的持久场景。日志采集、指标上报这类来了就收、消息不落地的场景用得上但业务订单这类要求可靠投递的消息千万不要用临时队列。4. Topic Exchange通配符帮你把路由键玩出花4.1 Topic 的匹配规则点分字符串加 * 和Topic Exchange 是四种类型里最灵活、也最容易出错的。它匹配的对象还是 Routing Key但 Binding Key 可以带两个通配符*代表正好匹配一个单词。#代表匹配零个或多个单词。这里的单词指的是以点号.分隔的每一段。比如 Binding Key 是order.*它可以匹配order.created、order.paid、order.cancelled但匹配不了order因为order只有零个点而*必须占据一个单词也匹配不了order.paid.success因为这里多了一个单词。如果 Binding Key 是order.#则order、order.paid、order.paid.success全都能匹配。这个特性让 Topic 非常适合按层级结构组织业务事件。比如一个日志系统我们可以把 Routing Key 设计成log.级别.模块像log.info.auth、log.error.payment、log.warn.inventory。消费者各取所需审计队列绑定log.info.#只收所有 info 级别日志。支付错误队列绑定log.error.payment只收支付模块错误日志。全量报警队列绑定log.#收所有日志反正报警系统不嫌多。我实际用 Topic 做过频次统计同一个消息流一个队列绑定metric.#做全量漏斗一个队列绑定metric.user.*只统计用户维度的指标还有一个队列绑定metric.order.paid只看订单支付成功完全不需要生产者发多份消息。4.2 Topic 的代码示例与匹配细节声明和绑定 Topic 的代码和 Direct 非常像区别只在 exchange_typeimport pika connection pika.BlockingConnection(pika.ConnectionParameters(localhost)) channel connection.channel() channel.exchange_declare(exchangelog_topic, exchange_typetopic, durableTrue) channel.queue_declare(queuelog_all, durableTrue) channel.queue_declare(queuelog_error_payment, durableTrue) channel.queue_declare(queuelog_info, durableTrue) channel.queue_bind(queuelog_all, exchangelog_topic, routing_key#) channel.queue_bind(queuelog_error_payment, exchangelog_topic, routing_keylog.error.payment) channel.queue_bind(queuelog_info, exchangelog_topic, routing_keylog.info.#) channel.basic_publish( exchangelog_topic, routing_keylog.error.payment, bodypayment timeout, propertiespika.BasicProperties(delivery_mode2) ) print(日志消息已发布)这里有三个细节值得展开第一#可以出现在路由键的任意位置不一定只能放开头或结尾。比如 Binding Keyuser.#.login能匹配user.page.login和user.mobile.login但匹配不了user.login因为#表示零个或多个单词但user.#.login中#前后都有点号分隔此时需要至少存在一个中间词才能匹配这在实际使用中非常容易搞混。最稳妥的办法是在项目里统一约定不带歧义就用*控制词数要跨层级就使用#不要为了炫技写出复杂的组合。第二一个消息可以同时命中多个 Binding。比如上面的#绑定和log.error.payment绑定同时匹配RabbitMQ 会把消息复制成两份分别投递到两个队列。所以不要以为多个绑定之间是排他的它们没有优先级之分。第三AMQP 0-9-1 协议里 Routing Key 最大长度是 255 字节业务在设计事件名层级时就要克制别搞出十几段的长键不然容易踩边界坑。4.3 Topic 为什么是我最常用的类型如果让我给四种 Exchange 排个使用频率Topic 会排第一。原因很简单Direct 能做的精确匹配Topic 通过指定一对一 Binding Key 也能做Fanout 能做的广播Topic 通过绑定#也能做。反过来Topic 的模糊匹配和层级匹配Direct 和 Fanout 都做不到。当然能者多劳不代表你要到处滥用。Topic 对事件命名规范的要求最高路由键不是随便起一个字符串而是要当作一套领域事件协议来治理。我建议在项目里维护一个事件字典文件把每个事件对应的 Routing Key 用常量定义出来例如ORDER_PAID order.paid发布方和订阅方都必须引用它禁止在代码里硬编码裸字符串。这样即使以后换 Exchange 类型改动也能限制在很小的范围内。5. Headers Exchange基于消息头匹配的隐藏玩法5.1 Headers 的匹配机制不看路由键看消息属性四种 Exchange 里Headers 是最容易被忽略但也最特别的一种。它不匹配 Routing Key而是匹配消息发布时附带的 Headers一组键值对。Binding 时可以指定一组键值对作为条件消息发布时带的 Headers 如果满足这些条件消息就会被投递到对应队列。Binding 上需要额外指定一个参数x-matchx-matchall消息的 Headers 必须包含 Binding 上声明的所有键值对才算匹配。x-matchany消息的 Headers 只要匹配 Binding 上声明的任意一个键值对就算匹配。举个具体场景。假设有一个推送系统要根据用户客户端类型和会员等级决定推送到哪个渠道高优先级渠道队列只收clientios且viptrue的消息Binding 设置x-matchall然后声明两个键值clientios、viptrue。泛用户队列只要消息带clientandroid就收Binding 设置x-matchany声明clientandroid。发布消息时在 Headers 里带上clientios和viptrue第一条消息就会进入高优先级渠道队列第二条泛用户队列因为 Binding 条件里有clientandroid不满足所以不收除非你换x-matchany且条件包含clientios。pika 里发布带 Headers 的消息时直接在 BasicProperties 里传 headers 即可channel.basic_publish( exchangenotify_headers, routing_key, bodypush content, propertiespika.BasicProperties( delivery_mode2, headers{client: ios, vip: True} ) )声明 Headers 类型 Exchange 的绑定也略有不同需要传 argumentschannel.exchange_declare(exchangenotify_headers, exchange_typeheaders, durableTrue) channel.queue_declare(queuevip_ios_queue, durableTrue) channel.queue_bind( queuevip_ios_queue, exchangenotify_headers, arguments{ x-match: all, client: ios, vip: True } )5.2 为什么实际项目里 Headers 用得并不多我在真实项目里见 Headers Exchange 的概率很低大部分团队宁可把匹配条件塞进 Routing Key 里用 Topic 也不愿用 Headers。这背后有几个很现实的原因第一可观测性差。在 RabbitMQ 管理界面里你一眼能看到 Routing Key 的绑定关系但 Headers 的匹配条件藏在 arguments 里排查问题时不够直观。你很难从队列列表快速判断这条消息进了那个队列为什么没进这个队列。第二客户端支持参差不齐。虽然很多语言库都支持 headers 参数但不同版本的序列化方式偶尔会不一致。比如布尔值、整数在不同语言间的类型表达会有差异vipTrue和viptrue在某些库的匹配逻辑里可能并不等价这会带来非常隐蔽的匹配失败。第三性能开销相对大。RabbitMQ 在匹配 Headers 时需要对键值对做更多比较操作在超高吞吐的场景下理论上不如 Direct 和 Topic 那套字符串匹配来得轻量。当然大多数业务规模根本到不了这个瓶颈但既然有更简单的方案自然没必要增加复杂度。我的个人判断是只有当你遇到多个维度组合过滤的需求且维度数量超过三层、每个维度都可能动态添加时才值得考虑 Headers Exchange。比如按用户地域、设备平台、渠道来源、灰度版本等多个条件组合投递单纯用 Routing Key 拼接会变得非常冗长和维护困难这时候 Headers 反而更清晰。除此之外90% 的场景用 Topic 就够了。6. 四种 Exchange 横向对比场景、参数和那些年踩过的坑6.1 一图看清四种 Exchange 的差异不看参数只看本质四种 Exchange 可以这样概括Exchange 类型匹配依据是否看 Routing Key支持通配符典型场景DirectRouting Key 精确匹配是否定向投递、命令分发、支付/订单事件Fanout所有绑定队列都收否否广播通知、配置刷新、多副本处理TopicRouting Key 模糊匹配是*匹配一个词#匹配零到多个词按层级/维度路由、日志收集、事件总线Headers消息 Headers 键值对匹配否无但支持多条件 all/any多维度组合过滤、按用户属性投递选型时的决策路径我个人会按下面这个思路走只有一条消息要精确进一个或少数几个队列选 Direct。一条消息必须同时给所有绑定队列各来一份选 Fanout。消息要根据路由键做层级、通配或多种组合匹配选 Topic。匹配条件不藏在路由键里而是依赖多个消息头属性组合判断选 Headers。实在拿不准的时候先从 Topic 起步因为它在覆盖 Direct 和部分 Headers 场景的同时还能保留路由键的可读性。如果业务确实要广播再切 Fanout 也不迟。6.2 Exchange 层最常踩的坑几乎都是这几个很多消息丢了的问题到最后都发现不是 Exchange 类型选错而是对整个投递链路的基本假设错了。我把这些年里反复遇到的坑整理出来都值得做一次自查。消息发到了没有队列绑定的 Exchange 上。交换机本身不存储消息如果没有队列和它绑定消息到 Exchange 后发现无处可去默认直接被丢弃。这是消息丢失最常见的原因。要规避它一是发布前确认绑定关系存在二是开启 Publisher Confirms 或者使用mandatory标志配合 Alternate Exchange备用交换机把不可路由的消息转移到专门的备份队列。Exchange 持久化了但队列没持久化消息还是丢。消息真正落地的位置是队列不是 Exchange。Exchange 的 durable 只保证交换机定义重启不丢队列如果声明为非 durable重启后队列消失里面的消息自然也没了。再加上消息本身的 delivery_mode 不设为 2队列即使持久也不保证消息可靠。所以要做到消息不丢Exchange durable、Queue durable、消息 delivery_mode2 这三个条件必须同时满足。同名 Exchange 类型冲突导致声明失败。解决办法只有一个初始化时用幂等的方式声明并且把 exchange 名称和类型当作不可变约定。要改类型就换新名字做双写迁移不要在生产环境里直接删了重建。临时队列用完不清理。auto-deletetrue的队列在消费端断开后会自动删除但如果你在代码里手动声明了固定名称的临时队列又没有设置 auto-delete项目跑一段时间后 RabbitMQ 里会积压一堆僵尸队列。内存和文件句柄都是白花花被耗掉的建议运维定期巡检队列数量。Direct 和 Topic 的 Binding Key 与发布 Routing Key 不匹配。这属于最常见的低级问题但排查成本很高。我后来给团队定的排查顺序是先看消息发布时用的 exchange 和 routing key再去管理界面看目标队列的绑定关系最后才怀疑消费者代码。管理界面里每个队列的 Bindings 标签页可以直接看到绑定键比对一眼就知道有没有进错道。6.3 关于命名规范和监控的一点经验Exchange 的命名看起来是个小事但影响维护效率。我习惯用事件领域来命名 Exchange比如订单域用order_events用户域用user_events日志域用log_eventsRouting Key 则按照层级事件名来写比如order.created.v1、user.profile.updated.v1带上版本号可以避免以后事件结构变化时老消费者解析失败。监控方面RabbitMQ 管理界面的 Exchanges 页面里可以看到每个 Exchange 的 Message rates我建议把每个核心 Exchange 的 publish 速率和队列的 deliver 速率都接到监控告警上。如果某个 Exchange 的 publish 很高但所有绑定队列的 deliver 都很低第一反应就应该是检查绑定关系和路由键而不是消费者负载。最后再分享一个我自己的使用习惯每次新建 Exchange 之前先打开管理界面看一眼同名 Exchange 是否存在类型是什么。多个项目如果共用一个 RabbitMQ 服务这种同名冲突简直防不胜防。不要问我为什么这么熟练——都是吃过亏换来的。
返回列表