Featured image of post 第一话|RabbitMQ 的架构与特性:一条消息的一生

第一话|RabbitMQ 的架构与特性:一条消息的一生

一条消息从客户端发出到被消费掉,要穿过 vhost、connection、channel、交换机、绑定、队列这几层结构。在 RabbitMQ 4.3.5 上逐层看这套结构:三种队列类型的差别是消费语义而不是性能档位,quorum 上有一档溢出配置声明会被接受却不生效,而三个 5xx 错误码关掉的是整条连接而不是信道。

系列 GYD(Get Your Distributed) 第 1 / 7 篇
主题 消息队列RabbitMQAMQP分布式架构

一笔订单的消息发了出去,下游没有收到,发送端也没有报错。

这不是配置写错了,而是 RabbitMQ 的默认路径本来就不保证「发出去了」等于「进了队列」。同一类落差还有另外两种常见的落法,三种都不用改业务代码,改一个声明参数或者换一个路由键就能撞上:

  • 把消息发到一个没有任何绑定关系的路由键上,发送端拿到的是成功,消息也没有进任何队列;
  • 给一个已经存在的队列的声明补上一个 TTL 参数,应用下次启动报 406;
  • 想把一个已经存在的 classic 队列改成 quorum,重新声明一次就直接失败。

三条都不是 bug。它们各自对应一条结构性的机制,而这三条机制是同一套骨架的不同侧面:

现象真实机制对应章节
无绑定的路由键:不报错,也不投递落点由交换机类型与绑定共同决定,落点数可以是 0;默认路径下发送端拿不到异常二
改声明参数 → 406声明是幂等比对而不是覆盖,任何参数不一致都被拒七
换队列类型 → 失败与上一条是同一个机制:x-queue-type 本身就是被比对的参数之一三(陈述事实)、七(机制)

全文按一条消息走过的顺序展开:它先在多大的范围里活动(一),然后被投到哪个落点(二),落在哪种队列上(三),接着谈它什么时候算安全(四)、怎么被消费掉(五),再看挂在队列上的三个常用特性(六),最后回到「声明」这一步(七)。第八节把这七节收成两条主轴。

读完之后应当能做到两件事:解释上面这三个现象为什么发生;以及在动手之前判断出「我选的这个队列类型,支不支持我要用的那个队列级特性」。

边注:文中标为「边注」的段落是限定与例外。它们不影响主线,第一遍可以跳过;但其中几条会改变边界,真用到那一节时值得回头看。

环境与运行方式

本文全部结论对应 RabbitMQ 4.3.5 与客户端 amqp-client 5.30.0,JDK 21。客户端刻意用裸 AMQP 库而不是 Spring AMQP:RabbitTemplate 和 MessageListenerContainer 恰好把 connection 与 channel 这两层封装掉了,而这两层正是下面要看的对象。完整实现与复现命令在仓库 c01-rabbitmq-architecture,文中每个结论后面括注的示例名就是复现入口。

一、连上 Broker:vhost、connection、channel 是三个不同粒度的作用域

一条连接建立起来,再在它上面开出若干条逻辑信道。vhost、connection、channel 这三层东西名字都很熟,但它们的用途经常被混着说。

连接阶段一次性谈定了一批东西,粒度层层收窄:最外层是这次进哪个 vhost、用哪个身份;中间是这条 TCP 连接的参数:服务端自报的版本、单条连接允许的最大信道数(2047)、单个帧的最大字节数(131072)、心跳间隔(60 秒);最里面才是若干条逻辑信道(topology)。

vhost 是资源命名空间。同名交换机与同名队列在不同 vhost 里是两个互不相干的实体,客户端在连接协商时指定自己进哪个 vhost。同一条 connection 上开了三条信道时也一样:队列在 #1 上声明、在 #2 上被动声明、在 #3 上删除,三个动作都成立。队列挂在 vhost 上,跟哪条信道碰它没有关系。

