<?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/%E5%AD%98%E5%82%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/%E5%AD%98%E5%82%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><item><title>第四话｜RocketMQ 4.x 的架构与机制：一条消息从发送到消费的全程</title><link>https://zh.renxinblog.cn/post/gyd-c04-rocketmq-architecture/</link><pubDate>Wed, 18 Feb 2026 00:00:00 +0000</pubDate><guid>https://zh.renxinblog.cn/post/gyd-c04-rocketmq-architecture/</guid><description>&lt;img src="https://zh.renxinblog.cn/images/gyd-c04-rocketmq-architecture-cover.png" alt="Featured image of post 第四话｜RocketMQ 4.x 的架构与机制：一条消息从发送到消费的全程" /&gt;&lt;p&gt;一条消息从生产到消费，中间经历了哪些环节？网络是怎么连接的，磁盘又是怎么存储的？&lt;/p&gt;
&lt;p&gt;先把 RocketMQ 4.x 的全貌铺开：有哪些角色、各自负责什么、谁跟谁通信（一）。然后跟着一条消息走完主链路——客户端启动后怎么找到 broker（二），生产者怎么把消息发出去（三），broker 收到之后怎么落盘（四、五），主从之间怎么保证不丢（六），消费者又是怎么把它取走（七）。剩下的几节停在这条链路上挂着的功能上（八～十一），逐个说明它的取舍与代价，最后一节把这些代价来源并排放在一起（十二）。&lt;/p&gt;
&lt;p&gt;全文以 &lt;code&gt;apache/rocketmq&lt;/code&gt; 4.9.7 为准，覆盖的是传统 Remoting 客户端与静态主从部署。5.x 引入的 Proxy、gRPC 客户端、Controller 自动切换、TimerWheel 毫秒延迟与 Topic 类型约束，都不在本文范围内。正文引用的官方文档（&lt;code&gt;Design_Store.md&lt;/code&gt;、&lt;code&gt;Design_Filter.md&lt;/code&gt;、&lt;code&gt;Design_Trancation.md&lt;/code&gt;、&lt;code&gt;Feature.md&lt;/code&gt;、&lt;code&gt;Example_Delay.md&lt;/code&gt; 等）均指 &lt;code&gt;apache/rocketmq&lt;/code&gt; 仓库 &lt;code&gt;rocketmq-all-4.9.7&lt;/code&gt; tag 下的 &lt;code&gt;docs/cn/&lt;/code&gt; 原文，不是随时变动的 develop 分支。凡本机没跑过的机制，正文会写明是「结构推导」，不当成已证实的结论。&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="gyd-c04-scene"&gt;一、全貌：四个角色，五条通信线
&lt;/h2&gt;&lt;p&gt;&lt;img alt="RocketMQ 的四个角色与五条通信线：客户端的 Producer 与 Consumer 各占一个方框，服务端的 NameServer、Broker master、Broker slave 各占一个方框" class="gallery-image" data-flex-basis="477px" data-flex-grow="198" decoding="async" height="336" 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-c04-rocketmq-architecture.svg" width="668"&gt;&lt;/p&gt;
&lt;p&gt;图上这五个方框就是 RocketMQ 4.x 的全部角色，彼此独立、没有包含关系：浅蓝的两个是客户端（&lt;strong&gt;Producer&lt;/strong&gt; 和 &lt;strong&gt;Consumer&lt;/strong&gt;），浅绿的三个是服务端（&lt;strong&gt;NameServer&lt;/strong&gt;，以及 &lt;strong&gt;Broker&lt;/strong&gt; 的主与从）。箭头方向就是连接（TCP）的发起方向，线上的编号与后文逐条对应；注意它不等于数据流向——⑤ 里连接由从节点发起，数据却是主节点往从节点写。&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;NameServer&lt;/td&gt;
					&lt;td&gt;broker 的注册与存活、每个 topic 在哪台 broker 上有几个读写队列&lt;/td&gt;
					&lt;td&gt;不存消息、不记位点；节点之间互不通信&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Broker&lt;/td&gt;
					&lt;td&gt;接收并存储消息、投递给消费者、存集群模式的消费位点；主从一组时，从节点做副本&lt;/td&gt;
					&lt;td&gt;不管寻址：客户端连它之前，先向 NameServer 拿到它的地址&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Producer&lt;/td&gt;
					&lt;td&gt;拉路由、选队列、把消息发出去，按返回码决定要不要换队列重试&lt;/td&gt;
					&lt;td&gt;不参与存储与投递&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Consumer&lt;/td&gt;
					&lt;td&gt;拉路由、做 rebalance、长轮询拉消息、上报位点&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;ul&gt;
&lt;li&gt;&lt;strong&gt;① Producer / Consumer → NameServer&lt;/strong&gt;：每 30 秒拉一次路由，换回一张「哪个 topic 在哪台 broker 的第几个队列」的地址表；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;② Broker → NameServer&lt;/strong&gt;：主和从&lt;strong&gt;各自&lt;/strong&gt;每 30 秒上报一次，整张 topic 配置一并带上；但只有主的那份进路由表（队列数与读写权限），从的那份只用来证明它自己活着；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;③ Producer → Broker&lt;/strong&gt;：发送请求，走一条常驻连接；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;④ Consumer → Broker&lt;/strong&gt;：投递走长轮询；位点随拉取请求捎带、另按固定间隔批量写回；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;⑤ Slave → Master&lt;/strong&gt;：主从复制，从节点主动连上主节点的 HA 端口去拉，不是主推过去。它连哪个地址，是向 NameServer 注册时从响应里问回来的（见第六节）；&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;五条线里没有一条是服务端回连客户端：每次通信都由发起方主动去连，服务端之间的 ② 与 ⑤ 也一样。&lt;strong&gt;在这条普通消息主链路上，消息一律由客户端主动发起，服务端只应答。&lt;/strong&gt; 唯一的例外是事务消息回查（第十一节）：那时 Broker 会主动向 Producer 发起回查请求，是后文才加入的一条反向 RPC，不在主链路这五条里。&lt;/p&gt;
&lt;p&gt;角色和通信线之外，还有一个概念要在全貌里定下来：&lt;strong&gt;组（group）&lt;/strong&gt;。RocketMQ 的客户端不是孤零零一个实例，而是挂在一个组名之下，组是它后面几乎所有机制的&lt;strong&gt;作用域&lt;/strong&gt;。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;消费组（Consumer Group）&lt;/strong&gt;：rebalance、消费位点、重试与死信，都按「主题 + 消费组」记。同一个消费组内的实例，共享一份位点、分摊队列；不同消费组各自独立消费同一主题，互不影响。同组的实例订阅关系必须一致，否则 rebalance 结果不可预测。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;生产者组（Producer Group）&lt;/strong&gt;：普通发送里它几乎不做事——不参与选队列、不分配队列，名字更多是业务上的归类。它真正起作用的地方是&lt;strong&gt;事务消息回查&lt;/strong&gt;（第十一节）：broker 回查本地事务结果时，要到一个生产者组里去找还活着的实例来回答。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;把组立住之后，后面几节的「位点按组记」「rebalance 在组内做」「回查找同组实例」才有着落。与组并列的另一个基础概念是 &lt;strong&gt;topic 与队列的关系&lt;/strong&gt;：&lt;/p&gt;
&lt;p&gt;topic 是客户端手里的逻辑名字，到了 broker 就成了一份配置，队列数记在这份配置里（读、写各一个数字，本文都填 4），&lt;strong&gt;每条队列都落在某台 broker 上&lt;/strong&gt;——本文只有一个 brokerName，所以这 4 条都在 broker-a 上。&lt;/p&gt;
&lt;p&gt;读与写是两个独立数字，因为它们在客户端各管一段：拉回路由时，发送那侧只取&lt;strong&gt;写&lt;/strong&gt;队列摊成发送列表，消费那侧只取&lt;strong&gt;读&lt;/strong&gt;队列摊成订阅列表，两个列表可以不一样（本文都填 4，看不出差别）。&lt;/p&gt;
&lt;p&gt;队列不是消息的容器，是消息的&lt;strong&gt;归属&lt;/strong&gt;：开 4 条队列，等于把「谁来写、谁来读、按什么顺序读」切成 4 份。发送时挑一份写进去；消费时每个实例领一份，&lt;strong&gt;同一个消费组内，一条队列同一时刻只归一个消费实例&lt;/strong&gt;。这里说的是集群消费模式；广播模式不同，每个实例都消费全部队列，位点也保存在本地（第七节）。&lt;/p&gt;
&lt;p&gt;它管归属，不管消息存在哪：磁盘上它只留下一个索引目录 &lt;code&gt;consumequeue/&amp;lt;topic&amp;gt;/&amp;lt;queueId&amp;gt;/&lt;/code&gt;，&lt;strong&gt;那个目录就是队列唯一的物理残留&lt;/strong&gt;，消息本体落在哪一层是第四节的事。&lt;/p&gt;
&lt;p&gt;&lt;img alt="同一个主题的 4 条队列在三处是三样东西：发送侧是 4 个写队列，客户端挑一条写入；消费侧是 4 个读队列，两个实例各领两条、一条同时只归一个实例；存储侧只剩 4 个索引目录和一份共用的 CommitLog" class="gallery-image" data-flex-basis="516px" data-flex-grow="215" decoding="async" height="316" 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-c04-topic-and-queues.svg" width="680"&gt;&lt;/p&gt;
&lt;p&gt;图上把同一个主题的 4 条队列画成了三副样子：发送侧写、消费侧读、存储侧只剩目录。这个数还能拿来自查：主题建 4 条队列，发送侧就摊出 4 条并行车道；消费侧（同一消费组内）最多 4 个实例真正并行，第 5 个分不到队列、只能空转（第七节）；顺序消息的并发上限同样是 4。&lt;strong&gt;三个 4 是同一个 4&lt;/strong&gt;——队列数在消费侧就是并行度上限，它在第三节被定死，第八节收账。&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;边注&lt;/strong&gt;：队列在客户端不是一个裸编号，而是「topic + brokerName + queueId」三件套：同一个主题在两个 brokerName 上各有一条 0 号队列，它们不是同一条；主从共享同一份队列定义，从节点也不会另外多出一份。&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;p&gt;下面各节对照的集群就是三个容器：一个 NameServer，加 broker-a 的一组主从，主题显式建成上面说的那 4 条读写队列。客户端到 broker 那条连接在第三节展开，broker 内部怎么存消息在第四节。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="二客户端启动之后从-namesrvaddr-到一条真的建起来的连接"&gt;二、客户端启动之后：从 namesrvAddr 到一条真的建起来的连接
&lt;/h2&gt;&lt;p&gt;RocketMQ 的客户端不直连 broker。它拿到 &lt;code&gt;namesrvAddr&lt;/code&gt; 之后做的第一件事，是去 NameServer 换一份路由——和 Kafka 客户端先连一个 bootstrap 节点换元数据是同一个路子，区别在于 NameServer 把自己压到了最小。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;NameServer 是一张节点之间互不通信的路由表。&lt;/strong&gt; 它存三类东西：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;broker 注册表&lt;/strong&gt;：有哪些 brokerName、每个下面有哪些 brokerId、各自地址；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;每台 broker 的存活状态&lt;/strong&gt;；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;每个 topic 的路由&lt;/strong&gt;：只到「哪台 broker 上有几个读队列、几个写队列、可读可写否」为止。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;哪条消息落在哪个队列、消费位点走到哪，它一概不记。&lt;/p&gt;
&lt;p&gt;这三样都不是它自己维护出来的，是靠两个&lt;strong&gt;每 30 秒一次&lt;/strong&gt;的动作拼出来的：broker 上报心跳（顺带把整张 topic 配置报上来，只有主节点那份进路由表，所以队列数是 broker 报的，不是它定的），客户端来拉路由。既然节点之间不通信，它也就没有选主、没有一致性协议——每台各存一份上报结果，容错靠客户端在 &lt;code&gt;namesrvAddr&lt;/code&gt; 里多写几个地址。&lt;/p&gt;
&lt;p&gt;客户端拉回来的这份路由不是权威真相，而是一张&lt;strong&gt;最终一致的可用地址表&lt;/strong&gt;：broker 刚下线、客户端手里的路由可能还指着它，这个窗口最长就是一个刷新间隔（默认 30 秒）。&lt;/p&gt;
&lt;p&gt;路由拿到了，接下来是连。这一步的变数全在&lt;strong&gt;地址&lt;/strong&gt;上：客户端要连的那个地址由 broker 报给它，它自己决定不了。&lt;/p&gt;
&lt;p&gt;而这个地址由 &lt;code&gt;brokerIP1&lt;/code&gt; 决定。它是 broker 对外&lt;strong&gt;通告&lt;/strong&gt;的地址，写进 &lt;code&gt;broker.conf&lt;/code&gt;：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ini" data-lang="ini"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# broker.conf&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;brokerIP1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;192.168.31.132 # 宿主机可达的局域网 IP&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;这个值必须是客户端所在的那台机器也能到达的地址。在 Docker 里把它填成容器名（&lt;code&gt;brokerIP1 = broker-a-master&lt;/code&gt;），只有容器网络内解析得了这个名字，宿主机上的客户端解析不了，后果直接就是连不上。&lt;/p&gt;
&lt;p&gt;两步合起来，一次启动的时序是这样的：&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;%%{init: {'theme':'neutral'}}%%
sequenceDiagram
 autonumber
 participant C as 客户端
 participant NS as NameServer
 participant B as Broker（主）

 B-&gt;&gt;NS: 注册 + 心跳上报（每 30s）
 C-&gt;&gt;NS: 拉路由（启动时 + 每 30s）
 NS--&gt;&gt;C: brokerIP1 通告的地址 + 存活状态 + 队列数
 C-&gt;&gt;B: 用这个地址建常驻连接&lt;/pre&gt;&lt;hr&gt;
