<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>RabbitMQ on Renxin's Blog</title><link>https://zh.renxinblog.cn/tags/rabbitmq/</link><description>Recent content in RabbitMQ on Renxin's Blog</description><generator>Hugo -- gohugo.io</generator><language>zh</language><lastBuildDate>Sun, 22 Feb 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://zh.renxinblog.cn/tags/rabbitmq/index.xml" rel="self" type="application/rss+xml"/><item><title>第五话｜RabbitMQ 集群的架构与高可用：一条消息从发到收走了哪些节点</title><link>https://zh.renxinblog.cn/post/gyd-c05-rabbitmq-cluster-storage/</link><pubDate>Sun, 22 Feb 2026 00:00:00 +0000</pubDate><guid>https://zh.renxinblog.cn/post/gyd-c05-rabbitmq-cluster-storage/</guid><description>&lt;img src="https://zh.renxinblog.cn/images/gyd-c05-rabbitmq-cluster-storage-cover.png" alt="Featured image of post 第五话｜RabbitMQ 集群的架构与高可用：一条消息从发到收走了哪些节点" /&gt;&lt;p&gt;这篇文章讲 RabbitMQ 的集群架构，以及它如何靠这套架构实现高可用。要看清这套架构，得先拆掉「集群把消息复制了三份」这个最常见的误解——集群真正复制的其实只有两类：&lt;strong&gt;集群元数据和队列内容。&lt;/strong&gt; 而客户端和某个节点之间的连接状态，根本不进集群，只握在客户端自己手里。搞清楚这三样东西各自由谁负责、出故障时各自怎么表现，就理解了这套高可用架构到底可靠在哪、又会在哪里失效。&lt;/p&gt;
&lt;p&gt;第一话在单台节点上把一条消息的一生走了一遍，那时队列类型之间的差别只体现在消费语义上；连成集群之后，同一个差别会变成两种完全不同的故障表现——这正是集群高可用要解决的那一层。&lt;/p&gt;
&lt;p&gt;正文里会碰到一个具体的矛盾：&lt;strong&gt;客户端连的是节点 A，队列的 Leader 在节点 B，消息为什么还能发成功？&lt;/strong&gt; 这个问题在&lt;a class="link" href="#gyd-c05-scene" &gt;第四节&lt;/a&gt;和第六节会拆开讲，答案正是上面那三类数据的分工——客户端状态留在原地，队列内容在集群内部被定位与复制，两者各走各的通道。&lt;/p&gt;
&lt;p&gt;全文以 RabbitMQ 4.3.5 为准（Erlang 27.3.4.17、&lt;code&gt;amqp-client 5.30.0&lt;/code&gt;、JDK 21）。集群是三台容器里的节点，同宿主机、仅面向本机实验——它不构成任何生产容量或性能结论，文中给出的秒数与条数只对这个环境负责。凡是本机没有跑过的机制，正文会写明它来自官方文档或源码，不当成已证实的结论。&lt;/p&gt;

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

 &lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2 id="一amqp-模型消息不直接进队列而是先到交换机"&gt;一、AMQP 模型：消息不直接进队列，而是先到交换机
&lt;/h2&gt;&lt;p&gt;讲集群之前，先把 RabbitMQ 在单机上的那套对象模型立住。生产者与消费者之间隔着三样东西：&lt;strong&gt;vhost、交换机（exchange）、队列（queue）&lt;/strong&gt;，消息要经过一次路由才会落进队列。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;vhost 是逻辑隔离层。&lt;/strong&gt; 一个 vhost 里有自己一套独立的交换机、队列和绑定，权限也按 vhost 划分。默认开箱即有一个 vhost（名字是 &lt;code&gt;/&lt;/code&gt;）。本文的所有操作都发生在默认 vhost 里，不提 vhost 就是指它。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;生产者把消息发到交换机，不是直接发到队列。&lt;/strong&gt; 每发一条消息都要带一个 routing key（路由键），交换机根据自身类型和 binding（绑定）决定把它投给哪些队列。消息在交换机这一层完成路由，之后才落到队列里。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;队列是消息最终落点，消费者从队列取。&lt;/strong&gt; 队列通过 binding 挂到交换机上，binding 规定了「什么样的 routing key 投到这个队列」。消费者订阅的是队列，不是交换机。&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;%%{init: {'theme':'neutral'}}%%
flowchart LR
 P[生产者&lt;br/&gt;发消息 + routing key] --&gt; E{交换机&lt;br/&gt;按类型和 binding 路由}
 E --&gt;|binding 命中| Q1[队列 1]
 E --&gt;|binding 命中| Q2[队列 2]
 Q1 --&gt; C1[消费者 1]
 Q2 --&gt; C2[消费者 2]&lt;/pre&gt;&lt;p&gt;交换机类型决定路由规则，最常见的是四种：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;direct&lt;/strong&gt;：routing key 和 binding key 精确相等才投；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;topic&lt;/strong&gt;：用 &lt;code&gt;.&lt;/code&gt; 分段的通配匹配（&lt;code&gt;*&lt;/code&gt; 匹配一段、&lt;code&gt;#&lt;/code&gt; 匹配零或多段）；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;fanout&lt;/strong&gt;：忽略 routing key，广播给所有绑定队列；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;headers&lt;/strong&gt;：按消息头匹配，几乎不用。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;四种里本文只会在两处用到 &lt;strong&gt;fanout&lt;/strong&gt;——第六节「一条消息路由命中多个队列」和第八节「confirm 由所有命中队列共同决定」，都依赖 fanout 把一条消息同时投进多个队列。其余三种类型与本文的集群主题无关。&lt;/p&gt;
&lt;p&gt;一句话立住这个模型：&lt;strong&gt;生产者 → 交换机 →（binding 路由）→ 队列 → 消费者&lt;/strong&gt;。集群要做的事，就是把这条链上「交换机、队列、绑定的定义」和「队列里的消息」这两样，在多个节点之间正确地复制与同步。下面先看复制靠什么机制，再回到这条链本身。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="二三种队列类型classicquorum-与-stream"&gt;二、三种队列类型：classic、quorum 与 stream
&lt;/h2&gt;&lt;p&gt;第一节里的「队列」在 RabbitMQ 里不是一个铁板一块的东西，而是分三种类型。它们共享同一套 AMQP 模型（都要挂到交换机上、都由消费者订阅），差别在于&lt;strong&gt;一份数据存几份、以什么方式存&lt;/strong&gt;。后面全文反复出现的「classic / quorum 两类」指的就是其中两种，这里先一次说清，免得读到中途才去猜。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;classic queue：一份数据，只存在属主那台节点上。&lt;/strong&gt; 这是 RabbitMQ 最老、默认的队列类型，消息只在属主（owner）节点落一份。它适合不需要跨节点复制、追求简单与低延迟的场景；代价是一旦属主节点离线，队列就不可用——因为数据没有第二份。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;quorum queue：三份数据，靠 Raft 复制到多数节点。&lt;/strong&gt; 它把队列实现为一组 Raft 副本（典型三副本，也可配其它奇数），消息被复制到多数节点，靠多数派保证可用性与不丢。它适合要求「挂掉一个节点还能继续收发」的高可用场景；代价是每次写入要走一轮复制确认，比 classic 多一次网络往返。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;stream：按追加日志存，适合大吞吐的连续流。&lt;/strong&gt; 它面向的不是经典的生产/消费队列语义，而是「可以反复重放、按偏移量读」的日志型数据，消息不会被消费即删。它的副本也是多数派复制，但副本管理、确认边界都和 quorum 有细微差别。&lt;/p&gt;
&lt;p&gt;三种类型在应用里只差一个 &lt;code&gt;x-queue-type&lt;/code&gt; 参数，其余声明姿势完全一样（&lt;code&gt;amqp-client&lt;/code&gt; 的 &lt;code&gt;Channel&lt;/code&gt; 接口）：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-java" data-lang="java"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// 三种队列的声明只差 x-queue-type 这一个参数&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;queueDeclare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;orders.classic&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;queueDeclare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;orders.quorum&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;x-queue-type&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;quorum&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;queueDeclare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;orders.stream&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;x-queue-type&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;stream&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;不写 &lt;code&gt;x-queue-type&lt;/code&gt;，拿到的是默认的 classic。类型在声明那一刻就锁死——之后再用一个不同的 &lt;code&gt;x-queue-type&lt;/code&gt; 去声明同一个队列，broker 会拒绝这次声明，而不会把队列改掉。&lt;/p&gt;
&lt;p&gt;三种类型落在同一套 AMQP 模型上，差别只在「一份数据存几份、怎么存」：classic 只有一份数据，属主节点离线队列就不可用；quorum 把数据复制成三份，靠多数派保证可用；stream 也是三份，但按追加日志存、可反复重放。副本数是「高可用」的地基，这个地基在这一节先立住，后面几节再讲它怎么被多数派与选举用起来。&lt;/p&gt;
&lt;p&gt;三种类型里，&lt;strong&gt;本文的主线只有 classic 和 quorum 两类&lt;/strong&gt;——因为这一话的主题是「集群高可用」，而 classic 与 quorum 正好是一副最干净的对照：一份数据 vs 三份数据、单点 vs 多数派。stream 只在文末第十三节选型时出现一次，作为第三种选项补全决策维度，不进入正文的机制叙述。需要知道的是，两类队列在 AMQP 模型上是完全等价的：同一个 fanout 可以把一条消息同时投进一个 classic 和一个 quorum，两边各自按自己的规则接不接受、各确认各的，互不相干。&lt;/p&gt;
&lt;p&gt;有了这个分类，后面每一节里「classic 一个副本 / quorum 三个副本」的说法就有了出处。下面先看这些副本靠什么机制复制——也就是 Raft。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="三raft-的最小模型角色任期日志多数派与选举"&gt;三、Raft 的最小模型：角色、任期、日志、多数派与选举
&lt;/h2&gt;&lt;p&gt;RabbitMQ 集群的高可用，底层靠的是一套叫 Raft 的共识算法：把一份状态复制成多份，靠内部选举与复制，保证其中几台挂了整体还能服务。要理解它，不需要读完 Raft 教科书，只需要下面这个最小模型——一个副本组内部在做什么。模型到「能推出后续行为」为止，术语只保留最少的几个。&lt;/p&gt;
&lt;p&gt;&lt;img alt="一个 Raft 副本组的最小模型：写操作到 Leader，追加为日志条目并复制给 Followers，多数派确认后提交" class="gallery-image" data-flex-basis="466px" data-flex-grow="194" decoding="async" height="350" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://zh.renxinblog.cn/images/gyd-c05-raft-commit.svg" width="680"&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;角色。&lt;/strong&gt; 一个组里有一个 Leader 和若干 Followers。所有写操作先到 Leader。这里的「操作」是泛称：任何需要多个副本达成一致的变更都算，不必限定成某一种。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;任期与日志。&lt;/strong&gt; Leader 更替会更换任期。选举只允许日志足够新的候选者获胜，所以新 Leader 本身就带着既有日志，不是从零开始；它要处理的是上任期可能遗留的未提交尾部，把这些尾部在新任期里提交或覆盖，组内就「已提交到哪」达成一致之后继续服务。每个操作以日志条目的形式追加在 Leader 上，Leader 负责把条目复制给 Followers。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;多数派。&lt;/strong&gt; 这是模型里最要紧的一条：Raft 组&lt;strong&gt;只在多数副本可达时可用&lt;/strong&gt;。三成员组里一个节点故障还剩两个，多数派成立；再故障一个就只剩一个，多数派不成立。多个 Raft 组可以并存，各自独立地选举与判定多数派。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;提交点。&lt;/strong&gt; 多数派接受并确认之后，这条日志才算提交，此时才向客户端返回。所以确认的语义是「多数派已经收下」，它并不要求每个副本都写完。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;组大小与容错。&lt;/strong&gt; 三个成员支撑一个节点故障，五个支撑两个。为什么用奇数：四成员和它小一号的三成员一样只能容忍一个故障，却要多复制一份，纯亏。也正因如此，两节点集群不可取——两个成员里任何一个故障都会让多数派消失，容错能力为零，代价却照付。&lt;/p&gt;
&lt;p&gt;成员不共享节点：同一个组的几个成员分布在几台不同节点上，否则一台机器挂掉会同时带走两个成员。规模也不是越大越好，超过五个成员之后性能下降、不建议超过七个。&lt;/p&gt;
&lt;h2 id="gyd-c05-scene"&gt;四、三台节点连成集群：一条消息从发到收，走了哪些节点
&lt;/h2&gt;&lt;p&gt;三台 RabbitMQ 节点组成集群后，客户端连接其中任意一台即可声明队列、收发消息，无论这个队列实际由哪台节点持有。本节沿一条消息的完整路径，说明发布、消费各经过哪些节点，以及集群在节点间同步了哪些数据。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;发布：客户端只连一台，其余由集群内部完成。&lt;/strong&gt; 客户端连接的那台节点称为接入节点，它只作为入口，不必然持有目标队列。队列的写操作由 Leader 承担（classic queue 中称属主），Leader 可以位于集群内任意节点。发布时，消息先到达接入节点上的 Channel 进程；Channel 根据队列元数据，在 broker 内部直接定位到队列 Leader（而不是先投到一个本地队列再等转发）。Leader 将其追加为日志条目，再复制给该队列的其余副本（Follower）。多数派副本确认后，确认沿原路返回客户端。整个过程客户端只与接入节点通信，无需感知 Leader 的位置——「接入节点」与「Leader」之间的那一步是 broker 内部寻址，不是客户端另建连接。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;同步：集群复制的只有两类数据。&lt;/strong&gt; 上述路径中，真正在节点间复制的数据分两类，机制相互独立：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;元数据&lt;/strong&gt;：队列定义、binding 等声明性内容，由 Khepri 维护，全体节点共享同一份。在任一节点声明队列，其余节点立即可见。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;队列内容&lt;/strong&gt;：消息本体。副本数量取决于队列类型——classic queue 仅 Leader 持有一份；quorum queue 有 Leader 与两个 Follower 共三份。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;除此以外的状态——客户端与某节点的连接、信道上未完成的命令——不进入集群，只保留在客户端侧。因此「部署三台节点」不等于「消息自动复制三份」：元数据确有三份，队列内容按类型而定，客户端状态则一份都没有。&lt;/p&gt;
&lt;p&gt;&lt;img alt="元数据、队列内容、客户端状态三个范围各自的副本份数" class="gallery-image" data-flex-basis="416px" data-flex-grow="173" decoding="async" height="392" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://zh.renxinblog.cn/images/gyd-c05-three-scopes.svg" width="680"&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;消费：同样先到 Leader。&lt;/strong&gt; 消费并不从接入节点直接读取。消费者连接某接入节点后，投递由队列的 Leader 发起，Leader 将消息推送（或由消费者拉取），消费者确认后再返回 Leader。因此无论发布还是消费，队列的读写都由 Leader 仲裁，接入节点上的 Channel 进程只负责在 broker 内部把请求直接定位到 Leader，本身不持有队列数据。&lt;/p&gt;
&lt;p&gt;一句话收口：&lt;strong&gt;发布与消费都经过「接入节点上的 Channel → 队列 Leader」这条 broker 内部路径——客户端始终只连接入节点这一台，节点间定位 Leader 是集群内部完成的；集群所做的是把 Leader 上的变更同步给 Follower，同步范围仅限元数据与队列内容两类。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;读完本文，读者应能对一个具体队列自行判断：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;这个队列是什么类型、它当前有几个副本在线、是否仍有多数派、以及 Publisher Confirm 在这个队列类型上确认到了哪一步。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;边界在这里可以直接量一下：只连一个节点声明两个队列，三个节点都查到了同一份定义（两条），exchange 与 binding 也一样；但两类队列的成员数立刻分开了——classic 只有一个成员，quorum 有三个（&lt;code&gt;raw-scope-of-replication.log&lt;/code&gt;）。&lt;/p&gt;
&lt;p&gt;本文所有实测读数来自同一套三节点集群，先交代它的边界：三个节点跑在同一台宿主机上，都是 RabbitMQ 4.3.5。同宿主机意味着节点间没有真实网络分区，只有进程被杀与容器被停两种故障，所以本文能说明「节点消失时集群怎么反应」，不能说明「网络抖动或分区时怎么反应」，也不构成任何生产容量或性能结论。节点间的 &lt;code&gt;net_ticktime&lt;/code&gt; 在本机设成 10 秒（默认 60），后面出现的超时数字都基于这个值。集群的起停步骤收在 GYD 仓库该模块的 README 里，正文不复现；跑不通或读数可疑时，翻文末的排错索引。凡本机没有跑过的机制，正文会写明它来自官方文档或源码，不当成已证实的结论。&lt;/p&gt;
&lt;h2 id="五元数据集群里唯一全局共享的一份"&gt;五、元数据：集群里唯一全局共享的一份
&lt;/h2&gt;&lt;p&gt;元数据是最容易被顺手想成「也是数据、也是复制三份」的那个范围。它确实在复制，但它复制的是「有哪些队列」，不是「队列里有什么」。&lt;/p&gt;
&lt;p&gt;4.x 里这套元数据存储的实现叫 Khepri，基于 Raft，保存的是定义类的东西：用户与权限、vhost、exchange 和 queue 的定义、binding、policy。它不保存消息，也不保存队列的运行状态。一条消息是否已落盘，在它这里查不到。&lt;/p&gt;
&lt;p&gt;元数据与队列内容是&lt;strong&gt;两个独立的 Raft 组&lt;/strong&gt;。这一点在磁盘上看得最直接：同一个数据目录下 &lt;code&gt;coordination/&lt;/code&gt; 与 &lt;code&gt;quorum/&lt;/code&gt; 并列，前者装集群级元数据，后者装每个队列自己的 Raft 日志。既然是两个组，各自的 Leader 也可以不是同一台节点——本机实测里元数据 Raft 的 Leader 落在 &lt;code&gt;rabbitmq2&lt;/code&gt;，而集群名节点是 &lt;code&gt;rabbitmq1&lt;/code&gt;，两者没有必要一致。&lt;/p&gt;
&lt;p&gt;这套元数据存储在 4.3 里由 Khepri 独家接管。演进线是：3.13 实验性支持，4.2 成为新集群默认，到 4.3 它已是&lt;strong&gt;唯一受支持的元数据存储&lt;/strong&gt;——Mnesia 连同配套的分区处理策略一起被彻底移除。本机实测印证了这一点：数据目录里没有 &lt;code&gt;schema.DAT&lt;/code&gt;，&lt;code&gt;mnesia&lt;/code&gt; 相关的 schema 命令数为零（&lt;code&gt;env-baseline.log&lt;/code&gt;）。&lt;/p&gt;
&lt;h2 id="六一次发布从发出到确认经历了什么"&gt;六、一次发布从发出到确认，经历了什么
&lt;/h2&gt;&lt;p&gt;前面提出过一个具体矛盾：客户端连在节点 A，队列 Leader 在节点 B，消息仍然发送成功。这一节把一次发布从头到尾走一遍，说明它为什么成立。&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;%%{init: {'theme':'neutral'}}%%
flowchart LR
 subgraph A["节点 A（客户端连这里）"]
 CH[Channel 进程&lt;br/&gt;持有 Leader 位置]
 end
 CH --&gt;|① broker 内部直接寻址| X{路由&lt;br/&gt;fanout}
 X --&gt;|② 一份| Q1[classic 队列&lt;br/&gt;Leader 在节点 B&lt;br/&gt;单副本，直接确认]
 X --&gt;|② 另一份| Q2[quorum 队列&lt;br/&gt;Leader 在节点 B]
 Q2 --&gt;|③ 复制| F[Follower ×2&lt;br/&gt;多数派确认]
 F -.-&gt;|④ confirm 返回| CH&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;第一步，客户端确定目标：Channel 持有的是 Leader 的位置，不是队列的位置。&lt;/strong&gt; 连接建立后，客户端从集群取得每个目标队列的 Leader 位置，发布时直接按这个位置投递。关键在于「直接寻址」发生在&lt;strong&gt;节点 A 内部&lt;/strong&gt;：客户端仍只连 A 这一台，但 A 上的 Channel 进程根据队列元数据，在 broker 内部直接定位到队列 Leader 所在节点 B，并不经过「先投到一个本地队列、再等转发」这一步。所以「Leader 在 B、客户端连的是 A」并不构成障碍——消息走的是 A 内 Channel 到 B 的 broker 内部通道，不是客户端另建一条网络连接。Leader 换届会改变这个位置，客户端在下一次操作时取得新值，按新值继续发送。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第二步，路由可能分叉：一个 fanout 会同时命中多个队列。&lt;/strong&gt; 消息到达后先做路由。这一步可能把一条消息复制为多份、投给多个队列，此后每一份走各自的目标队列，&lt;strong&gt;各自决定接不接受&lt;/strong&gt;。整条路径上不存在「整条消息落一次盘」这样的全局动作：一个 fanout 同时绑了 classic 与 quorum 时，同一条消息分别进入两个队列的判定，两边的确认也互不相干。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第三步，写入是异步的：客户端不逐条等待确认。&lt;/strong&gt; 客户端不是「发一条、等确认、再发下一条」，而是连续发出，确认异步返回。多数派副本确认后，confirm 沿原路返回客户端。这是跨节点发布仍能维持吞吐的原因。&lt;/p&gt;