connection 是协商与身份的单位。官方文档讲连接生命周期时的顺序是:先协商协议版本与参数,再做认证,认证通过之后每一个操作还要各自过一次授权检查。这句话的工程含义是:连接建立成功只代表身份被接受了,不代表后面每一个动作都被许可,授权是逐操作判定的,所以一个能建连的账号仍可能在某个队列上被拒。

边注:不要把 connection 理解成「一个进程对应一条连接」,官方没有这层意思。它是一条 TCP 长连接加上一套会话状态,不绑定进程模型。

channel 是一条 TCP 上的逻辑连接。官方对它的定义是「共享一条 TCP 连接的轻量连接」。每个协议操作都必须落在某条 channel 上,协议方法按 channel ID 分流,所以同一个 connection 上的多条 channel 互不阻塞对方。更要紧的是错误的边界停在 channel 上:在一条信道上被动声明一个不存在的队列,broker 返回 404,被关掉的只有这条信道,connection 还活着,同一条连接上新开一条即可继续(topology)。

那为什么要有 channel 这一层?因为握手与认证的成本落在 connection 上,而复用同一条 TCP 承载多个逻辑会话的成本低得多。「一个进程开多个 connection」和「一个 connection 开多条 channel」不是等价的两种写法,后者省掉的正是重复的协商与认证。

二、发布一条消息:落点由交换机类型与绑定共同决定,落点可以是 0

发布这一步的第一站不是队列,是交换机——消息交给交换机,由它按绑定决定落到哪些队列,不存在「直接发进队列」这回事。客户端库里那个发布方法,第一个参数就是交换机名;平时习惯写的「只填队列名」的写法,是把它省略成了空串,而空串是一个内建交换机的名字。

先看正常的路径。交换机拿到消息后,按自己上面挂的绑定(binding)分发;一条绑定由「匹配规则 + 目标队列」两部分组成。同一句发布、同一个路由键,换个交换机类型,落点数量就完全不同:

交换机类型匹配规则order.created 的落点no.match.at.all 的落点
direct路由键全等1 个队列0
fanout忽略路由键2 个队列(全部绑定)2 个队列
topic按 . 分段,* 匹配恰好一段、# 匹配零或多段2 个队列0
headers按消息头匹配(匹配语义未经验证)未验证未验证

这张表逐行核对过(route)。

而发送端在这一路上始终没有抛异常。no.match.at.all 这个路由键在 direct 和 topic 上都落 0 个队列,消息直接被丢弃,发送端拿到的却是成功,这就是开篇第一个现象。默认路径下「发出去了」与「进了队列」是两件事,中间隔着一次可能为空的匹配:

要让发送端知道自己没投出去,得用 publisher confirm 加 mandatory 那一套参数。这里只需要记住落点可以是 0,而默认路径不会告诉你。

四个内建交换机有预声明的名字:amq.direct、amq.fanout、amq.topic、amq.match。除此之外还有一个名字是空串的 default exchange,它有两条特殊性质:每个队列在声明时会自动以自己的队列名为路由键与它绑好;它本身不能被操作。

第二条比预期严格。官方只说不要拿它做显式绑定,实际是两种手势都被拒:被动声明它与往它上面加绑定都返回 403。管理 API 里读到的绑定记录还证实了那条特殊绑定确实存在,source="" 而路由键就是队列名(default-exchange)。所以「直接发到队列名」这种写法一直在走交换机,只是交换机名和路由键都被省略成了默认值。

三、消息停在哪:三种队列类型的差别是消费语义,不是性能档位

消息落到队列之后,队列本身还有一个属性要选:类型。类型由声明参数 x-queue-type 指定,取值 classic(不写就是这个)、quorum、stream。

要和队列的另外三个属性区分开:durable(是否持久化)、exclusive(是否独占)、auto-delete(无人使用后是否自动删除)。这三个改的是同一套语义下的行为,而类型换的是消费语义本身。类型在声明期就锁定,改它会失败(机制见第七节)。

