<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>架构 on Renxin's Blog</title><link>https://zh.renxinblog.cn/tags/%E6%9E%B6%E6%9E%84/</link><description>Recent content in 架构 on Renxin's Blog</description><generator>Hugo -- gohugo.io</generator><language>zh</language><lastBuildDate>Sun, 08 Feb 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://zh.renxinblog.cn/tags/%E6%9E%B6%E6%9E%84/index.xml" rel="self" type="application/rss+xml"/><item><title>第一话｜RabbitMQ 的架构与特性：一条消息的一生</title><link>https://zh.renxinblog.cn/post/gyd-c01-rabbitmq-architecture/</link><pubDate>Sun, 08 Feb 2026 00:00:00 +0000</pubDate><guid>https://zh.renxinblog.cn/post/gyd-c01-rabbitmq-architecture/</guid><description>&lt;img src="https://zh.renxinblog.cn/images/gyd-c01-rabbitmq-architecture-cover.png" alt="Featured image of post 第一话｜RabbitMQ 的架构与特性：一条消息的一生" /&gt;&lt;p&gt;一笔订单的消息发了出去，下游没有收到，发送端也没有报错。&lt;/p&gt;
&lt;p&gt;这不是配置写错了，而是 RabbitMQ 的默认路径本来就不保证「发出去了」等于「进了队列」。同一类落差还有另外两种常见的落法，三种都不用改业务代码，改一个声明参数或者换一个路由键就能撞上：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;把消息发到一个没有任何绑定关系的路由键上，发送端拿到的是成功，消息也没有进任何队列；&lt;/li&gt;
&lt;li&gt;给一个已经存在的队列的声明补上一个 TTL 参数，应用下次启动报 &lt;code&gt;406&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;想把一个已经存在的 &lt;code&gt;classic&lt;/code&gt; 队列改成 &lt;code&gt;quorum&lt;/code&gt;，重新声明一次就直接失败。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;三条都不是 bug。它们各自对应一条结构性的机制，而这三条机制是同一套骨架的不同侧面：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;现象&lt;/th&gt;
					&lt;th&gt;真实机制&lt;/th&gt;
					&lt;th&gt;对应章节&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;无绑定的路由键：不报错，也不投递&lt;/td&gt;
					&lt;td&gt;落点由交换机类型与绑定共同决定，&lt;strong&gt;落点数可以是 0&lt;/strong&gt;；默认路径下发送端拿不到异常&lt;/td&gt;
					&lt;td&gt;二&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;改声明参数 → &lt;code&gt;406&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;声明是&lt;strong&gt;幂等比对&lt;/strong&gt;而不是覆盖，任何参数不一致都被拒&lt;/td&gt;
					&lt;td&gt;七&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;换队列类型 → 失败&lt;/td&gt;
					&lt;td&gt;与上一条是同一个机制：&lt;code&gt;x-queue-type&lt;/code&gt; 本身就是被比对的参数之一&lt;/td&gt;
					&lt;td&gt;三（陈述事实）、七（机制）&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;全文按一条消息走过的顺序展开：它先在多大的范围里活动（一），然后被投到哪个落点（二），落在哪种队列上（三），接着谈它什么时候算安全（四）、怎么被消费掉（五），再看挂在队列上的三个常用特性（六），最后回到「声明」这一步（七）。第八节把这七节收成两条主轴。&lt;/p&gt;
&lt;p&gt;读完之后应当能做到两件事：解释上面这三个现象为什么发生；以及在动手之前判断出「我选的这个队列类型，支不支持我要用的那个队列级特性」。&lt;/p&gt;

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

 &lt;/blockquote&gt;
&lt;h2 id="环境与运行方式"&gt;环境与运行方式
&lt;/h2&gt;&lt;p&gt;本文全部结论对应 RabbitMQ &lt;strong&gt;4.3.5&lt;/strong&gt; 与客户端 &lt;code&gt;amqp-client&lt;/code&gt; &lt;strong&gt;5.30.0&lt;/strong&gt;，JDK 21。客户端刻意用裸 AMQP 库而不是 Spring AMQP：&lt;code&gt;RabbitTemplate&lt;/code&gt; 和 &lt;code&gt;MessageListenerContainer&lt;/code&gt; 恰好把 connection 与 channel 这两层封装掉了，而这两层正是下面要看的对象。完整实现与复现命令在&lt;a class="link" href="https://github.com/renxin2024/GYD/tree/main/c01-rabbitmq-architecture" target="_blank" rel="noopener"
 &gt;仓库 &lt;code&gt;c01-rabbitmq-architecture&lt;/code&gt;&lt;/a&gt;，文中每个结论后面括注的示例名就是复现入口。&lt;/p&gt;
&lt;h2 id="一连上-brokervhostconnectionchannel-是三个不同粒度的作用域"&gt;一、连上 Broker：vhost、connection、channel 是三个不同粒度的作用域
&lt;/h2&gt;&lt;p&gt;一条连接建立起来，再在它上面开出若干条逻辑信道。vhost、connection、channel 这三层东西名字都很熟，但它们的用途经常被混着说。&lt;/p&gt;
&lt;p&gt;连接阶段一次性谈定了一批东西，粒度层层收窄：最外层是这次进哪个 vhost、用哪个身份；中间是这条 TCP 连接的参数：服务端自报的版本、单条连接允许的最大信道数（2047）、单个帧的最大字节数（131072）、心跳间隔（60 秒）；最里面才是若干条逻辑信道（&lt;code&gt;topology&lt;/code&gt;）。&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart TB
 B["Broker：vhost=/ 这个环境"]
 C["connection：一条 TCP，协商与认证在这一层完成"]
 CH1["channel #1"]
 CH2["channel #2"]
 CH3["channel #3"]
 B --- C
 C --- CH1
 C --- CH2
 C --- CH3&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;vhost 是资源命名空间&lt;/strong&gt;。同名交换机与同名队列在不同 vhost 里是两个互不相干的实体，客户端在连接协商时指定自己进哪个 vhost。同一条 connection 上开了三条信道时也一样：队列在 #1 上声明、在 #2 上被动声明、在 #3 上删除，三个动作都成立。队列挂在 vhost 上，跟哪条信道碰它没有关系。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;connection 是协商与身份的单位&lt;/strong&gt;。官方文档讲连接生命周期时的顺序是：先协商协议版本与参数，再做认证，认证通过之后每一个操作还要各自过一次授权检查。这句话的工程含义是：连接建立成功只代表身份被接受了，不代表后面每一个动作都被许可，授权是逐操作判定的，所以一个能建连的账号仍可能在某个队列上被拒。&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;边注&lt;/strong&gt;：不要把 connection 理解成「一个进程对应一条连接」，官方没有这层意思。它是一条 TCP 长连接加上一套会话状态，不绑定进程模型。&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;channel 是一条 TCP 上的逻辑连接&lt;/strong&gt;。官方对它的定义是「共享一条 TCP 连接的轻量连接」。每个协议操作都必须落在某条 channel 上，协议方法按 channel ID 分流，所以同一个 connection 上的多条 channel 互不阻塞对方。更要紧的是错误的边界停在 channel 上：在一条信道上被动声明一个不存在的队列，broker 返回 &lt;code&gt;404&lt;/code&gt;，被关掉的只有这条信道，connection 还活着，同一条连接上新开一条即可继续（&lt;code&gt;topology&lt;/code&gt;）。&lt;/p&gt;
