<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>高可用 on Renxin's Blog</title><link>https://zh.renxinblog.cn/tags/%E9%AB%98%E5%8F%AF%E7%94%A8/</link><description>Recent content in 高可用 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/%E9%AB%98%E5%8F%AF%E7%94%A8/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></channel></rss>