类型存储与复制消费语义关键约束
classic单节点,4.x 已无复制消费即删除默认类型;支持独占、支持全局 QoS
quorumRaft 多数派复制消费即删除恒为 durable、不支持独占(改用单活跃消费者)、不支持全局 QoS
stream复制 + append-only非破坏性读(按 offset 读,读不走消息)不支持死信与优先级;TTL 与队列长度限制改为 retention(官方矩阵写作 no (but see Retention),用 x-max-age / x-max-length-bytes 表达);成员变更手动

分界线在最后一列之外,也就是「消费会不会破坏数据」:

classic 与 quorum 的差别在复制:一个单节点、一个多数派,但在消费语义上两者一致,都是读完就删。声明 quorum 时不要沿用 classic 里 durable=false 的写法,它恒为 durable,durable=false 直接不合法。stream 换掉的是这一层语义:它是一份 append-only 的日志,消费者按 offset 读,读这个动作不删数据。官方列出的许多特性支持与否,都从这一点推出来。

这个差别在读数上很直接:同一条 stream 上让两个消费者各自从头读,两边都拿到完整的 5 条;同样的动作在 classic 上,第二个消费者拿到 0 条(queue-types)。这也说明了为什么 stream 在第六节那三个特性上没有位置:TTL、长度限制、死信都以「消息会被删掉」为前提,而 stream 的读不删数据,前提本身不成立。它用按时间或字节保留的 retention 替代前两个。

边注:官方把 stream 称作「not really queues in the traditional sense」,并把原因说得很直接:「Many features will never be supported by streams due to their non-destructive read semantics.」

四、消息什么时候算安全:持久化与复制是两个独立维度

队列选定之后,接下来是「消息写到什么程度算安全」。这里有两个经常被合并成一个的维度。

维度一是持久化,它由两个开关共同决定:队列的 durable 管队列本身的元数据,消息自己的发布属性 deliveryMode 管消息内容,deliveryMode=2 即通常说的持久化消息。两个开关各管一半。

维度二是复制,它决定「一台机器挂了,另一个节点上有没有这份数据」。这个维度只跟队列类型有关:quorum 与 stream 有副本,classic 在 4.x 没有:官方原话是 classic 历史上支持复制,但这个特性「was removed for RabbitMQ 4.x」。

两个维度不能互相替代,最容易搞混的是持久化这一侧。官方在这一句上写得很直白:durable 队列会在节点启动时恢复,包括以 persistent 发布的消息;而以 transient 发布的消息在恢复时会被丢弃,即使它们存在 durable 队列里。后半句是这一段的关键:队列的 durable 不会替消息做决定。

要验证这一点,得让 broker 真的重启一次:往同一个 durable 队列里放 2 条 deliveryMode=2 与 1 条 deliveryMode=1,重启,再重声明队列并抽干。重启前队列里是 3 条,重启后剩下 2 条,队列本身仍在,而少的那条正是以 transient 发布的那条。发布时三条都在同一个队列里、同一次连接、同一批调用,唯一的差别就是发布属性(persistence-write → persistence-read,中间重启一次 broker)。

复制这个维度在正文里只到这一步。这里要拿到的是两个维度正交这个结论。

五、消费一条消息:ack 才是「可删除」的条件,prefetch 决定未确认消息的上限

默认的消费方式是推送:客户端订阅之后,服务端用 basic.deliver 把消息推过来,一个订阅由它自己的标签(consumer tag)标识。收到之后怎么算「处理完了」,取决于 ack 模式。

手动确认有三种应答:成功;失败并要求重新排队;失败且拒绝重排(这一种不能批量)。协议里它们分别叫 basic.ack / basic.nack(多带一个是否重排的标志)/ basic.reject。其中「成功」那一档的语义值得单独说清楚:它是「这条消息可以被删掉了」,不是「我读到了」。在这句话兑现之前,消息同时存在于两个地方:

这张图给出了「消费」的准确边界:它不是一次瞬时动作,而是一段有起止的状态。从交付到 ack 之间,消息既在服务端的未确认集合里,也在客户端手里。所以「队列空了」不等于「处理完了」,而「客户端收到了」也不等于「不会被重投」。

这段状态在队列深度上读得出来。5 条消息、prefetch 设成 2:刚发布完 ready 是 5;2 条被投递到客户端手里之后降到 3,此时再等也不会有第 3 条过来;直到其中 1 条被 ack、名额腾出来,第 3 条这才被推进来,深度走到 2;最后把信道关掉,尚未确认的那 2 条退回 ready,深度回到 4(consume-ack)。

从 5 到 3 减掉的 2 条,就是被投递但还没 ack 的那两条。它们没有消失,只是从 ready 挪到了未确认集合。这也说明了 prefetch 管的是什么:它是同一条信道上未确认消息的条数上限,不是投递速率,它限制并发在途的量,而不管每秒能发多少条。最后那一步回到 4,是因为此刻已投递 3 条、已 ack 1 条,剩下 2 条未确认,把信道关掉,这 2 条退回 ready。5 条里 1 条被 ack 后永久删除,剩下 4 条一条没丢。

另一种收手势的结果和直觉不同。把消费者取消掉,但连接与信道都留着,ready 仍是 2;只有把信道也关掉,它们才退回 4。所以未确认消息退回的触发点是信道或连接关闭,取消消费者本身不构成退回(consume-ack)。排查「消费者停了但消息没回来」这类现象时,这一条能省下不少时间。

另一种取法是主动拉取(pull 模型,协议里的 basicGet),官方口径是低效、应当避免,这里只用它做一个对照:连续三次 basicGet,第三次返回 null,因为队列已经空了。

channel 与线程

最后回到信道本身。交付和 ack 都发生在某条 channel 上,而 channel 不是给多线程共享用的。这件事在客户端库那里的措辞是禁止级的:官方客户端 5.30.0 的 Channel 类说明用的词是 must not:「Channel 实例不得在线程之间共享」,并且点名了后果:其中一部分操作会导致帧在同一连接上交错(incorrect frame interleaving on the wire),而且在多线程之间共享信道还会干扰 publisher confirms。

后半句有一个很直观的读数:让 8 个线程共享同一条信道,每个线程各取一次发布序号、再发布,一共 1600 条,读到的 1600 个序号里互不相同的只有 1572 个(重复 28 个),确认回调也只到了 193 次。这两个数字就是「干扰 publisher confirms」的具体形状:序号账本是 publisher confirm 用来对账的凭据,本该一次发布对应一个唯一序号(topology)。

正确的姿势是每个线程(或每个进程)持有自己的 channel,共享的是 connection,它本身是设计给多线程共用的。Spring AMQP 的并发消费者配置正是按这个模型,把 channel 分摊到各个消费线程上:

六、挂在队列上的三个常用特性:TTL、死信、长度限制,可用性由队列类型决定

TTL、死信、长度限制是队列上最常用的三个配置。它们放在同一节,是因为它们有同一个挂载点:三者都是队列级特性,都配置在队列上(声明参数或 policy),而不是配置在客户端代码里。这个共同点直接决定了一件事:它们能不能用,由队列类型决定。

TTL

项结论
队列级 x-message-ttl毫秒单位的非负整数,可写在声明参数或 policy 里
单条消息 expiration发布时设置,必须是数字的字符串形式
两者并存取较小的那个
到期语义官方用词是 guarantees:到期的消息不会通过 basic.deliver 投给消费者,basic.get 也取不到
队头规则只有排到队头时才会真正被丢弃;单条消息 TTL 下,先过期的消息会堵在未过期消息后面,资源不释放、仍计入队列统计
requeue 之后保留原来的到期时间
TTL = 0到达即过期,除非当时正好能投给消费者;它是 immediate 参数的替代品,且不产生 basic.return
队列过期 x-expires必须是正整数,只在队列未被使用时计时