&lt;p&gt;那为什么要有 channel 这一层？因为握手与认证的成本落在 connection 上，而复用同一条 TCP 承载多个逻辑会话的成本低得多。「一个进程开多个 connection」和「一个 connection 开多条 channel」不是等价的两种写法，后者省掉的正是重复的协商与认证。&lt;/p&gt;
&lt;h2 id="二发布一条消息落点由交换机类型与绑定共同决定落点可以是-0"&gt;二、发布一条消息：落点由交换机类型与绑定共同决定，落点可以是 0
&lt;/h2&gt;&lt;p&gt;发布这一步的第一站不是队列，是交换机——消息交给交换机，由它按绑定决定落到哪些队列，&lt;strong&gt;不存在「直接发进队列」这回事&lt;/strong&gt;。客户端库里那个发布方法，第一个参数就是交换机名；平时习惯写的「只填队列名」的写法，是把它省略成了空串，而空串是一个内建交换机的名字。&lt;/p&gt;
&lt;p&gt;先看正常的路径。交换机拿到消息后，按自己上面挂的绑定（binding）分发；一条绑定由「匹配规则 + 目标队列」两部分组成。同一句发布、同一个路由键，换个交换机类型，落点数量就完全不同：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;交换机类型&lt;/th&gt;
					&lt;th&gt;匹配规则&lt;/th&gt;
					&lt;th&gt;&lt;code&gt;order.created&lt;/code&gt; 的落点&lt;/th&gt;
					&lt;th&gt;&lt;code&gt;no.match.at.all&lt;/code&gt; 的落点&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;direct&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;路由键全等&lt;/td&gt;
					&lt;td&gt;1 个队列&lt;/td&gt;
					&lt;td&gt;0&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;fanout&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;忽略路由键&lt;/td&gt;
					&lt;td&gt;2 个队列（全部绑定）&lt;/td&gt;
					&lt;td&gt;2 个队列&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;topic&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;按 &lt;code&gt;.&lt;/code&gt; 分段，&lt;code&gt;*&lt;/code&gt; 匹配恰好一段、&lt;code&gt;#&lt;/code&gt; 匹配零或多段&lt;/td&gt;
					&lt;td&gt;2 个队列&lt;/td&gt;
					&lt;td&gt;0&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;headers&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;按消息头匹配（&lt;strong&gt;匹配语义未经验证&lt;/strong&gt;）&lt;/td&gt;
					&lt;td&gt;未验证&lt;/td&gt;
					&lt;td&gt;未验证&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;这张表逐行核对过（&lt;code&gt;route&lt;/code&gt;）。&lt;/p&gt;
&lt;p&gt;而&lt;strong&gt;发送端在这一路上始终没有抛异常&lt;/strong&gt;。&lt;code&gt;no.match.at.all&lt;/code&gt; 这个路由键在 direct 和 topic 上都落 0 个队列，消息直接被丢弃，发送端拿到的却是成功，这就是开篇第一个现象。默认路径下「发出去了」与「进了队列」是两件事，中间隔着一次可能为空的匹配：&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart LR
 P["发布一条消息"] --&gt; X["交换机"]
 X --&gt; B{"有匹配的绑定吗"}
 B -- "有" --&gt; Q["投到绑定的队列（0 个到多个）"]
 B -- "没有" --&gt; N["0 个队列：默认路径下发送端不报错"]&lt;/pre&gt;&lt;p&gt;要让发送端知道自己没投出去，得用 publisher confirm 加 &lt;code&gt;mandatory&lt;/code&gt; 那一套参数。这里只需要记住落点可以是 0，而默认路径不会告诉你。&lt;/p&gt;