&lt;p&gt;发布这一侧在应用里就是「发消息」+「开 confirm」两件事。发消息用 &lt;code&gt;basicPublish&lt;/code&gt;，开 confirm 用 &lt;code&gt;confirmSelect&lt;/code&gt;，确认结果由回调异步送达：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-java" data-lang="java"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// 打开 publisher confirm，之后每条 basicPublish 都会收到一个 ack 或 nack&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;confirmSelect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;basicPublish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;orders.quorum&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;一条订单&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getBytes&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addConfirmListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sequenceNumber&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;multiple&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 成功：这条（或这几条）消息已被 broker 确认接收&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;System&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;confirmed: &amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;sequenceNumber&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sequenceNumber&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;multiple&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 失败：这条消息没有落地，需要重发&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;System&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;nack-ed: &amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;sequenceNumber&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;});&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;confirmSelect&lt;/code&gt; 之后发的每一条消息都会对应一个确认序号（&lt;code&gt;sequenceNumber&lt;/code&gt;），回调里拿到这个序号就能知道是哪一条被确认或拒绝。这就是后面第八节要展开的「消息不丢」的第一块拼图——没有 confirm，消息在落盘前丢了，生产者完全无从察觉。&lt;/p&gt;
&lt;p&gt;这套步骤不依赖接入节点与 Leader 的关系。实测中，把 Leader 置于不同节点、把客户端连到不同节点轮换，九种排列组合&lt;strong&gt;全部拿到确认&lt;/strong&gt;；其中客户端所连节点既非 Leader、也非副本成员时，发布依然成功。也就是说，客户端连到集群里任意一台节点，消息都能走完上面三步、到达目标队列。&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;边注&lt;/strong&gt;：这组实验的一个变体使用&lt;strong&gt;两个成员&lt;/strong&gt;的队列（与前面默认的三成员队列不同），让客户端所连节点落在副本组之外，单独验证。每批 100 条、单组 0.099–0.136 秒（&lt;code&gt;raw-non-leader-publish.log&lt;/code&gt;）。&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2 id="七两类队列在磁盘上各是什么样子"&gt;七、两类队列在磁盘上各是什么样子
&lt;/h2&gt;&lt;p&gt;前面说 classic queue 一个副本、quorum queue 三个副本，落到磁盘上是什么样，把三台节点各自的数据目录摆在一起就看得清。&lt;/p&gt;
&lt;p&gt;&lt;img alt="三台节点的数据目录对比：元数据各一份、classic 消息只一份、quorum 日志各一份" class="gallery-image" data-flex-basis="429px" data-flex-grow="178" decoding="async" height="380" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://zh.renxinblog.cn/images/gyd-c05-disk-layout.svg" width="680"&gt;&lt;/p&gt;
&lt;p&gt;三类目录并列在同一个数据目录下，各装一种东西，各自的「有几份」也一眼可见：&lt;code&gt;coordination/&lt;/code&gt; 装集群元数据（Khepri 存储），每台节点各一份；&lt;code&gt;msg_stores/&lt;/code&gt; 装 classic queue 的消息本体，只在属主节点 1 有一份（本轮约 252 KB，另外两台根本不存在这个目录）；&lt;code&gt;quorum/&lt;/code&gt; 装每个 quorum queue 自己的 Raft 日志，每台节点各一份（各约 552 KB）。这就是「元数据全体共享、队列内容按类型而定、客户端状态一份都没有」在磁盘目录上的落点。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;classic queue 的消息只有一份，且只在属主节点上。&lt;/strong&gt; 声明在节点 1 的 classic 队列，消息目录就只在节点 1 出现，另外两台节点的数据目录里找不到它。这就是「一个副本」在磁盘上的样子：不是「复制了三份但只标了一份」，是根本没有第二份。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;quorum queue 的 Raft 日志每台节点各一份。&lt;/strong&gt; 同一个队列在三台节点上各有一份日志目录。每份目录的路径形如 &lt;code&gt;quorum/&amp;lt;节点名&amp;gt;/&amp;lt;前缀_编码名&amp;gt;/&lt;/code&gt;，节点名那一层就说明了它是谁持有的副本。&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;边注&lt;/strong&gt;：&lt;code&gt;msg_stores/&lt;/code&gt; 与 &lt;code&gt;quorum/&lt;/code&gt; 管的是「消息在哪里」。队列自己的定义在另一处，即元数据里（Khepri 那一份）。一个队列因此在两个地方同时留下痕迹：定义在 Khepri 的 Raft 组里，内容在由队列类型决定的那个组里。&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;p&gt;classic 的落盘目录内部还能看到更细的形态：属性文件、索引文件与段文件分开，索引按队列内的序号命名。把队列里的消息消费干净之后，大部分文件会消失，只剩属性文件（&lt;code&gt;raw-classic-owner-failure.log&lt;/code&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="八发布的可靠性rabbitmq-拿什么保证消息不丢"&gt;八、发布的可靠性：RabbitMQ 拿什么保证消息不丢
&lt;/h2&gt;&lt;p&gt;一条消息从生产者发出，到它在 broker 里「真正安全」落地，中间可能在哪几处丢？RabbitMQ 给出的不是单一机制，而是四个可以叠加的手段，每个手段防住一类丢失。这一节把这四个手段讲清，再说明把它们全用上之后，仍然有两种丢失要生产者自己兜底。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;手段一：路由退回（&lt;code&gt;mandatory&lt;/code&gt;），防「消息根本没进队列」。&lt;/strong&gt; 默认情况下，一条消息如果路由不到任何队列，broker 会直接丢弃它，生产者拿不到任何提示。发布时带上 &lt;code&gt;mandatory&lt;/code&gt; 标志，broker 就会在发现消息不可路由时，用 &lt;code&gt;basic.return&lt;/code&gt; 把它退回给生产者，附带原因码。这样「消息没进队列」这件事不再静默。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;手段二：持久化（队列 durable、消息 persistent），防「broker 重启后消息没了」。&lt;/strong&gt; 队列要声明成 durable（重启后队列本身还在），消息要以 persistent 投递模式发出（重启后消息还在）。两者缺一不可：只持久化队列，重启后消息丢；只持久化消息，重启后队列连同消息一起没。持久化消息投到非持久化队列时，按非持久化处理——所以「队列 durable」是前提，不是可选项。即便两者都设了，消息也只在&lt;strong&gt;落盘之后&lt;/strong&gt;才算持久；broker 为了减少 &lt;code&gt;fsync&lt;/code&gt; 次数会批量写盘，落盘前节点挂掉，消息仍会丢。这正是它必须和手段三配合的原因。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;手段三：Publisher Confirm，防「生产者不知道消息丢了」。&lt;/strong&gt; Confirm 是一条消息拿到一个确认的机制，有肯定结果 &lt;code&gt;basic.ack&lt;/code&gt; 和否定结果 &lt;code&gt;basic.nack&lt;/code&gt;。它解决的是上一段那个「落盘前挂掉」的问题：不用 Confirm，消息在落盘前丢失时生产者无从得知；用了 Confirm，broker 会在消息真正落地后才回 &lt;code&gt;basic.ack&lt;/code&gt;，落地前就挂掉的消息不会收到 ack，生产者因此能察觉并决定是否重发。同一个 &lt;code&gt;basic.ack&lt;/code&gt;，在不同路径上「落地」的含义不同：&lt;/p&gt;
&lt;p&gt;&lt;img alt="同一个 basic.ack 在不可路由、classic、quorum、多队列四种路径下的不同含义" class="gallery-image" data-flex-basis="388px" data-flex-grow="161" decoding="async" height="420" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://zh.renxinblog.cn/images/gyd-c05-confirm-boundaries.svg" width="680"&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;手段四：副本（quorum queue 的多数派），防「单台节点挂掉消息就没了」。&lt;/strong&gt; 前三个手段保证的是「消息在某一台节点上落稳了」，但经典队列只有一个副本，那台节点一旦永久损坏，落盘的消息照样丢。quorum queue 把消息复制到多数派副本，&lt;code&gt;basic.ack&lt;/code&gt; 意味着多数派已经接受，单台节点故障不影响消息存在。这就是「发布可靠性」和前面几章讲的「集群高可用」交汇的地方：副本机制让持久化的消息不再依赖单一节点。&lt;/p&gt;
&lt;p&gt;四个手段在应用里不是四段分散的代码，而是落进同一次声明、同一条发布调用里：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-java" data-lang="java"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// 手段二 + 四：队列 durable=true、类型选 quorum（多数派副本）&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;queueDeclare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;orders.quorum&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;x-queue-type&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;quorum&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// 手段三：打开 publisher confirm&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;confirmSelect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addConfirmListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;multiple&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cm"&gt;/* 落地成功，可清理重发缓存 */&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;multiple&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cm"&gt;/* 落地失败，按 seq 重发这条消息 */&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;});&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// 手段一 + 二：mandatory=true（不可路由则退回）、deliveryMode=2（持久化）&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;AMQP&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;BasicProperties&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;AMQP&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;BasicProperties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="na"&gt;deliveryMode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;basicPublish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;orders.quorum&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;一条订单&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getBytes&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addReturnListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;returned&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// mandatory 退回：这条消息没有进任何队列&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;System&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;returned: &amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;returned&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getBody&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;});&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;四行注释各对上一个手段：&lt;code&gt;queueDeclare&lt;/code&gt; 里的 &lt;code&gt;durable=true&lt;/code&gt; 和 &lt;code&gt;x-queue-type=quorum&lt;/code&gt; 是手段二和四，&lt;code&gt;confirmSelect&lt;/code&gt; 是手段三，&lt;code&gt;basicPublish&lt;/code&gt; 里的 &lt;code&gt;mandatory=true&lt;/code&gt; 和 &lt;code&gt;deliveryMode=2&lt;/code&gt; 是手段一和二。缺任何一样，「消息不丢」就断掉一环。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;四个手段各有覆盖，但没有一个是「消费确认」。&lt;/strong&gt; 上面四件事做完，&lt;code&gt;basic.ack&lt;/code&gt; 保证的是「这条可路由消息已被所有命中队列接受、且按各自类型的确认边界落了地」。它不代表消息被消费——本文几组发布实验全程没有消费者，确认数与队列留存条数一致，消息一直躺在队列里，直到收尾阶段才被逐条取回。多队列那组的基线 10 条在阶段 1 就拿到了 &lt;code&gt;basic.ack&lt;/code&gt;，到阶段 5 才被消费掉，中间五十多秒无人处理。消费侧的确认与幂等是另一篇的事。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;两个手段合起来仍有两个洞，要生产者自己兜。&lt;/strong&gt; 一是重复：多队列场景下 &lt;code&gt;basic.ack&lt;/code&gt; 是 AND——只要一个命中队列没接受，整条消息就拿不到 ack。实测让一个 fanout 同时绑 classic 与 quorum，把 quorum 的 Leader 摆到节点 2，SIGKILL 节点 1（classic 的属主），再从节点 2 发布 10 条：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;客户端侧：10 条&lt;strong&gt;全部失败&lt;/strong&gt;，客户端库报 &lt;code&gt;NackError&lt;/code&gt;，耗时 0.00–0.01 秒；&lt;/li&gt;
&lt;li&gt;服务端侧同时读 quorum queue 的条数：&lt;strong&gt;从 10 涨到 20，正好增加 10&lt;/strong&gt;；&lt;/li&gt;
&lt;li&gt;同一时刻只往 quorum 发 5 条（对照）：5/5 拿到确认，排除「客户端连不上」或「quorum 不可用」这两个解释；&lt;/li&gt;
&lt;li&gt;节点 1 恢复后：classic queue 里仍然只有基线那 10 条，故障中那 10 条一条也没进去（&lt;code&gt;raw-fanout-per-queue.log&lt;/code&gt;）。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这几组读数说明三件事，缺一句都会误判：&lt;code&gt;basic.ack&lt;/code&gt; 是 AND 不是 OR（一个队列没接受就整体 nack）；&lt;code&gt;basic.nack&lt;/code&gt; 不等于哪儿都没进去（quorum 侧已收下 10 条，只是不能替 classic 回答）；客户端从结局里看不出是哪个队列拒绝的（返回的只是一个 &lt;code&gt;NackError&lt;/code&gt;，没按队列拆开）。后两条合起来就是重复的来源：拿到 &lt;code&gt;basic.nack&lt;/code&gt; 就整条重投，会在已经接受它的那些队列里再写一遍，而 Confirm 机制本身不消重。&lt;/p&gt;
&lt;p&gt;二是不可路由的语义陷阱：不可路由消息同样会收到 &lt;code&gt;basic.ack&lt;/code&gt;（交换机为它做了判定），但这条路径上的 &lt;code&gt;basic.ack&lt;/code&gt; 说的不是「进了队列」。这条本文没有实测，只引官方口径——对 quorum queue 而言，成功返回 &lt;code&gt;basic.ack&lt;/code&gt; 的可路由消息，在托管该队列的多数节点没有永久不可用的前提下，RabbitMQ 才给出相应保证；这个保证不覆盖不可路由消息，不覆盖 classic queue。&lt;/p&gt;
&lt;p&gt;classic 侧还有一个更硬的证据：属主节点被 SIGKILL 之后，队列里那 51 条持久化消息在节点恢复后&lt;strong&gt;一条不少&lt;/strong&gt;，逐条消费取回验证通过；而故障期间往同一个队列发的 20 条&lt;strong&gt;一条都没拿到 &lt;code&gt;basic.ack&lt;/code&gt;&lt;/strong&gt;，恢复后队列里也没有多出这 20 条（&lt;code&gt;raw-classic-owner-failure.log&lt;/code&gt;）。本节与相邻几节的读数分别出自 &lt;code&gt;raw-non-leader-publish.log&lt;/code&gt;、&lt;code&gt;raw-failover-timeline.log&lt;/code&gt;、&lt;code&gt;raw-classic-owner-failure.log&lt;/code&gt;、&lt;code&gt;raw-fanout-per-queue.log&lt;/code&gt;。&lt;/p&gt;
&lt;h2 id="九消费的可靠性ackprefetch-与交付语义"&gt;九、消费的可靠性：ack、prefetch 与交付语义
&lt;/h2&gt;&lt;p&gt;第八节讲消息如何从生产者侧可靠进入，这一节讲它如何从队列侧可靠出去。两侧用的是两套完全独立、互不知晓的机制：Publisher Confirm 只关心消息是否被所有命中队列接受，不关心是否有消费者；Consumer Ack 只关心消费者是否处理完成，不关心消息如何进来。消息不丢，靠的是两侧各自守住自己的一端。&lt;/p&gt;
&lt;p&gt;&lt;strong&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;/strong&gt; 消费者处理完一条消息，需要通过消费确认（consumer acknowledgement）把结果回给 broker，broker 据此决定删除还是保留。三种方法分别表达三种结果：&lt;code&gt;basic.ack&lt;/code&gt; 是正向确认，表示处理成功、消息可删除；&lt;code&gt;basic.nack&lt;/code&gt; 是否定确认，表示处理失败，可携带 &lt;code&gt;requeue&lt;/code&gt; 参数决定是否放回队列，并支持批量；&lt;code&gt;basic.reject&lt;/code&gt; 同样是否定确认，但每次只能拒绝一条，不能批量。&lt;code&gt;basic.nack&lt;/code&gt; 的能力覆盖了 &lt;code&gt;basic.reject&lt;/code&gt;，因此多数实现只用 ack 与 nack。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;消息在 broker 侧有两种状态。&lt;/strong&gt; 队列统计里的 &lt;code&gt;ready&lt;/code&gt; 和 &lt;code&gt;unacknowledged&lt;/code&gt; 是消息的两种状态：刚入队、尚未投递给任何消费者的消息处于 &lt;code&gt;ready&lt;/code&gt;；已投递给消费者、消费者尚未 ack 的处于 &lt;code&gt;unacknowledged&lt;/code&gt;，两者之和是队列的总消息数 &lt;code&gt;total&lt;/code&gt;。发布时消息从无到有进入 &lt;code&gt;ready&lt;/code&gt;；投递时从 &lt;code&gt;ready&lt;/code&gt; 转入 &lt;code&gt;unacknowledged&lt;/code&gt;；消费者 ack 后才从 &lt;code&gt;unacknowledged&lt;/code&gt; 移除。异常路径则往回走：消费者崩溃或连接断开，未 ack 的消息自动从 &lt;code&gt;unacknowledged&lt;/code&gt; 退回 &lt;code&gt;ready&lt;/code&gt;，等待再次投递。&lt;code&gt;unacknowledged&lt;/code&gt; 持续增长而 &lt;code&gt;ready&lt;/code&gt; 不涨，通常说明消费者收到消息却不 ack——这是排查消费异常时最先看的一个信号。&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;%%{init: {'theme':'neutral'}}%%
stateDiagram-v2
 [*] --&gt; ready: 消息入队
 ready --&gt; unacknowledged: 投递给消费者
 unacknowledged --&gt; [*]: 消费者 ack，消息移除
 unacknowledged --&gt; ready: 消费者崩溃或断连，退回重投&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;消费确认在两类队列里，走的是两套不同的底层机制。&lt;/strong&gt; classic queue 的持久化层由「队列索引 + 消息存储」两个组件构成：队列索引记录每条消息的位置、是否已投递、是否已确认，消费者 ack 一条消息，broker 就在队列索引里把它标记为已确认，随后由消息存储的 GC 压缩把这条消息的数据真正回收。quorum queue 则不同——它把消息发布和消费确认都记录为 Raft 日志条目：消费者 ack 一条消息，Leader 就把这条 ack 追加进日志、复制给副本，与消息发布走同一条复制路径。所以「消息被消费」在 quorum queue 磁盘上的表现不是「那条消息被删掉」，而是「多了一条 ack 记录」，已 ack 消息的数据仍留在 segment 文件里，靠日志压缩逐步回收。这个回收机制在 4.3 前有一个缺陷：一条乱序的未确认消息就能挡住整段日志的截断，导致磁盘无界增长；4.3 引入的 Ra v3 压缩允许乱序 ack 与不同速率的确认共存，仍能正常回收空间。两类队列的差别正好落在第七节的磁盘结构上：classic 在 &lt;code&gt;msg_stores/&lt;/code&gt; 里按消息存储的 GC 回收，quorum 在 &lt;code&gt;quorum/&lt;/code&gt; 的 Raft 日志里按日志压缩回收。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;确认模式决定交付语义。&lt;/strong&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;自动确认（auto-ack）&lt;/td&gt;
					&lt;td&gt;至多一次&lt;/td&gt;
					&lt;td&gt;broker 把消息写入 TCP 后立即移除，不等待消费者反馈&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;手动确认（manual-ack）&lt;/td&gt;
					&lt;td&gt;至少一次&lt;/td&gt;
					&lt;td&gt;消费者显式 &lt;code&gt;basic.ack&lt;/code&gt; 后移除；未 ack 的消息在连接或信道关闭时自动重新入队&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;两种保证各有代价。自动确认下，消费者还没处理完就崩溃，消息已经没了，这是&lt;strong&gt;丢失&lt;/strong&gt;；手动确认下，消费者处理完了、但 ack 还没发出去就断连，消息会重新投递，这是&lt;strong&gt;重复&lt;/strong&gt;。官方文档对自动确认的定性是「应视为不安全」，它只适用于消息丢失可容忍的场景（指标、遥测）。可靠消费必须采用手动确认。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;prefetch 限制在途消息数。&lt;/strong&gt; 手动确认下，未确认的消息会一直留在消费者一侧。若不加限制，broker 会把积压消息全部推给消费者，既可能耗尽消费者内存，也让其他消费者无消息可处理。&lt;code&gt;basic.qos&lt;/code&gt; 的 prefetch 值规定一个信道上最多同时未确认多少条：达到上限后 broker 停止投递，直到消费者 ack 一条、腾出一个位置。quorum queue 会把 prefetch 上限强制限制为 2000。取值依任务而定：计算密集型取小值（如 1，让多个消费者均匀分摊），IO 密集型取较大值（抵消网络往返延迟）。&lt;/p&gt;
