<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>RocketMQ on Renxin's Blog</title><link>https://zh.renxinblog.cn/tags/rocketmq/</link><description>Recent content in RocketMQ on Renxin's Blog</description><generator>Hugo -- gohugo.io</generator><language>zh</language><lastBuildDate>Wed, 18 Feb 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://zh.renxinblog.cn/tags/rocketmq/index.xml" rel="self" type="application/rss+xml"/><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>