&lt;p&gt;四个内建交换机有预声明的名字：&lt;code&gt;amq.direct&lt;/code&gt;、&lt;code&gt;amq.fanout&lt;/code&gt;、&lt;code&gt;amq.topic&lt;/code&gt;、&lt;code&gt;amq.match&lt;/code&gt;。除此之外还有一个名字是空串的 default exchange，它有两条特殊性质：每个队列在声明时会自动以自己的队列名为路由键与它绑好；它本身不能被操作。&lt;/p&gt;
&lt;p&gt;第二条比预期严格。官方只说不要拿它做显式绑定，实际是两种手势都被拒：被动声明它与往它上面加绑定都返回 &lt;code&gt;403&lt;/code&gt;。管理 API 里读到的绑定记录还证实了那条特殊绑定确实存在，&lt;code&gt;source=&amp;quot;&amp;quot;&lt;/code&gt; 而路由键就是队列名（&lt;code&gt;default-exchange&lt;/code&gt;）。所以「直接发到队列名」这种写法一直在走交换机，只是交换机名和路由键都被省略成了默认值。&lt;/p&gt;
&lt;h2 id="三消息停在哪三种队列类型的差别是消费语义不是性能档位"&gt;三、消息停在哪：三种队列类型的差别是消费语义，不是性能档位
&lt;/h2&gt;&lt;p&gt;消息落到队列之后，队列本身还有一个属性要选：类型。类型由声明参数 &lt;code&gt;x-queue-type&lt;/code&gt; 指定，取值 &lt;code&gt;classic&lt;/code&gt;（不写就是这个）、&lt;code&gt;quorum&lt;/code&gt;、&lt;code&gt;stream&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;要和队列的另外三个属性区分开：&lt;code&gt;durable&lt;/code&gt;（是否持久化）、&lt;code&gt;exclusive&lt;/code&gt;（是否独占）、&lt;code&gt;auto-delete&lt;/code&gt;（无人使用后是否自动删除）。这三个改的是同一套语义下的行为，而类型换的是消费语义本身。类型在声明期就锁定，改它会失败（机制见第七节）。&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;类型&lt;/th&gt;
					&lt;th&gt;存储与复制&lt;/th&gt;
					&lt;th&gt;消费语义&lt;/th&gt;
					&lt;th&gt;关键约束&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;classic&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;单节点，4.x 已无复制&lt;/td&gt;
					&lt;td&gt;消费即删除&lt;/td&gt;
					&lt;td&gt;默认类型；支持独占、支持全局 QoS&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;quorum&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;Raft 多数派复制&lt;/td&gt;
					&lt;td&gt;消费即删除&lt;/td&gt;
					&lt;td&gt;恒为 durable、不支持独占（改用单活跃消费者）、不支持全局 QoS&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;stream&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;复制 + append-only&lt;/td&gt;
					&lt;td&gt;非破坏性读（按 offset 读，读不走消息）&lt;/td&gt;
					&lt;td&gt;不支持死信与优先级；TTL 与队列长度限制改为 retention（官方矩阵写作 &lt;code&gt;no (but see Retention)&lt;/code&gt;，用 &lt;code&gt;x-max-age&lt;/code&gt; / &lt;code&gt;x-max-length-bytes&lt;/code&gt; 表达）；成员变更手动&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;分界线在最后一列之外，也就是「消费会不会破坏数据」：&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart TB
 Q["x-queue-type"]
 CL["classic：单节点，消费即删除"]
 QU["quorum：Raft 多数派复制，消费即删除"]
 ST["stream：复制 + append-only，非破坏性读"]
 Q --&gt; CL
 Q --&gt; QU
 Q --&gt; ST&lt;/pre&gt;&lt;p&gt;&lt;code&gt;classic&lt;/code&gt; 与 &lt;code&gt;quorum&lt;/code&gt; 的差别在复制：一个单节点、一个多数派，但在消费语义上两者一致，都是读完就删。声明 quorum 时不要沿用 classic 里 &lt;code&gt;durable=false&lt;/code&gt; 的写法，它恒为 durable，&lt;code&gt;durable=false&lt;/code&gt; 直接不合法。&lt;code&gt;stream&lt;/code&gt; 换掉的是这一层语义：它是一份 append-only 的日志，消费者按 offset 读，读这个动作不删数据。官方列出的许多特性支持与否，都从这一点推出来。&lt;/p&gt;
&lt;p&gt;这个差别在读数上很直接：同一条 stream 上让两个消费者各自从头读，两边都拿到完整的 5 条；同样的动作在 classic 上，第二个消费者拿到 0 条（&lt;code&gt;queue-types&lt;/code&gt;）。这也说明了为什么 stream 在第六节那三个特性上没有位置：TTL、长度限制、死信都以「消息会被删掉」为前提，而 stream 的读不删数据，前提本身不成立。它用按时间或字节保留的 retention 替代前两个。&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;边注&lt;/strong&gt;：官方把 stream 称作「not really queues in the traditional sense」，并把原因说得很直接：「Many features will never be supported by streams due to their non-destructive read semantics.」&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;h2 id="四消息什么时候算安全持久化与复制是两个独立维度"&gt;四、消息什么时候算安全：持久化与复制是两个独立维度
&lt;/h2&gt;&lt;p&gt;队列选定之后，接下来是「消息写到什么程度算安全」。这里有两个经常被合并成一个的维度。&lt;/p&gt;
&lt;p&gt;维度一是持久化，它由两个开关共同决定：队列的 &lt;code&gt;durable&lt;/code&gt; 管队列本身的元数据，消息自己的发布属性 &lt;code&gt;deliveryMode&lt;/code&gt; 管消息内容，&lt;code&gt;deliveryMode=2&lt;/code&gt; 即通常说的持久化消息。两个开关各管一半。&lt;/p&gt;
&lt;p&gt;维度二是复制，它决定「一台机器挂了，另一个节点上有没有这份数据」。这个维度只跟队列类型有关：&lt;code&gt;quorum&lt;/code&gt; 与 &lt;code&gt;stream&lt;/code&gt; 有副本，&lt;code&gt;classic&lt;/code&gt; 在 4.x 没有：官方原话是 classic 历史上支持复制，但这个特性「was removed for RabbitMQ 4.x」。&lt;/p&gt;
&lt;p&gt;两个维度不能互相替代，最容易搞混的是持久化这一侧。官方在这一句上写得很直白：durable 队列会在节点启动时恢复，包括以 persistent 发布的消息；而以 transient 发布的消息在恢复时会被丢弃，即使它们存在 durable 队列里。后半句是这一段的关键：&lt;strong&gt;队列的 &lt;code&gt;durable&lt;/code&gt; 不会替消息做决定&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;要验证这一点，得让 broker 真的重启一次：往同一个 durable 队列里放 2 条 &lt;code&gt;deliveryMode=2&lt;/code&gt; 与 1 条 &lt;code&gt;deliveryMode=1&lt;/code&gt;，重启，再重声明队列并抽干。重启前队列里是 3 条，重启后剩下 2 条，队列本身仍在，而少的那条正是以 transient 发布的那条。发布时三条都在同一个队列里、同一次连接、同一批调用，唯一的差别就是发布属性（&lt;code&gt;persistence-write&lt;/code&gt; → &lt;code&gt;persistence-read&lt;/code&gt;，中间重启一次 broker）。&lt;/p&gt;
&lt;p&gt;复制这个维度在正文里只到这一步。这里要拿到的是两个维度正交这个结论。&lt;/p&gt;
&lt;h2 id="五消费一条消息ack-才是可删除的条件prefetch-决定未确认消息的上限"&gt;五、消费一条消息：ack 才是「可删除」的条件，prefetch 决定未确认消息的上限
&lt;/h2&gt;&lt;p&gt;默认的消费方式是推送：客户端订阅之后，服务端用 &lt;code&gt;basic.deliver&lt;/code&gt; 把消息推过来，一个订阅由它自己的标签（consumer tag）标识。收到之后怎么算「处理完了」，取决于 ack 模式。&lt;/p&gt;
&lt;p&gt;手动确认有三种应答：成功；失败并要求重新排队；失败且拒绝重排（这一种不能批量）。协议里它们分别叫 &lt;code&gt;basic.ack&lt;/code&gt; / &lt;code&gt;basic.nack&lt;/code&gt;（多带一个是否重排的标志）/ &lt;code&gt;basic.reject&lt;/code&gt;。其中「成功」那一档的语义值得单独说清楚：它是「这条消息可以被删掉了」，不是「我读到了」。在这句话兑现之前，消息同时存在于两个地方：&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;sequenceDiagram
 participant C as 消费者
 participant S as Broker
 participant U as 未确认集合
 S-&gt;&gt;C: basic.deliver 投递一条消息
 S-&gt;&gt;U: 同时记入未确认集合
 C-&gt;&gt;S: basic.ack：这条可以删了
 S-&gt;&gt;U: 从集合里移出
 C--xS: 信道或连接断开
 U-&gt;&gt;S: 未确认的那几条退回 ready，可被重新投递&lt;/pre&gt;&lt;p&gt;这张图给出了「消费」的准确边界：它不是一次瞬时动作，而是一段有起止的状态。从交付到 ack 之间，消息既在服务端的未确认集合里，也在客户端手里。所以「队列空了」不等于「处理完了」，而「客户端收到了」也不等于「不会被重投」。&lt;/p&gt;