&lt;h2 id="三发送一条消息一次-tcp-往返和三道可以单独拨动的开关"&gt;三、发送一条消息：一次 TCP 往返，和三道可以单独拨动的开关
&lt;/h2&gt;&lt;p&gt;生产者把消息送到 broker，在网络上就是一次请求—响应往返：一条 TCP 长连接上发出一个带序号的请求帧，broker 把消息追加进 CommitLog，再回一个返回码。这条路上有三道开关——客户端换队列重试、刷盘策略、主从复制——每一道都能单独拨动：关掉换响应时间，打开换「不丢」。&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;%%{init: {'theme':'neutral'}}%%
flowchart LR
 A[应用调用 send] --&gt; B[选队列 · 组装请求]
 B --&gt; C[TCP 长连接 10911]
 C --&gt; D[broker 校验 · 追加 CommitLog]
 D --&gt;|追加成功| E{等刷盘 · 等从节点}
 E --&gt;|默认都不等| F[返回码 0/10/11/12]
 F -. 重试：换队列，上限 2 次 .-&gt; B&lt;/pre&gt;&lt;p&gt;图上是同步发送的六个环节：选队列在客户端，中间一节是那条 TCP 长连接，broker 侧只有「校验 + 追加」是必做的，后面两步等不等由配置决定；虚线那条是重试，只在抛异常或返回码命中白名单时才走。&lt;/p&gt;
&lt;h3 id="连接与协议"&gt;连接与协议
&lt;/h3&gt;&lt;p&gt;连接是 TCP 长连接，上面跑的是自定义二进制协议：4.9.7 的客户端与 broker 之间既不是 HTTP 也不是 gRPC——4.9.7 的代码库里没有 proxy 模块，两端只说这一套 Remoting 协议（5.x 才把 gRPC 客户端与 Proxy 作为另一条路线加入，见引言的范围说明）。发送、拉消息、查位点共用同一种帧：4 字节长度前缀切边界，头在默认配置下通常是 JSON（请求码、序号与各请求自带的字段；序列化格式由帧内的序列化类型标记决定，可切换），体就是消息体。&lt;/p&gt;
&lt;p&gt;连接按 broker 地址存在客户端实例里：同一个进程的多个 producer 共用一条，一条连接上并发跑多个请求。空闲 120 秒（&lt;code&gt;clientChannelMaxIdleTimeSeconds&lt;/code&gt;）会被断开，下次要发时重建，建连超时 3 秒。所谓「常驻连接」，省掉的是重复握手，不是永不重建。&lt;/p&gt;
&lt;h3 id="客户端六件事与三种发送模式"&gt;客户端六件事与三种发送模式
&lt;/h3&gt;&lt;p&gt;这一步是六件事：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;校验消息&lt;/strong&gt;：体非空、不超 4 MiB、topic 合法；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;取路由&lt;/strong&gt;：手里没有就回头向 NameServer 拉；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;选队列&lt;/strong&gt;：&lt;code&gt;sendWhichQueue&lt;/code&gt; 计数器对着队列列表轮询，自定义选择器可覆盖；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;查出 broker 地址&lt;/strong&gt;；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;生成全局唯一 ID&lt;/strong&gt;（&lt;code&gt;UNIQ_KEY&lt;/code&gt;）并按需压缩：体超过 4 KiB 时自动压缩，阈值 &lt;code&gt;compressMsgBodyOverHowmuch&lt;/code&gt; 默认 4096，压缩标记写进消息标志位 &lt;code&gt;sysFlag&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;写进连接&lt;/strong&gt;。&lt;/li&gt;
&lt;/ul&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;code&gt;send&lt;/code&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;阻塞到响应或超时（默认 3000ms）&lt;/td&gt;
					&lt;td&gt;抛异常，或返回非 &lt;code&gt;SEND_OK&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;换队列，最多 2 次&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;异步&lt;/td&gt;
					&lt;td&gt;立即返回 &lt;code&gt;null&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;回调里拿到异常&lt;/td&gt;
					&lt;td&gt;换队列，最多 2 次&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;单向&lt;/td&gt;
					&lt;td&gt;立即返回 &lt;code&gt;null&lt;/code&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;/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;// 同步：阻塞到拿到 SendResult 或超时&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;SendResult&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;r&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;producer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&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="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSendStatus&lt;/span&gt;&lt;span class="p"&gt;()&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;SendStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SEND_OK&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="c1"&gt;// 返回码是 10/11/12 那一类：消息已写，但保证没兑现&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="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;// 异步：立即返回，结果走回调&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;producer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&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;SendCallback&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="nd"&gt;@Override&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;public&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;onSuccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SendResult&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;r&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 class="cm"&gt;/* queueId、SendStatus */&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="nd"&gt;@Override&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;public&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;onException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Throwable&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 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="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;// 单向：写完就返回，没有结果也没有回调&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;producer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sendOneway&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&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;单向是协议层的事：帧头置了单向标记，broker 处理完不写任何响应，调用方既拿不到成功也拿不到失败。&lt;strong&gt;超时也不等于没写进去&lt;/strong&gt;：3000ms 只是客户端愿意等的窗口，超时那一刻 broker 可能已经把消息追加进 CommitLog 了。&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;边注&lt;/strong&gt;：两处数值官方文档与代码不一致，正文按代码写。&lt;code&gt;sendMsgTimeout&lt;/code&gt; 官方说 10 秒，代码默认 3000ms；异步重试官方说「0 次」，代码是 &lt;code&gt;retryTimesWhenSendAsyncFailed = 2&lt;/code&gt;，而且重试时会像同步一样优先换一个 broker（挑不出别的才回到原队列）——那句「0 次」很容易被读成「异步不重试」。&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;h3 id="broker-侧两道开关"&gt;broker 侧两道开关
&lt;/h3&gt;&lt;p&gt;能不能回 &lt;code&gt;SEND_OK&lt;/code&gt;，由 broker 侧两道开关决定。帧到达后先校验（topic 在不在路由里、有没有写权限、队列号是否越界），通过就追加进 CommitLog（追加的细节在第四节展开）。追加成功不等于能回 &lt;code&gt;SEND_OK&lt;/code&gt;：默认配置下它立刻回，两道开关各自能让它多等一段。这两道都在 broker 端，客户端改不了。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第一道是刷盘策略 &lt;code&gt;flushDiskType&lt;/code&gt;。&lt;/strong&gt; &lt;code&gt;ASYNC_FLUSH&lt;/code&gt;（默认）下，消息追加进 PageCache 就算写成功，刷盘交给后台线程：按 &lt;code&gt;flushIntervalCommitLog&lt;/code&gt;（默认 500 毫秒）醒一次，攒够 &lt;code&gt;flushCommitLogLeastPages&lt;/code&gt;（默认 4 页，16 KiB）才真刷，攒不够也会在 &lt;code&gt;flushCommitLogThoroughInterval&lt;/code&gt;（默认 10 秒）后全量刷一次。成功与否只看「追加」这一步，掉电丢的就是这一瞬还没落盘的那段。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;SYNC_FLUSH&lt;/code&gt; 则要求 &lt;code&gt;fsync&lt;/code&gt; 完成才回：写线程把「请刷到这个偏移」挂进刷盘线程的请求队列，等它落盘。它不是一个请求一次 &lt;code&gt;fsync&lt;/code&gt;：队列里的请求会被攒成一批，刷盘线程挨个检查各自的目标偏移，已刷位置还没盖过谁就刷一次、盖过了的直接放行——批里的第一个几乎总在触发刷盘，后面那些「已经被刷过头了」就不必再刷。一次 &lt;code&gt;fsync&lt;/code&gt; 摊到整批上，这才是同步刷盘还能留住吞吐的原因。&lt;/p&gt;
&lt;p&gt;等不到就由 &lt;code&gt;syncFlushTimeout&lt;/code&gt;（默认 5 秒）截断：这条计时不由发送方自己守着，而是一条独立的看门狗线程（&lt;code&gt;FlushDiskWatcher&lt;/code&gt;）盯着，到点直接把请求标记成超时。刷盘线程再忙，发送方也不会无限期挂着。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第二道是复制角色 &lt;code&gt;brokerRole&lt;/code&gt;。&lt;/strong&gt; 取 &lt;code&gt;ASYNC_MASTER&lt;/code&gt;（默认）、&lt;code&gt;SYNC_MASTER&lt;/code&gt; 或 &lt;code&gt;SLAVE&lt;/code&gt;，主从成对配：主节点配前两者之一，从节点配 &lt;code&gt;SLAVE&lt;/code&gt;。&lt;code&gt;ASYNC_MASTER&lt;/code&gt; 下主节点的确认不等待从节点，主宕机时丢的是「主已写、还没推给从」的那段偏移差。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;SYNC_MASTER&lt;/code&gt; 下，写线程挂一个请求进同步等待队列，等从节点复制到的最大偏移追平这条消息的结束偏移：追平回 &lt;code&gt;PUT_OK&lt;/code&gt;，超过 &lt;code&gt;slaveTimeout&lt;/code&gt;（默认 3 秒）回 &lt;code&gt;FLUSH_SLAVE_TIMEOUT&lt;/code&gt;。有一种情况连等都不等：判定要求「有从节点连着」和「主已写但还没推给从的偏移差小于 &lt;code&gt;haSlaveFallbehindMax&lt;/code&gt;（默认 256 MiB）」两条同时成立，任一不满足就直接回 &lt;code&gt;SLAVE_NOT_AVAILABLE&lt;/code&gt;，&lt;strong&gt;不进入等待&lt;/strong&gt;——所以从节点掉线时，发送耗时反而比它在线时更短。&lt;/p&gt;
&lt;p&gt;还有一层要分清：主节点等的是「从节点的复制偏移追上了」，不是「从节点已经落盘」。从节点收到数据同样是先追加进自己的 CommitLog，&lt;code&gt;fsync&lt;/code&gt; 与否由&lt;strong&gt;从节点自己&lt;/strong&gt;的 &lt;code&gt;flushDiskType&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;th&gt;消息还在不在&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;0&lt;/code&gt; &lt;code&gt;SEND_OK&lt;/code&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;code&gt;10&lt;/code&gt; &lt;code&gt;FLUSH_DISK_TIMEOUT&lt;/code&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;code&gt;12&lt;/code&gt; &lt;code&gt;FLUSH_SLAVE_TIMEOUT&lt;/code&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;code&gt;11&lt;/code&gt; &lt;code&gt;SLAVE_NOT_AVAILABLE&lt;/code&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;MQBrokerException&lt;/code&gt;，默认也不会换 broker 重发（&lt;code&gt;retryAnotherBrokerWhenNotStoreOK&lt;/code&gt; 默认关闭）。&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;边注&lt;/strong&gt;：&lt;code&gt;SLAVE_NOT_AVAILABLE&lt;/code&gt; 的码值是 &lt;code&gt;11&lt;/code&gt;、&lt;code&gt;FLUSH_SLAVE_TIMEOUT&lt;/code&gt; 是 &lt;code&gt;12&lt;/code&gt;，与名字顺序相反，翻日志时别按顺序猜。&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;p&gt;&lt;img alt="三道开关在发送路径上的位置：客户端一道（换队列重试），broker 两道（刷盘、复制），各自独立、可以只开其中一道" class="gallery-image" data-flex-basis="526px" data-flex-grow="219" decoding="async" height="310" 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-c04-send-path-switches.svg" width="680"&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;三道开关合起来定义的是「不丢」。&lt;/strong&gt; 两道在 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;默认&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;/td&gt;
					&lt;td&gt;&lt;code&gt;retryTimesWhenSendFailed&lt;/code&gt; / &lt;code&gt;retryTimesWhenSendAsyncFailed&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;2（同步共发 3 次）&lt;/td&gt;
					&lt;td&gt;换队列、尽量换 broker 重发&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;code&gt;flushDiskType&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;ASYNC_FLUSH&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;一批请求一次落盘确认&lt;/td&gt;
					&lt;td&gt;5 秒（&lt;code&gt;syncFlushTimeout&lt;/code&gt;）&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;10&lt;/code&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;主从&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;brokerRole&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;ASYNC_MASTER&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;一次从节点追平确认&lt;/td&gt;
					&lt;td&gt;3 秒（&lt;code&gt;slaveTimeout&lt;/code&gt;）&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;12&lt;/code&gt; / &lt;code&gt;11&lt;/code&gt;&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;三道全开是最严的组合，代价是每条消息的响应时间被最慢的那一环拖住；三道全关就是默认那副样子：消息还没落盘，&lt;code&gt;SEND_OK&lt;/code&gt; 就回去了。&lt;/p&gt;