&lt;p&gt;可靠消费在应用里就是「设 prefetch + 手动确认」两件事，消费逻辑写在投递回调里：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-java" data-lang="java"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// 手动确认 + prefetch=10：同一时刻最多 10 条消息未确认&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;basicQos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;DeliverCallback&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;consumerTag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;delivery&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;long&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;delivery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getEnvelope&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getDeliveryTag&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;boolean&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;redelivered&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;delivery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getEnvelope&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="na"&gt;isRedeliver&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delivery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getBody&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;basicAck&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 处理成功，确认删除&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Exception&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;basicNack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 处理失败，重新入队&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;};&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// 第二个参数 autoAck=false：由上面的回调显式确认，而不是 broker 自动删&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;basicConsume&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;orders.quorum&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;consumerTag&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{});&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;两处要盯住：&lt;code&gt;basicConsume&lt;/code&gt; 的第二个参数必须是 &lt;code&gt;false&lt;/code&gt;（否则变成自动确认，ack 逻辑形同虚设）；&lt;code&gt;basicAck&lt;/code&gt; 的第二个参数 &lt;code&gt;multiple&lt;/code&gt; 设为 &lt;code&gt;false&lt;/code&gt;，只确认当前这一条，避免误把还没处理完的批量消息一起确认掉。&lt;code&gt;delivery.getEnvelope().isRedeliver()&lt;/code&gt; 返回 &lt;code&gt;true&lt;/code&gt; 时，说明这是一条之前投递过、但没来得及 ack 就被重投的消息——下面要讲的「重复」正是从这里来的。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;重复是可靠消费需要应用侧自行消解的代价。&lt;/strong&gt; 至少一次的代价是重复：处理成功、&lt;code&gt;basic.ack&lt;/code&gt; 尚未发出时连接断开，该消息会被重新投递，并携带 &lt;code&gt;redelivered=true&lt;/code&gt; 标记。RabbitMQ 不提供 exactly-once，只保证「至少一次」并标记重投，把「消除重复」交给应用侧：用消息中的业务唯一 ID 去重、用数据库唯一约束兜底、或用版本号与状态机做前置校验。&lt;/p&gt;
&lt;p&gt;重复在本实验里有直接记录：失去多数派恢复后，3000 条预置消息共投递 3001 次，多出的一次是消费者被强杀时未及 ack 的那条被重新投递，队列剩余为 0、无丢失——即至少一次交付的粒度（读数出自 &lt;code&gt;raw-quorum-consumption.log&lt;/code&gt;，机制细节见下一节）。&lt;/p&gt;
&lt;p&gt;至此，消费可靠性的边界明确：手动确认与 prefetch 保证消息不丢、消费者不被压垮；余下的重复由应用以幂等消解。下一节将发布与消费置于同一批故障中，看这两套机制在故障时的表现。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="十故障分轻重程度不同rabbitmq-的应对也不同"&gt;十、故障分轻重：程度不同，RabbitMQ 的应对也不同
&lt;/h2&gt;&lt;p&gt;故障不只有「有」和「没有」两档，严重程度是逐级上升的。这一节按严重程度从轻到重走一遍：每一级故障下，发布和消费分别会发生什么，RabbitMQ 的架构又是靠哪一层把它扛住或放弃的。三级的差异本身就是信息——同样是节点停掉，停的节点角色不同，架构给出来的结局就完全不同。&lt;/p&gt;
&lt;p&gt;&lt;img alt="三级故障各自的结局：从最轻到最重，每级的发布与消费表现" class="gallery-image" data-flex-basis="214px" data-flex-grow="89" decoding="async" height="760" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://zh.renxinblog.cn/images/gyd-c05-failure-modes.svg" width="680"&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;最轻的一级：接入节点故障（该节点不承载目标队列成员）。&lt;/strong&gt; 客户端连的那台节点停了，而且这台节点不承载目标队列的任何成员，既不是属主，也不是 quorum 副本。发布端的表现：broker 强制关闭连接，客户端收到 &lt;code&gt;CONNECTION_FORCED (320)&lt;/code&gt;；实测 76 次尝试里 75 次异常，只有 1 次落在线路断开前的窗口里。把客户端改连到存活节点之后，178 次发布连续全部成功（&lt;code&gt;raw-failover-timeline.log&lt;/code&gt;）。&lt;/p&gt;
&lt;p&gt;消费端的表现：连在这台节点上的消费者会断链一次，异常原文是 &lt;code&gt;StreamLostError: Transport indicated EOF&lt;/code&gt;，改连存活节点后 0.5 秒内恢复投递。队列侧什么都没发生：定义、副本、Leader 都不受影响，需要做恢复动作的只有客户端自己。&lt;/p&gt;
&lt;p&gt;「只断连接」有一个必须交代的前提：普通集群节点往往同时是接入节点和多个队列的副本节点。一旦停掉的节点恰好还是某个队列的属主或 quorum 成员，它就升级成更严重的中间一级，不再是单纯的最轻一级。&lt;/p&gt;
&lt;p&gt;对维护者来说，这一级几乎不需要动手：客户端换节点重连就能继续，队列侧没有状态要修。真正要做的只有一件事——&lt;strong&gt;先判断这次停掉的节点到底是不是某个队列的属主或 quorum 成员&lt;/strong&gt;。判断方法就是看这个队列的成员列表（&lt;code&gt;rabbitmqctl list_queues&lt;/code&gt; 的 &lt;code&gt;members&lt;/code&gt; 字段）里有没有这台节点；没有，就按最轻一级处理，等客户端自愈；有，就得按下面的中间一级或最重一级处置，而不是停在「反正能重连」的错觉里。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;中间的一级：Leader 故障，多数派仍在。&lt;/strong&gt; Leader 被 SIGKILL，剩下两个副本还在，多数派成立，组内重新选举。发布端的表现分两层：机制层，官方口径是选举窗口内写操作会被推迟、多数已发布的消息会保留并转交给新 Leader，但部分场景会以 &lt;code&gt;basic.nack&lt;/code&gt; 拒绝；观察层，这一轮 3206 次发布全部拿到确认、0 异常，单条最长阻塞 348 毫秒，没有出现 &lt;code&gt;basic.nack&lt;/code&gt;。两层不矛盾——前者说的是「可能」的范围，后者只是其中一次观察落在「全保留」这一端，所以这一条不能写成「只停顿、永不失败」的固定保证。&lt;/p&gt;
&lt;p&gt;消费端的表现：重新选举期间投递暂停，选出新 Leader 后自动恢复；连在故障节点上的那个消费者断链一次后改连存活节点，前后投递 346 条，连在存活节点的对照消费者投递 348 条，最长无投递窗口 A 0.55 秒、B 0.24 秒。客户端在整个过程中不需要知道 Leader 换了人，它只感觉到一次停顿。&lt;/p&gt;
&lt;p&gt;停顿的长短与「怎么停」有关。换成 SIGTERM（正常终止）再测一遍，4507 次发布同样全部成功，单条最长阻塞只有 41 毫秒，比硬杀小一个量级：正常终止的节点有机会先把状态交代清楚，硬杀让组内直接从「失联」开始判定。两种停法各只跑了一轮，这两个数字按单样本口径看。&lt;/p&gt;
&lt;p&gt;对维护者来说，这一级是&lt;strong&gt;最该忍住别动手&lt;/strong&gt;的一级。多数派还在，架构会自己重新选举、自己恢复，此刻最不该做的就是急着往组里加成员、删成员或强制改 Leader——成员变更是要等应用落地、且超时会回滚的操作，故障当口去动它，反而可能把本来还能自愈的局面搅乱。维护者该做的是两件事：盯住选举窗口里的写停顿（发布端最长阻塞、消费端最长无投递这两条时长指标），确认新 Leader 真的选出来了（&lt;code&gt;rabbitmqctl list_queues&lt;/code&gt; 看 &lt;code&gt;leader&lt;/code&gt; 字段是否指向存活节点）。停顿是预期内的、短暂的，只要多数派还在，不需要人工接替。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;最重的一级：失去多数派。&lt;/strong&gt; 三副本里再停一个，只剩一个。需要共识的操作无法继续推进——请求可能被保留、等多数派恢复，也可能在超时后失败。发布端的两种表现必须分开写，否则会得出「失去多数派也不报错」这种错结论：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;已登记的信道会被挂住。&lt;/strong&gt; 故障前就用过这个队列的信道，它的发布不会立刻失败，而是被保留着等多数派回来。实测里这条发布挂了 37.7 秒仍未返回，直到观察窗结束。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;新连接会明确失败。&lt;/strong&gt; 一个新连接的首条发布在 15.01 秒后以 &lt;code&gt;basic.nack&lt;/code&gt; 结束，拿到的是明确的否定结局。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;差别来自超时预算：信道持有的队列状态里有一个命令超时，值等于 &lt;code&gt;net_ticktime&lt;/code&gt; 加 5 秒。本机 &lt;code&gt;net_ticktime&lt;/code&gt; 是 10 秒，所以超时是 15 秒，15.01 秒正好对上。已登记那条挂得更久，是因为它在等待期间被反复重试，不是一次判定。&lt;/p&gt;
&lt;p&gt;失去多数派之下消费端的表现最反直觉：&lt;strong&gt;静默停止&lt;/strong&gt;。两个消费者做对照，A 连故障节点、B 连存活节点，预置 3000 条积压并限速消费（否则基线就会把积压吃光，分不清「停了」还是「没东西可投」）。失去多数派期间两者都是 0 条投递，连接未断、无异常、不重连，最长无投递 50.59 秒，等于整个观察窗。与发布侧「挂住 / 明确失败」不同，消费侧连「失败」这个信号都没有。应用侧只能靠&lt;strong&gt;投递停滞时长&lt;/strong&gt;监控发现它，靠「连接是否存活」什么也看不出来。&lt;/p&gt;
&lt;p&gt;多数派恢复之后，两侧都继续工作：恢复后 A、B 各投递 245 条，最长无投递窗口缩到 2.85 秒。3000 条预置消息最后投递了 3001 次，多出的那一条是消费者 A 被强杀时手里没来得及 ack 的那条被重新投递，队列剩余为 0、没有丢失。这是 at-least-once 的粒度实测，数字如实留档、不做归一化（消费侧读数出自 &lt;code&gt;raw-quorum-consumption.log&lt;/code&gt;）。&lt;/p&gt;
&lt;p&gt;对维护者来说，这一级没有任何「在线操作」能补救：队列的数据已经不足多数派，加成员、删成员、切 Leader 都救不回一个没有多数派的组。唯一有效的动作是&lt;strong&gt;尽快让离队的副本重新上线&lt;/strong&gt;，多数派一旦回来，两侧就会按上面说的那样自动恢复。所以这一级的运维重心不在「怎么修」，而在「怎么早点发现」——因为消费侧是静默停止、连接还活着，靠连接存活探测什么也看不出来，只能靠&lt;strong&gt;投递停滞时长&lt;/strong&gt;这条监控在 50 秒级别内报警，而不是等应用侧先崩了才后知后觉。&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;边注&lt;/strong&gt;：失去多数派时，连「查一下队列现在什么样」的结果也不稳定。Khepri 默认的本地缓存读是最终一致的，失去 Leader 时在可达节点上仍能继续；只有走 Raft Leader 的线性一致读才会在选举期间暂停。本机有两轮实测：一轮在只剩一个副本时 &lt;code&gt;list_queues&lt;/code&gt; 失败或超时（元数据 Raft 停在 &lt;code&gt;pre_vote&lt;/code&gt;），另一轮同一时刻 &lt;code&gt;list_queues&lt;/code&gt; 仍然可用（读到 1911 条）。两轮只在「哪个节点存活」与故障顺序上不同，单样本、结果不一致；两轮差异可能与实际采用的读取路径有关，但现有实验没有定位到，不下确定归因。真遇到时别把一次查询结果当成队列状态判据。&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;p&gt;三级故障的全部读数出自 &lt;code&gt;raw-failover-timeline.log&lt;/code&gt; 与 &lt;code&gt;raw-quorum-consumption.log&lt;/code&gt;，完整输出在模块 README 里逐条可查。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="十一同一个故障quorum-如何实现高可用"&gt;十一、同一个故障，quorum 如何实现高可用
&lt;/h2&gt;&lt;p&gt;第十节按严重程度讲了「故障下会发生什么」，但没正面回答「quorum 为什么能在 Leader 挂了之后继续服务」。这一节就回答这个：quorum 的高可用，靠的是&lt;strong&gt;多副本、多数派、自动选举&lt;/strong&gt;三样机制叠在一起，而不是某一个节点特别可靠。用 classic 队列作反衬——它只有一份数据，同样的故障下就不可用，正好把 quorum 靠「副本数」这一件事说清楚。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第一样：多副本，数据不在一台节点上。&lt;/strong&gt; 一个 quorum 队列的每一条消息，不是只落在 Leader 一台节点，而是被复制到组内所有成员。Leader 挂了，消息还在其它副本手里，没有跟着那台节点一起消失。这是高可用的地基——没有多副本，后面两样机制无从谈起。classic 队列就是反面：它只有一个副本，数据只在属主那一台，属主一停，数据也跟着「离线」，谁也读不到。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第二样：多数派，少一个副本不致命。&lt;/strong&gt; quorum 的成员数是奇数（典型是三副本），一致性只要求「多数」在线，不要求全部。三个副本坏一个，还剩两个，仍是多数，队列照常可用；坏两个，只剩一个，不再是多数，队列就停摆。所以 quorum 的「高可用」不是「永不坏」，而是「容忍少数副本故障」——这个容忍度直接由副本数决定：三副本容忍一个，五副本容忍两个。classic 没有这个余地：一个副本就是&lt;strong&gt;唯一&lt;/strong&gt;一份，坏一个就是全部，容忍度是零。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第三样：自动选举，Leader 换了人而客户端无感。&lt;/strong&gt; Leader 挂掉后，剩下的 follower 自己发起一轮选举，选出一个新 Leader 接替。选举的窗口很短，期间写操作会短暂停顿（第十节实测的单条最长阻塞是 348 毫秒），选出之后发布和消费照常继续；客户端全程不需要知道 Leader 换了人，它只感觉到一次停顿。没有这第三样，多副本和多数派只是「数据还在」，却没有人能继续处理写入。&lt;/p&gt;
&lt;p&gt;三样机制叠起来，才是这一节标题那句话的完整答案：&lt;strong&gt;quorum 靠「数据复制到多台 + 只需多数在线 + 自动换 Leader」实现高可用&lt;/strong&gt;，缺一不可。&lt;/p&gt;
&lt;p&gt;反过来看 classic，就能把这件事看得更清楚。做一次对照：把 quorum 的 Leader 和 classic 的属主摆到同一台节点 1，再 SIGKILL 节点 1——对 classic 来说它失去了唯一副本，对 quorum 来说它只失去了三份里的 Leader。&lt;/p&gt;
&lt;p&gt;&lt;img alt="同一个 SIGKILL 下 classic 与 quorum 两条队列的对照时间线" class="gallery-image" data-flex-basis="263px" data-flex-grow="109" decoding="async" height="620" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://zh.renxinblog.cn/images/gyd-c05-two-queue-fault.svg" width="680"&gt;&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;&lt;/th&gt;
					&lt;th&gt;classic（唯一副本离线）&lt;/th&gt;
					&lt;th&gt;quorum（Leader 离线，2/3 在线）&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;故障中发布&lt;/td&gt;
					&lt;td&gt;旧信道与新信道各 10 条，&lt;strong&gt;全部被立即拒绝&lt;/strong&gt;&lt;/td&gt;
					&lt;td&gt;10 条全部成功确认&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;故障中消费&lt;/td&gt;
					&lt;td&gt;挂 &lt;strong&gt;67.4 / 68.2 / 68.3 秒&lt;/strong&gt;后才以 404 失败&lt;/td&gt;
					&lt;td&gt;正常取到，没有阻塞&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;属主恢复后&lt;/td&gt;
					&lt;td&gt;51 条一条不少，逐条消费验证通过&lt;/td&gt;
					&lt;td&gt;55 条，&lt;code&gt;online&lt;/code&gt; 回到 3/3&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;同一个 SIGKILL 落在两类队列上，结局分岔在副本数这一个变量：classic 一份数据离线就是不可用，quorum 少一个副本仍有多数派。这个对照不是要讲 classic 有多差，而是用「没有多副本会怎样」反证 quorum 高可用到底靠的是什么。&lt;/p&gt;