&lt;p&gt;这段状态在队列深度上读得出来。5 条消息、prefetch 设成 2：刚发布完 &lt;code&gt;ready&lt;/code&gt; 是 5；2 条被投递到客户端手里之后降到 3，此时再等也不会有第 3 条过来；直到其中 1 条被 ack、名额腾出来，第 3 条这才被推进来，深度走到 2；最后把信道关掉，尚未确认的那 2 条退回 &lt;code&gt;ready&lt;/code&gt;，深度回到 4（&lt;code&gt;consume-ack&lt;/code&gt;）。&lt;/p&gt;
&lt;p&gt;从 5 到 3 减掉的 2 条，就是被投递但还没 ack 的那两条。它们没有消失，只是从 ready 挪到了未确认集合。这也说明了 prefetch 管的是什么：&lt;strong&gt;它是同一条信道上未确认消息的条数上限，不是投递速率&lt;/strong&gt;，它限制并发在途的量，而不管每秒能发多少条。最后那一步回到 4，是因为此刻已投递 3 条、已 ack 1 条，剩下 2 条未确认，把信道关掉，这 2 条退回 ready。5 条里 1 条被 ack 后永久删除，剩下 4 条一条没丢。&lt;/p&gt;
&lt;p&gt;另一种收手势的结果和直觉不同。把消费者取消掉，但连接与信道都留着，&lt;code&gt;ready&lt;/code&gt; 仍是 2；只有把信道也关掉，它们才退回 4。所以未确认消息退回的触发点是信道或连接关闭，取消消费者本身不构成退回（&lt;code&gt;consume-ack&lt;/code&gt;）。排查「消费者停了但消息没回来」这类现象时，这一条能省下不少时间。&lt;/p&gt;
&lt;p&gt;另一种取法是主动拉取（pull 模型，协议里的 &lt;code&gt;basicGet&lt;/code&gt;），官方口径是低效、应当避免，这里只用它做一个对照：连续三次 &lt;code&gt;basicGet&lt;/code&gt;，第三次返回 &lt;code&gt;null&lt;/code&gt;，因为队列已经空了。&lt;/p&gt;
&lt;h3 id="channel-与线程"&gt;channel 与线程
&lt;/h3&gt;&lt;p&gt;最后回到信道本身。交付和 ack 都发生在某条 channel 上，而 channel 不是给多线程共享用的。这件事在客户端库那里的措辞是禁止级的：官方客户端 5.30.0 的 &lt;code&gt;Channel&lt;/code&gt; 类说明用的词是 &lt;strong&gt;must not&lt;/strong&gt;：「Channel 实例不得在线程之间共享」，并且点名了后果：其中一部分操作会导致帧在同一连接上交错（incorrect frame interleaving on the wire），而且在多线程之间共享信道还会干扰 publisher confirms。&lt;/p&gt;
&lt;p&gt;后半句有一个很直观的读数：让 8 个线程共享同一条信道，每个线程各取一次发布序号、再发布，一共 1600 条，读到的 1600 个序号里互不相同的只有 1572 个（重复 28 个），确认回调也只到了 193 次。这两个数字就是「干扰 publisher confirms」的具体形状：序号账本是 publisher confirm 用来对账的凭据，本该一次发布对应一个唯一序号（&lt;code&gt;topology&lt;/code&gt;）。&lt;/p&gt;
&lt;p&gt;正确的姿势是每个线程（或每个进程）持有自己的 channel，共享的是 connection，它本身是设计给多线程共用的。Spring AMQP 的并发消费者配置正是按这个模型，把 channel 分摊到各个消费线程上：&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart TB
 CO["connection（多线程可共用）"]
 CH1["channel #1"] --&gt; T1["消费线程 1"]
 CH2["channel #2"] --&gt; T2["消费线程 2"]
 CO --&gt; CH1
 CO --&gt; CH2&lt;/pre&gt;&lt;h2 id="六挂在队列上的三个常用特性ttl死信长度限制可用性由队列类型决定"&gt;六、挂在队列上的三个常用特性：TTL、死信、长度限制，可用性由队列类型决定