&lt;p&gt;配置改不了，但单条消息能让它们不等：属性 &lt;code&gt;WAIT&lt;/code&gt; 为 &lt;code&gt;false&lt;/code&gt; 时（&lt;code&gt;msg.setWaitStoreMsgOK(false)&lt;/code&gt;，缺省是 &lt;code&gt;true&lt;/code&gt;），broker 拿到它就不把请求挂进等待队列，直接回 &lt;code&gt;SEND_OK&lt;/code&gt;——落盘和复制照旧在后台做，只是这条消息不等它们。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;「不丢」保住了，「不重」不在这里。&lt;/strong&gt; 一次超时或一次重试，都会让同一条消息在 CommitLog 里出现两次，&lt;code&gt;UNIQ_KEY&lt;/code&gt; 与消息体都一样——发送侧不去重，它给的是 at-least-once。要消掉重复投递，只能靠消费端幂等；事务消息补的是「本地事务与消息发送之间的一致性」（第十一节），它&lt;strong&gt;不&lt;/strong&gt;保证消费结果不重复，也替代不了消费端幂等。&lt;/p&gt;
&lt;h3 id="发送失败三类走向"&gt;发送失败三类走向
&lt;/h3&gt;&lt;p&gt;判据是返回码白名单：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;第①类&lt;/strong&gt;：返回码命中白名单（&lt;code&gt;1&lt;/code&gt; / &lt;code&gt;14&lt;/code&gt; / &lt;code&gt;16&lt;/code&gt; / &lt;code&gt;17&lt;/code&gt; / &lt;code&gt;204&lt;/code&gt; / &lt;code&gt;205&lt;/code&gt;，即系统错误、服务不可用、无权限、主题不存在、无买家 ID、不在当前单元）→ &lt;strong&gt;静默换队列重试&lt;/strong&gt;，调用方看不见；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;第②类&lt;/strong&gt;：不在白名单 → 有部分结果就先返回，否则抛 &lt;code&gt;MQBrokerException&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;第③类&lt;/strong&gt;：不抛异常，但状态不是 &lt;code&gt;SEND_OK&lt;/code&gt;（就是上面那张返回码表的后三行）→ 原样交给调用方。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;第③类最容易踩：主从同步复制下停掉从节点，发送返回 &lt;code&gt;SLAVE_NOT_AVAILABLE&lt;/code&gt;，既不抛异常也不重试，耗时反而比从节点在线时更短——它并没有等（&lt;code&gt;failmode&lt;/code&gt;）。消息确实写进了主节点的 CommitLog，返回码只是如实说明那份保证没兑现。&lt;/p&gt;
&lt;h3 id="自动建主题定死队列数"&gt;自动建主题定死队列数
&lt;/h3&gt;&lt;p&gt;发到一个不存在的主题，先暴露出来的信号是查不到路由：客户端手里没有这个 topic 的路由，显式调 &lt;code&gt;getTopicRouteInfoFromNameServer(topic, 3000L)&lt;/code&gt; 查一次会直接抛 &lt;code&gt;MQClientException CODE=17&lt;/code&gt;（&lt;code&gt;TOPIC_NOT_EXIST&lt;/code&gt;）。随后的真正 &lt;code&gt;send(...)&lt;/code&gt; 并不报这个错——发送请求会带上自动建主题专用的键 &lt;code&gt;TBW102&lt;/code&gt;，broker 收到才建主题，队列数取 &lt;code&gt;min(客户端 4, broker 8) = 4&lt;/code&gt;。也就是说：&lt;strong&gt;「主题不存在」报错发生在显式查路由这一步，发送路径本身走的是自动建主题&lt;/strong&gt;。这个数一旦落下来就是所有人共用的：&lt;strong&gt;第一个发消息的客户端，替后面所有人定死了队列数&lt;/strong&gt;，而顺序消息的并发度上限正好等于它（第八节）。自动建主题是练习环境的能力，生产里主题应当预先建好、定好队列数与消息类型。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="四broker-收到之后一份只追加的日志"&gt;四、Broker 收到之后：一份只追加的日志
&lt;/h2&gt;&lt;h3 id="只追加的日志"&gt;只追加的日志
&lt;/h3&gt;&lt;p&gt;消息到了 broker，先落成一份只追加的文件。它不做队列级分文件：什么主题、哪条队列来的都一样，按到达顺序接在文件末尾。这份文件叫 CommitLog，官方文档里那个「混合存储」说的就是它，一个 broker 实例上的队列共用这一份日志。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;「只追加」是它快的原因，也是它全部的限制。&lt;/strong&gt; 文件通过内存映射摊进进程地址空间，写消息就是往映射区末尾顺序搬字节：不用在文件里找位置，也不用为每条队列各维护一份写指针，写入位置只有一处。&lt;/p&gt;
&lt;p&gt;写满 1 GiB 切下一个文件，文件名就是它承接的起始偏移。磁盘上第一个文件叫 &lt;code&gt;store/commitlog/00000000000000000000&lt;/code&gt;，往后每切一个，名字就加 1 GiB（&lt;code&gt;inspect-storage&lt;/code&gt;）。文件名自带顺序，重启时按名字排一下就得到这份日志的范围。&lt;/p&gt;
&lt;h3 id="消息长什么样"&gt;消息长什么样
&lt;/h3&gt;&lt;p&gt;它不做对齐，长度写在记录自己头上：开头的 4 个字节是这条消息的总长度，紧接着一个标记值，用来确认「这里确实是一条消息的开头」。往后依次是队列号、它在队列里排第几、它在日志里的物理偏移、产生与落盘的时刻，末尾是消息体、主题和属性串。消息体长短不一，每条记录的长度也就不一样。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;长度写在数据自己头上，换来两件事&lt;/strong&gt;：从头往下走时，读 4 个字节就知道这条到哪结束，跳到下一条接着读；手上已经有一个物理偏移时，读那 4 个字节就能把整条取出来。前者是顺序重放（重启恢复、后台线程补索引）用的，后者是定点取消息用的，同一套字段支撑两种读法。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;属性也在这条记录里，和消息体、主题放在一起。&lt;/strong&gt; &lt;code&gt;tag&lt;/code&gt; 与 &lt;code&gt;key&lt;/code&gt; 是拼成一串的键值对，&lt;code&gt;tag&lt;/code&gt; 的原文只有读这条记录才拿得到；落到索引时，broker 对 &lt;code&gt;tag&lt;/code&gt; 字符串算一次 hashCode，把这个数字另外抄一份进索引条目。于是同一个信息有了两个位置：&lt;strong&gt;&lt;code&gt;tag&lt;/code&gt; 的原文只在日志正文里，它的 hashCode 只在索引条目里&lt;/strong&gt;——要拿原文必须读正文，拿 hashCode 只看索引就够。这个位置之分，是后面消费过滤与定时投递能「只靠索引、不读正文」的地基。&lt;/p&gt;
&lt;p&gt;&lt;img alt="一条消息在日志里长什么样：一串字段不做对齐，开头的 4 字节是这条的总长度，往后依次是标记值、队列号、队列内序号、物理偏移、产生与落盘时刻，末尾是消息体、主题与属性串；属性串里 tag 与 key 拼成键值对，tag 原文留在这里，它的 hashCode 另抄一份进消费索引条目" class="gallery-image" data-flex-basis="510px" data-flex-grow="212" decoding="async" height="320" 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-c04-commitlog-layout.svg" width="680"&gt;&lt;/p&gt;
&lt;p&gt;代价是它只有一个顺序，而落下来的内容混在一起：前 110 条消息里出现了 12 个不同主题，相邻两条主题不同的有 22 次，业务主题的消息之间夹着延迟消息、半消息、位点变更事件。混写把队列顺序抹掉了：这份文件只提供一条落盘顺序。&lt;/p&gt;
&lt;h2 id="五索引消费索引与查询索引日志的两个派生物"&gt;五、索引：消费索引与查询索引，日志的两个派生物
&lt;/h2&gt;&lt;p&gt;日志本身只有一条落盘顺序，回答不了「某条队列的下一条在哪」「某个 key 的那条在哪」。索引就是为这两个问法各建的一份派生物。&lt;/p&gt;
&lt;h3 id="两份索引"&gt;两份索引
&lt;/h3&gt;&lt;p&gt;「某条队列的下一条消息在哪」，这份日志回答不了。按顺序读出 &lt;code&gt;gyd-c03-send&lt;/code&gt; 0 号队列那 6 条消息，得在 1 GiB 里从头往下翻、边翻边筛；而它们物理上分散在偏移 1346 到 17857 之间，中间隔的全是别人的消息。索引存在的理由就是这个，并且按两种问法各建一份：&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;strong&gt;消费索引&lt;/strong&gt;&lt;/td&gt;
					&lt;td&gt;某条队列的下一条消息在哪&lt;/td&gt;
					&lt;td&gt;一条队列一个目录 &lt;code&gt;consumequeue/&amp;lt;topic&amp;gt;/&amp;lt;queueId&amp;gt;/&lt;/code&gt;&lt;/td&gt;
					&lt;td&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;某个 key 的那条消息在哪&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;messageIndexEnable&lt;/code&gt; 默认是开的。&lt;/p&gt;
&lt;h3 id="定长条目与寻址"&gt;定长条目与寻址
&lt;/h3&gt;&lt;p&gt;反过来：消费索引的条目是定长的，每条只装三件事——消息在日志里的物理偏移、这条消息有多大、一个 8 字节的比较值。这个比较值装什么，随主题变：&lt;strong&gt;普通业务主题装的就是 &lt;code&gt;tag&lt;/code&gt; 字符串的 hashCode&lt;/strong&gt;，发送时对 &lt;code&gt;tag&lt;/code&gt; 原文算一次 hash、把结果抄进来，原文仍留在日志正文里；定时消息的内部主题装的是到期投递时刻（第十节）。长度固定，位置就能算：&lt;strong&gt;第 N 条落在「N × 每条长度」这个字节位置上&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;&lt;img alt="一个消费索引条目长什么样：20 字节定长按 8 + 4 + 8 切开，依次是消息在日志里的物理偏移、这条消息的长度、tag 的 hashCode；最后这 8 字节比较值随主题换语义，普通业务主题装 tag 字符串的 hashCode，定时消息的内部主题装到期投递时刻" class="gallery-image" data-flex-basis="544px" data-flex-grow="226" decoding="async" height="300" 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-c04-consumequeue-entry.svg" width="680"&gt;&lt;/p&gt;
&lt;p&gt;两份索引的读法，和日志那套「读长度跳下一条」正好是两套寻址：日志是顺序读，读开头的总长度、跳过它、接着读下一条；消费索引是定点读，第 N 条的位置由 &lt;code&gt;N × 20&lt;/code&gt; 一次乘法算出来。broker 处理一次读请求，就是先乘出字节位置，再按索引文件大小取余，得到「哪个文件、文件内哪个位置」。这份索引文件同样按固定字节数切开，文件名就是它承接的那段起点，和日志文件一个命名规则。&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;&lt;/th&gt;
					&lt;th&gt;日志（CommitLog）&lt;/th&gt;
					&lt;th&gt;消费索引（ConsumeQueue）&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;全 broker 一条&lt;/td&gt;
					&lt;td&gt;每个队列一条&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;定长还多给了一件事：折半比较。&lt;/strong&gt; 要找「某个时刻之后的第一条」，中间那条的位置同样是一次乘法算出来的，所以能在这串条目上做二分；把消费进度挪到「某个时刻之后」这个操作，靠的就是它。&lt;/p&gt;