&lt;p&gt;对照里还有一个不对称值得一提，但只是支线，不展开：classic 那条路径上发布 0.01 秒就被拒，消费却挂到约 68 秒才有结果，方向性的原因是发布与消费走了两套不同的队列访问封装。完整归因和故障期间管理 API 的观测陷阱，见文末排错索引。&lt;/p&gt;
&lt;p&gt;（对照读数出自 &lt;code&gt;raw-classic-owner-failure.log&lt;/code&gt; 与耗时归因探针 &lt;code&gt;raw-phase2-timing.log&lt;/code&gt;。）&lt;/p&gt;
&lt;h2 id="十二故障之后rabbitmq-怎样一步步恢复"&gt;十二、故障之后：RabbitMQ 怎样一步步恢复
&lt;/h2&gt;&lt;p&gt;第十一节讲的是故障当口 quorum 靠什么不倒下；这一节讲故障之后——离队的节点回来，RabbitMQ 从「少一个副本」走回「全量副本」的完整过程。这个过程不是节点一起来就瞬间完成，而是分几步，每一步都有它自己的判断点。&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;%%{init: {'theme':'neutral'}}%%
stateDiagram-v2
 [*] --&gt; 离队: 节点故障
 离队 --&gt; 进程重启: 节点回来
 进程重启 --&gt; 续传追平: Leader 补日志
 续传追平 --&gt; 追平验收: Last Log Index 一致
 追平验收 --&gt; 重新参与多数派: 副本回到全量
 重新参与多数派 --&gt; [*]: 队列恢复全量可用
 续传追平 --&gt; 永久不可用: 多数节点永久丢失
 永久不可用 --&gt; [*]: 只能删重建&lt;/pre&gt;&lt;p&gt;图里有两条岔路：上面那条是「少数副本暂时离队」的正常恢复，一步一步走到重新参与多数派；下面那条是「多数节点永久丢失」，续传没有对象，直接跌入永久不可用、只能删重建。这一节按上面这条主路走，最后再回到那条岔路。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第一步：节点重启，进程先回来，数据还没追平。&lt;/strong&gt; 容器重新起来，只说明这个 RabbitMQ 进程活了，不代表它的日志已经和组里其它成员对齐。一个离队很久的副本，落下的是它离开期间产生的所有新条目，重启那一刻它还停在旧的日志位置。所以「进程起来」和「副本追平」是两件事，不能划等号。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第二步：follower 从它离开的地方续传。&lt;/strong&gt; 这是恢复路径上自动发生、不需要人工干预的一侧。重新上线的副本向 Leader 报告自己的日志位置，Leader 把落下的条目一批批补给它，直到它追到最新。这段时间里集群看起来是健康的，但这个副本还没有真的跟上——如果它还没追平就又发生故障，它的「多数派」贡献仍是空的。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第三步：追平验收，用官方指标，不用容器状态。&lt;/strong&gt; 追平没有完成，得有一条可复核的判据。最直接的两个是三份 &lt;code&gt;Last Log Index&lt;/code&gt; 是否一致、&lt;code&gt;online&lt;/code&gt; 副本数是否回到全量；实测里追平后的三份 &lt;code&gt;Last Log Index&lt;/code&gt; 是一致的（一轮是 10516、另一轮是 72）。&lt;code&gt;check_if_node_is_quorum_critical&lt;/code&gt; 的退出码则是&lt;strong&gt;节点级关停检查&lt;/strong&gt;，它回答的是「再把目标节点关掉，会不会让某个组件或队列失去多数派」，不是「某个队列是否已经追平」，所以它适合在关停前做风险评估，不适合当追平验收。容器报 &lt;code&gt;healthy&lt;/code&gt; 只说明进程活着，不说明日志追上了——这一步的判据必须落在日志与副本数上，而不是容器状态。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第四步：追平后重新参与多数派。&lt;/strong&gt; 副本一旦追到最新，就重新成为多数派里的有效一员。第十一节讲过多数派才是可用性的关键——一个离线副本在追平之前不贡献多数派，追平之后才真正「回来」了。这一步完成，队列才回到故障前的可用状态。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;追不平的另一侧：永久不可用。&lt;/strong&gt; 如果多数节点是永久丢失，而不是暂时离队，那么数据已经不足多数派，续传没有对象、追平无从谈起。这个队列就永久不可用，任何自动化都救不回来，只能强制删除重建。所以恢复过程有一个前提：丢失的只是&lt;strong&gt;少数&lt;/strong&gt;副本。多数一旦没了，走的就是「删重建」而不是「恢复」。&lt;/p&gt;