&lt;/h2&gt;&lt;p&gt;TTL、死信、长度限制是队列上最常用的三个配置。它们放在同一节，是因为它们有同一个挂载点：三者都是队列级特性，都配置在队列上（声明参数或 policy），而不是配置在客户端代码里。这个共同点直接决定了一件事：它们能不能用，由队列类型决定。&lt;/p&gt;
&lt;h3 id="ttl"&gt;TTL
&lt;/h3&gt;&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;项&lt;/th&gt;
					&lt;th&gt;结论&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;队列级 &lt;code&gt;x-message-ttl&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;毫秒单位的非负整数，可写在声明参数或 policy 里&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;单条消息 &lt;code&gt;expiration&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;发布时设置，&lt;strong&gt;必须是数字的字符串形式&lt;/strong&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;两者并存&lt;/td&gt;
					&lt;td&gt;&lt;strong&gt;取较小的那个&lt;/strong&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;到期语义&lt;/td&gt;
					&lt;td&gt;官方用词是 guarantees：到期的消息不会通过 &lt;code&gt;basic.deliver&lt;/code&gt; 投给消费者，&lt;code&gt;basic.get&lt;/code&gt; 也取不到&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;strong&gt;队头规则&lt;/strong&gt;&lt;/td&gt;
					&lt;td&gt;只有排到队头时才会真正被丢弃；单条消息 TTL 下，先过期的消息会堵在未过期消息后面，资源不释放、仍计入队列统计&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;requeue 之后&lt;/td&gt;
					&lt;td&gt;保留&lt;strong&gt;原来的到期时间&lt;/strong&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;TTL = 0&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;到达即过期，除非当时正好能投给消费者；它是 &lt;code&gt;immediate&lt;/code&gt; 参数的替代品，且不产生 &lt;code&gt;basic.return&lt;/code&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;队列过期 &lt;code&gt;x-expires&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;&lt;strong&gt;必须是正整数&lt;/strong&gt;，只在队列未被使用时计时&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;队头规则是这里最容易误判的一条：只有排到队头时才会真正被丢弃。&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart TB
 A["队头消息"] --&gt; C{"超过 TTL 了吗"}
 C -- "是" --&gt; D["丢弃：basic.deliver 不会投，basic.get 也取不到"]
 C -- "否" --&gt; E["投递给消费者"]
 F["排在后面的消息：还没成为队头之前不会被检查"] --&gt; A&lt;/pre&gt;&lt;p&gt;这一条在读数上很好分辨。先发一条 60 秒的 A、再发一条 2 秒的 B，3 秒后 B 早该过期，但此时队列深度仍是 2；直到消费时消费者只拿到 A 一条，深度才直接归零：A 被消费掉，B 在排到队头的那一刻被丢弃（&lt;code&gt;ttl&lt;/code&gt;）。所以设了 TTL 之后队列长度不会立刻下降，要等消息挪到队头才会掉。&lt;/p&gt;
&lt;p&gt;另外两条规则也在同一组读数里：队列级 8 秒与单条 1.5 秒并存时，3 秒时深度为 1、9 秒时归零，生效的是较小的那个；一条 12 秒的消息被另一条连接取走后按住 6 秒再退回，仍在 &lt;code&gt;t=13.8s&lt;/code&gt; 到期消失，说明 requeue 不会重新计时，它按首次发布的时刻算。最后这条是专门为了分开两种假设设计的：如果 requeue 会重新计时，消息应该活到 &lt;code&gt;t≈18s&lt;/code&gt;（&lt;code&gt;ttl&lt;/code&gt;）。&lt;/p&gt;
&lt;h3 id="死信"&gt;死信
&lt;/h3&gt;&lt;p&gt;满足下面四个条件之一时，消息会被投到 &lt;code&gt;x-dead-letter-exchange&lt;/code&gt; 指定的交换机上：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;触发点&lt;/th&gt;
					&lt;th&gt;说明&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;basic.reject&lt;/code&gt; 或 &lt;code&gt;basic.nack&lt;/code&gt; 且 &lt;code&gt;requeue=false&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;消费者主动拒绝并声明不再投回&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;消息 TTL 到期&lt;/td&gt;
					&lt;td&gt;队列级与单条两种配置都算&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;超过长度限制被丢弃&lt;/td&gt;
					&lt;td&gt;见下一小节&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;quorum&lt;/code&gt; 上返回次数超过 &lt;code&gt;delivery-limit&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;4.0 起默认 20&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart LR
 A["四种触发：reject/nack 且 requeue=false · TTL 到期 · 超长度被丢弃 · quorum 超过 delivery-limit"]
 B["x-dead-letter-exchange 指定的交换机"]
 C["死信队列"]
 D["例外：整个队列被 x-expires 删掉时，消息不进死信"]
 A --&gt; B --&gt; C
 C -.-&gt; D&lt;/pre&gt;&lt;p&gt;几个配套细节：路由键没设时用消息的原路由键（含 &lt;code&gt;CC&lt;/code&gt; 头，不含 &lt;code&gt;BCC&lt;/code&gt;）；被死信的消息会带上 &lt;code&gt;x-death&lt;/code&gt; 数组，记录 &lt;code&gt;queue&lt;/code&gt; / &lt;code&gt;reason&lt;/code&gt; / &lt;code&gt;count&lt;/code&gt; / 时间戳，&lt;code&gt;reason&lt;/code&gt; 取值是 &lt;code&gt;rejected&lt;/code&gt;、&lt;code&gt;expired&lt;/code&gt;、&lt;code&gt;maxlen&lt;/code&gt;、&lt;code&gt;delivery_limit&lt;/code&gt;；消息被死信时原本的 &lt;code&gt;expiration&lt;/code&gt; 会被移除，防止它在新队列里再次到期。&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;边注&lt;/strong&gt;：死信转发默认不开内部 publisher confirm，也就是 at-most-once：目标队列不可用时消息会丢。要用它的场景需要自己把这一环补上。&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;p&gt;四个触发点各自的表现（&lt;code&gt;dlx&lt;/code&gt;）：主动拒绝（&lt;code&gt;nack&lt;/code&gt; 且 &lt;code&gt;requeue=false&lt;/code&gt;）、单条 TTL 到期、队列级 &lt;code&gt;x-message-ttl&lt;/code&gt; 到期各进死信 1 条，而整队被 &lt;code&gt;x-expires&lt;/code&gt; 删掉时进死信 0 条，符合文档的例外说明。&lt;/p&gt;