&lt;p&gt;&lt;img alt="一份混写的日志与两份索引：日志里记录长度各不相同、长度写在每条开头；消费索引一条队列一个目录、条目定长，找第 N 条的位置由序号乘出，条目录出的是物理偏移，指向日志里的那一条；查询索引按时间切文件" 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-c04-log-and-indexes.svg" width="680"&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;两份索引都只回答「去哪找」，不回答「是什么」。&lt;/strong&gt; 条目把人领到日志的某个位置就结束了；消息本体始终只在日志里存一份。&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;边注&lt;/strong&gt;：定长条目的 20 字节按 8 + 4 + 8 切开，依次是物理偏移、消息长度、那个比较值；&lt;strong&gt;消费索引&lt;/strong&gt;一个文件装 30 万条（6,000,000 字节）换下一个。比较值装的什么随主题变化：普通业务主题装 &lt;code&gt;tag&lt;/code&gt; 的 hashCode，&lt;code&gt;SCHEDULE_TOPIC_XXXX&lt;/code&gt;（定时消息的内部主题）装的是到期投递时刻。同一个位置按主题换语义，就是这一份定长布局能被 &lt;code&gt;tag&lt;/code&gt; 过滤与定时投递共用的原因。&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;h3 id="查询索引key-怎么哈希怎么避冲突"&gt;查询索引：key 怎么哈希，怎么避冲突
&lt;/h3&gt;&lt;p&gt;查询索引按 key 找人，key 是一条消息可选的业务主键（比如订单号）。它的文件结构分成三段：一个 40 字节的文件头，接着一串哈希槽，最后是定长的索引条目。写一条 key 时，先对这个 key 字符串算一个 &lt;code&gt;keyHash&lt;/code&gt;（就是 Java 的 &lt;code&gt;Math.abs(key.hashCode())&lt;/code&gt;），再用它对槽数取余，落到某个槽上。&lt;/p&gt;
&lt;p&gt;这个槽里存的不是 key 本身，而是一个指向索引条目的位置。每个索引条目 20 字节，装着 &lt;code&gt;keyHash&lt;/code&gt;、消息的物理偏移、一个相对文件起始时刻的秒数、以及&lt;strong&gt;上一条冲突条目的位置&lt;/strong&gt;。两个不同的 key 算出同一个槽时，新条目就用这第四个字段回链到旧条目，在槽后面串成一条链表；找 key 时沿这条链比对 &lt;code&gt;keyHash&lt;/code&gt;，命中才返回物理偏移。&lt;/p&gt;
&lt;p&gt;所以查询索引也是「先哈希定位、再沿链确认」，和第九节 tag 的 hashCode 一样，&lt;code&gt;keyHash&lt;/code&gt; 相同并不等于 key 相同，最终要靠读回日志里的 key 原文再核一次。它按时间切文件，是因为排查时「按时间段翻」比「按队列翻」更自然，文件名就是创建时刻。&lt;/p&gt;
&lt;h3 id="写路径不碰索引后台异步补"&gt;写路径不碰索引，后台异步补
&lt;/h3&gt;&lt;p&gt;追加进日志，写路径就返回了（要不要等它落盘，由刷盘与复制那两个开关决定）；索引由一条后台线程追着补。这条线程叫 &lt;code&gt;ReputMessageService&lt;/code&gt;：它反复把日志里的消息读回来，分派给「建消费索引」与「建查询索引」两个动作，一轮做完只睡 1 毫秒又去看有没有新数据。官方 &lt;code&gt;Design_Store.md&lt;/code&gt; 讲这段路径用的词是「不停地分发请求并异步构建 ConsumeQueue 和 IndexFile 数据」。&lt;/p&gt;
&lt;p&gt;它怎么知道「补到哪了」？靠一个叫 &lt;code&gt;reputFromOffset&lt;/code&gt; 的游标：每轮从游标位置顺序往后读日志，解析出一条就分派一条，游标同步往前推。日志末尾有写入就唤醒它，游标追平末尾就睡 1 毫秒再探一次。所以&lt;strong&gt;消费索引条目是日志落盘后异步补出来的，不是写日志时同步写&lt;/strong&gt;——追加返回的那一刻，消费索引里可能还没有这条消息的条目。这个窗口的具体时长没有保证，受消息量、分派工作量、磁盘与线程调度影响；能确认的只是在在线空载实验里它很快收敛到 &lt;code&gt;dispatchBehindBytes=0&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;分派时还有一个关键开关：它按消息的事务标记决定写不写消费索引。普通消息、以及事务消息 commit 后重投的那条，标记是「普通 / 已提交」，会写进消费索引；事务的 prepare 与 rollback 两种标记，分派时直接跳过、不写索引——这正是第十一节「半消息不在原主题索引里」的源码依据。&lt;/p&gt;
&lt;p&gt;&lt;img alt="写路径只追加日志就返回；索引由后台分派线程追着补：反复把日志里的消息读回来，分派给建消费索引与建查询索引两个动作，落后量是 dispatchBehindBytes" class="gallery-image" data-flex-basis="562px" data-flex-grow="234" decoding="async" height="290" 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-c04-write-and-dispatch.svg" width="680"&gt;&lt;/p&gt;
&lt;p&gt;追到哪了是可观测的：日志最大偏移减去索引已补到的位置，就是 &lt;code&gt;dispatchBehindBytes&lt;/code&gt;，在线情形下它收敛到 0。日志本身也不会无限长：默认保留 72 小时，过期的文件由清理任务删除。&lt;/p&gt;
&lt;h3 id="重启以日志为准"&gt;重启以日志为准
&lt;/h3&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;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;边注&lt;/strong&gt;：日志文件内部有三个独立游标（写入位置、已提交位置、已刷盘位置）。它们的差值决定掉电会丢哪一段，也正好是刷盘与复制那两个开关在等的东西，属于结构推导。&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2 id="六高可用刷盘与复制两道开关和一份整机级的只读副本"&gt;六、高可用：刷盘与复制两道开关，和一份整机级的只读副本
&lt;/h2&gt;&lt;p&gt;消息什么时候算「安全」？主节点挂了会怎样？这两个问题的答案，藏在两个互相独立的开关里。&lt;/p&gt;
&lt;p&gt;刷盘（&lt;code&gt;flushDiskType&lt;/code&gt;）和复制（&lt;code&gt;brokerRole&lt;/code&gt;）是两个开关，各取一档，拼出四种搭配。这两个开关在发送路径上各自要多等什么已经交代过；这里只看它们组合起来，消息在什么情况下会丢。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;flushDiskType&lt;/code&gt; 管「还没写进磁盘的那一段」&lt;/strong&gt;：&lt;code&gt;ASYNC_FLUSH&lt;/code&gt;（异步刷盘）返回 &lt;code&gt;SEND_OK&lt;/code&gt; 时数据可能还在内存里（PageCache），机器一断电就没了；&lt;code&gt;SYNC_FLUSH&lt;/code&gt;（同步刷盘）返回前先写进磁盘文件，单机断电不丢已确认消息。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;brokerRole&lt;/code&gt; 管「从节点还没拿到的那一段」&lt;/strong&gt;：&lt;code&gt;ASYNC_MASTER&lt;/code&gt;（异步复制）不等从节点就返回，主节点整机坏掉时这一段真没了；&lt;code&gt;SYNC_MASTER&lt;/code&gt;（同步复制）等从节点收下才返回，主节点这时候坏掉，从节点手里是齐的——代价是从节点不在场时拿不到 &lt;code&gt;SEND_OK&lt;/code&gt;（返回 &lt;code&gt;SLAVE_NOT_AVAILABLE&lt;/code&gt;，消息仍在主节点上），这正是发送失败第三类里的那个坑。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="四种搭配"&gt;四种搭配
&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;strong&gt;默认值&lt;/strong&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;strong&gt;本机实测这一档&lt;/strong&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;只丢「从节点还没拿到」那段&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&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;h3 id="复制通道"&gt;复制通道
&lt;/h3&gt;&lt;p&gt;复制机制的关键在方向：&lt;strong&gt;是从节点主动连上来拉，不是主推过去&lt;/strong&gt;。它去连的那个地址也不是自己配的——从节点向 NameServer 注册时，响应里就把主节点的地址与 HA 地址一并回了过来。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;复制只有一条通道。&lt;/strong&gt; 主节点在 &lt;code&gt;haListenPort&lt;/code&gt;（默认 10912）上监听，一个从节点一条 TCP 长连接；连接建起来的第一件事，是从节点把自己的最大物理偏移报上去，主节点从那个位置开始顺序读 CommitLog 推给它——复制因此天生是断点续传，重连之后不用从头再来。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;这条通道上跑两个方向。&lt;/strong&gt; 主节点往从节点推数据，每批不超过 &lt;code&gt;haTransferBatchSize&lt;/code&gt;（默认 32 KiB），批头带着这批数据的起始偏移；从节点每处理完一批，把「我已经写到哪儿」回报给主节点。&lt;/p&gt;
&lt;p&gt;主节点手里的偏移差就是两个数相减：它已经写到的偏移，减去从节点回报的已写入偏移。这个数有两个用途——同步复制的写请求等的正是它追平，以及判断从节点还跟不跟得上：偏移差超过 &lt;code&gt;haSlaveFallbehindMax&lt;/code&gt;（默认 256 MiB），主节点就认定这个从节点落后太多。&lt;/p&gt;
&lt;p&gt;所以「有从节点在场」是两件事同时成立：连接还在（&lt;code&gt;haSendHeartbeatInterval&lt;/code&gt; 5 秒心跳、&lt;code&gt;haHousekeepingInterval&lt;/code&gt; 20 秒做保活与清理），并且偏移差没有超过那个上限。&lt;/p&gt;
&lt;p&gt;&lt;img alt="主从复制的唯一通道：从节点连主节点的 10912 端口、连上先上报自己的最大偏移，主节点从该偏移起顺序读 CommitLog 推过去（每批不超过 32 KiB），从节点每批回报「已写到哪儿」，主节点据此算出偏移差" class="gallery-image" data-flex-basis="658px" data-flex-grow="274" decoding="async" height="248" 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-c04-ha-channel.svg" width="680"&gt;&lt;/p&gt;
&lt;p&gt;复制的&lt;strong&gt;单位&lt;/strong&gt;也是整机：主从之间追平的是同一份 CommitLog 的偏移差，同一个 brokerName 下的主与从各持一份完整的 &lt;code&gt;store/&lt;/code&gt;。从节点把复制来的 CommitLog &lt;strong&gt;自己再分派一遍&lt;/strong&gt;，生成自己的 ConsumeQueue——索引不在复制的内容里，是本地按同样的规则重建出来的。&lt;/p&gt;
&lt;h3 id="从节点能做什么"&gt;从节点能做什么
&lt;/h3&gt;&lt;p&gt;从节点只能读：写请求在从节点上直接被拒（&lt;code&gt;SERVICE_NOT_AVAILABLE&lt;/code&gt;），延迟消息的投递任务在从节点上也不启动。消费默认也不会落到它身上——&lt;code&gt;slaveReadEnable&lt;/code&gt; 默认关闭；打开之后，也只在「消费跟不上」时把拉取建议转给从节点，而从节点默认不做位点校正（&lt;code&gt;offsetCheckInSlave&lt;/code&gt; 为 false）。所以 4.x 的副本是&lt;strong&gt;整机级的只读副本&lt;/strong&gt;：一个 topic 的队列不会在别的 broker 上再存一份，跨 brokerName 的队列之间也没有复制关系。&lt;/p&gt;
&lt;p&gt;副本的粒度是&lt;strong&gt;整台 broker 的整份 &lt;code&gt;store/&lt;/code&gt;&lt;/strong&gt;：偏移差是整台机器的属性，不存在「某个队列的副本落后了」这种状态。队列这一层的切分只是逻辑上的，它不构成一个能独立复制、独立迁移的单元。&lt;/p&gt;
&lt;p&gt;把队列这一层与副本这一层叠起来看，粒度关系是这样的：&lt;/p&gt;
&lt;p&gt;&lt;img alt="队列与副本各自落在存储的哪一层：topic 的 4 个队列各对应一个 ConsumeQueue 索引目录，消息本体共用 master 上一份顺序追加的 CommitLog；从节点复制的是整份 CommitLog，索引由它本地重新分派，且只读" class="gallery-image" data-flex-basis="523px" data-flex-grow="218" decoding="async" height="308" 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-c04-queue-and-replica.svg" width="672"&gt;&lt;/p&gt;
&lt;h3 id="主节点挂了"&gt;主节点挂了
&lt;/h3&gt;&lt;p&gt;主从的角色是静态的：从节点只做复制，不参与选举。写入只发生在主节点上，从节点的 &lt;code&gt;brokerId&lt;/code&gt; 恒为 1、角色恒为 &lt;code&gt;SLAVE&lt;/code&gt;——硬杀主节点（模拟宕机，不是优雅退出）之后，从节点全程没有任何升主记录。&lt;/p&gt;
&lt;p&gt;客户端的反应因此是「没有可写的主」：主节点一连断线，NameServer 就立即把它注销（断开连接即注销，不等心跳超时那 2 分钟兜底）。&lt;/p&gt;
&lt;p&gt;40 秒后发送拿到的是 &lt;code&gt;MQClientException: No route info of this topic&lt;/code&gt;——不是「连不上 10911」，而是本地已经没有可写的路由；同一类现象在发送侧还有一个 &lt;code&gt;CODE=17&lt;/code&gt;（&lt;code&gt;ha-failover&lt;/code&gt;）。&lt;/p&gt;
&lt;p&gt;所以主节点宕机后写入要等人介入，这是静态角色这个设定的直接后果，不是配置没调对。范围限 4.9.7、单组主从，观察窗口 40 秒内如此。&lt;/p&gt;
&lt;p&gt;这里也要划清一条边界：这一主一从是&lt;strong&gt;同机房的副本，不是灾备&lt;/strong&gt;。它挡的是单节点故障（主宕、主盘坏），不挡机房级故障——两台机器连机架一起没了，副本也跟着没；同步复制确认的只是「从节点追平了」，不等于数据被复制到了另一个地域。保留时间、误删除、两副本同时损坏，仍可能让数据无法恢复。要跨地域容灾，得靠别的机制（异地双活、备份导出），不在本节范围内。&lt;/p&gt;
&lt;h3 id="元数据另一条通道"&gt;元数据另一条通道
&lt;/h3&gt;&lt;p&gt;从节点启动 3 秒后开始，之后每 10 秒向主节点拉一次，拉四样：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;主题配置；&lt;/li&gt;
&lt;li&gt;消费位点；&lt;/li&gt;
&lt;li&gt;延迟位点；&lt;/li&gt;
&lt;li&gt;订阅组配置。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;四样都走 broker 之间的请求响应，不经过 HA 那条连接；拉不到就保持上一次同步到的值。消息体那边是「推数据 + 报偏移」的闭环，元数据这边是定时拉取，两条线各自独立——「副本落后了多少」这个说法只对前一条成立。&lt;/p&gt;
&lt;p&gt;另外，「索引落后日志多少」这个指标主从两侧都会打，不能拿它当复制是否正常的证据。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="七消费一条消息rebalance长轮询与位点"&gt;七、消费一条消息：rebalance、长轮询与位点
&lt;/h2&gt;&lt;p&gt;Push 消费端底下走的是拉：&lt;strong&gt;客户端主动去要，broker 在没有消息时把这次请求挂住不回&lt;/strong&gt;，这个形态叫长轮询（long polling）。一条消息到消费者手上要过三件事：队列先分给实例，实例把队列里的消息拉回来，拉到哪儿记在位点上。&lt;/p&gt;
&lt;h3 id="队列归谁"&gt;队列归谁
&lt;/h3&gt;&lt;p&gt;这一步由客户端自己算：分配不经过 broker，broker 只提供名单。每个消费实例每 30 秒向 broker 发一次心跳，心跳里带着消费组名和自己的客户端 id，broker 把这些攒在一张表里。轮到分配时，客户端先向 broker 问一句「这个组现在有哪些实例」，再把本地路由表里这个主题的队列集合拿出来；两边各自排好序，然后按实例序号切段。&lt;/p&gt;
&lt;p&gt;默认的切法是平均分：队列集合切成与实例数相同的段，不能整除时靠前的段各多一条。4 条队列 2 个实例，就是实例 A 拿 0、1 号，实例 B 拿 2、3 号；3 个实例则是 2、1、1。&lt;strong&gt;这套在实例之间重新分队列的动作就是 rebalance。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;分配的单位是队列，一条队列同一时刻只归一个实例。&lt;/strong&gt; 这句话有两个直接后果：4 条队列的组里放进第 5 个实例，它一条队列都领不到，只能空转——&lt;strong&gt;加实例不等于加并行度，队列数才是并行度上限&lt;/strong&gt;；同一条队列的消息也因此总在同一个实例上被处理。&lt;/p&gt;
&lt;p&gt;&lt;img alt="同一个主题的 4 条队列分给 2 个实例：实例 A 领 0、1 号，实例 B 领 2、3 号，第 3 个实例一条队列都领不到；分配由客户端自己算，队列易主时位点留在 broker 上不跟着搬" class="gallery-image" data-flex-basis="544px" data-flex-grow="226" decoding="async" height="300" 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-c04-consume-rebalance.svg" width="680"&gt;&lt;/p&gt;
&lt;p&gt;rebalance 每 20 秒跑一次，收到「组内实例变了」的通知时立刻再跑，不必等下一轮。结果变化就意味着&lt;strong&gt;队列易主&lt;/strong&gt;：一条队列从实例 A 换到实例 B，B 接着往下消费；而位点在 broker 上按主题、消费组、队列号三者记，&lt;strong&gt;不跟着实例搬家&lt;/strong&gt;，B 从 broker 上那份位点继续读。扩容、缩容、实例重启之后，队列归属变了、位点归属没变，这是「重复消费」在位点上报之外的另一个来路。&lt;/p&gt;
&lt;h3 id="gyd-c04-threadpool"&gt;把消息拉回来
&lt;/h3&gt;&lt;p&gt;长轮询在客户端这一侧是一个拉取线程管所有队列：它从待办里逐个取出拉取请求发出去，发完不等响应就取下一个。请求带四样：主题、队列号、队列内序号、最多要几条。&lt;strong&gt;每条队列同时只有一份请求在飞&lt;/strong&gt;，请求没回来之前客户端不会再发第二份。&lt;/p&gt;
&lt;p&gt;队列里没有新消息时，broker 不回这个请求，把它挂在内存里；等这条队列有新消息落盘、索引分派完，再把它取出来回掉。&lt;strong&gt;「推送」的实时性就是这个观感：一次拉取请求被长时间挂着，而不是服务端主动推。&lt;/strong&gt; 没有消息时靠每 5 秒一轮的巡检兜底，那时顺带检查挂起有没有到期，到期就回一个空响应。&lt;/p&gt;
&lt;p&gt;挂多久由客户端在请求头里指定，broker 照它执行，4.9.7 客户端给的上限是 15 秒；官方文档里那个 30 秒是另一次 RPC 的超时，不是挂起时长。&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;%%{init: {theme: neutral}}%%
sequenceDiagram
 participant P as 拉取线程
 participant B as Broker
 participant S as 存储与分派
 participant Q as 消费线程池
 P-&gt;&gt;B: 拉取请求（主题、队列号、序号、条数）
 alt 队列里有消息
 B--&gt;&gt;P: 立即返回
 else 没有新消息
 B-&gt;&gt;B: 挂起请求，不回
 S--&gt;&gt;B: 新消息落盘、索引分派完成
 B--&gt;&gt;P: 唤醒并返回
 end
 P-&gt;&gt;Q: 消息进缓存队列，交给消费线程池&lt;/pre&gt;&lt;p&gt;消费侧的入口是 &lt;code&gt;DefaultMQPushConsumer&lt;/code&gt;——名字里带 Push，底下走的仍是上一段说的拉取。业务逻辑注册在监听器里：&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="n"&gt;DefaultMQPushConsumer&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;consumer&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;DefaultMQPushConsumer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;gyd-c03-consumer&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;consumer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setNamesrvAddr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;localhost:9876&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;consumer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;gyd-c03-send&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;*&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 主题 + tag 表达式&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;consumer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;registerMessageListener&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;MessageListenerConcurrently&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="n"&gt;msgs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ctx&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="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MessageExt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;m&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="n"&gt;msgs&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;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&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="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ConsumeConcurrentlyStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CONSUME_SUCCESS&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="n"&gt;consumer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;start&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;strong&gt;拉取和消费是两拨线程&lt;/strong&gt;：拉取线程把消息放进这条队列的缓存队列，消费线程池（默认 20 个线程）再从缓存里取走、执行监听器。缓存有两道闸，条数超过 1000 或者体积超过 100 MiB，下一次拉取就推迟 50ms；并发消费时还多一道，缓存里最早与最新的序号相差超过 2000 也推迟。&lt;strong&gt;这三道闸是按消费能力给的流控&lt;/strong&gt;，挡不住会积压，挡太紧会空转。&lt;/p&gt;
