一笔订单的消息发了出去,下游没有收到,发送端也没有报错。
这不是配置写错了,而是 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 |
quorum | Raft 多数派复制 | 消费即删除 | 恒为 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-limit | 4.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=false | 541 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。

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