&lt;p&gt;其中队列级 TTL 到期这一条值得留意，因为官方两个页面的措辞不一致：dlx 那一页只写了「因单条消息 TTL 到期」，而 ttl 那一页同时覆盖队列级配置，这里的结论是两者都进死信。两条路径在数据上可以区分：队列级这条的 &lt;code&gt;x-death&lt;/code&gt; 里没有 &lt;code&gt;original-expiration&lt;/code&gt;，单条 TTL 那条有。如果代码要靠 &lt;code&gt;x-death&lt;/code&gt; 判断死因，这个差别会用到。&lt;/p&gt;
&lt;h3 id="长度限制"&gt;长度限制
&lt;/h3&gt;&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;项&lt;/th&gt;
					&lt;th&gt;结论&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;参数&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;x-max-length&lt;/code&gt;（条数）或 &lt;code&gt;x-max-length-bytes&lt;/code&gt;（字节）&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;x-overflow&lt;/code&gt; 三档&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;drop-head&lt;/code&gt;（默认）、&lt;code&gt;reject-publish&lt;/code&gt;、&lt;code&gt;reject-publish-dlx&lt;/code&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;drop-head&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;从队头（最早的）开始丢，发送端感知不到&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;reject-publish&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;丢最新的，发送端通过 &lt;code&gt;basic.nack&lt;/code&gt; 感知&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;reject-publish-dlx&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;被拒的那几条改道进死信队列&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;quorum&lt;/code&gt; 上的 &lt;code&gt;reject-publish-dlx&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;官方说 quorum「do not support」这一档；&lt;strong&gt;实际行为&lt;/strong&gt;：声明不会报错，但会静默退回 &lt;code&gt;drop-head&lt;/code&gt;&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;四段对照里有两处值得留意（&lt;code&gt;max-length&lt;/code&gt;）。一是 &lt;code&gt;reject-publish&lt;/code&gt; 这一档发送端确实能感知到：上限 3 条时依次发 5 条，确认回调里 ack 3 次、nack 2 次；而默认的 &lt;code&gt;drop-head&lt;/code&gt; 从队头丢，留下的是最后 3 条，发送端拿到的全是成功。&lt;/p&gt;
&lt;p&gt;二是 &lt;code&gt;quorum&lt;/code&gt; 队列上那次 &lt;code&gt;reject-publish-dlx&lt;/code&gt;：声明被接受、没有报错，主队列 3 条、死信 2 条。只看深度，这和真正生效那一档的形状完全一致。要分开这两个假设只能看死信里是哪两条：真正生效那次死信里是最新的两条，这一次是最早的两条，说明它在 quorum 上并没有按 &lt;code&gt;reject-publish-dlx&lt;/code&gt; 走，而是静默退回成了队头丢弃。&lt;strong&gt;声明成功不等于行为生效&lt;/strong&gt;。&lt;/p&gt;

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

 &lt;/blockquote&gt;
&lt;p&gt;三个特性齐了，回到它们共同的那条边界：这三项在 &lt;code&gt;classic&lt;/code&gt; 与 &lt;code&gt;quorum&lt;/code&gt; 上都能用（&lt;code&gt;quorum&lt;/code&gt; 上只有溢出那一档有差别），在 &lt;code&gt;stream&lt;/code&gt; 上则被 retention 取代。所以它们可用与否，取决于消费会不会删数据；客户端代码怎么写、队列是 &lt;code&gt;classic&lt;/code&gt; 还是 &lt;code&gt;quorum&lt;/code&gt;，都不构成决定因素。&lt;/p&gt;
&lt;h2 id="七声明为什么会被拒绝幂等比对与-430-的-transient-队列"&gt;七、声明为什么会被拒绝：幂等比对，与 4.3.0 的 transient 队列
&lt;/h2&gt;&lt;p&gt;回到开篇的另外两个现象：改声明参数报 &lt;code&gt;406&lt;/code&gt;，换队列类型直接失败。两条都不在发布、消费路径上，而在声明这一步。&lt;/p&gt;
&lt;h3 id="先立错误分级"&gt;先立错误分级
&lt;/h3&gt;&lt;p&gt;AMQP 的协议异常分两级，判据不是错误文本，而是 broker 关掉了哪一个实体：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;级别&lt;/th&gt;
					&lt;th&gt;错误码&lt;/th&gt;
					&lt;th&gt;broker 关掉的是谁&lt;/th&gt;
					&lt;th&gt;之后还能不能继续用这条 connection&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;soft（信道级）&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;404&lt;/code&gt; / &lt;code&gt;406&lt;/code&gt; / &lt;code&gt;403&lt;/code&gt; / &lt;code&gt;405&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;这条 channel&lt;/td&gt;
					&lt;td&gt;能，新开一条继续&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;hard（连接级）&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;501&lt;/code&gt; / &lt;code&gt;540&lt;/code&gt; / &lt;code&gt;541&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;整条 connection&lt;/td&gt;
					&lt;td&gt;不能，得重建连接&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code&gt;406&lt;/code&gt; 属于前者，也就是关一条信道、连接还活着。这与第一节那次「被动声明一个不存在的队列」拿到的 &lt;code&gt;404&lt;/code&gt; 是同一类表现。&lt;/p&gt;