&lt;h2 id="十三如何选择队列类型和副本位置"&gt;十三、如何选择队列类型和副本位置
&lt;/h2&gt;&lt;p&gt;前面把机制讲完了，这里落到两个实际决定：&lt;strong&gt;选哪种队列类型，副本放在哪。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;队列类型，按「要多少可靠性」选。&lt;/strong&gt; 三种类型本质上是三档不同的可靠性：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;classic&lt;/strong&gt;：数据只在属主节点一份。属主离线队列就不可用，但写入最快、最简单。适合能接受「节点故障时短暂不可用」的场景。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;quorum&lt;/strong&gt;：数据复制到多数节点（典型三副本）。容忍少数节点故障，写入要多一轮复制确认。适合要求「挂掉一个节点还能继续收发」的场景。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;stream&lt;/strong&gt;：按追加日志存、可反复重放，面向大吞吐流。副本也是多数派复制，但&lt;strong&gt;不显式 fsync&lt;/strong&gt;，不受控关机可能丢数据，所以比 quorum 的保证弱一档——RabbitMQ 自己在文档里都说「要更安全就用 quorum queue」。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;一句话记住这个选择：&lt;strong&gt;classic 是单点、quorum 是多数派、stream 是带重放的多数派（但可靠性略降）&lt;/strong&gt;。要「不丢且高可用」就 quorum，要「极致简单或能容忍单点」就 classic，要「日志型重放」才考虑 stream。三档的关键不是「谁最好」，而是「代价和可靠性一起涨」：classic 写入最快，代价是属主一挂就不可用；quorum 靠多数派把可靠性顶上去，代价是每次写入多一轮复制确认；stream 在多数派之外加了日志重放，但「不显式 fsync」让它的可靠性比 quorum 弱一档。选型时先定「能接受多大的不可用窗口、能承受多大的写入代价」，再对着这三档取。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;副本位置，靠 &lt;code&gt;queue-leader-locator&lt;/code&gt; 控制，但只能「建议」不能「指定」。&lt;/strong&gt; 这个配置决定声明队列时副本落在哪些节点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;两个有效取值：&lt;code&gt;client-local&lt;/code&gt;（默认）与 &lt;code&gt;balanced&lt;/code&gt;。另外三个已弃用的取值（&lt;code&gt;random&lt;/code&gt;、&lt;code&gt;least-leaders&lt;/code&gt;、&lt;code&gt;min-masters&lt;/code&gt;）在内部都映射到 &lt;code&gt;balanced&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;成员怎么挑：声明队列时，&lt;strong&gt;声明它的客户端所连的那台节点一定被放上一个副本&lt;/strong&gt;；其余成员在队列总数少于 1000 时按「哪个节点同类队列成员最少」来挑，达到 1000 后改随机。&lt;/li&gt;
&lt;li&gt;关键限制：&lt;code&gt;client-local&lt;/code&gt; 对 quorum、stream 只是&lt;strong&gt;建议&lt;/strong&gt;——它保证副本落在声明节点，但&lt;strong&gt;不保证这个节点一直是 Leader&lt;/strong&gt;，因为 Leader 由之后的 Raft 选举决定。&lt;code&gt;balanced&lt;/code&gt; 则去均衡 Leader 分布。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;由此，那句「Leader 位置影响跨节点流量与热点」要这样理解：热点确实存在，但配置只能把副本放过去、不能把 Leader 钉死。要治理热点，靠的是选节点，不是指望一条配置锁住 Leader。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;副本位置之外，还有成员变更的边界。&lt;/strong&gt; 副本数不是定死就不能动的，声明之后运维还能手动加删成员，但有两条要记住：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;加成员默认是 &lt;code&gt;promotable&lt;/code&gt;（非投票成员），声明时创建的才是 &lt;code&gt;voter&lt;/code&gt;；只加不提升，就不参与多数派计算。&lt;/li&gt;
&lt;li&gt;删不掉最后一个成员（返回 &lt;code&gt;last_node&lt;/code&gt;）；成员变更 20 秒超时会回滚。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;（成员变更更细的底层行为——比如 &lt;code&gt;ra:add_member&lt;/code&gt; 要等 &lt;code&gt;applied&lt;/code&gt; 才真正落地——见排错索引与源码，这里不展开，不影响选型这个主线。）&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="十四收口整条链的分工与保证边界"&gt;十四、收口：整条链的分工与保证边界
&lt;/h2&gt;&lt;p&gt;把整条链各方的分工摆在一起看：Publisher Confirm 负责把「消息已被接受」这件事如实告诉你，它不负责不重复；客户端重连负责把「接入节点没了」变成一次可恢复的断线，它不负责不中断；消费端幂等负责兜住 at-least-once 语义下的重复投递；而队列类型与副本放置，决定上面每件事发生在哪种故障模型里。&lt;/p&gt;
&lt;p&gt;几个「安全」类说法，都成立于一个具体的故障模型，脱离那个模型就不成立：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;「quorum queue 能容忍一个节点故障」成立于三副本、且任一时刻仍有多数派在线。关键不是故障是否「同时」发生，而是第一个副本尚未恢复之前，第二个故障到来同样会失去多数派。&lt;/li&gt;
&lt;li&gt;「已确认的消息不会丢」成立于「托管该队列的多数节点不是永久不可用」。一旦多数节点永久丢失，这个保证就不复存在，队列只能强制删除重建。&lt;/li&gt;
&lt;li&gt;「客户端不需要知道 Leader 在哪」成立于客户端能连上任意一台存活节点。接入节点故障时，需要换节点的是客户端自己。&lt;/li&gt;
&lt;li&gt;「消费不会丢」——本文实验只验证到 at-least-once：3000 条投递了 3001 次，没有丢失，但有重复。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以 quorum queue 并不保证「不丢消息」，也不保证有多数派就等于 exactly-once。&lt;code&gt;basic.ack&lt;/code&gt; 保证的是「这条可路由消息已被所有命中队列接受」；消费侧的补偿与幂等是另一件事。&lt;/p&gt;
&lt;p&gt;最后跨产品对照一句：RabbitMQ 与 RocketMQ 在这类系统上的差别是&lt;strong&gt;副本粒度&lt;/strong&gt;——RocketMQ 复制的是整份日志，RabbitMQ 在 quorum queue 上的复制单位是队列。这一句的依据是本系列第四话的副本粒度结论，加上本文实测到的 RabbitMQ 侧副本范围。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="排错索引"&gt;排错索引
&lt;/h2&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;th&gt;证据&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;读到的队列条数比 &lt;code&gt;basic.ack&lt;/code&gt; 成功的条数少&lt;/td&gt;
					&lt;td&gt;条数指标按周期上报（quorum queue 上是 Ra 的 tick 回调，默认 5 秒），读到的是上一份快照&lt;/td&gt;
					&lt;td&gt;等两次读数一致再取值；不要据此判定丢消息&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;raw-failover-timeline.log&lt;/code&gt; 末尾&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;管理 API 查不到刚建立的连接&lt;/td&gt;
					&lt;td&gt;连接表是周期性采集的&lt;/td&gt;
					&lt;td&gt;轮询到出现为止，别把「查不到」当成「落在别的节点」&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;raw-classic-owner-failure.log&lt;/code&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;故障期间管理 API 仍报 &lt;code&gt;state = 'running'&lt;/code&gt;，&lt;code&gt;node&lt;/code&gt; 指向已离线节点&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;state&lt;/code&gt; 反映的是元数据里的记录，不是可用性&lt;/td&gt;
					&lt;td&gt;用 &lt;code&gt;quorum_status&lt;/code&gt; 与 &lt;code&gt;list_queues&lt;/code&gt; 判断；但这两个查询本身也可能读不到（见下一条）&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;raw-classic-owner-failure.log&lt;/code&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;transfer_leadership&lt;/code&gt; 返回非零&lt;/td&gt;
					&lt;td&gt;目标已是 Leader 时退出码为 69，且结果头在 stdout、错误在 stderr&lt;/td&gt;
					&lt;td&gt;接受 &lt;code&gt;already the leader&lt;/code&gt; 为成功；判定文本要两路都读&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;raw-classic-owner-failure.log&lt;/code&gt; 首行&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;list_queues&lt;/code&gt; 在失去多数派时&lt;strong&gt;可能&lt;/strong&gt;失败或超时&lt;/td&gt;
					&lt;td&gt;Khepri 的缓存读与线性一致读行为不同，两轮实测结果不一致，&lt;strong&gt;归因未定&lt;/strong&gt;（见第十节边注）&lt;/td&gt;
					&lt;td&gt;别把一次查询结果当成确定的队列状态判据；先看元数据存储状态再下结论&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;raw-failover-timeline.log&lt;/code&gt;、&lt;code&gt;raw-quorum-consumption.log&lt;/code&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;消费挂住很久才报 404&lt;/td&gt;
					&lt;td&gt;classic 的读取路径有 2000 × 30 毫秒的重试预算&lt;/td&gt;
					&lt;td&gt;这是 broker 侧的耗时尺度（约 1 分钟量级）；应用侧的超时按自己的 SLA、幂等与重试策略设，不必照抄这个预算&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;raw-phase2-timing.log&lt;/code&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;节点重启后立刻读写&lt;/td&gt;
					&lt;td&gt;容器起来 ≠ 副本追平&lt;/td&gt;
					&lt;td&gt;用 &lt;code&gt;quorum_status&lt;/code&gt; 的 &lt;code&gt;Last Log Index&lt;/code&gt; 与 &lt;code&gt;online&lt;/code&gt; 验收&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;raw-failover-timeline.log&lt;/code&gt;、&lt;code&gt;raw-classic-owner-failure.log&lt;/code&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;发布拿到 &lt;code&gt;basic.nack&lt;/code&gt;，以为这条消息哪儿都没进去&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;basic.ack&lt;/code&gt; 是所有命中队列的 AND；被拒绝的那一个决定了结局，别的队列可能已经收下了&lt;/td&gt;
					&lt;td&gt;重投前先按消息 id 去重，或把多队列命中改成显式检查；不要假设「&lt;code&gt;basic.nack&lt;/code&gt; = 没写入」&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;raw-fanout-per-queue.log&lt;/code&gt; 阶段 3 / 3b&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;td&gt;用「投递停滞时长」而不是「连接是否存活」做告警&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;raw-quorum-consumption.log&lt;/code&gt; 阶段 3&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;重启节点后出现少量重复消费&lt;/td&gt;
					&lt;td&gt;消费者所在节点被强杀时，手里未 ack 的消息会被重投；这是 at-least-once 的粒度&lt;/td&gt;
					&lt;td&gt;消费侧做幂等；不要期望重连后不重复&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;raw-quorum-consumption.log&lt;/code&gt; 阶段 5（3001 / 3000）&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h2 id="参考资料"&gt;参考资料
&lt;/h2&gt;&lt;ol&gt;
&lt;li&gt;RabbitMQ, &lt;em&gt;Clustering Guide&lt;/em&gt;. &lt;a class="link" href="https://www.rabbitmq.com/docs/clustering" target="_blank" rel="noopener"
 &gt;https://www.rabbitmq.com/docs/clustering&lt;/a&gt; —— 集群复制什么与不复制什么、客户端操作的路由、奇数节点与 Leader 放置&lt;/li&gt;
&lt;li&gt;RabbitMQ, &lt;em&gt;Metadata Store&lt;/em&gt;. &lt;a class="link" href="https://www.rabbitmq.com/docs/metadata-store" target="_blank" rel="noopener"
 &gt;https://www.rabbitmq.com/docs/metadata-store&lt;/a&gt; —— Khepri 保存的范围，及其与 Mnesia 的关系（与 partitions 页口径冲突，见正文第五节）&lt;/li&gt;
&lt;li&gt;RabbitMQ, &lt;em&gt;Quorum Queues&lt;/em&gt;. &lt;a class="link" href="https://www.rabbitmq.com/docs/quorum-queues" target="_blank" rel="noopener"
 &gt;https://www.rabbitmq.com/docs/quorum-queues&lt;/a&gt; —— Leader 与 Follower 的角色、「操作」的定义、Publisher Confirm 的确认边界、组大小与容错、成员与放置&lt;/li&gt;
&lt;li&gt;RabbitMQ, &lt;em&gt;Reliability Guide&lt;/em&gt;. &lt;a class="link" href="https://www.rabbitmq.com/docs/reliability" target="_blank" rel="noopener"
 &gt;https://www.rabbitmq.com/docs/reliability&lt;/a&gt; —— 选举期间投递暂停、消费者是否要自己恢复&lt;/li&gt;
&lt;li&gt;RabbitMQ, &lt;em&gt;Publisher Confirms&lt;/em&gt;. &lt;a class="link" href="https://www.rabbitmq.com/docs/confirms" target="_blank" rel="noopener"
 &gt;https://www.rabbitmq.com/docs/confirms&lt;/a&gt; —— 三种路径分别何时发 confirm、没有 confirm 就没有保证&lt;/li&gt;