&lt;h3 id="位点"&gt;位点
&lt;/h3&gt;&lt;p&gt;位点（offset）是队列内的序号，不是消息的全局编号。它和「一条队列只归一个实例」合起来，才是「每条队列各推各的进度」这件事。&lt;/p&gt;
&lt;p&gt;位点存哪取决于消费模式：集群模式存在 broker 上，广播模式存在本地。集群模式下，位点从客户端内存抵达 broker 有三条路：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;随下一次拉取请求捎带&lt;/strong&gt;：只有内存里的位点大于 0 才带；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;定时批量刷&lt;/strong&gt;：启动 10 秒后首刷，之后每 5 秒一次；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;正常关闭时同步刷一次&lt;/strong&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;broker 收到先写内存，再定时落到 &lt;code&gt;config/consumerOffset.json&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;第一条路和长轮询是同一个节奏：没有消息时那份请求正被挂着，要等它被回掉才有下一次捎带的机会。&lt;strong&gt;位点上报的节奏被拉取的节奏拖着&lt;/strong&gt;，定时刷就是给这个节奏兜底的那条路。&lt;/p&gt;
&lt;p&gt;顺带一个默认值：组在 broker 上还没有位点时，起点取队列当前的最大序号，&lt;strong&gt;新组默认只消费启动之后进来的消息&lt;/strong&gt;，不补历史。&lt;/p&gt;
&lt;h3 id="两条边界"&gt;两条边界
&lt;/h3&gt;&lt;p&gt;位点不随主题删除而清空：它在 broker 上按主题和消费组记，删主题不清位点。删掉 &lt;code&gt;gyd-c03-order&lt;/code&gt; 再重建、重新灌 6 条（队列内序号 0–5）之后，这个组手上的位点还是上一轮留下的 6，&lt;strong&gt;正好顶在队尾&lt;/strong&gt;：broker 回「没拉到」，一条都不给，也不报错。恢复动作是把消费进度重置到某个时刻，重置后这 6 条全部收到。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;重放边界。&lt;/strong&gt; 位点不是消费一条写回一条，而是批量走。同一主题、同一消费组，消费到第 4 条时进程被强杀，broker 上的位点还停在 0，重启后这 20 条从头再来一遍；消费完 20 条正常退出之后位点已经落到 20，重启后一条都不会重来。&lt;strong&gt;「重复消费」的成因很具体：位点还没走完那段路，进程先没了。&lt;/strong&gt;&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;边注&lt;/strong&gt;：broker 回「没拉到」的背后有四种情形——队列为空、位点比队列最小值还小、位点正好顶在队尾、位点越过队尾太多。它们对客户端的含义分两类：顶在队尾时客户端会立刻再问一次，观感是原地等；越过或低于最小值时 broker 会给一个纠正后的位点，客户端把手上这条队列丢掉，按新位点重来。上面那个强杀样本落在「顶在队尾」这一格。&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;h3 id="消费失败之后"&gt;消费失败之后
&lt;/h3&gt;&lt;p&gt;前面只讲了「监听器成功、位点提交」这条成功路径。监听器返回失败（&lt;code&gt;RECONSUME_LATER&lt;/code&gt;，或抛异常）时，消息不是被丢掉，而是进一条&lt;strong&gt;重试链路&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;第一次失败，消息被改投到该消费组的&lt;strong&gt;重试主题&lt;/strong&gt; &lt;code&gt;%RETRY%&amp;lt;消费组名&amp;gt;&lt;/code&gt;，投递次数写进消息属性，等一个退避间隔后再次投回原主题；&lt;/li&gt;
&lt;li&gt;重试主题也有自己的消费队列，这条链路继续由原消费者消费，位点在重试主题上按组记录；&lt;/li&gt;
&lt;li&gt;投递次数累计到一个上限（默认 16 次）后，消息被改投到&lt;strong&gt;死信主题&lt;/strong&gt; &lt;code&gt;%DLQ%&amp;lt;消费组名&amp;gt;&lt;/code&gt;，不再自动重试，等人手工介入。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这一小段为什么关键，因为它把「至少一次」和「消费端幂等」钉在了一起：重试会带来&lt;strong&gt;同一消息被多次投递&lt;/strong&gt;，业务处理必须幂等；第三节说「发送侧只给 at-least-once」，这里就是重复在消费侧的具体落点——不是发送重试造成的，是消费失败重投造成的。它也和第三节的问题 1 首尾相接：事务消息不解决重复，解决重复的是幂等，而幂等正是被这条重试链路逼出来的。&lt;/p&gt;
&lt;p&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;RETRY&lt;/code&gt; 主题的队列数、&lt;code&gt;delayLevelWhenNextConsume&lt;/code&gt; 等）都是消费组级的配置，默认行为是「逐次退避、16 次进死信」，具体档位可调。死信主题不会被普通消费者订阅，需要单独拉取或走运维通道。&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2 id="八顺序消息先保住两条前提再用锁立住它"&gt;八、顺序消息：先保住两条前提，再用锁立住它
&lt;/h2&gt;&lt;h3 id="顺序消息解决什么"&gt;顺序消息解决什么
&lt;/h3&gt;&lt;p&gt;有些消息之间是有先后的：同一个订单的「下单、支付、发货」必须按这个顺序被处理，先处理「发货」再处理「下单」就是错账。顺序消息要保证的就是这件事——&lt;strong&gt;一批相关的消息，被消费的顺序和它们发出来的顺序一致&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;但默认的并发消费做不到。上一节说过，消费线程池有 20 个线程，同一条队列的几批消息会被同时取走、各自处理：先提交的那批还没处理完，后提交的那批可能已经跑完了。&lt;strong&gt;顺序正是在这「同时处理」里丢掉的&lt;/strong&gt;——不是消息落盘时乱，是消费时几批并行，先后没有保证。&lt;/p&gt;
&lt;h3 id="难点在哪"&gt;难点在哪
&lt;/h3&gt;&lt;p&gt;顺序消息的讨论放在前面已经固定的集群设定上：主题 4 条读写队列（&lt;a class="link" href="#gyd-c04-scene" &gt;第一节&lt;/a&gt;）、消费线程池 20 个线程（&lt;a class="link" href="#gyd-c04-threadpool" &gt;第七节&lt;/a&gt;）。&lt;/p&gt;
&lt;p&gt;要让顺序成立，得同时按住两件事，缺一个都不行：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;发送侧&lt;/strong&gt;：相关的消息必须进&lt;strong&gt;同一条队列&lt;/strong&gt;，而不是被轮询摊到 4 条队列上——摊开了，连「它们本来是一组的」这个信息都没了；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;消费侧&lt;/strong&gt;：同一条队列，同一时刻只能有&lt;strong&gt;一个消费者&lt;/strong&gt;在处理——否则又是几批并行，顺序照散。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;队列本身是有序的（第七节），这两件事同时成立，顺序就自然成立。难点不在「想到这两件事」，而在「在分布式、多实例、可能宕机的环境里，把它们固定成不可被绕过的约束」。&lt;/p&gt;
&lt;h3 id="第一件事发送侧按业务键选定队列"&gt;第一件事：发送侧按业务键选定队列
&lt;/h3&gt;&lt;p&gt;默认的选队列动作是轮询，&lt;code&gt;sendWhichQueue&lt;/code&gt; 那个计数器对着队列列表转，同一个业务键的消息会被摊到 4 条队列上，顺序在源头就被打散。要保序，得给发送端挂一个选择器，按业务键决定落点，最常见的写法是业务键对队列数取模：同一个订单号的「下单、支付、发货」三条消息，业务键相同，取模落到同一条队列。对应到 API，就是实现 &lt;code&gt;MessageQueueSelector&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;// 按业务键（订单号）选队列：同一订单的消息进同一条队列&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;SendResult&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;r&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;producer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&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;MessageQueueSelector&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="nd"&gt;@Override&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="kd"&gt;public&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;MessageQueue&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;MessageQueue&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;mqs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;arg&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="kt"&gt;long&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orderId&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;Long&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;arg&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="kt"&gt;int&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;idx&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="kt"&gt;int&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="n"&gt;orderId&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;mqs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;size&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="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;mqs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;idx&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="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orderId&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;strong&gt;发送端保证的是「相关的消息进同一条队列」，不保证它们落盘的先后&lt;/strong&gt;——同一个业务键的消息由多个发送线程并发发出时，谁先落到 broker 上就不一定了。要连落盘顺序也保证，得发送端自己串行发。&lt;/p&gt;
&lt;h3 id="第二件事消费侧认领队列"&gt;第二件事：消费侧认领队列
&lt;/h3&gt;&lt;p&gt;消费侧分两层。第一层是「这条队列现在归不归我」：队列归谁由客户端自己算、broker 只给名单（第七节）。顺序模式不采用这个结论——「算给我了」还不够，因为另一个实例也可能算出同样的结果。它还必须在 broker 那儿留一份登记，证明「这条队列此刻只归我」：客户端把分配到的每条队列都走一遍 &lt;code&gt;lock&lt;/code&gt;，拿不到就把这条队列整个摘掉、一条都不拉（&lt;code&gt;if (isOrder &amp;amp;&amp;amp; !this.lock(mq)) continue;&lt;/code&gt;）。&lt;strong&gt;在顺序模式里，「算给我了」和「现在归我」是两件事。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这次确认不是阻塞锁，是一次登记。客户端把要用的队列打包进一个 &lt;code&gt;LockBatchMQ&lt;/code&gt; 请求（超时 1000 ms）发给 broker，broker 侧按消费组维护一张表（&lt;code&gt;RebalanceLockManager&lt;/code&gt;），每行记「这条队列现在归哪个 clientId、最后一次续租在什么时候」。&lt;strong&gt;归属靠续租维持，不是一次授权就长期有效。&lt;/strong&gt; 客户端每 20 秒续一次（&lt;code&gt;REBALANCE_LOCK_INTERVAL&lt;/code&gt;），续上就刷新时间戳；本地 30 秒没续上就把这条队列的锁标记成过期（&lt;code&gt;REBALANCE_LOCK_MAX_LIVE_TIME&lt;/code&gt;），消费随即停下；broker 侧要 60 秒没等到续租才判失效、放给别的实例（&lt;code&gt;rocketmq.broker.rebalance.lockMaxLiveTime&lt;/code&gt;）。&lt;/p&gt;
&lt;p&gt;消费请求本身也认这两个条件：进来先查「锁在手上、且没过期」，任一不满足就把这条队列推到 10 ms 后重试，重试仍拿不到锁就改 3 秒后再来。&lt;/p&gt;
&lt;p&gt;推到极端就是顺序模式比并发模式贵的地方：&lt;strong&gt;一个实例整机挂掉，它在 broker 上登记的那几条队列要等 60 秒没人续租才放出来&lt;/strong&gt;，别的实例才接得走。这个 60 秒是从两侧的常量推出来的，本机没有实测过。&lt;/p&gt;
&lt;h3 id="第三件事实例内部逐批串行"&gt;第三件事：实例内部逐批串行
&lt;/h3&gt;&lt;p&gt;第二层在实例内部，这一层才是真正的锁：客户端给每条队列准备一个锁对象（&lt;code&gt;MessageQueueLock&lt;/code&gt;），消费请求进来先 &lt;code&gt;synchronized&lt;/code&gt; 住它，这一批处理完下一批才进得来；不同队列各锁各的，仍然并行。对照并发消费：它没有这一层，同一个队列的几批消息按 &lt;code&gt;consumeMessageBatchMaxSize&lt;/code&gt; 切完就直接提交进 20 个线程的池子，同队列并行——顺序正是在这一步丢失的。&lt;/p&gt;
&lt;p&gt;消费侧要拿到这套串行语义，注册的是 &lt;code&gt;MessageListenerOrderly&lt;/code&gt; 而非普通的 &lt;code&gt;MessageListenerConcurrently&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="n"&gt;consumer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;registerMessageListener&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;MessageListenerOrderly&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="nd"&gt;@Override&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="kd"&gt;public&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ConsumeOrderlyStatus&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;consumeMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;MessageExt&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;msgs&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;ConsumeOrderlyContext&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ctx&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="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="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MessageExt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;m&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="n"&gt;msgs&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;processOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&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="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ConsumeOrderlyStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SUCCESS&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="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;MessageListenerOrderly&lt;/code&gt; 内部，&lt;code&gt;consumeMessage&lt;/code&gt; 对同一条队列是串行进入的；锁住的是「这一批与下一批不并发」。&lt;/p&gt;
&lt;p&gt;&lt;img alt="并发消费与顺序消费的差别：并发模式下同一条队列的 3 批消息同时进了线程池的 3 个线程；顺序模式下同一条队列一次只有 1 批占着一个线程，池子里其余线程在别的队列上照样跑，因而并发上限等于队列数" class="gallery-image" data-flex-basis="523px" data-flex-grow="217" decoding="async" height="312" 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-c04-orderly-concurrency.svg" width="680"&gt;&lt;/p&gt;
&lt;h3 id="代价并发度上限就是队列数"&gt;代价：并发度上限就是队列数
&lt;/h3&gt;&lt;p&gt;三件事都成立之后，代价就具体了：20 个消费线程都还在，但同一条队列同一时刻只有一批在处理，&lt;strong&gt;线程数不等于并发度&lt;/strong&gt;——4 条队列最多也就是 4 路并行。而这个 4 在主题建出来的时候就定死了：要么显式 &lt;code&gt;-w 4&lt;/code&gt; 建，要么第一个发消息的客户端自动建 &lt;code&gt;min(4,8)=4&lt;/code&gt;。队列既是存储的容量单位，也是顺序的最小单位，顺序消息的代价和存储骨架是同一个问题。&lt;/p&gt;
&lt;p&gt;官方 &lt;code&gt;Operations_Consumer.md&lt;/code&gt; 对这一步的措辞很直接：顺序消费会 lock 每个队列，「&lt;strong&gt;This will cause a performance loss&lt;/strong&gt;」，「性能损失」四个字就是本节说的代价；同一份文档也写明，需要保序的消息必须有序地发进同一个队列。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;还有一条边界。顺序消息保证的是顺序，不保证不重复。&lt;/strong&gt; 进程被强杀之后，落回队列的那批消息会再来一遍，顺序照样成立，重复也照样发生——顺序管的是先后，重不重复由位点决定，两件事互不替代。&lt;/p&gt;
&lt;h2 id="九消息过滤先弄清过滤什么再看它落在哪一层"&gt;九、消息过滤：先弄清过滤什么，再看它落在哪一层
&lt;/h2&gt;&lt;h3 id="过滤要解决什么"&gt;过滤要解决什么
&lt;/h3&gt;&lt;p&gt;一个主题里的消息并不全是同一个消费者要的。比如订单主题里混着「下单、支付、发货、退款」四类事件，做对账的那个消费者只关心「支付成功」，其余三类它一条都不想收。&lt;strong&gt;订阅本身就是一个过滤动作&lt;/strong&gt;：消费者在订阅时声明「我只要哪种消息」，broker 据此决定哪些投递、哪些不下发。&lt;/p&gt;
&lt;p&gt;这个过滤放在 broker 侧做，而不是让消费者把消息全拉回来自己筛，省的是传输和处理的浪费：不符合条件的消息根本不下发，也就不占网络、不占消费端的 CPU。RocketMQ 给这个声明提供了两种写法：&lt;strong&gt;tag&lt;/strong&gt; 和 &lt;strong&gt;SQL92&lt;/strong&gt;。&lt;/p&gt;
&lt;h3 id="两种写法长什么样"&gt;两种写法长什么样
&lt;/h3&gt;&lt;p&gt;&lt;strong&gt;tag 是消息身上的一个标签字段。&lt;/strong&gt; 发送时给消息打一个 tag，订阅时用 &lt;code&gt;tagA || tagB&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;// 发送侧：这条消息打上「支付成功」的 tag&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;Message&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;msg&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;Message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;OrderTopic&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;PaySuccess&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;订单 1001 已支付&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="c1"&gt;// 订阅侧：只收 tag 为 PaySuccess 或 Refund 的消息&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;consumer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;OrderTopic&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;PaySuccess || Refund&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;strong&gt;SQL92 是借用 SQL-92 标准的表达式，按消息属性筛选。&lt;/strong&gt; 消息除了 tag，还能带一组键值属性（比如金额、地区）；SQL92 订阅就是写一条 &lt;code&gt;WHERE&lt;/code&gt;，对这些属性做比较、范围、集合判断。能力比 tag 强得多：&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;// 发送侧：带上属性&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;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;putUserProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;amount&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;150&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;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;putUserProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;region&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;hz&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;// 订阅侧：只收金额大于 100 且地区在指定集合里的消息&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;consumer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;OrderTopic&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;MessageSelector&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;bySql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;amount &amp;gt; 100 AND region IN (&amp;#39;hz&amp;#39;, &amp;#39;sh&amp;#39;)&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;tag 是贴在消息上的一个短标签，SQL92 是对消息属性的一组判断：前者简单、只按字符串匹配，后者能表达任意属性组合。写法就这两种；但 SQL92 还有一个&lt;strong&gt;可选优化&lt;/strong&gt;，叫 bitmap，下面要一起讲——所以后文说的是「三种形态」，而不是三种写法。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;bitmap 是什么，先说清楚。&lt;/strong&gt; SQL92 的代价是每条候选都要读正文、求值表达式，读得越多越慢。bitmap 就是把这个求值&lt;strong&gt;提前&lt;/strong&gt;：broker 在构建消费队列时，就拿到「这个主题下所有带 SQL92 条件的消费组」，对这条消息挨个用编译好的表达式先算一遍，把「哪些消费组匹配」的结果，用布隆过滤器（bloom）算成一个二进制位图，存进消费队列的扩展存储。&lt;/p&gt;
&lt;p&gt;消费时第一层先查这个位图：位图说「不匹配」就直接跳过、不读正文；说「可能匹配」才落第二层确认。它们真正的差别在「这个判断在 broker 的哪一层做」，也就是下面这套两层判定。&lt;/p&gt;
&lt;h3 id="两层判定"&gt;两层判定
&lt;/h3&gt;&lt;p&gt;broker 侧的过滤是一个&lt;strong&gt;两层判定&lt;/strong&gt;：tag 和 SQL92 的区别，就落在「被判掉在哪一层」。为什么会有这两层，答案在第四节那个位置之分——&lt;strong&gt;索引条目里只有 &lt;code&gt;tag&lt;/code&gt; 的哈希、属性原文只在日志正文里&lt;/strong&gt;，于是手上只有索引时，够判 tag、不够判 SQL92。这两层是这样的：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;第一层&lt;/strong&gt;：手上只有索引条目里那个 tag 的 hashCode，够判断 tag，不够判断 SQL92；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;第二层&lt;/strong&gt;：手上才有消息正文和属性，才能求值 SQL92 表达式。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;逐条循环把这两层串成一个&lt;strong&gt;短路&lt;/strong&gt;：先走第一层，判掉就不读正文，判不了才落第二层。三种形态（tag、SQL92、SQL92 + bitmap）的区别，全在这条短路上：&lt;/p&gt;
&lt;p&gt;&lt;img alt="两层判定短路：第一层手上只有索引条目里的 tag hashCode，tag 够判、SQL92 没信息、bitmap 靠 bloom 预筛；判不掉的才落第二层，读正文、解析属性求值" class="gallery-image" data-flex-basis="544px" data-flex-grow="226" decoding="async" height="300" 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-c04-filter-two-layer.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;tag&lt;/th&gt;
					&lt;th&gt;SQL92&lt;/th&gt;
					&lt;th&gt;SQL92 + bitmap&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;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;td&gt;第一层 bloom 预筛 + 第二层确认&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;判断需要的数据&lt;/td&gt;
					&lt;td&gt;索引条目里的 tag hashCode&lt;/td&gt;
					&lt;td&gt;消息属性（在正文里）&lt;/td&gt;
					&lt;td&gt;建索引时算好的 bitmap&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;是否要读消息正文&lt;/td&gt;
					&lt;td&gt;&lt;strong&gt;不读&lt;/strong&gt;（第二层对 tag 直接放行）&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;/td&gt;
					&lt;td&gt;&lt;code&gt;enablePropertyFilter&lt;/code&gt;（默认关闭）&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;enableCalcFilterBitMap&lt;/code&gt; + &lt;code&gt;enableConsumeQueueExt&lt;/code&gt;（位图要存进扩展存储，两个都得开，默认都关）&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;主要代价&lt;/td&gt;
					&lt;td&gt;一次 hash 查表&lt;/td&gt;
					&lt;td&gt;读正文 + 属性求值&lt;/td&gt;
					&lt;td&gt;建索引时预计算；bloom 假阳性会白读一次&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;「tag 过滤为什么不必读消息体」这一行，答案也在这里：tag 的判定落在第一层，用索引条目里那个 hashCode 就能在&lt;strong&gt;读正文之前&lt;/strong&gt;把不匹配的消息淘汰掉，只有候选消息才读正文、传给客户端。这也是第四节那个比较值的用途：它让 tag 过滤在 broker 这边先靠索引筛一道。&lt;/p&gt;