队头规则是这里最容易误判的一条:只有排到队头时才会真正被丢弃。

这一条在读数上很好分辨。先发一条 60 秒的 A、再发一条 2 秒的 B,3 秒后 B 早该过期,但此时队列深度仍是 2;直到消费时消费者只拿到 A 一条,深度才直接归零:A 被消费掉,B 在排到队头的那一刻被丢弃(ttl)。所以设了 TTL 之后队列长度不会立刻下降,要等消息挪到队头才会掉。

另外两条规则也在同一组读数里:队列级 8 秒与单条 1.5 秒并存时,3 秒时深度为 1、9 秒时归零,生效的是较小的那个;一条 12 秒的消息被另一条连接取走后按住 6 秒再退回,仍在 t=13.8s 到期消失,说明 requeue 不会重新计时,它按首次发布的时刻算。最后这条是专门为了分开两种假设设计的:如果 requeue 会重新计时,消息应该活到 t≈18s(ttl)。

死信

满足下面四个条件之一时,消息会被投到 x-dead-letter-exchange 指定的交换机上:

触发点说明
basic.reject 或 basic.nack 且 requeue=false消费者主动拒绝并声明不再投回
消息 TTL 到期队列级与单条两种配置都算
超过长度限制被丢弃见下一小节
quorum 上返回次数超过 delivery-limit4.0 起默认 20

几个配套细节:路由键没设时用消息的原路由键(含 CC 头,不含 BCC);被死信的消息会带上 x-death 数组,记录 queue / reason / count / 时间戳,reason 取值是 rejected、expired、maxlen、delivery_limit;消息被死信时原本的 expiration 会被移除,防止它在新队列里再次到期。

边注:死信转发默认不开内部 publisher confirm,也就是 at-most-once:目标队列不可用时消息会丢。要用它的场景需要自己把这一环补上。

四个触发点各自的表现(dlx):主动拒绝(nack 且 requeue=false)、单条 TTL 到期、队列级 x-message-ttl 到期各进死信 1 条,而整队被 x-expires 删掉时进死信 0 条,符合文档的例外说明。

其中队列级 TTL 到期这一条值得留意,因为官方两个页面的措辞不一致:dlx 那一页只写了「因单条消息 TTL 到期」,而 ttl 那一页同时覆盖队列级配置,这里的结论是两者都进死信。两条路径在数据上可以区分:队列级这条的 x-death 里没有 original-expiration,单条 TTL 那条有。如果代码要靠 x-death 判断死因,这个差别会用到。

长度限制

项结论
参数x-max-length(条数)或 x-max-length-bytes(字节)
x-overflow 三档drop-head(默认)、reject-publish、reject-publish-dlx
drop-head从队头(最早的)开始丢,发送端感知不到
reject-publish丢最新的,发送端通过 basic.nack 感知
reject-publish-dlx被拒的那几条改道进死信队列
quorum 上的 reject-publish-dlx官方说 quorum「do not support」这一档;实际行为:声明不会报错,但会静默退回 drop-head

四段对照里有两处值得留意(max-length)。一是 reject-publish 这一档发送端确实能感知到:上限 3 条时依次发 5 条,确认回调里 ack 3 次、nack 2 次;而默认的 drop-head 从队头丢,留下的是最后 3 条,发送端拿到的全是成功。

二是 quorum 队列上那次 reject-publish-dlx:声明被接受、没有报错,主队列 3 条、死信 2 条。只看深度,这和真正生效那一档的形状完全一致。要分开这两个假设只能看死信里是哪两条:真正生效那次死信里是最新的两条,这一次是最早的两条,说明它在 quorum 上并没有按 reject-publish-dlx 走,而是静默退回成了队头丢弃。声明成功不等于行为生效。

边注:reject-publish 这一档在 quorum 上不严格强制上限,会超出上限至少一条消息:它的机制是达到上限之后逐个通知发布方信道,此期间还在途的消息仍会被接受。要用精确上限的场景要注意这一点。