&lt;li&gt;RabbitMQ, &lt;em&gt;Network Partitions&lt;/em&gt;. &lt;a class="link" href="https://www.rabbitmq.com/docs/partitions" target="_blank" rel="noopener"
 &gt;https://www.rabbitmq.com/docs/partitions&lt;/a&gt; —— 4.3.0 移除了什么、失去多数派时哪些操作停摆&lt;/li&gt;
&lt;li&gt;RabbitMQ, &lt;em&gt;Streams&lt;/em&gt;. &lt;a class="link" href="https://www.rabbitmq.com/docs/streams" target="_blank" rel="noopener"
 &gt;https://www.rabbitmq.com/docs/streams&lt;/a&gt; —— stream 的副本管理、确认边界与恢复动作（本文选型表的官方依据）&lt;/li&gt;
&lt;li&gt;RabbitMQ Server &lt;strong&gt;v4.3.5&lt;/strong&gt; 源码（&lt;code&gt;rabbit_quorum_queue.erl&lt;/code&gt;、&lt;code&gt;rabbit_queue_location.erl&lt;/code&gt;、&lt;code&gt;rabbit_fifo_client.erl&lt;/code&gt;、&lt;code&gt;rabbit_amqqueue.erl&lt;/code&gt; 等）—— 成员变更的三条边界、Leader 放置的实现、classic 读取路径的重试预算&lt;/li&gt;
&lt;li&gt;本系列：第一话《RabbitMQ 的架构与特性》、第四话《RocketMQ 4.x 的架构与机制》—— 前者是本文的队列类型基础，后者的副本粒度结论是本文末节跨产品对照的依据&lt;/li&gt;
&lt;/ol&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/infra/rabbitmq-cluster" target="_blank" rel="noopener"
 &gt;GYD 仓库 &lt;code&gt;infra/rabbitmq-cluster/&lt;/code&gt;&lt;/a&gt;。集群的起停、运行顺序、完整命令、原始输出与每一步的预期观察点，都在该模块的 README 里；正文里每个读数的出处也在那里。这篇文章不复现命令与输出——它负责机制，复现交给仓库。&lt;/p&gt;

 &lt;/blockquote&gt;</description></item><item><title>第二话｜消息队列的可靠投递与可靠消费：不丢靠三段兜底，不重只能靠幂等</title><link>https://zh.renxinblog.cn/post/gyd-c02-mq-reliable-delivery/</link><pubDate>Wed, 11 Feb 2026 00:00:00 +0000</pubDate><guid>https://zh.renxinblog.cn/post/gyd-c02-mq-reliable-delivery/</guid><description>&lt;img src="https://zh.renxinblog.cn/images/gyd-c02-mq-reliable-delivery-cover.png" alt="Featured image of post 第二话｜消息队列的可靠投递与可靠消费：不丢靠三段兜底，不重只能靠幂等" /&gt;&lt;p&gt;一张订单表里多了一行，下游的发货系统没有收到这条消息。&lt;/p&gt;
&lt;p&gt;订单服务在创建订单成功后投递 &lt;code&gt;order.created&lt;/code&gt;，发货、积分、通知三个下游各自消费它。这一笔的发送日志打着成功，订单表里有数据，下游的队列里没有消息。&lt;strong&gt;这条消息死在了哪一段？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;消息队列从进入架构的第一天就带着「可靠」的承诺，但这句承诺不是某一个组件给的，而是生产者、Broker、消费者各兜一段才拼出来的。任何一段缺了兜底，都会出现「发送成功、下游没收到」。&lt;/p&gt;
&lt;p&gt;这条链路的骨架：消息穿过生产者、Broker、消费者三个边界，Broker 里是交换机到队列的一段路由；路径上还有两个反向回执——Broker 回给生产者的 confirm、消费者回给 Broker 的 ack。&lt;/p&gt;
&lt;p&gt;&lt;img alt="RabbitMQ 消息数据流与三段边界架构图" class="gallery-image" data-flex-basis="652px" data-flex-grow="272" decoding="async" height="250" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://zh.renxinblog.cn/images/gyd-c02-mq-reliable-delivery-architecture.svg" width="680"&gt;&lt;/p&gt;
&lt;p&gt;顺着这条消息的生命周期走一遍，它有三个可能消失的位置：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;发送时&lt;/strong&gt;：生产者以为发出去了，消息却没进 Broker，或者进了 Broker 却没进任何队列。那笔订单的发送日志打的就是「成功」。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;存储时&lt;/strong&gt;：Broker 收下了，回执也回了，消息还没挺过一次重启就从磁盘上消失。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;消费时&lt;/strong&gt;：消费者读到了，处理到一半进程挂掉，重启后不知道这一笔该不该再处理。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这三个位置分别由生产者、Broker、消费者兜住，彼此没有替代关系：生产端再严谨也管不到消费者崩溃，Broker 副本再多也管不到生产者发错了路由键。&lt;/p&gt;
&lt;p&gt;这条链路上有两类问题，经常被混在一起谈：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;不丢&lt;/strong&gt;：一条消息从产生到被处理完，中途不能消失。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;不重&lt;/strong&gt;：同一笔业务不能被处理两次。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;在 Broker 这一层，这两类目标是冲突的：要保证不丢，就得允许重复投递。真正的「既不丢也不重」要到消费端用幂等去拼，Broker 给不了。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;GYD 这个系列讲分布式系统里各种解决方案的实现方式。消息队列出现得早，也最容易被当成「基础设施，接上就行」的一环，而可靠性缺口通常就藏在这个默认判断里。&lt;/p&gt;
&lt;p&gt;这三段在 RabbitMQ 4.3.5 上分别跑一遍。结论对应的是 RabbitMQ 4.3.5 + Spring Boot 4.1.1 + Spring AMQP 4.1.1 + amqp-client 5.30.0 + JDK 21；完整实现、端点对照与复现方式在仓库 &lt;a class="link" href="https://github.com/renxin2024/GYD/tree/main/c02-mq-reliable-delivery" target="_blank" rel="noopener"
 &gt;&lt;code&gt;c02-mq-reliable-delivery&lt;/code&gt;&lt;/a&gt;，下面每处结论后面也各自带了落点。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="一生产端confirm-和-return-是两件事"&gt;一、生产端：confirm 和 return 是两件事
&lt;/h2&gt;&lt;p&gt;RabbitMQ 的投递路径是 &lt;strong&gt;生产者 → 交换机 → 绑定 → 队列&lt;/strong&gt;。生产者把消息发给交换机，交换机再根据绑定关系决定它进哪些队列。&lt;/p&gt;
&lt;p&gt;消息先到交换机这一层，意味着「到了 Broker」和「进了队列」是两个可以分开发生的事件。RabbitMQ 给了两个独立机制来报告它们：&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;publisher confirm&lt;/td&gt;
					&lt;td&gt;视路由结果分情况：可路由＝路由到的队列已接收；不可路由＝确认没有目标队列&lt;/td&gt;
					&lt;td&gt;消息被交换机处理之后&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;publisher return&lt;/td&gt;
					&lt;td&gt;消息没能路由到任何队列&lt;/td&gt;
					&lt;td&gt;交换机找不到匹配的绑定，且投递时设置了 &lt;code&gt;mandatory&lt;/code&gt;&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;这两道回执在配置里是两个分开的开关（&lt;code&gt;publisher-confirm-type&lt;/code&gt; 收 confirm、&lt;code&gt;publisher-returns&lt;/code&gt; 收退回），默认都不开；客户端代码侧还要给发送模板打上 &lt;code&gt;mandatory&lt;/code&gt; 标记、并分别注册两个回调。缺了任何一处，等于少了一道回执。&lt;/p&gt;
&lt;p&gt;把一条消息投到没有任何队列绑定的路由键上，日志会连出三件事：生产端记下自己把消息投了出去；Broker 退回一条 &lt;code&gt;NO_ROUTE&lt;/code&gt;，说明没有任何队列接收它；紧接着是一条 confirm 的 ack，说 Broker 已确认。再去查队列深度，两个业务队列都是 0 条（端点 &lt;code&gt;unroutable&lt;/code&gt;）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;confirm 返回了 ack，消息已经被丢掉了。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;官方文档在描述这件事时语气很平淡：「对不可路由的消息，Broker 会在交换机确认没有队列可路由之后发出 confirm；如果消息同时以 mandatory 方式发布，&lt;code&gt;basic.return&lt;/code&gt; 会在 &lt;code&gt;basic.ack&lt;/code&gt; 之前发给客户端。」文档没有单独警告不处理 return 会怎样，但把两者的定义摆在一起，结论是清楚的：&lt;strong&gt;confirm 的 ack 不是一律「成功」，它跟着路由结果走。路由到了队列，ack 表示队列已接收；路由不到，ack 只表示「确认了没有队列」，这条消息已经丢了。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;只监听 confirm 而不管 return 的服务，等于在发送环节装了一个常亮的绿灯。&lt;/p&gt;
&lt;p&gt;这里还有个更隐蔽的边界：return 的语义是「一个队列都没路由到」，所以「没有 return」只说明&lt;strong&gt;至少有一个队列接收了&lt;/strong&gt;，不能证明发货、积分、通知三个预期订阅都配置正确。漏配了一个订阅，消息照样 confirm ack、照样不 return，只是那个下游安静地没收到。这个边界单靠发送端的回执是看不见的。&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart LR
 A["投递一条消息"] --&gt; B{"有队列绑定这个路由键吗"}
 B -- 有 --&gt; C["消息进入队列&lt;br/&gt;confirm: ack"]
 B -- 没有 --&gt; D["没有队列接收&lt;br/&gt;confirm: ack&lt;br/&gt;return: NO_ROUTE"]&lt;/pre&gt;&lt;p&gt;需要等回执再继续的场景，可以同步等确认结果（示例里给了 3 秒超时）。这里有一处容易写漏：不可路由时 confirm 依然返回 ack，此时消息已被退回、并未进队列，所以&lt;strong&gt;只查 ack 会把「路由不到队列」当成「发送成功」，必须再查一次退回结果&lt;/strong&gt;。同步等回执会拖住发送方。文档里给了个量级：在持续负载下，ack 的延迟可以达到数百毫秒，因此它建议把 confirm 当作异步事件流处理，或者批量投递、统一等待整批回执。同步等待更适合这里这种单条验证，一眼就能看出这一条到底有没有被 Broker 收下。&lt;/p&gt;
&lt;p&gt;生产端的可靠性到这里只解决了一半。ack 在「路由不到队列」时并不代表成功，不是回执说了假话，是应用把成功条件理解少了：只盯 confirm 不看 return，就把「没有队列」当成了「发送成功」。而一旦把发送失败当成功、靠重试兜底，「同一笔订单发了两遍」的概率就不再为零。&lt;strong&gt;可靠性问题的另一半，是从生产端的重试里长出来的。&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id="二broker-端持久化要分清消息恢复和拓扑恢复"&gt;二、Broker 端：持久化要分清「消息恢复」和「拓扑恢复」
&lt;/h2&gt;&lt;p&gt;消息进了队列，下一个问题是它会不会在 Broker 这一侧消失。这里要先把两件事分开：&lt;strong&gt;已经入队的消息能不能挺过重启&lt;/strong&gt;，和&lt;strong&gt;重启后拓扑（交换机、绑定）还在不在&lt;/strong&gt;。它们由不同的属性决定，不能混成一句话。&lt;/p&gt;
&lt;p&gt;对一条 classic queue 里的消息，「能不能挺过重启」只看两个属性：&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;code&gt;durable&lt;/code&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;deliveryMode=2&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;code&gt;durable(true)&lt;/code&gt; 是另一件事，管的是&lt;strong&gt;拓扑恢复&lt;/strong&gt;：它决定重启后这个交换机还在不在、还能不能继续往它投，但&lt;strong&gt;不决定已经入队的消息能否恢复&lt;/strong&gt;。所以别把交换机、队列、消息并列成「三个都要持久化」——交换机那条管的是「以后还能不能投」，跟「已经投进去的还在不在」是两码事。&lt;/p&gt;
&lt;p&gt;官方文档对「能不能挺过重启」的说法是：durable queue 的元数据写磁盘，重启时会被恢复，「包括其中以 persistent 方式发布的消息。以 transient 方式发布的消息会在恢复过程中被丢弃，&lt;strong&gt;即使它们的队列是 durable 的&lt;/strong&gt;。」&lt;/p&gt;
&lt;p&gt;这两个属性里，消息持久化这条更容易被漏：队列的 &lt;code&gt;durable&lt;/code&gt; 在声明时顺手就写了，消息的 &lt;code&gt;deliveryMode&lt;/code&gt; 却要每次投递单独指定。&lt;/p&gt;
&lt;p&gt;用一条不挂消费者的持久化探针队列做对照（这个实验只在 classic queue 上成立，quorum queue 的持久化不依赖 deliveryMode）：投一条持久化、一条非持久化，两条都会收到 confirm 的 ack；重启 Broker 之后队列深度从 2 掉到 1，&lt;code&gt;messages_persistent&lt;/code&gt; 一直是 1。再单独投一条非持久化消息复验：重启前深度 1，重启后归 0（端点 &lt;code&gt;probe&lt;/code&gt;）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;ack 是否意味着落盘，取决于消息和队列的持久化属性。&lt;/strong&gt; 文档给「持久化消息投到持久化队列」这种组合打了包票，那时 ack 意味着已经写盘。但只要消息属性没标持久化，ack 照样返回，重启后它无声消失。&lt;/p&gt;
&lt;p&gt;单机持久化挡得住进程重启，挡不住磁盘损坏和机器故障。要扛机器级故障，只能用副本，在 RabbitMQ 4.x 上意味着 &lt;strong&gt;quorum queue&lt;/strong&gt;——文档里那句「Classic queue mirroring was removed starting with RabbitMQ 4.0」，把过去的退路一并关掉了。&lt;/p&gt;
&lt;p&gt;quorum queue 基于 Raft，副本关系里有一个 leader 和若干 follower，可用性由「多数派是否在线」决定。单节点验证不了这件事，所以另起了一套 3 节点集群（&lt;code&gt;infra/rabbitmq-cluster/&lt;/code&gt;，端口与单节点错开，可以同时运行）。观察到的是这样一串：声明出来的 quorum queue 副本组实为 3 个成员、leader 落在 rmq1；投 3 条持久化消息全部收到 confirm；停掉 leader 所在节点后重新选主（两次运行分别落在 rmq2 与 rmq3），2/3 在线时继续投 2 条都成功，而且能取回全部 5 条，原 3 条一条不少；再停一个、只剩 1/3 时投递被拒，15.0s 后返回 &lt;code&gt;NackError: 0 message(s) NACKed&lt;/code&gt;；把停掉的节点恢复回来，队列自愈到 3/3、投递恢复，而被 NACK 的那条并没有留在队列里（脚本 &lt;code&gt;quorum-majority.py&lt;/code&gt;）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;quorum queue 的可用性由多数派决定：2/3 在线可以读写，掉到 1/3 就无法提交写入。&lt;/strong&gt; 这不是丢消息，是 Raft 为避免脑裂做的取舍：宁可拒绝写入，也不让两个分区各自接受写入。客户端看到的表现不一定是即时报错，可能是等待、超时，也可能是明确的 NACK（本次实测是 15s 后返回 &lt;code&gt;NackError&lt;/code&gt;）。&lt;/p&gt;
&lt;p&gt;顺带一个观测上的坑：失去多数派时，连读队列视图本身都会超时，&lt;code&gt;list_queues&lt;/code&gt; 时好时坏。故障期间的监控图表断点，有一部分来自这里。&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;维度&lt;/th&gt;
					&lt;th&gt;单机 durable queue&lt;/th&gt;
					&lt;th&gt;quorum queue&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;/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;td&gt;节点重启、单机磁盘损坏、少数派节点丢失&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;可用性前提&lt;/td&gt;
					&lt;td&gt;节点在线&lt;/td&gt;
					&lt;td&gt;多数派在线（3 节点可容忍 1 个）&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&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;quorum queue 的 confirm 语义也跟着变严：&lt;strong&gt;只有在消息被复制到多数派成员之后，Broker 才会发出 publisher confirm。&lt;/strong&gt; 这一点和单机队列不同，单机队列上「收到 ack」离「数据安全」还有距离，quorum queue 上这句话的分量更重。&lt;/p&gt;