&lt;p&gt;但 hashCode 会碰撞，broker 手上又没有 tag 原文——它比的是 &lt;code&gt;codeSet.contains(tagsCode)&lt;/code&gt;，只是两个整数是否相等；两个不同的 tag 撞出同一个 hashCode，第一层就会把「本不该匹配」的消息误放行。这个口子 broker 补不了，得靠客户端：消息拉到本地后，&lt;code&gt;PullAPIWrapper&lt;/code&gt; 里再跑一遍 &lt;code&gt;tagsSet.contains(msg.getTags())&lt;/code&gt;，这次比的是 tag 原文，把碰撞造成的假阳性丢掉。所谓「不读正文」，省的是 broker 读盘与传输，不是省掉最终那道精确比对——精确过滤始终在，只是从 broker 挪到了客户端。&lt;/p&gt;
&lt;p&gt;SQL92 则相反：第一层没有任何信息，每条候选都要读正文、解析属性、求值表达式。所以 SQL92 需要 broker 侧打开 &lt;code&gt;enablePropertyFilter&lt;/code&gt;（默认关闭）；开关没打开时，用 SQL92 订阅会被 broker 直接拒绝：&lt;code&gt;The broker does not support consumer to filter message by SQL92&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;bitmap 是给 SQL92 的优化：建队列时用 bloom 把「哪些消费组可能匹配」算成位图，第一层按这个位图预筛，判不中就不读正文。但 bloom 是概率结构，&lt;strong&gt;命中不等于真匹配&lt;/strong&gt;，仍需第二层确认；假阳性会白读一次。&lt;/p&gt;
&lt;p&gt;启用 bitmap 的这两个开关不在同一个配置项里：&lt;code&gt;enableCalcFilterBitMap&lt;/code&gt; 在 broker 配置，&lt;code&gt;enableConsumeQueueExt&lt;/code&gt; 在消息存储配置，别找错地方。&lt;/p&gt;
&lt;p&gt;官方 &lt;code&gt;Design_Filter.md&lt;/code&gt; 也把这道两层判定说穿了：服务端「只用 hashcode，因此无法精确，所以 Consumer 拉取后还要比对原始 tag 字符串」。服务端 &lt;code&gt;isMatchedByConsumeQueue&lt;/code&gt; 里比的是 &lt;code&gt;codeSet.contains(tagsCode)&lt;/code&gt;（整数），客户端 &lt;code&gt;PullAPIWrapper.processPullResult&lt;/code&gt; 里比的是 &lt;code&gt;tagsSet.contains(msg.getTags())&lt;/code&gt;（原文）——&lt;strong&gt;一道比 hashCode、一道比原文，碰撞的口子由后者补上。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="十定时消息18-级级表与-schedule_topic_xxxx"&gt;十、定时消息：18 级级表与 SCHEDULE_TOPIC_XXXX
&lt;/h2&gt;&lt;p&gt;定时消息为什么要绕一个内部主题？因为原主题的队列是顺序消费的（第六、七节）：一条 10 秒后才该投递的消息如果现在就进原队列，会挡住排在它后面、本应立即可见的消息。所以定时消息必须先隔离到一个内部主题、到期再投回来，不能占着原队列的位置。&lt;/p&gt;
&lt;p&gt;它和第四节「只追加日志」并不冲突，但「隔离」「投回」两个动作，各有各的写。&lt;strong&gt;隔离&lt;/strong&gt;：这条消息落盘时，topic 被改成 &lt;code&gt;SCHEDULE_TOPIC_XXXX&lt;/code&gt;，正文照常追加进 CommitLog，但消费索引建在内部主题下，原主题的消费者看不到它。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;投回&lt;/strong&gt;：到期后后台任务从这条消息的属性里还原出真实主题与队列，&lt;strong&gt;重新写一条 CommitLog&lt;/strong&gt;（一条全新的消息），新消息在原主题下建消费索引，消费者这时才在原队列里看到它。旧的内部主题索引不删，靠扫描任务自己的进度表记录「这条队列扫到哪了」，随日志过期一起回收。&lt;/p&gt;
&lt;p&gt;这个内部主题配合的是一张&lt;strong&gt;离散延迟级别表&lt;/strong&gt;（默认 18 级，从 1 秒到 2 小时，完整级表见下），而不是「每条消息一个定时器」。发送时给消息设一个延迟级别，broker 把它改投到内部主题 &lt;code&gt;SCHEDULE_TOPIC_XXXX&lt;/code&gt; 的第 &lt;code&gt;delayLevel - 1&lt;/code&gt; 个队列，由一个后台定时任务扫描到期后&lt;strong&gt;重新投回原 Topic&lt;/strong&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="n"&gt;Message&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;msg&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;Message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;OrderTopic&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;订单超时关闭&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;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setDelayTimeLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 第 3 级 = 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;producer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&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;/p&gt;
&lt;p&gt;所以第一个结论是：&lt;strong&gt;4.9.7 不支持每条消息直接指定任意投递时间，只能从 broker 配置好的离散延迟级别里选&lt;/strong&gt;。这套级别来自 broker 的 &lt;code&gt;messageDelayLevel&lt;/code&gt; 配置项，可以改，但改的是「可选哪几档」，改不出「任意时刻」这一档。官方 &lt;code&gt;Example_Delay.md&lt;/code&gt; 说得更直白：「does not support any time delay. It needs to set several fixed delay levels, which correspond to level 1 to 18 from 1s to 2h」。默认配置就是这 18 级，级表分布本身就是证据：1 分钟到 10 分钟这段每分钟一档，之后直接跳到 20 分钟、30 分钟、1 小时、2 小时，&lt;strong&gt;越长的延迟越粗糙&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;但「精度」还有另一半，级表管不到：定时消息保证的是&lt;strong&gt;不早于&lt;/strong&gt;该时刻投回原队列，不保证「一到点就被消费」。投回之后它就和普通消息一样排进原队列，原队列消费阻塞或积压时，实际被处理的时间照样往后漂。级表锁的是「能选哪些延迟」，消费侧排队锁的是「选中的延迟能不能准点兑现」，前者由 broker 定、后者由消费速度定。&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;边注&lt;/strong&gt;：默认 18 级级表按序是 &lt;code&gt;1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h&lt;/code&gt;。这套「离散档位」就是定时消息默认的全部可选精度，不存在「任意秒」这一档。&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;p&gt;第二个结论：&lt;strong&gt;超出 18 级的 delayLevel 不被拒绝，而是被静默夹到第 18 级&lt;/strong&gt;。落盘时若级别超出最大级，就把它压到最大级；而「级别」到「队列号」的映射就是「级别减一」。官方 &lt;code&gt;Feature.md&lt;/code&gt; §8 也明写了这条规则（「level &amp;gt; maxLevel 则 level == maxLevel，例如 level==20，延迟 2h」）。&lt;/p&gt;
&lt;p&gt;这条在磁盘上可以直接看到：&lt;code&gt;DELAY=19&lt;/code&gt; 与 &lt;code&gt;DELAY=99&lt;/code&gt; 两条的落点都是 &lt;code&gt;queueId=17&lt;/code&gt;（第 18 级），索引条目里那个投递时刻减去落盘时刻，差值恒等于 2 小时（7200000 毫秒），请求里写多少都不算数。&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;%%{init: {theme: neutral}}%%
flowchart LR
 A[发送 delayLevel] --&gt; B[改投 SCHEDULE_TOPIC_XXXX]
 B --&gt; C[第 delayLevel-1 个队列]
 C --&gt; D[后台定时任务扫描]
 D --&gt;|到期| E[投回原 Topic]&lt;/pre&gt;&lt;p&gt;客户端拿到 &lt;code&gt;SEND_OK&lt;/code&gt;、消息进了 &lt;code&gt;SCHEDULE_TOPIC_XXXX&lt;/code&gt; 的 Q17，在观察窗口内没有投递。「2 小时后投递」是结构推导：没有等满 2 小时做直接观测，这里标为未观测。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="十一事务消息半消息回查与一次主题替换造成的索引迁移"&gt;十一、事务消息：半消息、回查，与一次主题替换造成的索引迁移