三个特性齐了,回到它们共同的那条边界:这三项在 classic 与 quorum 上都能用(quorum 上只有溢出那一档有差别),在 stream 上则被 retention 取代。所以它们可用与否,取决于消费会不会删数据;客户端代码怎么写、队列是 classic 还是 quorum,都不构成决定因素。

七、声明为什么会被拒绝:幂等比对,与 4.3.0 的 transient 队列

回到开篇的另外两个现象:改声明参数报 406,换队列类型直接失败。两条都不在发布、消费路径上,而在声明这一步。

先立错误分级

AMQP 的协议异常分两级,判据不是错误文本,而是 broker 关掉了哪一个实体:

级别错误码broker 关掉的是谁之后还能不能继续用这条 connection
soft(信道级)404 / 406 / 403 / 405这条 channel能,新开一条继续
hard(连接级)501 / 540 / 541整条 connection不能,得重建连接

406 属于前者,也就是关一条信道、连接还活着。这与第一节那次「被动声明一个不存在的队列」拿到的 404 是同一类表现。

hard 这一类有一条反直觉的性质:它们是异步上报的。调用点不会抛同步异常,要注册 addShutdownListener 才拿得到。给发布方法带上 immediate=true(这个参数在 4.x 上已被移除)返回 540,手工塞一个非法帧类型的帧返回 501,两次调用点都是「正常返回」,连接却已经不在了(topology)。所以排查「整条连接突然断掉、日志里只有 5xx 错误码」这类故障时,不能用「有没有抛异常」作为判据,得看 shutdown listener 或连接状态。

声明是幂等比对,不是覆盖

声明一个已经存在的队列或交换机时,服务端做的是把这次声明的参数和已存在的实体逐项比对,而不是用新的覆盖旧的。任何一项对不上就报 406,报错文本落在 inequivalent arg 这个词上,并且关掉这条 channel。两个用例的表现(precondition):给一个已存在的队列补上 x-message-ttl=1000 再声明一次,报 406 并指出收到的值与当前值不一致;把已存在的 classic 队列重新声明成 quorum,同样报 406,指出 received 'quorum' but current is 'classic'。两次都是 connection.isOpen=true 而 channel.isOpen=false。

开篇现象 2 和现象 3 在这里合流:它们不是两件事。换队列类型失败,只是因为 x-queue-type 恰好也是被比对的一项。保留的语义是「这次声明不生效」,而不是「把旧的改成新的再失败」:被拒之后,原队列的类型没有变。

这条机制解释了一个很常见的现象:本地把队列参数改了一行,重启应用就报 406,而在一个新环境上跑得好好的,因为新环境里队列还不存在,第一次声明总能成功。所以声明参数应当当成接口契约来管:线上已经声明过的队列,参数就不能随手改。要改就得先删队列(会连消息一起删),或者改用 policy:它是动态下发、不参与这次比对的。

4.3.0 起,transient 非排他队列默认不可声明

剩下两个用例是版本边界和一个未预期的结果:

用例结果级别
队列名用保留前缀 amq.403 ACCESS_REFUSED(contains reserved prefix)信道级
durable=false + exclusive=false541 INTERNAL_ERROR(Feature transient_nonexcl_queues is deprecated)连接级
用另一条 connection 访问独占队列405 RESOURCE_LOCKED信道级

第二行是最值得记的一条。官方从 4.3.0 起把「非持久化且非排他的 classic 队列」列为弃用并默认拒绝(配置项 deprecated_features.permit.transient_nonexcl_queues),老教程里顺手写的 durable=False 在这个版本上直接撞上它。而它返回的是 541,属于连接级,不是 406。所以一条队列声明失败,代价是整条连接被带走,同一次运行里后面几步必须重建连接才能继续。把它和上面那张分级表对照着看:同样是「声明被拒」,406 只关信道,541 关整条连接,错误码决定了故障半径。

第三行补的是另一个方向:在连接 A 上声明的独占队列,用连接 B 去被动声明会得到 405 RESOURCE_LOCKED(信道级)。独占的语义是按连接判定的。