&lt;p&gt;副本组本身也有几条固定约束：quorum queue 必须是持久化队列（Raft 的副本机制不建立在内存队列上）；官方建议副本数为奇数，默认 group size 是 3；文档明确写了「不要在多过 7 个 RabbitMQ 节点上运行 quorum queue」，也提醒少于 3 个节点的集群拿不到 quorum queue 的完整保证。本次实测的 3 节点加 group size 3，正好是能观察到选主、多数派可用性和少数派拒写的最小配置。&lt;/p&gt;
&lt;h2 id="三消费端ack-时机与-prefetch-决定崩溃后重投多少条"&gt;三、消费端：ack 时机与 prefetch 决定崩溃后重投多少条
&lt;/h2&gt;&lt;p&gt;消息安全躺在队列里，最后一个缺口在消费者手里。&lt;strong&gt;ack 的时机就是一次投票：投给「不丢」，还是投给「不重」。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;处理成功后再确认、还是先确认再处理，是两个方向相反的取舍：先处理成功后 ack，中途崩溃消息会重投，于是可能重复处理；先 ack 再处理，中途崩溃时消息已删除，于是可能丢失。&lt;code&gt;autoAck&lt;/code&gt; 是后者的极端形式，消息一交给消费者就当作已确认，跑得最快，也最不设防。&lt;/p&gt;
&lt;p&gt;这里要插一句和 Spring 的边界：上面两种只是 AMQP 层的 &lt;code&gt;manual&lt;/code&gt; / &lt;code&gt;autoAck&lt;/code&gt;；Spring 还有一个默认的 &lt;code&gt;AUTO&lt;/code&gt; 模式，它是容器在监听方法成功返回后替你 ack，比 &lt;code&gt;autoAck&lt;/code&gt; 保守，比 &lt;code&gt;manual&lt;/code&gt; 省事。这套 demo 用的是 &lt;code&gt;manual&lt;/code&gt;，配置里关掉框架自动确认，把 ack 时机完全交给业务代码：业务成功才 ack，失败则带 &lt;code&gt;requeue=false&lt;/code&gt; 拒绝，交给重试链路（不重回原队列，否则会原地死循环）。&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart LR
 A["收到一条消息"] --&gt; B{"先做哪一步"}
 B -- "先处理，成功后 ack" --&gt; C["中途崩溃&lt;br/&gt;消息重投，可能重复处理"]
 B -- "先 ack，再处理" --&gt; D["中途崩溃&lt;br/&gt;消息已删，可能丢失"]&lt;/pre&gt;&lt;p&gt;选完 ack 时机，还有一个必须显式决定的参数：prefetch。&lt;/p&gt;
&lt;p&gt;RabbitMQ 的消费模型是「&lt;strong&gt;未确认消息的集合&lt;/strong&gt;」，不是 Kafka 那种记「读到哪了」的位点。Broker 把消息推给消费者，在收到 ack 之前一直记着这些消息属于这个消费者，没有 ack 就没有「位点前进」，只有「这条还没结清」。于是 prefetch（协议里的 &lt;code&gt;basic.qos&lt;/code&gt;）决定了&lt;strong&gt;一个消费者最多同时持有多少条未确认消息&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这个参数不影响单条消息的处理速度，它影响的是消费者崩溃时有多少条要重投。用 50 条慢消息（每条处理 200ms）只改 prefetch 来对照：设成 1 时未确认条数恒为 1，设成 20 时恒为 20，而 50 条排空都花约 10s（端点 &lt;code&gt;burst&lt;/code&gt;）。&lt;strong&gt;两者的吞吐相同，差别在同时在途的未确认条数。&lt;/strong&gt; 如果消费者在任一时刻崩溃，&lt;code&gt;prefetch=1&lt;/code&gt; 最多重投 1 条，&lt;code&gt;prefetch=20&lt;/code&gt; 最多重投 20 条。&lt;/p&gt;
&lt;p&gt;这里有个边界要说清楚：本实验的瓶颈在消费处理本身（每条固定 200ms），所以两者耗时相同。如果消费逻辑很快、网络往返占比高，prefetch 取值会明显影响吞吐，那是另一个结论，不能从这组数据外推。表里的「恒为」也只成立在积压充足阶段，消息源源不断时未确认条数稳定在 prefetch 上限；排空的最后一段，队列里剩不足 prefetch 条，未确认条数会掉下来。另外，实验只测了单消费者，多消费者共用一个队列时的负载分摊没有测。&lt;/p&gt;
&lt;p&gt;还有一件事值得和 prefetch 分开看：消费者崩溃后，那些未确认的消息会被重新投递，但&lt;strong&gt;不一定回到同一个消费者&lt;/strong&gt;。Broker 把它们放回队列等重新分配，多个消费者在场时通常落到别人手里。这就是为什么扩容之后「同一笔业务被两个消费者各处理一次」会更常见，幂等在那里不再是理论问题。&lt;/p&gt;
&lt;p&gt;至于 prefetch 该定多少，没有一个可以照抄的数。它取决于单条消息的处理耗时、下游能承受的并发、以及你愿意接受崩溃时重投多少条。文档里 &lt;code&gt;0&lt;/code&gt; 表示不设上限，等于把未确认集合交给内存和连接去决定，生产环境一般不会这么配。Broker 侧另有一个「消费者没指定时生效」的默认 prefetch，可在高级配置里调整（文档示例给的值是 250），所以不显式设置并不等于没有限制，只等于这个数由别人替你定了。&lt;/p&gt;
&lt;h2 id="四不丢和不重分别落在两层broker-管投递幂等管效果"&gt;四、不丢和不重分别落在两层：Broker 管投递，幂等管效果
&lt;/h2&gt;&lt;p&gt;前三段各自解决了一类丢失，代价是把重复引了进来：生产端的重试会重发，消费端的 ack 时机（先处理再确认）会重投。&lt;strong&gt;要理解这件事的边界，得先看清 Broker 能承诺什么。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;官方可靠性文档把话说得很直白：「使用 acknowledgements 保证 &lt;strong&gt;at least once&lt;/strong&gt; 投递。不使用 acknowledgements 时，发布和消费过程中都可能丢消息，只能保证 &lt;strong&gt;at most once&lt;/strong&gt;。」至于 exactly-once，文档在同一页给出了它的现实形态：生产者重传、确认丢失时消息可能重复，「因此消费端应用需要做去重，或者以幂等的方式处理收到的消息。」&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Broker 能给的只有 at-least-once 和 at-most-once。exactly-once 不是队列的承诺，而是「at-least-once 投递 + 消费端幂等」拼出来的效果，而且这个 exactly-once 只覆盖被幂等机制管住的那些业务效果。&lt;/strong&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;at-most-once&lt;/td&gt;
					&lt;td&gt;关掉 ack / autoAck&lt;/td&gt;
					&lt;td&gt;不重，可能丢&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;at-least-once&lt;/td&gt;
					&lt;td&gt;手动 ack + 失败重投&lt;/td&gt;
					&lt;td&gt;不丢，可能重&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;exactly-once&lt;/td&gt;
					&lt;td&gt;队列不提供&lt;/td&gt;
					&lt;td&gt;由 at-least-once + 消费端幂等拼出&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;这里要分清三个层次，它们经常被混成一句话。&lt;strong&gt;投递&lt;/strong&gt;可以重复：Broker 重投、生产者重发都会让同一条消息到两次。&lt;strong&gt;执行&lt;/strong&gt;可以重复：处理函数被调用两次。&lt;strong&gt;业务副作用&lt;/strong&gt;能不能只生效一次，取决于消费端有没有幂等，而且幂等只覆盖它自己管得住的那些副作用。数据库去重表能让「改库」只发生一次，但它不会自动让发货、短信、外部扣款也只发生一次；那些副作用要各自有幂等键（发货单号、短信去重、扣款流水号），或者接受「至少一次 + 人工对账」。&lt;/p&gt;
&lt;p&gt;注意幂等这一层必须落在&lt;strong&gt;业务侧&lt;/strong&gt;。Broker 手里也有一些计数，比如 quorum queue 会把一条消息失败的投递次数记在 &lt;code&gt;x-delivery-count&lt;/code&gt; 头里，配合 &lt;code&gt;delivery-limit&lt;/code&gt; 策略，投递超过上限的消息会被死信掉，而它解决的是「一条毒消息别在队列里无限打转」，不是「你的业务代码只跑一次」。这两个问题在不同的层，别指望前一层替后一层兜住。（&lt;code&gt;delivery-limit&lt;/code&gt; 的计数规则在 4.3 有调整，不是所有显式 nack 都计入失败次数，与第六节讲的 delayed retry 相关。）&lt;/p&gt;
&lt;p&gt;落回订单场景：这一笔订单可能被处理两次。幂等的最小形态是让消息带上业务唯一键，消费端用它去重，这套 demo 里就是消息体上的 &lt;code&gt;orderId&lt;/code&gt;。它由订单服务生成，重发时不变；消费端拿到它，先查这一步业务是否已经做过，再决定要不要执行。但要小心并发：去重记录和业务写入必须原子提交（比如唯一索引 + 同库事务），否则两个消费者同时「查无记录」、都往下执行，幂等就失效了。去重表、唯一索引、状态机校验各有各的适用场景，本 demo 没有落到那一层，&lt;code&gt;orderId&lt;/code&gt; 只负责把「同一笔业务」这个身份带过去。&lt;/p&gt;
&lt;h2 id="五兜底死信与延迟重试链路"&gt;五、兜底：死信与延迟重试链路
&lt;/h2&gt;&lt;p&gt;三段兜底之外，还有一类问题没有解决：消费一直失败怎么办。失败一次就重投，会得到一个高速空转的循环；失败三次就扔掉，会丢掉一条本该被处理的消息。&lt;/p&gt;
&lt;p&gt;RabbitMQ 在这一层的原语是&lt;strong&gt;死信&lt;/strong&gt;。消息在四种情况下会被重新投递到死信交换机：消费者以 &lt;code&gt;requeue=false&lt;/code&gt; 拒绝（&lt;code&gt;basic.nack&lt;/code&gt; / &lt;code&gt;basic.reject&lt;/code&gt;）、消息因 TTL 到期（per-message TTL，或队列级 &lt;code&gt;message-ttl&lt;/code&gt;）、队列超过长度上限、以及 quorum queue 上超过 &lt;code&gt;delivery-limit&lt;/code&gt; 的投递次数。&lt;/p&gt;
&lt;p&gt;这里有两个容易踩到的地方。如果队列上配了死信交换机，但消息需要死信时那个交换机并不存在，&lt;strong&gt;消息会被静默丢弃&lt;/strong&gt;，没有回执，没有异常。拓扑声明因此不能只声明队列，交换机、绑定、队列的生命周期要一起管。&lt;/p&gt;
&lt;p&gt;另一个和队列类型有关：classic queue 的死信过程默认不带 publisher confirm，消息从原队列删除后直接投给死信目标，目标不可用时它就丢了。quorum queue 可以做得更强，但&lt;strong&gt;默认仍是 at-most-once&lt;/strong&gt;，要显式配置 &lt;code&gt;dead-letter-strategy=at-least-once&lt;/code&gt;、&lt;code&gt;overflow=reject-publish&lt;/code&gt; 并配好死信交换机，才切到带 confirm 的 at-least-once 死信。同一套死信拓扑，队列类型和策略配置不同，兜底强度并不一样。本 demo 没有显式配置这套策略，死信转移仍是 at-most-once 语义。&lt;/p&gt;
&lt;p&gt;要做「重试 N 次再进死信」，标准做法是让业务队列的死信出口指向一条重试链路：业务队列 → nack → 重试交换机 → TTL 重试队列 → 到期回业务交换机 → 业务队列。这套 demo 的拓扑里，业务队列的死信出口不是最终死信队列，而是重试交换机；重试队列上配了 2000ms 的队列级 TTL，到期后经自身的死信出口回到业务交换机；最终死信交换机只接重试用尽以后由应用显式投递的消息。&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart LR
 Q["业务队列&lt;br/&gt;收到消息"] --&gt; C["处理失败&lt;br/&gt;nack(requeue=false)"]
 C -- "还没用尽重试" --&gt; R["重试队列&lt;br/&gt;TTL 2000ms"]
 R -- "到期回投，x-death 计数 +1" --&gt; Q
 C -- "重试用尽" --&gt; D["最终死信队列&lt;br/&gt;等待人工或定时任务"]&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;重试计数不一定非要应用自己维护，本 demo 读 Broker 写在 &lt;code&gt;x-death&lt;/code&gt; 头里的死信记录。&lt;/strong&gt; 应用自己维护一个进程内计数器，进程重启后就归零；要可靠就得把次数持久化到数据库、任务记录或消息头里，那是另一套存储。本 demo 选择读 &lt;code&gt;x-death&lt;/code&gt;，它跟着消息走，避免了另建一套重试计数存储。官方文档对它的定义是：每条记录里 &lt;code&gt;count&lt;/code&gt; 表示「这条消息因为这个原因从这个队列被死信了多少次」，并且记录会按 &lt;code&gt;{队列, 原因}&lt;/code&gt; 压缩，同一个 (队列, 原因) 组合只保留一条，&lt;code&gt;count&lt;/code&gt; 累加。&lt;/p&gt;