&lt;/h2&gt;&lt;p&gt;「改库 + 发消息」的原子性怎么被兜住？答案分两半：客户端一个监听器，broker 一套半消息机制。&lt;/p&gt;
&lt;p&gt;客户端是一个事务生产者 + 一个监听器，两个回调：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;executeLocalTransaction&lt;/code&gt;：执行本地事务、返回状态（commit / rollback / unknown）；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;checkLocalTransaction&lt;/code&gt;：broker 回查时，回答本地事务成没成。&lt;/li&gt;
&lt;/ul&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="n"&gt;TransactionMQProducer&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;producer&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;TransactionMQProducer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;gyd-c03-tx-producer&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;producer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setTransactionListener&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;TransactionListener&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="nd"&gt;@Override&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="kd"&gt;public&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;LocalTransactionState&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;executeLocalTransaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;arg&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="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="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;doLocalTx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&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;LocalTransactionState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;COMMIT_MESSAGE&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="n"&gt;LocalTransactionState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ROLLBACK_MESSAGE&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&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="nd"&gt;@Override&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="kd"&gt;public&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;LocalTransactionState&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;checkLocalTransaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MessageExt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;msg&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="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="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;queryLocalTx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&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;LocalTransactionState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;COMMIT_MESSAGE&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="n"&gt;LocalTransactionState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ROLLBACK_MESSAGE&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="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;producer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;start&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;producer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sendMessageInTransaction&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;Message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;OrderTopic&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;下单&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 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;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;strong&gt;回查接口必须由业务方实现&lt;/strong&gt;，这是事务消息的代价之一（第十二节）。&lt;/p&gt;
&lt;p&gt;broker 侧，把半消息写进内部主题 &lt;code&gt;RMQ_SYS_TRANS_HALF_TOPIC&lt;/code&gt;，处理 commit/rollback，并定期驱动回查。&lt;/p&gt;
&lt;p&gt;这里有一个容易记混的地方：&lt;strong&gt;半消息的「不可见」不是「不建索引」，而是「不建到原主题的索引下」&lt;/strong&gt;。落盘时做三件事：备份原 topic/queueId 进属性、把事务位重置为 0、换 topic 为 &lt;code&gt;RMQ_SYS_TRANS_HALF_TOPIC&lt;/code&gt;。结果是&lt;strong&gt;两个索引状态&lt;/strong&gt;：原主题的 ConsumeQueue 里没有它，内部主题的 ConsumeQueue 里有它；而且回查正是靠内部主题里的这条索引把半消息拉起来的。&lt;/p&gt;
&lt;p&gt;官方 &lt;code&gt;Design_Trancation.md&lt;/code&gt; 的原文是「消息并不会转发到该原主题的消息消费队列」，而不是「不建索引条目」：半消息确实建了条目，只是建在内部主题下：磁盘上 &lt;code&gt;RMQ_SYS_TRANS_HALF_TOPIC&lt;/code&gt; 的 &lt;code&gt;queueId=0&lt;/code&gt; 下有 4 条，与 4 条半消息一一对应。&lt;/p&gt;
&lt;p&gt;半消息一旦被 commit 或 rollback，还多一步：broker 向另一个内部主题 &lt;code&gt;RMQ_SYS_TRANS_OP_HALF_TOPIC&lt;/code&gt; 写一条 Op 消息，正文是那条半消息在 half 主题里的队列偏移。回查扫描时先看 Op 主题，已经出现在 Op 里的半消息直接跳过、不再回查。半消息自己的索引条目不物理删除，靠 Op 标记 + 日志过期一起清。&lt;/p&gt;
&lt;p&gt;commit 的语义也要说准：&lt;strong&gt;commit 是「重新落一条」，不是「解锁旧消息」&lt;/strong&gt;。还原真实主题后重新写一次，可见的是新写的那条。所以原主题的索引条目，只在 commit 之后才第一次出现（rollback 则永不出现）。这和第十节定时消息的「投回」是同一套写法：定时到期重写一条、事务 commit 也重写一条，都是「新落一条、在原主题下建索引」。&lt;/p&gt;
&lt;p&gt;回查是&lt;strong&gt;周期扫描任务&lt;/strong&gt;，不是「每条一个定时器」。它的循环就是「睡一个检查间隔 → 醒来扫一遍」，扫的是「落盘超时仍未定论」的半消息。所以一条半消息从落盘到被回查的等待 = &lt;strong&gt;超时阈值 + 扫描周期相位&lt;/strong&gt;：落盘 6 秒内没定论（&lt;code&gt;transactionTimeOut&lt;/code&gt;）才进入可回查范围，再等扫描轮询到它；所以不是固定 60 秒（观察到的这一次是在落盘后约 19 秒）。而且回查&lt;strong&gt;依赖生产者进程仍存活&lt;/strong&gt;——生产者挂了，没人回答回查。&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;%%{init: {theme: neutral}}%%
sequenceDiagram
 participant P as Producer
 participant B as Broker
 participant C as Consumer
 P-&gt;&gt;B: 第一次写（半消息）
 B-&gt;&gt;B: 写入 RMQ_SYS_TRANS_HALF_TOPIC / Q0
 Note over B: 内部主题索引已有条目
 P-&gt;&gt;P: 执行本地事务
 alt commit
 P-&gt;&gt;B: 第二次写（还原真实主题）
 Note over B: 原主题索引首次出现条目
 B--&gt;&gt;C: 可见
 else rollback
 P-&gt;&gt;B: rollback
 Note over B: 原主题永不出现条目
 else unknown
 B-&gt;&gt;B: 周期扫描回查
 B-&gt;&gt;P: 回查本地事务结果
 end&lt;/pre&gt;&lt;p&gt;事务消息解决的是&lt;strong&gt;一种&lt;/strong&gt;原子性：本地库改了、消息没发出去（或反过来）。它&lt;strong&gt;替代不了消费端幂等，也不解决跨库一致性&lt;/strong&gt;。不宣称端到端 exactly-once。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="十二收口四个功能的取舍边界"&gt;十二、收口：四个功能的取舍边界