八、收口:两层骨架与它划出的边界

把前面七节的对象串起来,一条消息走过的是一条单一方向的链:

路由层是交换机类型加绑定,它回答「去哪」,并且与队列类型无关:同一个路由键在 direct 下落 1 个、在 fanout 下落全部、在 topic 下按通配落若干,把队列从 classic 换成 quorum 不会改变这个结果。承载层是队列,它回答「以什么语义被保存与消费」,队列类型决定消费语义(消费即删除还是非破坏性读),而挂在队列上的那些特性也跟着这个语义走。

回到开篇那三个现象,它们都能从上面两条机制推出来:

  • 无绑定的路由键不报错,是路由层的直接结果:落点由绑定决定,而落点可以是 0;默认路径下发送端拿不到异常。
  • 改声明参数报 406,是承载层的直接结果:队列的参数是它自己的身份,声明只做比对不做覆盖。
  • 换队列类型失败,与上一条是同一件事,x-queue-type 也是被比对的一项。它看起来像第三个坑,其实是第二个坑的一个实例。

这套骨架有两处代价。三层作用域(vhost / connection / channel)换来了复用一条 TCP 的能力,代价是错误域分了级:同一个失败在不同错误码下可能只废掉一条信道,也可能废掉整条连接,所以错误码在 RabbitMQ 里不是一个诊断细节,而是故障半径本身。两层结构(路由 + 承载)让「去哪」和「怎么存」解耦,代价是队列类型一旦选定就锁住了它能用哪些特性,而且改不了:选 stream 那一刻,TTL 与长度限制就换成了 retention,死信则没有位置。

参考资料

  • AMQP 0-9-1 Model Explained:vhost / connection / channel 三层定义,交换机与绑定的关系,ack 语义。
  • Connections:连接生命周期、协商参数、认证与逐操作授权、连接是长生命周期对象这一前提。
  • Channels:信道的定义(一条 TCP 上的轻量连接)、协议异常分级(soft 关信道 / hard 关连接)、未确认消息随信道关闭退回。
  • Exchanges:四种内建交换机与预声明名,default exchange 的三条特殊性质。
  • Queues:队列属性、durable 队列恢复时包含 persistent 消息而 transient 消息被丢弃、classic 复制在 4.x 移除、x-queue-type 声明期不可变。
  • Quorum Queues:Raft 复制、恒 durable、不支持全局 QoS、长度限制不含 reject-publish-dlx、reject-publish 会超出上限至少一条。
  • Streams:非破坏性读语义、与 classic 的完整特性矩阵(含 TTL 与长度限制的 no (but see Retention))。
  • Time-To-Live and Expiration:队列级与单条 TTL、取较小值、队头规则、x-expires 的取值要求。
  • Dead Letter Exchanges:四个触发点、x-death 结构与 reason 取值、整队过期不进死信的例外、默认 at-most-once。
  • Queue Length Limits:x-overflow 三档行为与发送端的可感知差异。
  • Consumer Acknowledgements and Publisher Confirms:ack / nack / reject 的语义与批量限制、confirm 的序号账本。
  • Consumer Prefetch:basic.qos 与未确认消息上限。
  • Deprecated Features:transient_nonexcl_queues 自 4.3.0 起默认拒绝及其配置项。

演示代码:完整实现在 GYD 仓库 c01-rabbitmq-architecture,RabbitMQ 的编排在仓库级 infra/rabbitmq/。正文每个结论后面括注的名字就是复现入口,在仓库根用 ./gradlew :c01-rabbitmq-architecture:run --args="<名字>" 跑(JDK 21 需显式指定 JAVA_HOME);运行方式、每个示例做了什么、以及各结论对应的完整输出记录,都在该模块的 README 里。第四节的持久化那一段要按顺序跑两条命令、中间重启一次 broker。

留言

欢迎分享你的想法。评论提交后会在审核通过后显示。