&lt;p&gt;还要说清一个边界：&lt;code&gt;x-death&lt;/code&gt; 记的是「死信」次数，不是「投递」次数。消费者崩溃后消息被直接重投、没走死信的那一次，不会在 &lt;code&gt;x-death&lt;/code&gt; 里留记录。所以「rejected 次数 + 1 = 累计投递次数」这个等式，只在「每次失败都走 nack 死信」的本实验路径下成立。&lt;/p&gt;
&lt;p&gt;实测一条一直失败的消息（&lt;code&gt;orderId&lt;/code&gt; 里带 &lt;code&gt;fail&lt;/code&gt;），队列级 TTL 2000ms、最多重试 3 次。第一次失败时 &lt;code&gt;x-death&lt;/code&gt; 还是空的；第二次时里面已经有两条记录，业务队列上一条 &lt;code&gt;rejected&lt;/code&gt; 计数 1、重试队列上一条 &lt;code&gt;expired&lt;/code&gt; 计数 1；第三次两边各加 1；到第 4 次投递仍失败，消息转入最终死信队列，此时累计投递 4 次，也就是首次加上 3 次重试。相邻两次处理的间隔实测 2004 / 2003 / 2003ms，与配置的 2000ms 对得上（重跑会有几毫秒浮动）。&lt;/p&gt;
&lt;p&gt;计数时要挑对条目：同一次失败会留下两条记录，业务队列上一条 &lt;code&gt;reason=rejected&lt;/code&gt;，重试队列上一条 &lt;code&gt;reason=expired&lt;/code&gt;，两个都算就重复计数了。示例里的计数只挑与业务队列同名的 &lt;code&gt;rejected&lt;/code&gt; 记录求和。&lt;/p&gt;
&lt;p&gt;重试用尽之后怎么收尾，有一个容易写错的地方。这里的业务队列死信出口是重试交换机，所以&lt;strong&gt;不能再用 &lt;code&gt;nack&lt;/code&gt; 结束&lt;/strong&gt;，它会再一次进入重试循环。正确做法是显式投到最终死信交换机，再 ack 掉原消息，同时把尝试次数写进消息头备查。&lt;/p&gt;
&lt;h2 id="六单个-ttl-重试队列的限制混合延迟会出现队头阻塞"&gt;六、单个 TTL 重试队列的限制：混合延迟会出现队头阻塞
&lt;/h2&gt;&lt;p&gt;重试链路跑通之后，一个自然的想法是让间隔递增：1 秒、10 秒、60 秒，给下游留出恢复时间。这个想法在 RabbitMQ 的 TTL 上会撞到一件不那么直观的事。&lt;/p&gt;
&lt;p&gt;队列级的 &lt;code&gt;x-message-ttl&lt;/code&gt; 是队列的属性，同一条队列里的消息共用一个延迟，所以它天然只能做固定间隔，要递增就得给每一级配一条队列。另一条路是 per-message TTL：投递时给每条消息带上自己的 &lt;code&gt;expiration&lt;/code&gt;。直觉上「谁先到期谁先走」，实测是「队头说了算」。&lt;/p&gt;
&lt;p&gt;同一条队列投 3 条消息、各带不同的 &lt;code&gt;expiration&lt;/code&gt;，只换入队顺序（脚本 &lt;code&gt;ttl-head-of-line-blocking.py&lt;/code&gt;）：先投 6000ms 再投两条 1000ms，三条全部在 +6.0s 才死信；把顺序换成两条 1000ms 在前、6000ms 在后，前两条 +1.0s 就走，第三条 +6.0s。后两条的 &lt;code&gt;expiration&lt;/code&gt; 明明只有 1000ms，却等到 +6.0s 才死信，被队头那条 6000ms 的消息卡了约 5 秒。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;在本实验这种「同一条 classic 队列、只靠 per-message TTL 触发死信」的场景下，过期检查只在队头进行。&lt;/strong&gt; 队头那条没到期，排在它后面、已经过期的消息也不会被处理。&lt;/p&gt;
&lt;p&gt;官方文档写得很清楚：classic queue 与 quorum queue 都是「过期消息到达队头时才被死信掉」，并且「设置 per-message TTL 后，已过期的消息可能排在未过期消息后面，直到后者被消费或过期；这些过期消息占用的资源不会被释放，也会被计入队列的消息数」。&lt;/p&gt;
&lt;p&gt;所以递增退避的「手搓」做法有两条：每一级一条队列（1s 队列、10s 队列、60s 队列各配一条，用路由键区分级别），或者装延迟消息插件。本 demo 用固定 2000ms 间隔，正是因为它只需要一条重试队列。&lt;/p&gt;
&lt;p&gt;不过这两条都不是唯一的答案了。RabbitMQ 4.3 起给 quorum queue 提供了原生 delayed retry：通过 &lt;code&gt;x-delayed-retry-type&lt;/code&gt; / &lt;code&gt;x-delayed-retry-min&lt;/code&gt; / &lt;code&gt;x-delayed-retry-max&lt;/code&gt; 三个参数，消息被拒绝（reject/nack）后按投递次数自动线性退避，延迟 = &lt;code&gt;min(min_delay × delivery_count, max_delay)&lt;/code&gt;。它把「递增退避」做成了队列的内建能力，不用再叠一堆 TTL 队列或插件。注意三点：它只对 quorum queue 生效；默认 &lt;code&gt;x-delayed-retry-type=disabled&lt;/code&gt;，要显式开启；本 demo 用的是 classic queue + 单条 TTL 重试队列，走的是上面那条「手搓」路径，正好能看清队头阻塞这个底层限制。&lt;/p&gt;
&lt;h2 id="七一致性补偿broker-之外的那一段"&gt;七、一致性补偿：Broker 之外的那一段
&lt;/h2&gt;&lt;p&gt;上面讲的都是「消息发出去之后」的可靠性。还有一类问题在更早的地方：&lt;strong&gt;订单写进了数据库，消息没发出去，或者反过来。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;在 RabbitMQ 上这件事要靠应用层补偿。AMQP 的 channel 事务在这里帮不上忙，它的硬伤首先是&lt;strong&gt;事务边界&lt;/strong&gt;：channel 事务只能包住「发消息」这一个动作，包不住「数据库写 + 发消息」这对跨系统的操作，所以它根本解决不了原子提交；顺带它还有吞吐代价（文档给的是降到约 1/250）且和 confirm 模式互斥。RabbitMQ 也没有 RocketMQ 那种 broker 级的事务消息（半消息 + 回查）。可行的模式是&lt;strong&gt;本地消息表&lt;/strong&gt;（也叫 outbox）：在写订单的同一个本地事务里，往一张 outbox 表插一条待投递记录；事务提交后由后台任务把 outbox 里的记录投递出去，投递成功再标记完成；消费端仍然靠幂等兜底。&lt;/p&gt;
&lt;p&gt;这个模式的关键在「同一个本地事务」，数据库事务保证订单和待发消息一起落库，消息投递则从「必须成功」降级成了「迟早成功」。&lt;strong&gt;本 demo 没有实现本地消息表&lt;/strong&gt;，这里只说明它在链路里的位置：它是把「改库 + 发消息」这对跨系统操作，压回一个本地事务里的做法。&lt;/p&gt;
&lt;p&gt;三个角色可以分开看：订单表和 outbox 表写在同一个事务里，解决「改库成功但消息没发出去」；后台投递任务负责重试，解决「投递时下游不可用」；消费端幂等兜最后一道，解决「投递重试带来的重复」。三处缺一处，一致性就漏一处。&lt;/p&gt;
&lt;p&gt;这个模式也有自己的代价：outbox 表需要清理策略，否则它会跟着业务表一起无限增长；投递任务要独立控制并发和失败告警，它本身变成一条需要被监控的链路；投递逻辑和业务表放在同一个库时，事务的粒度会变大。它适合「本地写库、且要求通知最终一定送到」的场景，不适合拿来兜一个本来就不要求一致的通知。&lt;/p&gt;
&lt;h2 id="八boot-4-与-rabbitmq-43-上的几处版本差异"&gt;八、Boot 4 与 RabbitMQ 4.3 上的几处版本差异
&lt;/h2&gt;&lt;p&gt;这套代码跑在 Spring Boot 4.1.1 + Spring AMQP 4.1.1 + amqp-client 5.30.0 + JDK 21 上，和大多数 Boot 3 时代的 RabbitMQ 教程有几处对不上的地方：&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;Jackson2JsonMessageConverter&lt;/code&gt; 找不到依赖&lt;/td&gt;
					&lt;td&gt;Boot 4 用 Jackson 3（&lt;code&gt;tools.jackson&lt;/code&gt;），类路径上只有 &lt;code&gt;com.fasterxml.jackson&lt;/code&gt; 的注解包，没有 databind&lt;/td&gt;
					&lt;td&gt;改用 &lt;code&gt;JacksonJsonMessageConverter&lt;/code&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;消费侧抛 &lt;code&gt;The class ... is not in the trusted packages&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;转换器默认只信任 &lt;code&gt;java.util&lt;/code&gt; / &lt;code&gt;java.lang&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;按消息类的包名授权，不要写成 &lt;code&gt;&amp;quot;*&amp;quot;&lt;/code&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;Confirm.isAck()&lt;/code&gt; 报弃用&lt;/td&gt;
					&lt;td&gt;Spring AMQP 4 起确认结果改成了 record&lt;/td&gt;
					&lt;td&gt;用 &lt;code&gt;ack()&lt;/code&gt; / &lt;code&gt;reason()&lt;/code&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;死信监听器反序列化失败&lt;/td&gt;
					&lt;td&gt;参数写成 &lt;code&gt;org.springframework.amqp.core.Message&lt;/code&gt; 会走 typeId 反推，撞上信任包检查&lt;/td&gt;
					&lt;td&gt;参数直接用具体的消息类型&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;改完队列参数后应用启动失败，报 &lt;code&gt;inequivalent arg&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;队列的 &lt;code&gt;x-dead-letter-exchange&lt;/code&gt; 等参数不允许原地修改&lt;/td&gt;
					&lt;td&gt;先 &lt;code&gt;rabbitmqctl delete_queue&lt;/code&gt; 再重启，让应用重新声明（只适用于可清空的实验环境，删除队列会连消息一起删；生产环境应改用 policy 动态配置）&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;脚本里声明非持久化的非排他队列报 &lt;code&gt;transient_nonexcl_queues is deprecated&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;RabbitMQ 4.3 起默认不再允许这类队列&lt;/td&gt;
					&lt;td&gt;一律声明为持久化队列&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;最后一条是 4.3 引入的：官方文档把「transient（非持久化）且非排他的 classic queue」标为弃用，4.3.0 起默认不允许声明。写实验脚本时如果沿用了老教程里的 &lt;code&gt;durable=False&lt;/code&gt;，会直接撞上这个报错。&lt;/p&gt;
&lt;h2 id="九收口三段各兜住什么以及它划出的边界"&gt;九、收口：三段各兜住什么，以及它划出的边界
&lt;/h2&gt;&lt;p&gt;回到开篇那三个可能消失的位置，每一处现在都能对上兜底的一方，也能看清它兜到哪为止：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;发送时&lt;/strong&gt;：生产端的 confirm 加 return。两道回执都要看——confirm 跟着路由结果走，不可路由时它照样返回 ack，只监听 confirm 等于在发送环节装一盏常亮的绿灯；回执也覆盖不到订阅配置，没有 return 只说明至少有一个队列接收了。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;存储时&lt;/strong&gt;：Broker 端的持久化与副本。两个持久化属性回答「重启后还在不在」，副本回答「机器坏了还在不在」，后者把可用性前提从「节点在线」换成「多数派在线」，故障期间连读队列视图都会超时。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;消费时&lt;/strong&gt;：消费端的 ack 时机与 prefetch。前者决定崩溃时消息是重投还是丢失，后者决定重投的规模，但都没有改变「可能重复」这个结果。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;重复这件事在三段里没有归宿：Broker 侧的 &lt;code&gt;x-delivery-count&lt;/code&gt; 与 &lt;code&gt;delivery-limit&lt;/code&gt; 管的是毒消息别在队列里无限打转，不是业务代码只跑一次；消掉重复的位置在消费端的业务幂等里，而幂等只覆盖它自己管得住的那几个副作用。&lt;strong&gt;三段拼出来的承诺是「不丢」加 at-least-once，不是「既不丢也不重」&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;代价和上面三处一一对应：生产端要处理两道回执，并决定同步等还是异步收，同步等会把发送方拖进回执时延里（文档给的量级是持续负载下数百毫秒）；Broker 端要接受「多数派不在就拒绝写入」这个前提；消费端先处理再 ack 把重复引了进来，扩容之后同一笔业务落到两个消费者手里会更常见；重试链路上固定间隔只占一条 TTL 队列，改成递增退避，要么每级一条队列，要么接受过期检查只在队头这一条限制。&lt;/p&gt;
&lt;p&gt;还有一段不在这三段的视野里：订单写进数据库、消息没发出去（或者反过来）发生在第七节的位置。三段兜底保证消息不消失，保证不了业务状态和消息一起生效；把这件事压回一个本地事务里的做法是本地消息表，它的代价是 outbox 的清理策略、投递链路自己的监控，以及变大的事务粒度。&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/confirms" target="_blank" rel="noopener"
 &gt;Consumer Acknowledgements and Publisher Confirms&lt;/a&gt;：publisher confirm 与 consumer ack 的定义与触发时机，mandatory 与 &lt;code&gt;basic.return&lt;/code&gt; 的先后顺序。&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://www.rabbitmq.com/docs/reliability" target="_blank" rel="noopener"
 &gt;Reliability Guide&lt;/a&gt;：at-least-once / at-most-once 的边界，以及消费端去重与幂等的必要性。&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;：队列持久性与消息持久化的关系，transient 非排他 classic queue 的弃用说明。&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 副本、多数派可用性、publisher confirm 的发出时机、&lt;code&gt;x-delivery-count&lt;/code&gt; 与 &lt;code&gt;delivery-limit&lt;/code&gt;、at-least-once 死信（&lt;code&gt;dead-letter-strategy&lt;/code&gt;/&lt;code&gt;overflow&lt;/code&gt;）、4.3 原生 delayed retry。&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;{队列, 原因}&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;：per-queue 与 per-message TTL 的区别，过期检查发生在队头的行为。&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/metadata-store" target="_blank" rel="noopener"
 &gt;Metadata store&lt;/a&gt;：Khepri 自 4.2.0 起成为默认元数据存储，与 Mnesia 的关系。&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/c02-mq-reliable-delivery" target="_blank" rel="noopener"
 &gt;GYD 仓库 &lt;code&gt;c02-mq-reliable-delivery&lt;/code&gt;&lt;/a&gt;，该目录的 README 列了环境、运行方式、端点与本文各节的对照，以及本文引用的完整输出记录；RabbitMQ 与 3 节点集群的编排在仓库级 &lt;a class="link" href="https://github.com/renxin2024/GYD/tree/main/infra" target="_blank" rel="noopener"
 &gt;&lt;code&gt;infra/&lt;/code&gt;&lt;/a&gt;，两个实测脚本在 &lt;code&gt;infra/rabbitmq/scripts/&lt;/code&gt; 与 &lt;code&gt;infra/rabbitmq-cluster/scripts/&lt;/code&gt;。&lt;/p&gt;

 &lt;/blockquote&gt;</description></item><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>