&lt;/h2&gt;&lt;p&gt;主链路走完了。顺序、定时、事务、过滤这四个功能，都长在同一份 CommitLog 上，但它们各自付出了什么代价、代价的边界在哪，并不相同。逐个收口：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;顺序消息&lt;/strong&gt;：代价是&lt;strong&gt;并发度&lt;/strong&gt;，边界是&lt;strong&gt;队列数&lt;/strong&gt;。一个队列同一时刻只归一个消费线程，串行是锁出来的，并发上限就是队列数——队列数可以调，但调了会动路由、顺序粒度与并行度，不是无代价的旋钮（第八节）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;定时消息&lt;/strong&gt;：代价是&lt;strong&gt;精度&lt;/strong&gt;，边界是&lt;strong&gt;离散延迟级别&lt;/strong&gt;（默认 18 级）。改投的队列号就是「级别减一」，级表可配置，但选的是「哪几档」，选不出「任意时刻」，越长的延迟越粗糙（第十节）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;事务消息&lt;/strong&gt;：代价是「业务方实现回查接口」加「消息要重写一次」，边界来自框架设计。半消息先落到内部主题，commit 时重新写一条才在原主题可见；本地事务成没成，最终要业务方自己回答（第十一节）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;过滤&lt;/strong&gt;：代价是&lt;strong&gt;索引条目里的 tag hashCode&lt;/strong&gt;，边界来自索引布局。tag 订阅靠条目里的 code 在读正文前预筛；SQL92 订阅默认读候选消息的属性求值，开启 bitmap 后才多一层 bloom 预筛（第九节）。&lt;/li&gt;
&lt;/ul&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;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;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;宿主机客户端连不上 / 容器名解析失败&lt;/td&gt;
					&lt;td&gt;二（&lt;code&gt;brokerIP1&lt;/code&gt; 是通告地址）&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;显式查询不存在主题的路由，抛出 &lt;code&gt;CODE=17&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;三（&lt;code&gt;getTopicRouteInfoFromNameServer&lt;/code&gt; 探测）&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;发送报超时，消息却已经写在 broker 上&lt;/td&gt;
					&lt;td&gt;三（超时只是客户端愿意等的窗口）&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;返回了非 &lt;code&gt;SEND_OK&lt;/code&gt;，却既不抛异常也不重试&lt;/td&gt;
					&lt;td&gt;三（「发送失败」三类 + &lt;code&gt;retryAnotherBrokerWhenNotStoreOK&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;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;进程被 kill 后同一批消息又消费了一遍&lt;/td&gt;
					&lt;td&gt;七（位点上报的三条路都还没走完）&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;SQL92 订阅直接报错&lt;/td&gt;
					&lt;td&gt;九（&lt;code&gt;enablePropertyFilter&lt;/code&gt; 默认关闭）&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;设了很大的 delayLevel 却一直不投递&lt;/td&gt;
					&lt;td&gt;十（超范围级别被静默夹到第 18 级）&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;顺序消息和「加了消费实例、吞吐却不涨」都没有独立的排错条目：它们的问题不是「报错」，而是「并行度上不去」，属于能力边界而不是故障：前者的上限就是队列数，后者是实例数超过了队列数。&lt;/p&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/c04-rocketmq-architecture" target="_blank" rel="noopener"
 &gt;GYD 仓库 &lt;code&gt;c04-rocketmq-architecture&lt;/code&gt;&lt;/a&gt;，RocketMQ 集群的编排在仓库级 &lt;a class="link" href="https://github.com/renxin2024/GYD/tree/main/infra/rocketmq" target="_blank" rel="noopener"
 &gt;&lt;code&gt;infra/rocketmq/&lt;/code&gt;&lt;/a&gt;。正文里括注的名字分两类：&lt;code&gt;failmode&lt;/code&gt; / &lt;code&gt;orderly&lt;/code&gt; / &lt;code&gt;offsetprobe&lt;/code&gt; 是模块里的示例名，在 GYD 仓根用 &lt;code&gt;./gradlew :c04-rocketmq-architecture:run --args=&amp;quot;&amp;lt;示例名&amp;gt;&amp;quot;&lt;/code&gt; 跑（JDK 21 需显式指定 &lt;code&gt;JAVA_HOME&lt;/code&gt;）；&lt;code&gt;inspect-storage&lt;/code&gt; / &lt;code&gt;ha-failover&lt;/code&gt; 是三个 docker 层对照实验（端口映射、硬杀主节点、磁盘逐字节巡检）的巡检名。每个示例做了什么、各结论对应的完整输出记录、以及对照实验的做法与读数，都在该模块的 README 里。&lt;/p&gt;

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