&lt;p&gt;hard 这一类有一条反直觉的性质：它们是异步上报的。调用点不会抛同步异常，要注册 &lt;code&gt;addShutdownListener&lt;/code&gt; 才拿得到。给发布方法带上 &lt;code&gt;immediate=true&lt;/code&gt;（这个参数在 4.x 上已被移除）返回 &lt;code&gt;540&lt;/code&gt;，手工塞一个非法帧类型的帧返回 &lt;code&gt;501&lt;/code&gt;，两次调用点都是「正常返回」，连接却已经不在了（&lt;code&gt;topology&lt;/code&gt;）。所以排查「整条连接突然断掉、日志里只有 5xx 错误码」这类故障时，不能用「有没有抛异常」作为判据，得看 shutdown listener 或连接状态。&lt;/p&gt;
&lt;h3 id="声明是幂等比对不是覆盖"&gt;声明是幂等比对，不是覆盖
&lt;/h3&gt;&lt;p&gt;声明一个已经存在的队列或交换机时，服务端做的是把这次声明的参数和已存在的实体逐项比对，而不是用新的覆盖旧的。任何一项对不上就报 &lt;code&gt;406&lt;/code&gt;，报错文本落在 &lt;code&gt;inequivalent arg&lt;/code&gt; 这个词上，并且关掉这条 channel。两个用例的表现（&lt;code&gt;precondition&lt;/code&gt;）：给一个已存在的队列补上 &lt;code&gt;x-message-ttl=1000&lt;/code&gt; 再声明一次，报 &lt;code&gt;406&lt;/code&gt; 并指出收到的值与当前值不一致；把已存在的 &lt;code&gt;classic&lt;/code&gt; 队列重新声明成 &lt;code&gt;quorum&lt;/code&gt;，同样报 &lt;code&gt;406&lt;/code&gt;，指出 &lt;code&gt;received 'quorum' but current is 'classic'&lt;/code&gt;。两次都是 &lt;code&gt;connection.isOpen=true&lt;/code&gt; 而 &lt;code&gt;channel.isOpen=false&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;开篇现象 2 和现象 3 在这里合流：它们不是两件事。换队列类型失败，只是因为 &lt;code&gt;x-queue-type&lt;/code&gt; 恰好也是被比对的一项。保留的语义是「这次声明不生效」，而不是「把旧的改成新的再失败」：被拒之后，原队列的类型没有变。&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart TB
 A["声明一个已存在的队列"] --&gt; B{"参数与现有声明一致吗"}
 B -- "一致" --&gt; C["放行（声明是幂等的）"]
 B -- "不一致" --&gt; D["406 PRECONDITION_FAILED"]
 D --&gt; E["关掉这条 channel；connection 仍然可用"]&lt;/pre&gt;&lt;p&gt;这条机制解释了一个很常见的现象：本地把队列参数改了一行，重启应用就报 &lt;code&gt;406&lt;/code&gt;，而在一个新环境上跑得好好的，因为新环境里队列还不存在，第一次声明总能成功。所以声明参数应当当成接口契约来管：线上已经声明过的队列，参数就不能随手改。要改就得先删队列（会连消息一起删），或者改用 policy：它是动态下发、不参与这次比对的。&lt;/p&gt;
&lt;h3 id="430-起transient-非排他队列默认不可声明"&gt;4.3.0 起，transient 非排他队列默认不可声明
&lt;/h3&gt;&lt;p&gt;剩下两个用例是版本边界和一个未预期的结果：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;用例&lt;/th&gt;
					&lt;th&gt;结果&lt;/th&gt;
					&lt;th&gt;级别&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;队列名用保留前缀 &lt;code&gt;amq.&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;403 ACCESS_REFUSED&lt;/code&gt;（contains reserved prefix）&lt;/td&gt;
					&lt;td&gt;信道级&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;durable=false&lt;/code&gt; + &lt;code&gt;exclusive=false&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;541 INTERNAL_ERROR&lt;/code&gt;（Feature &lt;code&gt;transient_nonexcl_queues&lt;/code&gt; is deprecated）&lt;/td&gt;
					&lt;td&gt;&lt;strong&gt;连接级&lt;/strong&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;用另一条 connection 访问独占队列&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;405 RESOURCE_LOCKED&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;信道级&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;第二行是最值得记的一条。官方从 4.3.0 起把「非持久化且非排他的 classic 队列」列为弃用并默认拒绝（配置项 &lt;code&gt;deprecated_features.permit.transient_nonexcl_queues&lt;/code&gt;），老教程里顺手写的 &lt;code&gt;durable=False&lt;/code&gt; 在这个版本上直接撞上它。而它返回的是 &lt;code&gt;541&lt;/code&gt;，属于连接级，不是 &lt;code&gt;406&lt;/code&gt;。所以一条队列声明失败，代价是整条连接被带走，同一次运行里后面几步必须重建连接才能继续。把它和上面那张分级表对照着看：同样是「声明被拒」，&lt;code&gt;406&lt;/code&gt; 只关信道，&lt;code&gt;541&lt;/code&gt; 关整条连接，错误码决定了故障半径。&lt;/p&gt;
&lt;p&gt;第三行补的是另一个方向：在连接 A 上声明的独占队列，用连接 B 去被动声明会得到 &lt;code&gt;405 RESOURCE_LOCKED&lt;/code&gt;（信道级）。独占的语义是按连接判定的。&lt;/p&gt;
&lt;h2 id="八收口两层骨架与它划出的边界"&gt;八、收口：两层骨架与它划出的边界
&lt;/h2&gt;&lt;p&gt;把前面七节的对象串起来，一条消息走过的是一条单一方向的链：&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart TB
 subgraph R["路由层：决定去哪"]
 X["交换机类型 + 绑定"]
 end
 subgraph C["承载层：决定以什么语义保存与消费"]
 Q["队列类型：classic / quorum / stream"]
 F["队列级特性：TTL / 死信 / 长度限制"]
 end
 R --&gt; C&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;路由层&lt;/strong&gt;是交换机类型加绑定，它回答「去哪」，并且与队列类型无关：同一个路由键在 direct 下落 1 个、在 fanout 下落全部、在 topic 下按通配落若干，把队列从 classic 换成 quorum 不会改变这个结果。&lt;strong&gt;承载层&lt;/strong&gt;是队列，它回答「以什么语义被保存与消费」，队列类型决定消费语义（消费即删除还是非破坏性读），而挂在队列上的那些特性也跟着这个语义走。&lt;/p&gt;
&lt;p&gt;回到开篇那三个现象，它们都能从上面两条机制推出来：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;无绑定的路由键不报错，是路由层的直接结果：落点由绑定决定，而落点可以是 0；默认路径下发送端拿不到异常。&lt;/li&gt;
&lt;li&gt;改声明参数报 &lt;code&gt;406&lt;/code&gt;，是承载层的直接结果：队列的参数是它自己的身份，声明只做比对不做覆盖。&lt;/li&gt;
&lt;li&gt;换队列类型失败，与上一条是同一件事，&lt;code&gt;x-queue-type&lt;/code&gt; 也是被比对的一项。它看起来像第三个坑，其实是第二个坑的一个实例。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这套骨架有两处代价。三层作用域（vhost / connection / channel）换来了复用一条 TCP 的能力，代价是错误域分了级：同一个失败在不同错误码下可能只废掉一条信道，也可能废掉整条连接，所以错误码在 RabbitMQ 里不是一个诊断细节，而是故障半径本身。两层结构（路由 + 承载）让「去哪」和「怎么存」解耦，代价是队列类型一旦选定就锁住了它能用哪些特性，而且改不了：选 &lt;code&gt;stream&lt;/code&gt; 那一刻，TTL 与长度限制就换成了 retention，死信则没有位置。&lt;/p&gt;
&lt;h2 id="参考资料"&gt;参考资料
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;&lt;a class="link" href="https://www.rabbitmq.com/docs/amqp-concepts" target="_blank" rel="noopener"
 &gt;AMQP 0-9-1 Model Explained&lt;/a&gt;：vhost / connection / channel 三层定义，交换机与绑定的关系，ack 语义。&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://www.rabbitmq.com/docs/connections" target="_blank" rel="noopener"
 &gt;Connections&lt;/a&gt;：连接生命周期、协商参数、认证与逐操作授权、连接是长生命周期对象这一前提。&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://www.rabbitmq.com/docs/channels" target="_blank" rel="noopener"
 &gt;Channels&lt;/a&gt;：信道的定义（一条 TCP 上的轻量连接）、协议异常分级（soft 关信道 / hard 关连接）、未确认消息随信道关闭退回。&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://www.rabbitmq.com/docs/exchanges" target="_blank" rel="noopener"
 &gt;Exchanges&lt;/a&gt;：四种内建交换机与预声明名，default exchange 的三条特殊性质。&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://www.rabbitmq.com/docs/queues" target="_blank" rel="noopener"
 &gt;Queues&lt;/a&gt;：队列属性、durable 队列恢复时包含 persistent 消息而 transient 消息被丢弃、classic 复制在 4.x 移除、&lt;code&gt;x-queue-type&lt;/code&gt; 声明期不可变。&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://www.rabbitmq.com/docs/quorum-queues" target="_blank" rel="noopener"
 &gt;Quorum Queues&lt;/a&gt;：Raft 复制、恒 durable、不支持全局 QoS、长度限制不含 &lt;code&gt;reject-publish-dlx&lt;/code&gt;、&lt;code&gt;reject-publish&lt;/code&gt; 会超出上限至少一条。&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://www.rabbitmq.com/docs/streams" target="_blank" rel="noopener"
 &gt;Streams&lt;/a&gt;：非破坏性读语义、与 classic 的完整特性矩阵（含 TTL 与长度限制的 &lt;code&gt;no (but see Retention)&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://www.rabbitmq.com/docs/ttl" target="_blank" rel="noopener"
 &gt;Time-To-Live and Expiration&lt;/a&gt;：队列级与单条 TTL、取较小值、队头规则、&lt;code&gt;x-expires&lt;/code&gt; 的取值要求。&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://www.rabbitmq.com/docs/dlx" target="_blank" rel="noopener"
 &gt;Dead Letter Exchanges&lt;/a&gt;：四个触发点、&lt;code&gt;x-death&lt;/code&gt; 结构与 &lt;code&gt;reason&lt;/code&gt; 取值、整队过期不进死信的例外、默认 at-most-once。&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://www.rabbitmq.com/docs/maxlength" target="_blank" rel="noopener"
 &gt;Queue Length Limits&lt;/a&gt;：&lt;code&gt;x-overflow&lt;/code&gt; 三档行为与发送端的可感知差异。&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://www.rabbitmq.com/docs/confirms" target="_blank" rel="noopener"
 &gt;Consumer Acknowledgements and Publisher Confirms&lt;/a&gt;：ack / nack / reject 的语义与批量限制、confirm 的序号账本。&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://www.rabbitmq.com/docs/consumer-prefetch" target="_blank" rel="noopener"
 &gt;Consumer Prefetch&lt;/a&gt;：&lt;code&gt;basic.qos&lt;/code&gt; 与未确认消息上限。&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://www.rabbitmq.com/docs/deprecated-features" target="_blank" rel="noopener"
 &gt;Deprecated Features&lt;/a&gt;：&lt;code&gt;transient_nonexcl_queues&lt;/code&gt; 自 4.3.0 起默认拒绝及其配置项。&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;

 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;演示代码&lt;/strong&gt;：完整实现在 &lt;a class="link" href="https://github.com/renxin2024/GYD/tree/main/c01-rabbitmq-architecture" target="_blank" rel="noopener"
 &gt;GYD 仓库 &lt;code&gt;c01-rabbitmq-architecture&lt;/code&gt;&lt;/a&gt;，RabbitMQ 的编排在仓库级 &lt;a class="link" href="https://github.com/renxin2024/GYD/tree/main/infra/rabbitmq" target="_blank" rel="noopener"
 &gt;&lt;code&gt;infra/rabbitmq/&lt;/code&gt;&lt;/a&gt;。正文每个结论后面括注的名字就是复现入口，在仓库根用 &lt;code&gt;./gradlew :c01-rabbitmq-architecture:run --args=&amp;quot;&amp;lt;名字&amp;gt;&amp;quot;&lt;/code&gt; 跑（JDK 21 需显式指定 &lt;code&gt;JAVA_HOME&lt;/code&gt;）；运行方式、每个示例做了什么、以及各结论对应的完整输出记录，都在该模块的 README 里。第四节的持久化那一段要按顺序跑两条命令、中间重启一次 broker。&lt;/p&gt;

 &lt;/blockquote&gt;</description></item></channel></rss>