<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>分布式 on Renxin's Blog</title><link>https://zh.renxinblog.cn/tags/%E5%88%86%E5%B8%83%E5%BC%8F/</link><description>Recent content in 分布式 on Renxin's Blog</description><generator>Hugo -- gohugo.io</generator><language>zh</language><lastBuildDate>Fri, 18 Sep 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://zh.renxinblog.cn/tags/%E5%88%86%E5%B8%83%E5%BC%8F/index.xml" rel="self" type="application/rss+xml"/><item><title>第六话｜数据的存与算：Doris 的架构与机制</title><link>https://zh.renxinblog.cn/post/gyd-c06-doris-architecture/</link><pubDate>Fri, 18 Sep 2026 00:00:00 +0000</pubDate><guid>https://zh.renxinblog.cn/post/gyd-c06-doris-architecture/</guid><description>&lt;img src="https://zh.renxinblog.cn/images/gyd-c06-doris-architecture-cover.png" alt="Featured image of post 第六话｜数据的存与算：Doris 的架构与机制" /&gt;&lt;p&gt;笔者在工作中同时使用过 MySQL 和 Doris。MySQL 是事务型数据库，面向 OLTP（Online Transaction Processing）场景——比如订单处理、用户登录、库存扣减这类需要高并发、低延迟、强一致性的业务操作。Doris 是分析型数据库，面向 OLAP（Online Analytical Processing）场景——比如用户行为分析、销售报表、数据仓库这类需要扫描大量历史数据、按维度聚合统计的分析需求。&lt;/p&gt;
&lt;p&gt;Doris 最早是百度在 2017 年开源的（当时叫 Palo），后来捐赠给 Apache 基金会，定位为高并发、低延迟的 MPP（Massively Parallel Processing，大规模并行处理）分析型数据库。它不是 MySQL 的升级版，而是从存储到计算都为分析场景重新设计的系统。这篇文章以 MySQL 为参照系，讲 Doris 的架构与原理。&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;版本与口径&lt;/strong&gt;：下面的机制都以 Apache Doris 2.1.x 为准，出自 2.1 官方文档；涉及真实生产使用的地方标【生产经验】。3.x 起的存算分离形态不在此文讨论范围。&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;h2 id="一两种负载oltp-与-olap"&gt;一、两种负载：OLTP 与 OLAP
&lt;/h2&gt;&lt;p&gt;以订单表 &lt;code&gt;orders&lt;/code&gt;（字段：&lt;code&gt;order_id&lt;/code&gt; BIGINT（主键）, &lt;code&gt;user_id&lt;/code&gt; BIGINT, &lt;code&gt;region&lt;/code&gt; VARCHAR, &lt;code&gt;category&lt;/code&gt; VARCHAR, &lt;code&gt;amount&lt;/code&gt; DECIMAL, &lt;code&gt;status&lt;/code&gt; VARCHAR, &lt;code&gt;created_at&lt;/code&gt; DATETIME，几百万行）为例。&lt;/p&gt;
&lt;p&gt;业务后端每秒执行的是这类查询：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_id&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="mi"&gt;1024&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;UPDATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&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="s1"&gt;&amp;#39;paid&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_id&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="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;单次查询只涉及一行，走 &lt;code&gt;order_id&lt;/code&gt; 索引，毫秒级返回。&lt;/p&gt;
&lt;p&gt;分析师每月执行的是这类查询：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&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;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="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;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&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="s1"&gt;&amp;#39;2026-08-01&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AND&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;2026-09-01&amp;#39;&lt;/span&gt;&lt;span class="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;GROUP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;category&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;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;OLTP&lt;/strong&gt;（Online Transaction Processing，在线事务处理）：处理「单行数据的正确提交」。重点是事务、并发、单行性能。业务系统普遍采用。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;OLAP&lt;/strong&gt;（Online Analytical Processing，在线分析处理）：处理「大量数据的扫描与聚合」。重点是扫描、聚合、吞吐。报表和数据分析场景的首选。&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;OLTP（MySQL 的主场）&lt;/th&gt;
					&lt;th&gt;OLAP（Doris 的主场）&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;典型操作&lt;/td&gt;
					&lt;td&gt;单行/小批量的读取与更新&lt;/td&gt;
					&lt;td&gt;全表/大范围的扫描与聚合&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;访问方式&lt;/td&gt;
					&lt;td&gt;走索引点查，一次命中目标行&lt;/td&gt;
					&lt;td&gt;只读用到的列，跨大量行扫描&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;并发&lt;/td&gt;
					&lt;td&gt;高并发，大量短事务&lt;/td&gt;
					&lt;td&gt;大查询少，但单个查询计算量大&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;一致性&lt;/td&gt;
					&lt;td&gt;强一致，ACID 事务，出错可回滚&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;每秒事务数（TPS）、单次延迟&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;InnoDB 适合 OLTP，主要在下面这几处：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;存储结构&lt;/strong&gt;：InnoDB 把整张表按主键组织成一棵 B+ 树（聚簇索引）：叶子节点就是存放数据的页（Page，默认 16KB，落在表空间文件 .ibd 里），每行的各列连续存在同一页内，主键即索引、索引即数据。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;查询执行&lt;/strong&gt;：InnoDB 维护一个缓冲池（Buffer Pool），把频繁访问的页缓存在内存中。执行 &lt;code&gt;WHERE order_id = 1024&lt;/code&gt; 时，沿 根节点 → 中间节点 → 叶子页 逐层向下，走过的页数等于 B+ 树高度（几百万行通常 3 层左右）；根节点和上层索引页一般已命中缓冲池，真正落盘的往往只有叶子页那一次，再从中取出目标行。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;二级索引&lt;/strong&gt;：主键之外的列也建 B+ 树，叶子只存「索引列 + 主键值」，命中后回聚簇索引取整行，这就是回表。联合索引只能按最左前缀用：&lt;code&gt;WHERE col1 = 1 AND col2 = 2&lt;/code&gt; 能用 &lt;code&gt;(col1, col2)&lt;/code&gt; 索引，&lt;code&gt;WHERE col2 = 2&lt;/code&gt; 则不行（官方手册：只有左前缀能被优化器用于查找）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;事务与并发&lt;/strong&gt;：行级锁只锁被改动的行（官方特性表：锁粒度是 row）；多版本并发控制（MVCC）用回滚日志（undo log）保留旧版本，让读不阻塞写。默认隔离级别可重复读（REPEATABLE READ），保证同一事务内前后读取一致。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;水平扩展&lt;/strong&gt;：InnoDB 本质是单机存储引擎，一张表的数据完整在一个实例里，引擎自身不做单表分片（官方特性表：InnoDB 的 Cluster database support 为 No）。要多机承载，只能靠主从复制做高可用与读写分离，或靠中间件分库分表——都不是 InnoDB 单表原生的水平扩展。&lt;/p&gt;
&lt;p&gt;分析查询在 MySQL 上的表现，可以逐条对应：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;索引失效，只能全表扫描：报表常按日期、状态、类目这些列过滤分组，往往跟主键顺序对不上，B+ 树的快速定位用不上，只能逐页读取所有叶子页。&lt;/li&gt;
&lt;li&gt;行存读取整行数据：一行所有列连续存放在同一个页内。即使只查询 &lt;code&gt;amount&lt;/code&gt; 字段，也需要将整个页读入缓冲池。&lt;/li&gt;
&lt;li&gt;分组、排序需要额外开销：当聚合或 &lt;code&gt;ORDER BY&lt;/code&gt; 无法用索引顺序直接满足时，MySQL 要借助内存或临时磁盘文件做排序、分组（官方管这类操作叫 filesort）。&lt;/li&gt;
&lt;li&gt;计算资源受限于单机：所有查询共享同一个实例的 CPU、内存和缓冲池。分析大查询会挤占缓冲池空间，影响其他查询的性能。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;一条聚合查询可能同时触发多个瓶颈：全表扫描占用大量缓冲池 + 逐页读取产生大量磁盘 I/O + 排序/分组占用内存或临时文件 + 长时间占用实例资源。这是生产环境中「报表查询导致主库 CPU 告警」的典型场景【生产经验】。&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;边注&lt;/strong&gt;：MySQL 在隔离级别设计上也考虑了报表场景。官方文档在说明降低一致性以提升性能时，以批量报表为例。MySQL 对分析查询的特性有清晰认识，但其设计优先级是事务处理。&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;p&gt;Doris 是分布式的 MPP 分析型数据库，节点分两类：FE（Frontend）与 BE（Backend）。两类节点都多节点部署以消除单点，对外通过兼容 MySQL 协议的接口接入查询（官方架构文档）。这和 MySQL 相反：MySQL 的解析、优化、存储、执行全部在单个实例的进程内完成，也不做这种角色拆分。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;FE（Frontend）&lt;/strong&gt;：接收连接、解析并规划查询，并管理集群的元数据。库、表，以及分区、分桶这样的数据分布与副本位置——这一整套元数据只保存在 FE，并持久化到 FE 本地（每次元数据变更顺序写进 edit log，周期性把整份内存元数据落成 image 快照，重启时按快照加日志回放恢复）；BE 只存数据本身；节点存活与副本状态由 FE 负责心跳检测与副本修复。FE 分 Master、Follower、Observer 三种角色：Master 写元数据并调度副本，Follower 同步元数据、可在 Master 故障时接替，Observer 只读以分担读压力，多个 FE 由此保证管理层高可用。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;BE（Backend）&lt;/strong&gt;：真正存数据、算数据的一方。数据分片以列存文件落在 BE 上、各存多副本；BE 执行 FE 下发的计算算子（扫描、聚合等）。计算是&lt;strong&gt;向量化&lt;/strong&gt;执行——一次处理一批列数据而不是逐行处理（官方口径：列式内存布局 + SIMD 加速）；并以 &lt;strong&gt;Pipeline&lt;/strong&gt; 方式调度，把查询切成可由有限线程轮转执行的任务，避免一个查询占满线程。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;存算一体&lt;/strong&gt;：在 2.1 里，存储与计算发生在同一台 BE 上——数据分片存在哪台 BE，相关计算就在哪台 BE 上做，不必先把数据搬到别处（3.x 起的存算分离不在本文范围）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;数据分布&lt;/strong&gt;：Doris 把一张表分两级切开。第一级是分区（Partition），常按时间这类列做 Range 划分——比如 orders 表按 &lt;code&gt;created_at&lt;/code&gt; 每月一个分区，查询限定在某段时间时只扫对应分区、其余跳过。第二级是分桶（Bucket），在分区内再按某个键取哈希打散——比如按 &lt;code&gt;order_id&lt;/code&gt; 哈希分成若干桶。两级切到最后，每个桶就是一个 tablet：它是 Doris 存数据、做副本、调度（迁移与均衡）的最小单位。于是一张表被摊成「若干分区 × 每分区若干 tablet」，分布到多台 BE 上、每台各管一部分，tablet 又各存多副本（默认 3 副本）以防节点宕机丢数据——这是 InnoDB 单实例没有的能力，Doris 由引擎自己把单表分布出去。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;列式存储&lt;/strong&gt;：tablet 内部按列存放，查询只读用到的列、跳过无关列，不像行存那样即使只用一列也要把整行读进来。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;MPP 并行&lt;/strong&gt;：Doris 把一个查询切成执行片段，下推到持有相关 tablet 的多个 BE 上并行扫描、聚合，最后汇总，而不是由单台机器串行执行完。&lt;/p&gt;
&lt;h2 id="二数据的存tablet列存与版本合并"&gt;二、数据的存：tablet、列存与版本合并
&lt;/h2&gt;&lt;p&gt;一条数据从导入到可查询，在 Doris 里走的路径是：按分区、分桶落到某个 tablet；以列存格式写成一个不可变的 rowset，里面是一个个 segment 文件；tablet 的多副本分布在不同 BE 上；多数副本写入成功后，FE 提交事务，数据对外可见。此后每次导入都追加新的 rowset 和版本，由后台的 Compaction 把版本收敛回去。查询则反向使用这些结构：先裁剪分区，再用前缀索引定位数据块，最后只读所需的列。&lt;/p&gt;
&lt;h3 id="tablet数据落在哪台-be-上"&gt;tablet：数据落在哪台 BE 上
&lt;/h3&gt;&lt;p&gt;第一章说过，一张表先按分区、再按分桶切开。落到存储层，官方给的关系式是：&lt;strong&gt;Tablet 数 = 分区数 × 桶数 × 副本数&lt;/strong&gt;。以 orders 表为例：按 &lt;code&gt;created_at&lt;/code&gt; 每月一个分区、按 &lt;code&gt;order_id&lt;/code&gt; 哈希成 10 个桶、默认 3 副本，那么每个月就是 10 × 3 = 30 个 tablet，分散在不同 BE 上。tablet 是 Doris 存数据、做副本、做调度迁移的最小单位——副本往哪台机器放、哪个副本坏了要补，都以它为单位。&lt;/p&gt;
&lt;p&gt;这三个乘数落在建表语句里就是三行，本章后面反复用到的这张表长这样：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&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;created_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;DATETIME&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 排序键第 1 列
&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;region&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 排序键第 2 列
&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;category&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&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 列
&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;order_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&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&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;user_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&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;amount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;DECIMAL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&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;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&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;ENGINE&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;OLAP&lt;/span&gt;&lt;span class="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;DUPLICATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 排序键：必须是列定义的前 K 列
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;PARTITION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;RANGE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&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="c1"&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;PARTITION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p202608&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;2026-08-01&amp;#34;&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="s2"&gt;&amp;#34;2026-09-01&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="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;DISTRIBUTED&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;HASH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;BUCKETS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 分桶：决定落在哪个 tablet
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;PROPERTIES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;replication_num&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;3&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;-- 副本：每个 tablet 存 3 份
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;注意列的顺序和第一章那张表不一样：&lt;code&gt;created_at&lt;/code&gt;、&lt;code&gt;region&lt;/code&gt;、&lt;code&gt;category&lt;/code&gt; 提到了最前面——不是排版偏好，排序键只能取列定义的前 K 列。分区、分桶、副本三行各给出一个乘数，上面这一个分区就是 10 × 3 = 30 个 tablet；&lt;code&gt;SHOW TABLETS FROM orders&lt;/code&gt; 能把这 30 个逐条列出来，含每个副本在哪台 BE、当前版本、行数和大小。本文写作时在单机 Docker 里跑的就是这张表，单台 BE 只能存一份副本，那一行写的是 &lt;code&gt;&amp;quot;1&amp;quot;&lt;/code&gt;，tablet 数相应是每分区 10 个——其余行为与 3 副本一致。&lt;/p&gt;
&lt;p&gt;桶数不是随手填的。官方建表最佳实践的参考指标是：&lt;strong&gt;单个 tablet 的数据量保持在 1G–10G&lt;/strong&gt;。太小（桶切得过碎），每片都要单独写文件、单独记元数据，聚合效果差、FE 的元数据管理压力也大；太大（桶不够多），副本迁移和补齐的代价高，改表结构（Schema Change）、建物化视图这类要重写数据的操作，失败重试的代价也跟着单个 tablet 变大。维表这类小表，官方建议 tablet 数控制在 3–5 个，既保住一定并发，又不制造小文件。分区列方面，官方建议事实表用 date/datetime、一般每日一个分区（本例按月是为了数字好算），缓慢增长的维表可以单分区。分桶列要选&lt;strong&gt;高基数&lt;/strong&gt;（取值种类多）且分布均衡的列，比如 &lt;code&gt;order_id&lt;/code&gt;；选了低基数列，数据会挤在少数几个桶里，等于白分。不想绑定某个键时也可以用 RANDOM 分桶（不指定分桶列、随机撒），官方口径是它适合整体聚合分析、不适合高并发点查。&lt;/p&gt;
&lt;h3 id="segment按列存放与编码"&gt;segment：按列存放与编码
&lt;/h3&gt;&lt;p&gt;官方把 tablet 内部的存储层级概括为 &lt;strong&gt;Tablet → Rowset → Segment → Column → Page&lt;/strong&gt;，它们各自的用途是：&lt;strong&gt;rowset&lt;/strong&gt; 是一次导入产出的文件集合；&lt;strong&gt;segment&lt;/strong&gt; 是 rowset 里真正装数据的文件，一个 rowset 可以有一个或多个；&lt;strong&gt;column&lt;/strong&gt; 指 segment 内每一列单独组织，同一列的值连续排在一起；&lt;strong&gt;page&lt;/strong&gt; 是把一列的值按约 64KB 切出的小块，它是读取和压缩的最小单位——查询要么整页跳过，要么整页读进来解压。&lt;/p&gt;
&lt;p&gt;&lt;img alt="Doris 存储层级：Tablet → Rowset → Segment → Column → Page" class="gallery-image" data-flex-basis="534px" data-flex-grow="222" decoding="async" height="446" 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-c06-doris-architecture-storage-hierarchy.svg" width="993"&gt;&lt;/p&gt;
&lt;p&gt;按列存放带来的第二个好处是：同一列的相邻字节长得很像，可以针对性地换一种更省的记法，这就是编码。行存做不到这点——一行的各列挤在一起，相邻字节一会儿是字符串、一会儿是金额、一会儿是时间，没有规律可榨。编码和压缩是两道工序：&lt;strong&gt;编码&lt;/strong&gt;挑一种适合这一列数据规律的表示方式，&lt;strong&gt;压缩&lt;/strong&gt;在编码后的字节流上再跑一道通用无损压缩。Doris 按列的类型和取值分布自动挑编码：&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;PLAIN&lt;/td&gt;
					&lt;td&gt;没有规律可榨的列（官方口径：一般数值）&lt;/td&gt;
					&lt;td&gt;原样存&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;DICT（字典）&lt;/td&gt;
					&lt;td&gt;&lt;strong&gt;低基数&lt;/strong&gt;列，即不同取值很少（&lt;code&gt;status&lt;/code&gt; 只有几个状态、&lt;code&gt;region&lt;/code&gt; 只有几十个地区）&lt;/td&gt;
					&lt;td&gt;建一张「值 → 编号」字典，数据里只存短编号，不再重复存整个字符串&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;RLE（游程）&lt;/td&gt;
					&lt;td&gt;连续重复值多的列（官方举的例子是布尔列）&lt;/td&gt;
					&lt;td&gt;记「值 + 连续出现次数」，而不是把同一个值写很多遍&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;BIT_SHUFFLE&lt;/td&gt;
					&lt;td&gt;整数列（&lt;code&gt;order_id&lt;/code&gt;、时间戳）&lt;/td&gt;
					&lt;td&gt;把所有值的第 1 个字节放在一起、第 2 个字节放在一起；整数高位往往相同，于是相同字节连成大片，后面压缩特别有效&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;FOR（frame-of-reference，基准值）&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;编码之后才轮到通用压缩，默认 LZ4F，可以换成 ZSTD 等，官方给的口径是 ZSTD 典型能把表压到原来的 1/5 到 1/10；反过来，一道 page 压缩后省不到 10%，Doris 就干脆不压，免得解压白烧 CPU。&lt;/p&gt;
&lt;p&gt;收益直接落在读取上：查询只碰涉及列的 page，表越宽、查询涉及的列越少，省得越多。每个 page 带一份 &lt;strong&gt;zone map&lt;/strong&gt;——这一页该列的最大值、最小值、是否含 NULL，条件跟这个范围不沾边的页&lt;strong&gt;不解压就整页跳过&lt;/strong&gt;，这一份是内建的、每列都有。等值查询还想跳得更狠，就得自己加索引了：&lt;strong&gt;bloom filter 索引不是自动的&lt;/strong&gt;，要在建表属性里点名（&lt;code&gt;&amp;quot;bloom_filter_columns&amp;quot; = &amp;quot;user_id, order_id&amp;quot;&lt;/code&gt; 这类写法），Doris 才为这几列逐 page 各建一个 bloom filter。有了它，&lt;code&gt;=&lt;/code&gt; 和 &lt;code&gt;IN&lt;/code&gt; 就能先问一句「这一页里可能有这个值吗」，回答「肯定没有」的页直接跳过。bloom filter 只有假阳性、没有假阴性，说「没有」就是真没有，所以拿它跳页是安全的；代价是对高基数列（&lt;code&gt;user_id&lt;/code&gt;、订单号这类）跳过率高，对低基数列基本没用，Tinyint、Float、Double 三类还建不了。属性一旦写上，查询用到时自动生效、SQL 不用改写法，但它对 &lt;code&gt;=&lt;/code&gt; 和 &lt;code&gt;IN&lt;/code&gt; 之外的条件不帮忙。&lt;/p&gt;
&lt;p&gt;代价也要如实说：取一整行要把所有列各读一次，每列一次独立 I/O，官方文档直言宽表按主键点查会付出 N 倍 I/O，高频 &lt;code&gt;SELECT *&lt;/code&gt; 点查是列存的弱项。&lt;/p&gt;
&lt;h3 id="排序键与前缀索引tablet-内部怎么找到那一段数据"&gt;排序键与前缀索引：tablet 内部怎么找到那一段数据
&lt;/h3&gt;&lt;p&gt;一提到索引，从 MySQL 过来的人默认是 B+ 树：多层节点、叶子之间互相串联、每个二级索引各是一棵树、按查询条件一个个建。Doris 的&lt;strong&gt;前缀索引&lt;/strong&gt;形状简单得多，也单薄得多，它就是一份&lt;strong&gt;排好序的定长数组&lt;/strong&gt;，每项存两样东西：一小段拼接起来的排序键值，和这一段值所在的&lt;strong&gt;起始行号&lt;/strong&gt;。查找时在这个有序数组上二分，定位到目标值落在哪一块，拿到起止行号，其余块不读。它不长成树，也不为每个查询条件各建一套——一张表只有这一套，代价是能力比 B+ 树窄很多。&lt;/p&gt;
&lt;p&gt;它挂在「行」这个维度上，容易和上一节的 page 混：page 是&lt;strong&gt;一列&lt;/strong&gt;的值按 64KB 切出来的，每列各切各的；前缀索引的逻辑数据块是&lt;strong&gt;按行数&lt;/strong&gt;划的，跨所有列共同。两者粒度不重合。&lt;/p&gt;
&lt;p&gt;排序键就是建表时 Key 子句里那几列，官方明确 &lt;strong&gt;Key 列必须是表定义的前 K 列&lt;/strong&gt;（从第一列开始连续声明），单个 tablet 内的数据按这几列整体排序。有序是这一切的前提：一个条件对应的行必然连续成一段，才谈得上「记住每段起点、只读那一段」；无序就只能是全读之后逐行判断。官方对这套存储结构的说法是「类似 SSTable」——按键有序、写完不改、后台合并的不可变文件格式，Java 生态里 RocksDB、LevelDB、HBase 用的都是这一类。&lt;/p&gt;
&lt;p&gt;数组每项存的那段值叫&lt;strong&gt;短 key（short key）&lt;/strong&gt;，取法是把排序键按声明顺序拼接，累加到 &lt;strong&gt;36 字节&lt;/strong&gt;为止。上限只到 36 字节，是为了让整份索引小到能常驻内存：一亿行按每块若干行算下来仍有几万条索引项，每项再宽就成了查索引本身要扫一遍的大对象。每块到底多少行，2.1 的文档只写了「按行数划成逻辑数据块」，没给具体数字，这里也就不猜。碰到变长列时规则收紧：&lt;strong&gt;VARCHAR 可以进短 key，但只截取它自己的头几个字节，且到此为止，后面的列一概不再进入前缀索引&lt;/strong&gt;。官方给的两组数正好说明这条的分量：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;user_id BIGINT, age INT, message VARCHAR(100)&lt;/code&gt;：短 key = &lt;code&gt;user_id&lt;/code&gt; 的 8 字节 + &lt;code&gt;age&lt;/code&gt; 的 4 字节 + &lt;code&gt;message&lt;/code&gt; 的头 20 字节，凑满 36；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;user_name VARCHAR(20), user_id BIGINT&lt;/code&gt;：短 key 只有 &lt;code&gt;user_name&lt;/code&gt; 这 20 字节，没到 36 也停，因为 VARCHAR 之后的 &lt;code&gt;user_id&lt;/code&gt; 已经进不来了。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;对照 tablet 那节贴出的建表语句还有一个容易找错地方的地方：整份 DDL 里&lt;strong&gt;找不到「前缀索引」这个词&lt;/strong&gt;，也没有任何子句能调它——短 key 完全由 &lt;code&gt;DUPLICATE KEY(created_at, region, category)&lt;/code&gt; 那一行推出来，排查「我的前缀索引建成了什么样」时，要看的是表定义，不是索引列表。&lt;/p&gt;
&lt;p&gt;这条规则决定了 Key 的排列纪律，也就是&lt;strong&gt;最左匹配&lt;/strong&gt;的实际含义：条件里出现排序键&lt;strong&gt;连续的前几列&lt;/strong&gt;才走得通前缀索引，跳过中间一列，后面的列就匹配不上——这一点和 MySQL 的联合索引同构，直觉可以照搬。所以官方的建议是把最常过滤的定长列（尤其时间列）排在最前面，且不必贪多，超过三四列基本是白写。&lt;/p&gt;
&lt;p&gt;&lt;img alt="前缀索引：在有序的短 key 数组上二分，定位到一段行号" class="gallery-image" data-flex-basis="447px" data-flex-grow="186" decoding="async" height="536" 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-c06-doris-architecture-prefix-index-lookup.svg" width="1000"&gt;&lt;/p&gt;
&lt;p&gt;这条纪律落到 orders 表上有个不好听但必须说的结论。前面把它排成 &lt;code&gt;created_at&lt;/code&gt;、&lt;code&gt;region&lt;/code&gt;、&lt;code&gt;category&lt;/code&gt;，&lt;code&gt;created_at&lt;/code&gt; 是 DATETIME、定长，进得来；&lt;code&gt;region&lt;/code&gt; 是 VARCHAR，只能以头几个字节的形式参与；&lt;code&gt;category&lt;/code&gt; 则&lt;strong&gt;完全不在前缀索引里&lt;/strong&gt;，&lt;code&gt;WHERE category = 'food'&lt;/code&gt; 走不到它。也就是说这条查询从前缀索引得到的只有时间那一段。剩下两段要靠对某一列单独建的索引来补。另外，排序键放这三列不干涉分桶：明细模型不要求分桶列与 Key 列一致（那是聚合、主键模型的约束），分桶仍然可以用 &lt;code&gt;order_id&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;Key 在聚合和主键模型里还兼作聚合键、去重键，那是另一层语义；本章只用到它的排序作用。前缀索引到底过滤掉多少行，可在查询 profile 的 &lt;code&gt;RowsKeyRangeFiltered&lt;/code&gt; 指标里核对。&lt;/p&gt;
&lt;h3 id="索引体系哪些内建哪些要自己建"&gt;索引体系：哪些内建，哪些要自己建
&lt;/h3&gt;&lt;p&gt;上一节那份前缀索引是自动来的，建表时不用写任何东西。Doris 还有几种索引必须你点名，实际用表时最常碰到的就是「给某一列开 bloom filter」这类设置。它们和自动的那两种放在一起是这样：&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;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;前缀索引&lt;/td&gt;
					&lt;td&gt;排序键（Key）的前 36 字节&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;ZoneMap 索引&lt;/td&gt;
					&lt;td&gt;所有列，无一例外&lt;/td&gt;
					&lt;td&gt;每个 page 一份 min / max / 是否含 NULL&lt;/td&gt;
					&lt;td&gt;内建，自动&lt;/td&gt;
					&lt;td&gt;等值、范围、&lt;code&gt;IS NULL&lt;/code&gt; 的整页跳过&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;BloomFilter 索引&lt;/td&gt;
					&lt;td&gt;你点名的若干列&lt;/td&gt;
					&lt;td&gt;每个 page 一个&lt;/td&gt;
					&lt;td&gt;表属性 &lt;code&gt;bloom_filter_columns&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;只有 &lt;code&gt;=&lt;/code&gt; 和 &lt;code&gt;IN&lt;/code&gt; 的整页跳过&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;NGram BloomFilter 索引&lt;/td&gt;
					&lt;td&gt;你点名的字符串列&lt;/td&gt;
					&lt;td&gt;每个 page 一个&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;INDEX ... USING NGRAM_BF&lt;/code&gt;，可配 &lt;code&gt;gram_size&lt;/code&gt;、&lt;code&gt;bf_size&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;只有字符串 &lt;code&gt;LIKE '%…%'&lt;/code&gt; 的整页跳过&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;倒排索引&lt;/td&gt;
					&lt;td&gt;你点名的字符串、数值、日期、数组列&lt;/td&gt;
					&lt;td&gt;值或词项 → 命中的行号&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;INDEX ... USING INVERTED&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;等值、范围、关键词匹配，能定位到行&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;这条分界决定了你能优化什么。内建的两种没得选，能调整的只有「数据按什么顺序排」，也就是上一节；要自己建的三种是在排序之外&lt;strong&gt;再为个别列补一份结构&lt;/strong&gt;，所以选择全在「给哪一列、建哪一种」上，代价也全在这里——每补一份，这一列就多占空间、每次写入都要跟着维护，所以每一种都把自己限制在能真正省下读取的那几种条件上，表里「只加速什么」那一栏写的就是这条边界。&lt;/p&gt;
&lt;p&gt;要自己建的这三种里，前两种属于&lt;strong&gt;跳数索引&lt;/strong&gt;（skip index，ClickHouse 里同名同义的那个说法）。它不告诉你数据在哪，只对「这一页」回答一个是或否：这里头肯定没有，于是整块跳过。所以它的收益完全取决于数据分布和条件形状。分析型扫描的瓶颈通常是「读了太多不相关的页」而不是「找不到那一行」，这也是分析型表不必像 OLTP 那样按查询模式铺一串二级索引的原因——ZoneMap 是同一思路的内建版本，BloomFilter 是你手动加强的那一版。加强版也有自己的边界：BloomFilter 只对&lt;strong&gt;高基数&lt;/strong&gt;列有意义，低基数列（只有几个取值的 &lt;code&gt;status&lt;/code&gt;）几乎每页都含目标值，一个也跳不掉；Tinyint、Float、Double 这三种类型官方直接不让建。&lt;/p&gt;
&lt;p&gt;BloomFilter 只认完整值，所以帮不了模糊查询。&lt;strong&gt;NGram BloomFilter 补的就是这个洞&lt;/strong&gt;：把字符串按 N 个连续字符滑窗切开，&lt;code&gt;shanghai&lt;/code&gt; 在 &lt;code&gt;gram_size = 3&lt;/code&gt; 时切成 sha、han、ang、ngh、gha、hai 这一串片段，每个片段作为一个值塞进这一页的 BloomFilter；查询 &lt;code&gt;LIKE '%ang%'&lt;/code&gt; 时把 &lt;code&gt;ang&lt;/code&gt; 也照切一遍，逐个问「在不在过滤器里」，只要有一个不在，这一页就整块跳过。官方那条限制由此而来——&lt;strong&gt;LIKE 里连续的字符数要够到 &lt;code&gt;gram_size&lt;/code&gt;&lt;/strong&gt;，短于 N 就切不出一个完整片段，索引无从问起。它和上一段那个按完整值建的 BloomFilter 抢的是同一个位置，同一列上两者只能有一个。&lt;/p&gt;
&lt;p&gt;倒排索引是这几种里能力最宽的，因为它不走「跳过整块」那条路，而是直接给出&lt;strong&gt;哪些行命中&lt;/strong&gt;。「哪些行含某个值」这张表按值组织，等值、范围、&lt;code&gt;IN&lt;/code&gt; / &lt;code&gt;NOT IN&lt;/code&gt; 都查得动它，数组列可以按元素过滤。字符串列加上分词器才有全文能力：&lt;code&gt;parser&lt;/code&gt; 取 &lt;code&gt;english&lt;/code&gt; / &lt;code&gt;chinese&lt;/code&gt; / &lt;code&gt;unicode&lt;/code&gt;，中文还能选 &lt;code&gt;fine_grained&lt;/code&gt;（细粒度）或 &lt;code&gt;coarse_grained&lt;/code&gt;（粗粒度）；之后 &lt;code&gt;WHERE&lt;/code&gt; 里可以写 &lt;code&gt;MATCH_ANY&lt;/code&gt;（含任一关键词）、&lt;code&gt;MATCH_ALL&lt;/code&gt;（每个关键词都得有，不管顺序）、&lt;code&gt;MATCH_PHRASE&lt;/code&gt;（这些词得挨着、且按这个顺序出现）。它也有建不了的时候：聚合模型，以及没开 &lt;strong&gt;Merge-on-Write&lt;/strong&gt;（主键表的一种写入模式，写入时就把同键的行合并掉）的主键表，只有 Key 列能建倒排索引；&lt;code&gt;FLOAT&lt;/code&gt;、&lt;code&gt;DOUBLE&lt;/code&gt; 因为精度问题不支持。BITMAP 索引官方说已被倒排索引取代，不必再单独学。&lt;/p&gt;
&lt;p&gt;它到底能快多少，官方给的对比是关键词检索比等价的 &lt;code&gt;LIKE '%…%'&lt;/code&gt; 快 7–13 倍，样本是一百万行 HackerNews 数据，同一份数据建索引本身花了一两秒——这两个数得一起看才知道量级。这个量级我在本地一套单 FE、单 BE 的 2.1.11 上复现过：三百万行，&lt;code&gt;title&lt;/code&gt; 平均 121 字节、合计约 363 MB，八个桶，其中 1% 的行含一个生造词。同一个条件，&lt;code&gt;WHERE title LIKE '%那个词%'&lt;/code&gt; 的服务端耗时稳定在 36–54 ms，写成 &lt;code&gt;MATCH_ANY&lt;/code&gt; 是 5–6 ms，&lt;strong&gt;约 7 倍&lt;/strong&gt;。另外两个对照数才说清这钱花在哪：把 &lt;code&gt;LIKE&lt;/code&gt; 里的词换成一个必然不命中的，耗时不变，还是 33 ms，和强制把整列扫一遍的 &lt;code&gt;sum(length(title))&lt;/code&gt;（29–35 ms）基本相等——&lt;code&gt;LIKE&lt;/code&gt; 的成本就是全列扫描的成本，跟命中多少无关；而把关键词换成每行都出现的 &lt;code&gt;doris&lt;/code&gt;，&lt;code&gt;LIKE&lt;/code&gt; 28 ms、&lt;code&gt;MATCH_ANY&lt;/code&gt; 12 ms，差距缩到两倍出头：命中面铺满时，索引省下的扫描抵不掉它为命中行逐个产出行号的开销——后半句是按机制推的，我测到的只有那两个数本身。&lt;strong&gt;倒排索引的收益来自命中少，不是无条件快&lt;/strong&gt;，选列的时候这条比任何倍数都重要。&lt;/p&gt;
&lt;p&gt;最后是怎么补上去。三种索引的建法长在三个不同的地方，对照着 orders 表看一眼就分清了：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- BloomFilter：表属性，列名列表。没有这一行，这一列就没有 bloom filter
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;bloom_filter_columns&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;user_id, order_id&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;-- NGram BloomFilter：INDEX 子句，只服务字符串列的 LIKE
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INDEX&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;idx_region&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;USING&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;NGRAM_BF&lt;/span&gt;&lt;span class="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;PROPERTIES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;gram_size&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;3&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;bf_size&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;1024&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;-- 倒排索引：同一个 INDEX 子句换一种类型；事后加的要对已有数据补建一次
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INDEX&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;idx_status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;USING&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;INVERTED&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;BUILD&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INDEX&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;idx_status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&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;code&gt;CREATE INDEX&lt;/code&gt; 里；后两条是 &lt;code&gt;INDEX&lt;/code&gt; 子句，&lt;code&gt;ALTER TABLE orders ADD INDEX idx_status (status) USING INVERTED&lt;/code&gt; 与 &lt;code&gt;CREATE INDEX&lt;/code&gt; 等价。字符串列要做全文检索，再在 &lt;code&gt;PROPERTIES&lt;/code&gt; 里加 &lt;code&gt;&amp;quot;parser&amp;quot; = &amp;quot;chinese&amp;quot;&lt;/code&gt; 这类分词器，纯等值和范围过滤不用加。&lt;/p&gt;
&lt;p&gt;这里有个容易踩的坑。改 &lt;code&gt;bloom_filter_columns&lt;/code&gt; 不是改一行元数据就完事，Doris 会为它起一个 &lt;strong&gt;schema change 作业&lt;/strong&gt;——所谓 schema change 是 Doris 的在线改表流程：数据是一个个不可变文件，改结构就得由 FE 调度、BE 把这些文件重写或补齐，所以它是异步的。在 2.1.11 上，改完立刻到 &lt;code&gt;SHOW ALTER TABLE COLUMN&lt;/code&gt; 里能看到一个 PENDING 的作业，表停在 &lt;code&gt;SCHEMA_CHANGE&lt;/code&gt; 状态，这期间再发 &lt;code&gt;CREATE INDEX&lt;/code&gt; 会被直接拒掉（&lt;code&gt;Table[orders]'s state(SCHEMA_CHANGE) is not NORMAL&lt;/code&gt;），作业跑完属性才生效；小表是几秒，大表就是分钟级的事。倒排索引建完&lt;strong&gt;还要对已有数据跑一次 &lt;code&gt;BUILD INDEX&lt;/code&gt;&lt;/strong&gt;，漏了这一步，只有新写入的数据带着索引。&lt;/p&gt;
&lt;p&gt;核对的时候还有一处容易找错：&lt;code&gt;SHOW INDEX FROM orders&lt;/code&gt; 只列 &lt;code&gt;INDEX&lt;/code&gt; 子句建的东西（上面两个 &lt;code&gt;INVERTED&lt;/code&gt;、那个 &lt;code&gt;NGRAM_BF&lt;/code&gt;），&lt;strong&gt;&lt;code&gt;bloom_filter_columns&lt;/code&gt; 不在里面&lt;/strong&gt;，得回 &lt;code&gt;SHOW CREATE TABLE orders&lt;/code&gt; 看表属性。&lt;/p&gt;
&lt;h3 id="物化视图把聚合提前算好"&gt;物化视图：把聚合提前算好
&lt;/h3&gt;&lt;p&gt;索引省的是「读」，还有一类开销索引帮不上：同样的聚合，每次查询都重算一遍。报表每天按地区、类目出一次销售额，明细有几亿行，每天都要把这几亿行重扫重聚一次。物化视图的思路是把这类查询的结果提前算好存下来，查询来了直接取。Doris 2.1 里它有两种形态，分界在「谁来维护这份预计算数据、什么时候维护」。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;同步物化视图&lt;/strong&gt;：跟着导入走。建法是一条 &lt;code&gt;CREATE MATERIALIZED VIEW&lt;/code&gt;，定义里只写想提前算什么：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;MATERIALIZED&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VIEW&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;mv_region_amt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="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;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&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;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="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;GROUP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;category&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;本文在 2.1.11 上对 orders 表执行，&lt;code&gt;SHOW ALTER TABLE MATERIALIZED VIEW&lt;/code&gt; 里能看到一个 FINISHED 的作业，输出里有一栏 &lt;code&gt;RollupIndexName&lt;/code&gt;——它的实现就是基表上的一个预聚合副本，导入时同步更新，官方的口径是「只有同步完成后，相关命令才会结束」，无需人工维护。代价也在这里：每一张同步物化视图都让导入多写一份、多聚一次，官方直言单表上物化视图过多会拖慢导入。&lt;/p&gt;
&lt;p&gt;它生效的方式是&lt;strong&gt;透明改写&lt;/strong&gt;：查询照常写基表的名字，Doris 自动匹配能否用物化视图回答。把上一节那条不带时间条件的聚合拿来 &lt;code&gt;EXPLAIN&lt;/code&gt;，扫描节点显示 &lt;code&gt;TABLE: demo.orders(mv_region_amt)&lt;/code&gt;——语句里写的是 orders，实际读的是物化视图。验证命中与否看的也是 &lt;code&gt;EXPLAIN&lt;/code&gt;，文档把改写结果分了三种标记（&lt;code&gt;MaterializedViewRewriteSuccessAndChose&lt;/code&gt; 命中且选用、&lt;code&gt;MaterializedViewRewriteSuccessButNotChose&lt;/code&gt; 命中但按成本未选、&lt;code&gt;MaterializedViewRewriteFail&lt;/code&gt; 未命中），排查「为什么没命中」时到计划里找它们。&lt;/p&gt;
&lt;p&gt;透明改写有一条本文实测撞出来的边界。同一条聚合加上 &lt;code&gt;WHERE created_at &amp;gt;= '2026-08-01' AND created_at &amp;lt; '2026-09-01'&lt;/code&gt; 之后，计划回落到基表：这份物化视图里没有 &lt;code&gt;created_at&lt;/code&gt; 这一列，带时间条件的查询它回答不了。&lt;strong&gt;提前算好的口径必须盖得住查询的过滤条件&lt;/strong&gt;——分组列决定哪些谓词能落到这份预计算数据上，建视图时把常用于过滤的列一并考虑，否则存储白付、改写永不命中。它的能力边界也来自同一个原因：只支持单表（官方明确不支持 JOIN），聚合形式限于 &lt;code&gt;sum&lt;/code&gt; 这类常规聚合和 &lt;code&gt;bitmap_union&lt;/code&gt;（用它可以把 &lt;code&gt;count(distinct)&lt;/code&gt; 变快，官方给的标准做法）；它也不像普通表那样能直接 &lt;code&gt;SELECT&lt;/code&gt;——本文实测对它查询返回 &lt;code&gt;Unknown table&lt;/code&gt;，这是它与异步物化视图最直观的一个区别。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;异步物化视图&lt;/strong&gt;是 2.1 引入的另一种形态：它自己就是一张内表（底层是 Duplicate 模型），定义里可以写多表 JOIN，基表模型不限，数据靠创建时注册的刷新任务重算。本文实测的一条：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;MATERIALIZED&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VIEW&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;amv_orders_region&lt;/span&gt;&lt;span class="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;BUILD&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IMMEDIATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;REFRESH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;AUTO&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;MANUAL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&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;PROPERTIES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;replication_num&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;1&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;-- 单 BE 环境的限制，多 BE 集群不必写
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;amt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;cnt&lt;/span&gt;&lt;span class="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;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;GROUP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;region&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;建完 10 秒内直接 &lt;code&gt;SELECT&lt;/code&gt; 已有数据。刷新方式三种，写在建表语句里：&lt;code&gt;ON MANUAL&lt;/code&gt; 手动触发（&lt;code&gt;REFRESH MATERIALIZED VIEW … AUTO&lt;/code&gt;）、&lt;code&gt;ON SCHEDULE&lt;/code&gt; 定时（&lt;code&gt;EVERY 2 HOUR&lt;/code&gt; 这类）、&lt;code&gt;ON COMMIT&lt;/code&gt; 基表数据一变就刷。它同样支持透明改写（官方称基于 SPJG——SELECT-PROJECT-JOIN-GROUP-BY——模式的改写算法），代价是数据与基表&lt;strong&gt;最终一致&lt;/strong&gt;：官方口径是「无法实时同步」，通常用于对时效性要求不高的场景，「一般是 T+1 的数据」。&lt;/p&gt;
&lt;p&gt;两种形态的选型其实是三个问题：口径是单表还是多表——单表才能用同步，多表只能异步；结果要实时还是容忍延迟——导入即更新是同步的语义，异步是最终一致；写入放大付在何时——同步的每个导入都多写一份，异步把代价挪到刷新时刻。它和聚合模型的关系也在这里收拢：聚合模型在建模时就锁定了数据的聚合口径，物化视图是在已有表上给某几个固定口径补一份预计算——两者都是拿空间和写入换查询时间。索引省下的「读」，物化视图省的是「算」。&lt;/p&gt;
&lt;h3 id="每次导入产生一个新版本rowset"&gt;每次导入产生一个新版本：rowset
&lt;/h3&gt;&lt;p&gt;Doris 的写入属于 LSM 树那一套路子：&lt;strong&gt;写操作从不原地修改已有文件&lt;/strong&gt;，每次导入产生一个新的、不可变的 rowset，带一个版本号。于是一个 tablet 的数据就是一串按版本排开的 rowset。&lt;/p&gt;
&lt;p&gt;代价落在读取上：查询要把这些 rowset &lt;strong&gt;在读取时合并&lt;/strong&gt;起来看——新版本、被删掉的旧版本，都靠版本号和删除标记判断，这就是读时合并。rowset 少时无所谓，但 Stream Load、Group Commit 这类高频导入会让版本数涨得很快。官方给了硬限制：单 tablet 版本数超过 &lt;code&gt;max_tablet_version_num&lt;/code&gt;（默认 500）后导入直接失败，报 &lt;strong&gt;-235&lt;/strong&gt;，官方自己说这是用户群里最常见的生产事故之一。版本堆积还有两笔账：查询要合并的路径变长；被删除的行不会立刻消失，它们以 &lt;strong&gt;tombstone（删除标记）&lt;/strong&gt; 的形式留在旧 rowset 里，每次读都要额外过滤。&lt;/p&gt;
&lt;h3 id="compaction把版本收敛回去"&gt;Compaction：把版本收敛回去
&lt;/h3&gt;&lt;p&gt;把一串小 rowset 合并成少数几个大 rowset 的后台动作就是 Compaction。理解它的关键是一条分界线 &lt;strong&gt;cumulative point&lt;/strong&gt;：&lt;strong&gt;cumulative compaction&lt;/strong&gt; 高频、轻量，只合并 cumulative point 以上的新进小 rowset，先把刚灌进来的碎片归拢；&lt;strong&gt;base compaction&lt;/strong&gt; 低频、重量，把 cumulative point 以下的所有 rowset 合并成一个 &lt;strong&gt;base rowset&lt;/strong&gt;（这个 tablet 的底座数据）。cumulative 的合并结果一旦超过 promotion size（默认 1GB），下一轮就有资格并进 base。&lt;/p&gt;
&lt;p&gt;&lt;img alt="rowset 版本累积与 cumulative 合并" class="gallery-image" data-flex-basis="844px" data-flex-grow="351" decoding="async" height="444" 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-c06-doris-architecture-rowset-compaction.svg" width="1562"&gt;&lt;/p&gt;
&lt;p&gt;BE 给每个 tablet 打一个「乱」的分数来决定先合并谁，分数大致等于查询在这个 tablet 上要走的合并路径数，谁最乱先处理谁。合并完成后，读时合并的路径变短、小文件数回落、tombstone 也随旧 rowset 一起被清掉。&lt;/p&gt;
&lt;h3 id="副本多数派成功才算成功"&gt;副本：多数派成功才算成功
&lt;/h3&gt;&lt;p&gt;导入时，数据并行写入各副本所在的 BE，每个 BE 写完后向 FE 确认。FE 统计成功写入的副本数，&lt;strong&gt;超过总副本数的一半即视为导入成功&lt;/strong&gt;——三副本的表，至少 2 个副本写成功。随后 FE 提交导入事务，数据对外可见，这就是第一章说的「单批导入原子可见」的由来。&lt;/p&gt;
&lt;p&gt;没写成功的副本不会永久缺数据，由后台异步复制补齐，最终各副本一致；副本的检测与修复由 FE 负责，就是第一章「节点管理」那条职责。&lt;/p&gt;
&lt;h3 id="查询怎么用这套结构"&gt;查询怎么用这套结构
&lt;/h3&gt;&lt;p&gt;回到第一章那条聚合查询。它在存储层依次经过三道收缩：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;分区裁剪&lt;/strong&gt;：&lt;code&gt;created_at&lt;/code&gt; 的范围条件把扫描目标收窄到 8 月这一个分区，其余分区不碰。这一步 &lt;code&gt;EXPLAIN&lt;/code&gt; 里看得见：这条查询的计划里写着 &lt;code&gt;partitions=1/3 (p202608)&lt;/code&gt;，三个分区只有这一个进了扫描范围；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;前缀索引&lt;/strong&gt;：排序前缀把分区内的扫描范围缩到少数数据块；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;列裁剪与 zone map&lt;/strong&gt;：只读 &lt;code&gt;region&lt;/code&gt;、&lt;code&gt;category&lt;/code&gt;、&lt;code&gt;amount&lt;/code&gt; 三列的 page，zone map 再把条件不沾边的 page 整页跳过。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;读取量从「全表、所有列」收缩到「一个分区里、若干数据块的三列」。数据在磁盘上已经被摆成利于这种扫描的形状：分布开的 tablet、按列存放并逐列编码、按排序键有序、按版本追加再由 Compaction 收敛。&lt;/p&gt;
&lt;p&gt;但一条查询要同时在几十上百个 tablet 上执行这三步、再把结果汇总，光靠存储层的组织还不够，得有一整套把计算铺到多机上的执行机制。&lt;/p&gt;
&lt;h2 id="三数据的算fragment向量化与-pipeline"&gt;三、数据的算：fragment、向量化与 pipeline
&lt;/h2&gt;&lt;p&gt;存储层摆好数据只是回答了一半。本章沿一条 SQL 的执行过程走完另一半：计划怎么拆成并行单元、单元之间怎么交换数据、算子在什么线程模型上跑、以及这份计划是谁做的决定。主角仍是第一章那条聚合查询，中途引入一张几行的维表 &lt;code&gt;regions&lt;/code&gt; 来承载 join 的例子。本章的语句证据都来自作者在本机 Docker 里跑的 2.1.11 单 FE 单 BE 集群，&lt;code&gt;EXPLAIN&lt;/code&gt; 的原文为节选、行内注释为本文所加。&lt;/p&gt;
&lt;h3 id="先把计划拆开fragment-与两阶段聚合"&gt;先把计划拆开：fragment 与两阶段聚合
&lt;/h3&gt;&lt;p&gt;拿到 SQL，Doris 不是直接开跑，而是先编译出一份执行计划。计划的基本单位叫 &lt;strong&gt;fragment&lt;/strong&gt;：FE 把一条 SQL 拆成若干个 fragment，每个 fragment 是发给 BE 的最小执行单元；fragment 之间的数据传递经过 &lt;strong&gt;exchange&lt;/strong&gt;（数据交换节点）。这些结构在 &lt;code&gt;EXPLAIN&lt;/code&gt; 的输出里全部可见——上一章那张 orders 表、那条八月聚合，计划的关键部分长这样（原文列名后的 &lt;code&gt;[#N]&lt;/code&gt; 内部编号已略去）：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;PLAN FRAGMENT 2
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;PARTITION: HASH_PARTITIONED: order_id -- 这个单元处理的数据分布：分桶列
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 0:VOlapScanNode -- 扫描节点
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; partitions=1/3 (p202608) -- 三个分区只有 8 月进入计划
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; tablets=10/10, tabletList=11127,11129, ... -- 该分区 10 个 tablet 全部参与
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 1:VAGGREGATE (update serialize) -- 局部聚合：扫完数据就地先加一遍
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; output: partial_sum(amount)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; group by: region, category
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; STREAM DATA SINK
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; EXCHANGE ID: 02
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; HASH_PARTITIONED: region, category -- 部分和按分组键重分布
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;PLAN FRAGMENT 1
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;PARTITION: HASH_PARTITIONED: region, category -- 接收方按同样的键接收
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 3:VAGGREGATE (merge finalize) -- 最终聚合：把各处的部分和合并
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; output: sum(partial_sum(amount))
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;先看 &lt;code&gt;PARTITION&lt;/code&gt; 那一行：fragment 声明自己处理哪一份分布，扫描 fragment 的分布就是分桶列 &lt;code&gt;order_id&lt;/code&gt;——第二章说 tablet 按分桶列哈希散开，这一行就是那句话在计划里的样子。&lt;code&gt;partitions=1/3&lt;/code&gt; 和 &lt;code&gt;tablets=10/10&lt;/code&gt; 也都是第二章的旧相识：分区裁剪和 tablet 列表，FE 在计划阶段就把扫描范围定到了这 10 个 tablet 上。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;VAGGREGATE&lt;/code&gt; 出现了两次，这件事比看起来重要。聚合被拆成两阶段执行：扫描完每个 tablet，先就地按 &lt;code&gt;region, category&lt;/code&gt; 求一遍部分和（&lt;code&gt;update serialize&lt;/code&gt;，输出列叫 &lt;code&gt;partial_sum&lt;/code&gt;），再把部分和按分组键重分布，由另一个 fragment 合并出最终结果（&lt;code&gt;merge finalize&lt;/code&gt;）。为什么要两阶段？分布式的聚合必须有人把散在各处的中间结果收拢，直接把明细行全部传走，网络传的就是全部行；先局部聚合再传，网络上传的是「每个分组一行部分和」。拿本章实测的 bench 表算这笔账：300 万行、8 个桶，若分组数是 1 万，两阶段聚合让网络上跑的是 1 万 × BE 数行，而不是 300 万行——传输量从行数级降到分组数级，这一步省不出意义时（分组数接近行数，比如按用户 ID 分组），优化器可以退回单阶段，这也是它按成本选优的一部分。整个计划还有第三个 fragment（编号 0），负责把各处的最终结果收拢返回客户端，不再展开。&lt;/p&gt;
&lt;p&gt;&lt;img alt="两阶段聚合与 shuffle 的数据流" class="gallery-image" data-flex-basis="370px" data-flex-grow="154" decoding="async" height="648" 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-c06-doris-architecture-fragment-flow.svg" width="1000"&gt;&lt;/p&gt;
&lt;h3 id="join-怎么执行buildprobe-与三种数据分发"&gt;join 怎么执行：build、probe 与三种数据分发
&lt;/h3&gt;&lt;p&gt;聚合讲完讲 join。两张表 join 的标准动作是哈希连接：把其中一侧的连接键逐行读出、在内存里建一张哈希表——就是 Java 里 &lt;code&gt;HashMap.put&lt;/code&gt; 那一下；另一侧的每行拿着键去查这张表——&lt;code&gt;HashMap.get&lt;/code&gt;。前一步叫 &lt;strong&gt;build&lt;/strong&gt;，后一步叫 &lt;strong&gt;probe&lt;/strong&gt;。哪侧 build 不是任意的：build 侧的数据要整体进内存，probe 侧按流逐批读，所以倾向选小的一侧。&lt;/p&gt;
&lt;p&gt;多机执行让 join 多了一层问题：两张表的行分散在不同 BE 上，任意两行都可能满足连接条件，得先让相关的行碰面。Doris 有三种让它们碰面的办法，下面三种计划形状都来自本章的实测——broadcast 来自 &lt;code&gt;orders JOIN regions&lt;/code&gt;，shuffle 和 bucket shuffle 来自 bench 库里两张 300 万行的大表（&lt;code&gt;posts&lt;/code&gt;，分桶列 &lt;code&gt;id&lt;/code&gt;；&lt;code&gt;visits&lt;/code&gt;，分桶列 &lt;code&gt;uid&lt;/code&gt;）之间的 join：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;broadcast&lt;/strong&gt;：小表整表复制到每个相关 BE，各处各自完成 join。&lt;code&gt;orders JOIN regions&lt;/code&gt; 的计划里写着 &lt;code&gt;INNER JOIN(BROADCAST)&lt;/code&gt;。代价是小表大小乘以 BE 数，所以这条路只对维表这类小表成立。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;shuffle（PARTITIONED）&lt;/strong&gt;：两侧都大、谁都复制不起时，把两侧都按连接键哈希重分布，同一个键的行落到同一台 BE 再 join。用非分桶列的表达式把 posts 和 visits 相连，计划给出 &lt;code&gt;INNER JOIN(PARTITIONED)&lt;/code&gt;，两侧各挂一个重分布的 exchange——两份数据都得动。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;bucket shuffle&lt;/strong&gt;：如果连接键恰好是某张表的分桶列，这张表的数据本来就按连接键分布着，一行都不必移动——让另一侧按它的分桶规则重排即可。把 join 键换成 visits 的分桶列后，计划给出 &lt;code&gt;INNER JOIN(BUCKET_SHUFFLE)&lt;/code&gt;，大表一侧扫描节点直接接 join、中间没有任何 exchange，小表一侧的输出写着 &lt;code&gt;BUCKET_SHFFULE_HASH_PARTITIONED&lt;/code&gt;（&lt;code&gt;SHFFULE&lt;/code&gt; 是官方输出的原样拼写）。算一笔账：大表 3 亿行时，broadcast 要把小表复制 BE 数份，普通 shuffle 要把大表 3 亿行按连接键重排一遍，bucket shuffle 只把小表按大表的分桶规则重排一次，大表一行都不动。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;第四种是更彻底的：建表时就用 &lt;code&gt;&amp;quot;colocate_with&amp;quot; = &amp;quot;group_name&amp;quot;&lt;/code&gt; 声明两张表同分布，数据落盘时对应的分桶就落在同一台 BE 上，join 时连重排都省了（计划里显示 &lt;code&gt;colocate: true&lt;/code&gt;）。约束也在这里：同组的表分桶列的类型和数量、桶数、副本数必须一致（分区数不必一致）；副本修复或迁移期间组状态不稳定时，会退化成普通 join 并在计划里注明原因。colocate 的分布效果本机单 BE 验证不了，以上按 2.1 官方文档口径陈述。&lt;/p&gt;
&lt;p&gt;默认选哪种由优化器定；认为它选错了，可以在 SQL 里用 &lt;code&gt;[broadcast]&lt;/code&gt;、&lt;code&gt;[shuffle]&lt;/code&gt; 两个 hint 指定 join 右表的分发方式，再用 &lt;code&gt;EXPLAIN SHAPE PLAN&lt;/code&gt; 对比前后的计划。&lt;/p&gt;
&lt;p&gt;分发解决「碰面」，解决不了「不均」：某个连接键的行数远大于其他键，处理它的任务就成了长尾，其余任务干完只能等它。2.1 引入了 local shuffle：单个 BE 内部、在聚合前把数据在并行任务之间再重排一遍，把不均摊平。它与自适应并行扫描一起，是 2.1.0 发布说明里执行引擎一项的主要改进。&lt;/p&gt;
&lt;h3 id="向量化按批处理一次-4064-行"&gt;向量化：按批处理，一次 4064 行
&lt;/h3&gt;&lt;p&gt;上面计划里每个算子都带 V 前缀——&lt;code&gt;VOlapScanNode&lt;/code&gt;、&lt;code&gt;VAGGREGATE&lt;/code&gt;、&lt;code&gt;VEXCHANGE&lt;/code&gt;。V 指 vectorized。要讲清它替代了什么，得先说旧模型。业界通称的火山模型（Volcano）里，每个算子实现成一个迭代器：上层算子调下一层的 &lt;code&gt;next()&lt;/code&gt;，一次取一行。这是 Java 工程师最熟悉的 &lt;code&gt;Iterator&lt;/code&gt; 接口的形状，它的问题也是同一个：调用太密。一条 SQL 十几个算子、扫描几百万行时，算子之间「一行一次」的函数调用要发生几千万次，真正的计算反而不是时间大头。&lt;/p&gt;
&lt;p&gt;Doris 2.1 默认向量化执行，算子改成一次处理一批，批大小默认 &lt;code&gt;batch_size = 4064&lt;/code&gt; 行（本文实测的默认值）。一批数据按列连续存放——和第二章的列存一脉相承，同一列的值类型一致、排在一起，CPU 才能上 SIMD 指令：单条指令对一批数据做同一运算（Java 里 Vector API 的设计目标就是把它暴露给应用）。第二章的列裁剪也在这里兑现最后一层收益：查询没引用的列，从扫描那一步起就不进入批次。算一笔调用次数的账：300 万行、十几个算子，火山模型是行数乘算子数量级的 &lt;code&gt;next()&lt;/code&gt; 调用，向量化是行数除以 4064 再乘算子数——差了四千倍。&lt;/p&gt;
&lt;h3 id="pipeline算子谁来跑"&gt;Pipeline：算子谁来跑
&lt;/h3&gt;&lt;p&gt;fragment 拆好、算子也向量化了，还剩一个问题：这些算子由哪些线程跑。早期的做法是每个 fragment 实例独占一组线程，查询一多线程数跟着涨。2.1 的执行引擎文档把新模型要解决的问题直接写作线程爆炸（thread explosion）。&lt;/p&gt;
&lt;p&gt;2.1 起默认执行引擎是 pipeline 模型（本文实测 &lt;code&gt;experimental_enable_pipeline_engine&lt;/code&gt; 默认为 true）。它把 fragment 里的算子链按&lt;strong&gt;阻塞点&lt;/strong&gt;切开：聚合、排序这类算子必须收齐全部输入才能产出，算子链在这些位置断开，每段是一个 pipeline。Join 算子拆成 &lt;code&gt;JoinBuildOperator&lt;/code&gt; 和 &lt;code&gt;JoinProbeOperator&lt;/code&gt; 两段，聚合、排序同理拆成 Sink 与 Source 两段。pipeline 是逻辑概念，真正执行的是它实例化出来的 &lt;strong&gt;PipelineTask&lt;/strong&gt;，全部提交到一个 BE 级的固定线程池——线程数不再随查询数量增长，这是与旧模型的分界线。&lt;/p&gt;
&lt;p&gt;断开的位置用依赖（Dependency）接起来：build 侧的 pipeline 建完哈希表，调用 &lt;code&gt;set_ready&lt;/code&gt; 唤醒等在依赖上的 probe 侧 pipeline。每个任务跑到时间片用完、或者被依赖挡住时让出线程，线程马上转去执行其他就绪任务——少数线程在大量任务之间流转，而不是大量线程各自守着一个 fragment。&lt;/p&gt;
&lt;p&gt;&lt;img alt="pipeline 执行模型的分工" class="gallery-image" data-flex-basis="402px" data-flex-grow="167" decoding="async" height="596" 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-c06-doris-architecture-pipeline-model.svg" width="1000"&gt;&lt;/p&gt;
&lt;p&gt;并行度由 &lt;code&gt;parallel_pipeline_task_num&lt;/code&gt; 控制，默认 0，语义是取 BE 核数的一半（2.1 文档口径），在单查询延迟和并发吞吐之间取平衡：16 核的 BE，一个 fragment 默认开 8 个并行任务。2.1 还拆掉了一道历史限制——此前并行度受 tablet 数封顶，桶切少了并行度就上不去；2.1 起对明细模型和 Merge-on-Write 主键模型支持分片内并发读，官方口径的线程上限是 48。本地实测还有一层版本事实：&lt;code&gt;experimental_enable_pipeline_x_engine&lt;/code&gt; 也默认为 true。PipelineX 是这一代 pipeline 引擎改进版的名称，local shuffle 和自适应并行扫描都算它的组成部分；开关还挂在 experimental_ 前缀下，这个前缀本身值得读出来——生产上动它之前先想一下。&lt;/p&gt;
&lt;h3 id="nereids计划是谁做的决定"&gt;Nereids：计划是谁做的决定
&lt;/h3&gt;&lt;p&gt;前面所有的决定——拆几个 fragment、哪一侧 build、聚合要不要两阶段、扫描定到哪些分区——都是优化器做的。Doris 2.1 默认使用新优化器 Nereids：本地实测 &lt;code&gt;experimental_enable_nereids_planner&lt;/code&gt; 默认 true，每份 &lt;code&gt;EXPLAIN&lt;/code&gt; 的第一行也自报 &lt;code&gt;Explain String(Nereids Planner)&lt;/code&gt;；&lt;code&gt;enable_fallback_to_original_planner&lt;/code&gt; 默认 true，Nereids 处理不了的语句退回旧规划器执行。&lt;/p&gt;
&lt;p&gt;Nereids 的工作分四步：把 SQL 文本解析成语法树（AST）；做语义分析，检查表、列、函数是否存在、用法是否合法；用一组固定的规则重写计划，这一步称 RBO——列裁剪、谓词下推、分区裁剪都在这里，读者已经见过它们的产物；最后基于成本选优（CBO），在等价的计划集合里逐个估算执行成本，取最低者。RBO 的改写是确定性的，CBO 的估算靠统计信息：表和列的行数、每列不同值的个数（NDV，number of distinct values）、最大最小值、NULL 数。&lt;/p&gt;
&lt;p&gt;统计信息的收集默认也是开着的（&lt;code&gt;enable_auto_analyze&lt;/code&gt; 默认 true），后台自动对内部表做采样收集，结果存进一张内部表；要手动触发，&lt;code&gt;ANALYZE TABLE orders WITH SAMPLE ROWS 100000;&lt;/code&gt;。统计过期或缺失时，CBO 的成本估算只能建立在猜测上——查询突然变慢时，统计信息健康度应该在排查清单的前几项。规划本身也有开销：Nereids 默认 30 秒超时（&lt;code&gt;nereids_timeout_second&lt;/code&gt;），因为规划过程要对涉及的表加读锁，超时是为了不让复杂查询长期占着锁不放。&lt;/p&gt;
&lt;h3 id="runtime-filterjoin-现场生成的过滤器"&gt;Runtime filter：join 现场生成的过滤器
&lt;/h3&gt;&lt;p&gt;第二章讲过 bloom filter 索引：建表时点名某几列，存储层为它们逐 page 建过滤器。这一节的 runtime filter 名字里也有 bloom filter，是另一回事——它不在建表时创建，而是 join 执行到现场动态生成、查询结束即弃。&lt;/p&gt;
&lt;p&gt;机制是这样的：build 侧读数据建哈希表时，顺手把连接键的值收集起来做成过滤器，推给 probe 侧的扫描节点——扫描阶段就把肯定 join 不上的行丢掉。省下的不只是 probe 的计算，还有这些行的磁盘 I/O 和到算子的传输。过滤器有四种：IN（值集合小而精确）、Bloom（大集合的近似版，回答「没有」就真没有，回答「可能有」可能只是哈希碰撞）、MIN_MAX（只记最大最小值，适合范围条件）、IN_OR_BLOOM（按实际行数在 IN 和 Bloom 之间自动选）。默认启用后两类：&lt;code&gt;runtime_filter_type&lt;/code&gt; 默认值 12，对应 MIN_MAX 加 IN_OR_BLOOM——本文实测 &lt;code&gt;SHOW VARIABLES&lt;/code&gt; 显示 &lt;code&gt;IN_OR_BLOOM_FILTER,MIN_MAX&lt;/code&gt;，是同一件事的两种写法。多机时每个 join 实例只见过一部分数据，系统会选一个节点当 manager，把各实例生成的过滤器合并成全局的再统一下推。&lt;/p&gt;
&lt;p&gt;它同样写在计划里。visits 表（300 万行）join 一张 10 行的维表、维表侧带过滤条件时，join 节点那一行是 &lt;code&gt;runtime filters: RF000[min_max] &amp;lt;- uid, RF001[in_or_bloom] &amp;lt;- uid&lt;/code&gt;，扫描节点对应一行 &lt;code&gt;runtime filters: RF000[min_max] -&amp;gt; uid, RF001[in_or_bloom] -&amp;gt; uid&lt;/code&gt;——&lt;code&gt;&amp;lt;-&lt;/code&gt; 是生成，&lt;code&gt;-&amp;gt;&lt;/code&gt; 是下推；&lt;code&gt;EXPLAIN SHAPE PLAN&lt;/code&gt; 里则写作 &lt;code&gt;build RFs:RF0 uid-&amp;gt;[uid]&lt;/code&gt; 与 &lt;code&gt;apply RFs: RF0 RF1&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;但它的收益不是必然兑现的。我把同一条 join 开关 runtime filter 各跑了几遍，扫描耗时没有可分辨的差别：这张表的 uid 每 1000 行循环一轮，每个 page 都包含全部取值，过滤器只能逐行丢行，一个 page 也跳不掉——数据的分布又一次决定了优化结构的收益，和第二章低基数列用不好 bloom filter 是同一类教训。另外两个边界：build 侧本身没有过滤条件时，过滤器覆盖全部值、什么也省不下，优化器默认会把它裁掉；扫描节点等过滤器只等 1 秒（&lt;code&gt;runtime_filter_wait_time_ms&lt;/code&gt; 默认 1000），等不到就先扫，join 的 build 慢时过滤器可能根本没赶上用。&lt;/p&gt;
&lt;h3 id="一条查询的全链路"&gt;一条查询的全链路
&lt;/h3&gt;&lt;p&gt;把两章拼在一起，那条聚合查询的完整旅程是：SQL 文本进入 Nereids，解析、重写、按成本选出计划；计划拆成 fragment 发给相关 BE；每个 BE 把 fragment 切成 pipeline、实例化成并行任务、提交固定线程池；扫描节点完成第二章的三道收缩，读出第一批数据；算子按 4064 行一批向量化地执行聚合和 join；局部结果按分组键 shuffle、汇总、返回。&lt;/p&gt;
&lt;p&gt;存储层决定哪些数据不必读，执行层决定读进来的数据怎么算得快、传得少，优化器决定按哪份计划执行。排查一条慢查询，也就从这三层分别去问：多读了什么、多算了什么、计划为什么这么选。&lt;/p&gt;
&lt;h2 id="四数据的契约数据模型"&gt;四、数据的契约：数据模型
&lt;/h2&gt;&lt;p&gt;前两章有几处话只说了一半：宽表点查是列存的弱项，「怎么缓解」没有讲；索引体系表里出现过「Merge-on-Write」，只给了一句话解释；聚合模型、主键模型的名字多次出现，语义始终没展开。本章把这些收拢到同一个概念上——&lt;strong&gt;数据模型&lt;/strong&gt;：建表时选定的 Key 子句与列定义，签下一份同时约束存储语义和查询能力的契约。它决定 Compaction 对数据做什么、读时合并是否存在、谓词能不能下推、UPDATE 和 DELETE 支不支持，甚至 &lt;code&gt;avg()&lt;/code&gt; 返回什么。&lt;/p&gt;
&lt;h3 id="同一个键同一批数据三种答案"&gt;同一个键、同一批数据、三种答案
&lt;/h3&gt;&lt;p&gt;Doris 有三种数据模型，区别只在建表语句的 Key 子句和值列写法上。三种都建出来，喂同一批两行数据（同一个键的两笔订单），观察各自的结果：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&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;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;agg_demo&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;dt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;DATETIME&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SUM&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&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;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;REPLACE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&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="k"&gt;AGGREGATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_id&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;DISTRIBUTED&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;HASH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;BUCKETS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;PROPERTIES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;replication_num&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;1&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;-- 明细模型：值列就是普通列
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;dup_demo&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;dt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;DATETIME&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&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;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&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;DUPLICATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_id&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;DISTRIBUTED&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;HASH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;BUCKETS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;PROPERTIES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;replication_num&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;1&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;-- 主键模型
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;uni_demo&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;dt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;DATETIME&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&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;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&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="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_id&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;DISTRIBUTED&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;HASH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;BUCKETS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;PROPERTIES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;replication_num&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;1&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;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INTO&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;agg_demo&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;2026-08-01 10:00:00&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1001&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;new&amp;#39;&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="s1"&gt;&amp;#39;2026-08-01 10:00:00&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1001&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;paid&amp;#39;&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="c1"&gt;-- dup_demo、uni_demo 吃完全相同的两行
&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="k"&gt;SELECT&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;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;agg_demo&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&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&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;SELECT *&lt;/code&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;1 行：&lt;code&gt;amount = 80, status = 'paid'&lt;/code&gt;——SUM 把 50 与 30 相加，REPLACE 取后写的&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;明细模型&lt;/td&gt;
					&lt;td&gt;2 行：50/new 与 30/paid 全部保留&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;主键模型&lt;/td&gt;
					&lt;td&gt;1 行：&lt;code&gt;amount = 30, status = 'paid'&lt;/code&gt;——后写覆盖前写&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;列结构完全相同，答案不同，差别全部来自建表时选的模型。&lt;/p&gt;
&lt;p&gt;&lt;img alt="同一批写入在三种数据模型下的去向与结果" class="gallery-image" data-flex-basis="379px" data-flex-grow="158" decoding="async" height="632" 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-c06-doris-architecture-data-model-contract.svg" width="1000"&gt;&lt;/p&gt;
&lt;p&gt;这份契约的特别之处是&lt;strong&gt;签定后不可改&lt;/strong&gt;：官方文档明确「建表之后，表模型的属性就已确定，不能修改」，主键模型的实现模式同样「在创建时固定、不能通过 schema 变更改变」。Java 工程师对这个处境不陌生：选错模型不是改配置，是换表重灌。&lt;/p&gt;
&lt;h3 id="明细模型key-只是排序"&gt;明细模型：Key 只是排序
&lt;/h3&gt;&lt;p&gt;三种模型里最朴素的是&lt;strong&gt;明细模型（Duplicate Key）&lt;/strong&gt;：Key 列只代表排序，没有任何唯一性约束——官方文档的说法是「Key 列用于排序，没有唯一性约束」，存储层原样保留写入的每一行。第二章讲的排序键、前缀索引、列存编码，落在明细模型上就是它的全部：Key 决定数据怎么摆，仅此而已。&lt;/p&gt;
&lt;p&gt;它适合两类场景：必须保留全部原始记录（审计、对账）；查询维度事先不定、任何列都可能出现在 WHERE 和 GROUP BY 里（ad-hoc 分析）——明细都在，怎么切都行。对照表里它还有一个容易忽略的差异：DELETE 语句只算「部分支持」。&lt;/p&gt;
&lt;h3 id="聚合模型落盘即聚合"&gt;聚合模型：落盘即聚合
&lt;/h3&gt;&lt;p&gt;**聚合模型（Aggregate Key）**的 Key 列负责分组，非键的值列必须声明聚合方式，可选的有 SUM（累加）、MAX、MIN、REPLACE（后写替换前写）、REPLACE_IF_NOT_NULL（非 NULL 才替换）、HLL_UNION（HyperLogLog，近似去重计数）、BITMAP_UNION（位图并集）。聚合方式写进列定义本身，&lt;code&gt;SHOW CREATE TABLE&lt;/code&gt; 能直接看到：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;`amount` bigint SUM NULL,
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;`status` varchar(16) REPLACE NULL
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;AGGREGATE KEY(`dt`, `order_id`)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;聚合发生在三个时机，每个都在前两章出现过：导入时，每个批次生成一个新版本（第二章的 rowset 和版本号），版本内同键数据先聚一次；后台 Compaction 合并多个版本时，同键数据再聚一次；查询时，系统对跨版本的同键数据做最后的合并，保证结果正确——「读时合并」这个第二章的概念，在聚合模型上的含义就是第三步。所以聚合模型不是查询时才算，而是&lt;strong&gt;能提前聚的都提前聚了&lt;/strong&gt;，落盘的数据已经是聚合后的形态，原始明细不复存在。&lt;/p&gt;
&lt;p&gt;这带来聚合模型最大的收益和最大的坑，是同一件事的两面。收益：存储省了、聚合查询的计算量小了。坑：&lt;strong&gt;表里的行数不等于业务行数&lt;/strong&gt;。继续用刚才的表，再来一笔新订单，然后对两种模型跑同一个统计：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INTO&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;agg_demo&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;2026-08-02 10:00:00&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1002&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;paid&amp;#39;&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;INSERT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INTO&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;dup_demo&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;2026-08-02 10:00:00&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1002&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;paid&amp;#39;&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="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;avg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;agg_demo&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;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;avg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;dup_demo&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;三笔订单（50、30、10），两个模型的结果（本文实测）：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;模型&lt;/th&gt;
					&lt;th&gt;count&lt;/th&gt;
					&lt;th&gt;sum&lt;/th&gt;
					&lt;th&gt;avg&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;明细模型&lt;/td&gt;
					&lt;td&gt;3&lt;/td&gt;
					&lt;td&gt;90&lt;/td&gt;
					&lt;td&gt;&lt;strong&gt;30&lt;/strong&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;聚合模型&lt;/td&gt;
					&lt;td&gt;2&lt;/td&gt;
					&lt;td&gt;90&lt;/td&gt;
					&lt;td&gt;&lt;strong&gt;45&lt;/strong&gt;&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code&gt;sum&lt;/code&gt; 完全一致，&lt;code&gt;count&lt;/code&gt; 与 &lt;code&gt;avg&lt;/code&gt; 已经分道扬镳：聚合表里那张 50/30 的订单合成了一行，&lt;code&gt;avg(amount)&lt;/code&gt; 算出的是「部分和的平均」，不是「订单的平均」。官方文档把这条写成了通则：聚合模型「对聚合查询友好，但对 &lt;code&gt;count(*)&lt;/code&gt; 类查询不友好」，其他聚合语义的正确性需要使用者自行考量。报表场景（固定按天、按地区求和）天然匹配 SUM 语义，没问题；把聚合模型当明细表用，&lt;code&gt;avg&lt;/code&gt;、&lt;code&gt;count distinct&lt;/code&gt; 这类查询会安静地给你一个错误的数——不报错，只是不对。&lt;/p&gt;
&lt;h3 id="主键模型写入即-upsert"&gt;主键模型：写入即 upsert
&lt;/h3&gt;&lt;p&gt;**主键模型（Unique Key）**的 Key 列同时承担排序和唯一性约束：每个键只保留一行，最新写入的生效。这个语义 Java 工程师可以直接映射成 &lt;code&gt;INSERT ... ON DUPLICATE KEY UPDATE&lt;/code&gt;——存在就整行更新，不存在就插入。&lt;/p&gt;
&lt;p&gt;实现上它分成两种模式。&lt;strong&gt;Merge-on-Write（MoW，写时合并）&lt;/strong&gt;：写入时就地合并同键数据，存储层只保留每个键的最新版本——2.1 起它是主键模型的默认模式（本文实测建表回显里有 &lt;code&gt;&amp;quot;enable_unique_key_merge_on_write&amp;quot; = &amp;quot;true&amp;quot;&lt;/code&gt;）。&lt;strong&gt;Merge-on-Read（MoR，读时合并）&lt;/strong&gt;：写入只追加，同键保留多个版本，查询或 Compaction 时再按键合并。第二章讲的「读时合并」在主键模型上的代价形态就是 MoR：版本越积越多、查询要合并的路径越长，而且谓词无法下推到存储层；MoW 换来读侧干净（无合并、谓词可下推），代价是写入时多做合并。官方的建议是多数场景用 MoW，MoR 留给写多读少的场景。两种模式同样在建表时固定。它与聚合模型 REPLACE 的区别值得点一句：REPLACE 只替换挂了它的那一列，其余列各按各的聚合方式走；主键模型是整行覆盖。&lt;/p&gt;
&lt;p&gt;对照表里主键模型独占的几项能力都挂在这条契约上：&lt;code&gt;UPDATE&lt;/code&gt; 语句只有主键模型支持；导入时部分列更新（只更新某几列、其余列保持原值）也只有主键模型支持，且要求 MoW 模式。另有一条容易踩的约束：分区列必须包含在 Key 列中，否则数据唯一性保证不了。&lt;/p&gt;
&lt;h3 id="兑现第二章的欠条点查的短路径"&gt;兑现第二章的欠条：点查的短路径
&lt;/h3&gt;&lt;p&gt;第二章说过列存的代价：取一整行要把所有列各读一次，宽表高频点查付出 N 倍 I/O。当时欠的「怎么缓解」，答案现在可以拼出来了，而且是主键模型契约的一部分：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;MoW 是前提&lt;/strong&gt;：点查要在存储层快速定位「那个键的最新行」，读时合并的多版本形态撑不起这条路径；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;store_row_column&lt;/code&gt;&lt;/strong&gt;：建表时开启，表里额外存一份按行组织的副本（官方口径：多花磁盘换点查的 I/O），键等值查询直接从行存取整行，不必逐列凑；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;light_schema_change&lt;/code&gt;&lt;/strong&gt; 必须开：短路径依赖每列的唯一编号。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;三个条件凑齐，单表的键等值查询走一条短路径，一次 RPC 返回，&lt;code&gt;EXPLAIN&lt;/code&gt; 的输出里会出现 &lt;code&gt;SHORT-CIRCUIT&lt;/code&gt; 字样（本文在 MoW + 行存表上实测确认）。再配 JDBC 的 &lt;code&gt;useServerPrepStmts=true&lt;/code&gt; 用 PreparedStatement，省掉 FE 逐条解析 SQL 的 CPU（官方口径 CPU 瓶颈时可提效 4 倍以上），服务化高并发点查的整条链路就齐了。行存副本对应的行缓存默认是关的（BE 配置 &lt;code&gt;disable_storage_row_cache&lt;/code&gt;），要做高并发点查时记得打开。这套东西只对「按订单号查详情」这类服务场景有意义，纯分析查询用不上，也别为它付行存的存储成本。&lt;/p&gt;
&lt;h3 id="契约的选型"&gt;契约的选型
&lt;/h3&gt;&lt;p&gt;三种模型各自的领地，官方对照表加上本文的实测可以收成三句话：固定口径的报表和指标（按天、按地区求和计数）→ 聚合模型，落盘即预计算；有唯一性约束、要更新、要做服务点查 → 主键模型 + MoW；原始明细要保留、查询维度不定 → 明细模型。&lt;/p&gt;
&lt;p&gt;回头看这四章的骨架：第二章讲数据在磁盘上怎么摆，第三章讲摆好的数据怎么算，本章的契约签在两者中间——它决定 Compaction 帮你把数据聚到什么形态、读时有没有合并、点查有没有短路径，也决定 &lt;code&gt;count&lt;/code&gt; 和 &lt;code&gt;avg&lt;/code&gt; 返回的数是不是你想的那个数。建表语句里的每一个子句，都是在给这份契约签字。&lt;/p&gt;
&lt;h2 id="五数据的写入stream-load-的完整旅程"&gt;五、数据的写入：Stream Load 的完整旅程
&lt;/h2&gt;&lt;p&gt;机制讲完了，最后看数据怎么进来的。第一章承诺过两件事：单批导入原子可见、多数派副本写成功才算成功；第二章出现过 rowset、版本号和 -235 事故。本章沿一条真实的 Stream Load 请求走完全程，把这些承诺逐一兑现。本章所有请求与响应都在本机 2.1.11 集群实跑，输出原文照录。&lt;/p&gt;
&lt;h3 id="一条-http-请求的旅程"&gt;一条 HTTP 请求的旅程
&lt;/h3&gt;&lt;p&gt;Stream Load 不走 SQL 通道，走 HTTP：把一个 CSV/JSON 文件用 &lt;code&gt;PUT&lt;/code&gt; 推给 Doris。请求发往 FE 的 8030 端口：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -s -u root: &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -H &lt;span class="s2"&gt;&amp;#34;label:load_demo_001&amp;#34;&lt;/span&gt; -H &lt;span class="s2"&gt;&amp;#34;column_separator:,&amp;#34;&lt;/span&gt; -H &lt;span class="s2"&gt;&amp;#34;Expect:100-continue&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -T /tmp/sl2.csv &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; http://127.0.0.1:8030/api/loadtest/load_demo/_stream_load
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;第一条响应是 &lt;code&gt;HTTP 307 Temporary Redirect&lt;/code&gt;，&lt;code&gt;Location&lt;/code&gt; 指向某台 BE 的 8040 端口——FE 按轮询挑一台 BE 当本次导入的 &lt;strong&gt;coordinator&lt;/strong&gt;（协调节点），由它接收数据并分发给其他副本所在的 BE，FE 借此在多台 BE 间摊负载。文档同样允许跳过 FE 直接把请求发给某台 BE，两种姿势都合法。本机实测时撞上了这个机制的经典坑：重定向里的地址是 BE 的容器内网 IP，跨网络环境跟过去就是连接失败——官方为此单独写了一页「复杂网络环境 Stream Load」，常见解法就是直连 BE。改直连后导入成功，返回的 JSON 值得一屏屏看：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;TxnId&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;Label&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;load_demo_001&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;TwoPhaseCommit&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;false&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;Status&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Success&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;NumberTotalRows&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;NumberLoadedRows&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;NumberFilteredRows&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;NumberUnselectedRows&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;LoadBytes&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;69&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;CommitAndPublishTimeMs&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;63&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&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;TxnId&lt;/code&gt; 是这次导入对应的事务号——导入从进门的第一个响应就是事务语义的。底部还有一串时间分解：&lt;code&gt;BeginTxnTimeMs&lt;/code&gt;（开事务）、&lt;code&gt;StreamLoadPutTimeMs&lt;/code&gt;（FE 定计划）、&lt;code&gt;ReadDataTimeMs&lt;/code&gt;、&lt;code&gt;WriteDataTimeMs&lt;/code&gt;（写数据）、&lt;code&gt;CommitAndPublishTimeMs&lt;/code&gt;（提交并发布版本），几段相加基本就是总耗时——排查导入慢时，它直接告诉你时间花在哪一段。&lt;/p&gt;
&lt;h3 id="事务从-begin-到-publish-version"&gt;事务：从 begin 到 publish version
&lt;/h3&gt;&lt;p&gt;刚才那个 &lt;code&gt;TxnId 40&lt;/code&gt; 在 FE 日志里能找到完整的生命周期，三行原文按顺序是：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;beginTransaction: txn id 40 with label load_demo_001
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; from coordinator BE: 172.30.80.3
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;commitTransaction: [TransactionState. transaction id: 40, label: load_demo_001 ...]
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;PUBLISH_VERSION finishTransaction [transaction id: 40, label: load_demo_001 ...]
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;三行正好对应第一章承诺的兑现路径：&lt;strong&gt;begin&lt;/strong&gt;——coordinator BE 收到数据后向 FE 开事务；数据并行写入各副本 BE，多数派写成功后，coordinator 调 &lt;strong&gt;commit&lt;/strong&gt;；FE 再把版本&lt;strong&gt;发布&lt;/strong&gt;（publish version），数据对外可见。发布完成之前有一个窗口：事务已提交但数据还查不到，对应返回 JSON 里的另一种状态 &lt;code&gt;Publish Timeout&lt;/code&gt;——导入本身已完成，只是可见性有延迟。上一节那个 &lt;code&gt;CommitAndPublishTimeMs&lt;/code&gt; 计时的，就是最后这两步。&lt;/p&gt;
&lt;h3 id="label重试的安全网"&gt;Label：重试的安全网
&lt;/h3&gt;&lt;p&gt;第一次提交的 JSON 里有个 &lt;code&gt;Label: load_demo_001&lt;/code&gt;，它是导入的身份标识，规则只有一条：&lt;strong&gt;同一个 label 不能被导入两次&lt;/strong&gt;。实测把同一请求原样重发：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Status: Label Already Exists
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Message: [LABEL_ALREADY_EXISTS] Label [load_demo_001] has already been used,
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; relate to txn [40], status [VISIBLE].
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;报错原文把三个概念的关系一次讲全：label 关联着事务号，事务有自己的状态（&lt;code&gt;VISIBLE&lt;/code&gt; 表示已发布可见）。客户端网络超时后重试时，带着同一个 label 再来，Doris 会发现这批数据已经导入过、直接拒绝——于是重试不会产生重复数据，官方把这个保证写作 At-Most-Once，并强烈建议「同一批数据使用同一个 label」。label 默认保留三天（&lt;code&gt;label_keep_max_second&lt;/code&gt; 可调），期间都能查到某一批数据的导入状态；状态为 &lt;code&gt;CANCELLED&lt;/code&gt;（失败取消）的 label 可以复用。&lt;/p&gt;
&lt;h3 id="数据质量零容忍与-errorurl"&gt;数据质量：零容忍与 ErrorURL
&lt;/h3&gt;&lt;p&gt;导入对脏数据的默认态度是零容忍：&lt;code&gt;max_filter_ratio&lt;/code&gt; 默认为 0（取值 0 到 1），错误行比例一超，整批失败。实测把一个只有 4 列的 CSV 推给 7 列的表，返回 &lt;code&gt;Status: Fail&lt;/code&gt;、&lt;code&gt;[DATA_QUALITY_ERROR] too many filtered rows&lt;/code&gt;，&lt;code&gt;NumberFilteredRows = 2&lt;/code&gt;——两行全被过滤，一行没进。响应里附了一个 &lt;code&gt;ErrorURL&lt;/code&gt;，用 curl 访问就能看到逐行的坏行原因：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Reason: actual column number in csv file is less than schema column number.
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; actual number: 4, schema column number: 7
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;容忍比例调大后（比如 &lt;code&gt;max_filter_ratio:0.4&lt;/code&gt;），错误行不超比例的批次会成功入库，被丢弃的行数记在 &lt;code&gt;NumberFilteredRows&lt;/code&gt; 里。注意区分：查询里 &lt;code&gt;WHERE&lt;/code&gt; 条件过滤掉的行记在 &lt;code&gt;NumberUnselectedRows&lt;/code&gt;，不算错误、不占容忍额度——前者是「数据不合格」，后者是「按规则不要」。&lt;/p&gt;
&lt;h3 id="两阶段提交"&gt;两阶段提交
&lt;/h3&gt;&lt;p&gt;有些接入场景希望「数据先放进来、先别让下游看见」，等上游确认后再放行——Stream Load 用两阶段提交支持这个语义。实测全程：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 第一阶段：加 two_phase_commit 头，数据写入但不发布&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -s -u root: -H &lt;span class="s2"&gt;&amp;#34;label:tpc_demo_001&amp;#34;&lt;/span&gt; -H &lt;span class="s2"&gt;&amp;#34;two_phase_commit:true&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -H &lt;span class="s2"&gt;&amp;#34;column_separator:,&amp;#34;&lt;/span&gt; -T /tmp/sl2.csv &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; http://127.0.0.1:8040/api/loadtest/load_demo/_stream_load
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 返回 Status: Success, TwoPhaseCommit: true —— 事务处于 pre-commit 状态&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 此时查询：行数不变（实测 2 → 2），pre-commit 的数据不可见&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -s -u root: -X PUT -H &lt;span class="s2"&gt;&amp;#34;txn_operation:commit&amp;#34;&lt;/span&gt; -H &lt;span class="s2"&gt;&amp;#34;label:tpc_demo_001&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; http://127.0.0.1:8040/api/loadtest/load_demo/_stream_load_2pc
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 提交后查询：行数 2 → 4，数据可见&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;「写入成功」和「对外可见」在两阶段提交下被拆成两个独立的决定，中间的窗口留给上游业务。这条路径的端点和参数在 2.1 的 Stream Load 手册正文里没有展开，本节流程以本地实测为准。&lt;/p&gt;
&lt;p&gt;&lt;img alt="一条 Stream Load 从请求到数据可见" class="gallery-image" data-flex-basis="363px" data-flex-grow="151" decoding="async" height="660" 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-c06-doris-architecture-stream-load-sequence.svg" width="1000"&gt;&lt;/p&gt;
&lt;h3 id="高频写入的答案group-commit"&gt;高频写入的答案：Group Commit
&lt;/h3&gt;&lt;p&gt;Stream Load 的默认行为是「一次导入 = 一个独立事务 = 一个新版本」。这个模型干净，但高频小批量写入会把两头压垮：FE 要为每次导入解析、定计划，版本数飞涨——第二章那个 -235（单 tablet 版本数超 500 拒绝导入）的源头就在这里。2.1 给出的答案是 &lt;strong&gt;Group Commit&lt;/strong&gt;：官方文档对它的定位是「不是新的导入方式，而是对已有导入方式的优化扩展」——把多个小批量导入在后台合并成一个大事务提交。&lt;/p&gt;
&lt;p&gt;它有三种模式（本文实测确认 2.1 没有「自动选」的第四种）：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;off_mode&lt;/code&gt;（默认）：关闭，行为同上；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sync_mode&lt;/code&gt;：合并攒批，但事务提交后才返回——写入慢一点，数据立即可见；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;async_mode&lt;/code&gt;：数据先写 WAL（Write Ahead Log，预写日志）立即返回，后台按阈值攒批提交。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;攒批的触发条件是时间或量先到者：&lt;code&gt;group_commit_interval&lt;/code&gt; 默认 10 秒、&lt;code&gt;group_commit_data_bytes&lt;/code&gt; 默认 64 MB。可见性的代价用一次实测看：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;group_commit&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;async_mode&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;INSERT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INTO&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;load_demo&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;2026-08-03 09:00:00&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3001&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;new&amp;#39;&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;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;load_demo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 立即查：4 行，刚插入的不可见
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- 等 12 秒
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;load_demo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 5 行，攒批提交后可见
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;async_mode&lt;/code&gt; 的代价也要如实说：数据先落在单台 BE 的 WAL 文件里，官方明确「磁盘损坏或误删文件可能造成数据丢失」，运维下线 BE 时要按文档流程走；单条数据量太大、WAL 空间吃紧时自动转 &lt;code&gt;sync_mode&lt;/code&gt;。另外它不是处处生效：显式事务、带 label 的导入、两阶段提交这些场景会自动绕过——需要精确控制批次边界的功能，和攒批天然冲突。&lt;/p&gt;
&lt;h3 id="从-curl-到可见"&gt;从 curl 到可见
&lt;/h3&gt;&lt;p&gt;到这里，第一章立下的承诺全部兑现。一条 Stream Load 的完整时间线：HTTP 请求到 FE，307 重定向给一台 coordinator BE；coordinator 向 FE 开事务（begin），label 校验通过；数据分发到各副本 BE，写进 MemTable、刷成 rowset——第二章的版本号在这里 +1；多数派写成功，事务提交（commit）；FE 发布版本（publish），数据对外可见；下游从此能在这条数据上跑出第二章的扫描收缩、第三章的并行执行和第四章契约定义的语义。写入的可靠性靠 label 兜住重试，靠 &lt;code&gt;max_filter_ratio&lt;/code&gt; 和 ErrorURL 暴露脏数据，靠两阶段提交把「写入」和「可见」拆成两个决定，靠 Group Commit 把高频小批量的版本代价摊平。&lt;/p&gt;
&lt;p&gt;五个章节走完，从一次 OLTP 与 OLAP 的对比开始，到一条数据在集群里的完整一生结束。&lt;/p&gt;</description></item><item><title>第五话｜RabbitMQ 集群的架构与高可用：一条消息从发到收走了哪些节点</title><link>https://zh.renxinblog.cn/post/gyd-c05-rabbitmq-cluster-storage/</link><pubDate>Sun, 22 Feb 2026 00:00:00 +0000</pubDate><guid>https://zh.renxinblog.cn/post/gyd-c05-rabbitmq-cluster-storage/</guid><description>&lt;img src="https://zh.renxinblog.cn/images/gyd-c05-rabbitmq-cluster-storage-cover.png" alt="Featured image of post 第五话｜RabbitMQ 集群的架构与高可用：一条消息从发到收走了哪些节点" /&gt;&lt;p&gt;这篇文章讲 RabbitMQ 的集群架构，以及它如何靠这套架构实现高可用。要看清这套架构，得先拆掉「集群把消息复制了三份」这个最常见的误解——集群真正复制的其实只有两类：&lt;strong&gt;集群元数据和队列内容。&lt;/strong&gt; 而客户端和某个节点之间的连接状态，根本不进集群，只握在客户端自己手里。搞清楚这三样东西各自由谁负责、出故障时各自怎么表现，就理解了这套高可用架构到底可靠在哪、又会在哪里失效。&lt;/p&gt;
&lt;p&gt;第一话在单台节点上把一条消息的一生走了一遍，那时队列类型之间的差别只体现在消费语义上；连成集群之后，同一个差别会变成两种完全不同的故障表现——这正是集群高可用要解决的那一层。&lt;/p&gt;
&lt;p&gt;正文里会碰到一个具体的矛盾：&lt;strong&gt;客户端连的是节点 A，队列的 Leader 在节点 B，消息为什么还能发成功？&lt;/strong&gt; 这个问题在&lt;a class="link" href="#gyd-c05-scene" &gt;第四节&lt;/a&gt;和第六节会拆开讲，答案正是上面那三类数据的分工——客户端状态留在原地，队列内容在集群内部被定位与复制，两者各走各的通道。&lt;/p&gt;
&lt;p&gt;全文以 RabbitMQ 4.3.5 为准（Erlang 27.3.4.17、&lt;code&gt;amqp-client 5.30.0&lt;/code&gt;、JDK 21）。集群是三台容器里的节点，同宿主机、仅面向本机实验——它不构成任何生产容量或性能结论，文中给出的秒数与条数只对这个环境负责。凡是本机没有跑过的机制，正文会写明它来自官方文档或源码，不当成已证实的结论。&lt;/p&gt;

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

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

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

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

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

 &lt;/blockquote&gt;
&lt;p&gt;classic 的落盘目录内部还能看到更细的形态：属性文件、索引文件与段文件分开，索引按队列内的序号命名。把队列里的消息消费干净之后，大部分文件会消失，只剩属性文件（&lt;code&gt;raw-classic-owner-failure.log&lt;/code&gt;）。&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;边注&lt;/strong&gt;：这些文件形态是观察到的样子，不是对内部结构的解释。某一时刻目录里有几个文件、多大，取决于当时的段划分与写入量，不必当成稳定特征。&lt;/p&gt;

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

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

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

 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;演示代码&lt;/strong&gt;：三节点集群的编排与全部实测脚本在 &lt;a class="link" href="https://github.com/renxin2024/GYD/tree/main/infra/rabbitmq-cluster" target="_blank" rel="noopener"
 &gt;GYD 仓库 &lt;code&gt;infra/rabbitmq-cluster/&lt;/code&gt;&lt;/a&gt;。集群的起停、运行顺序、完整命令、原始输出与每一步的预期观察点，都在该模块的 README 里；正文里每个读数的出处也在那里。这篇文章不复现命令与输出——它负责机制，复现交给仓库。&lt;/p&gt;

 &lt;/blockquote&gt;</description></item><item><title>第四话｜RocketMQ 4.x 的架构与机制：一条消息从发送到消费的全程</title><link>https://zh.renxinblog.cn/post/gyd-c04-rocketmq-architecture/</link><pubDate>Wed, 18 Feb 2026 00:00:00 +0000</pubDate><guid>https://zh.renxinblog.cn/post/gyd-c04-rocketmq-architecture/</guid><description>&lt;img src="https://zh.renxinblog.cn/images/gyd-c04-rocketmq-architecture-cover.png" alt="Featured image of post 第四话｜RocketMQ 4.x 的架构与机制：一条消息从发送到消费的全程" /&gt;&lt;p&gt;一条消息从生产到消费，中间经历了哪些环节？网络是怎么连接的，磁盘又是怎么存储的？&lt;/p&gt;
&lt;p&gt;先把 RocketMQ 4.x 的全貌铺开：有哪些角色、各自负责什么、谁跟谁通信（一）。然后跟着一条消息走完主链路——客户端启动后怎么找到 broker（二），生产者怎么把消息发出去（三），broker 收到之后怎么落盘（四、五），主从之间怎么保证不丢（六），消费者又是怎么把它取走（七）。剩下的几节停在这条链路上挂着的功能上（八～十一），逐个说明它的取舍与代价，最后一节把这些代价来源并排放在一起（十二）。&lt;/p&gt;
&lt;p&gt;全文以 &lt;code&gt;apache/rocketmq&lt;/code&gt; 4.9.7 为准，覆盖的是传统 Remoting 客户端与静态主从部署。5.x 引入的 Proxy、gRPC 客户端、Controller 自动切换、TimerWheel 毫秒延迟与 Topic 类型约束，都不在本文范围内。正文引用的官方文档（&lt;code&gt;Design_Store.md&lt;/code&gt;、&lt;code&gt;Design_Filter.md&lt;/code&gt;、&lt;code&gt;Design_Trancation.md&lt;/code&gt;、&lt;code&gt;Feature.md&lt;/code&gt;、&lt;code&gt;Example_Delay.md&lt;/code&gt; 等）均指 &lt;code&gt;apache/rocketmq&lt;/code&gt; 仓库 &lt;code&gt;rocketmq-all-4.9.7&lt;/code&gt; tag 下的 &lt;code&gt;docs/cn/&lt;/code&gt; 原文，不是随时变动的 develop 分支。凡本机没跑过的机制，正文会写明是「结构推导」，不当成已证实的结论。&lt;/p&gt;

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

 &lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2 id="gyd-c04-scene"&gt;一、全貌：四个角色，五条通信线
&lt;/h2&gt;&lt;p&gt;&lt;img alt="RocketMQ 的四个角色与五条通信线：客户端的 Producer 与 Consumer 各占一个方框，服务端的 NameServer、Broker master、Broker slave 各占一个方框" class="gallery-image" data-flex-basis="477px" data-flex-grow="198" decoding="async" height="336" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://zh.renxinblog.cn/images/gyd-c04-rocketmq-architecture.svg" width="668"&gt;&lt;/p&gt;
&lt;p&gt;图上这五个方框就是 RocketMQ 4.x 的全部角色，彼此独立、没有包含关系：浅蓝的两个是客户端（&lt;strong&gt;Producer&lt;/strong&gt; 和 &lt;strong&gt;Consumer&lt;/strong&gt;），浅绿的三个是服务端（&lt;strong&gt;NameServer&lt;/strong&gt;，以及 &lt;strong&gt;Broker&lt;/strong&gt; 的主与从）。箭头方向就是连接（TCP）的发起方向，线上的编号与后文逐条对应；注意它不等于数据流向——⑤ 里连接由从节点发起，数据却是主节点往从节点写。&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;角色&lt;/th&gt;
					&lt;th&gt;负责什么&lt;/th&gt;
					&lt;th&gt;不管什么&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;NameServer&lt;/td&gt;
					&lt;td&gt;broker 的注册与存活、每个 topic 在哪台 broker 上有几个读写队列&lt;/td&gt;
					&lt;td&gt;不存消息、不记位点；节点之间互不通信&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Broker&lt;/td&gt;
					&lt;td&gt;接收并存储消息、投递给消费者、存集群模式的消费位点；主从一组时，从节点做副本&lt;/td&gt;
					&lt;td&gt;不管寻址：客户端连它之前，先向 NameServer 拿到它的地址&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Producer&lt;/td&gt;
					&lt;td&gt;拉路由、选队列、把消息发出去，按返回码决定要不要换队列重试&lt;/td&gt;
					&lt;td&gt;不参与存储与投递&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Consumer&lt;/td&gt;
					&lt;td&gt;拉路由、做 rebalance、长轮询拉消息、上报位点&lt;/td&gt;
					&lt;td&gt;不负责「不重复」，那是消费端幂等的事&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;四个角色之间，普通消息主链路上的通信数得出来五条，发起方都在客户端或从节点一侧（图上的编号边就是它们；① 和 ② 的发起方各有两个，所以各画了两条）：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;① Producer / Consumer → NameServer&lt;/strong&gt;：每 30 秒拉一次路由，换回一张「哪个 topic 在哪台 broker 的第几个队列」的地址表；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;② Broker → NameServer&lt;/strong&gt;：主和从&lt;strong&gt;各自&lt;/strong&gt;每 30 秒上报一次，整张 topic 配置一并带上；但只有主的那份进路由表（队列数与读写权限），从的那份只用来证明它自己活着；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;③ Producer → Broker&lt;/strong&gt;：发送请求，走一条常驻连接；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;④ Consumer → Broker&lt;/strong&gt;：投递走长轮询；位点随拉取请求捎带、另按固定间隔批量写回；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;⑤ Slave → Master&lt;/strong&gt;：主从复制，从节点主动连上主节点的 HA 端口去拉，不是主推过去。它连哪个地址，是向 NameServer 注册时从响应里问回来的（见第六节）；&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;五条线里没有一条是服务端回连客户端：每次通信都由发起方主动去连，服务端之间的 ② 与 ⑤ 也一样。&lt;strong&gt;在这条普通消息主链路上，消息一律由客户端主动发起，服务端只应答。&lt;/strong&gt; 唯一的例外是事务消息回查（第十一节）：那时 Broker 会主动向 Producer 发起回查请求，是后文才加入的一条反向 RPC，不在主链路这五条里。&lt;/p&gt;
&lt;p&gt;角色和通信线之外，还有一个概念要在全貌里定下来：&lt;strong&gt;组（group）&lt;/strong&gt;。RocketMQ 的客户端不是孤零零一个实例，而是挂在一个组名之下，组是它后面几乎所有机制的&lt;strong&gt;作用域&lt;/strong&gt;。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;消费组（Consumer Group）&lt;/strong&gt;：rebalance、消费位点、重试与死信，都按「主题 + 消费组」记。同一个消费组内的实例，共享一份位点、分摊队列；不同消费组各自独立消费同一主题，互不影响。同组的实例订阅关系必须一致，否则 rebalance 结果不可预测。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;生产者组（Producer Group）&lt;/strong&gt;：普通发送里它几乎不做事——不参与选队列、不分配队列，名字更多是业务上的归类。它真正起作用的地方是&lt;strong&gt;事务消息回查&lt;/strong&gt;（第十一节）：broker 回查本地事务结果时，要到一个生产者组里去找还活着的实例来回答。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;把组立住之后，后面几节的「位点按组记」「rebalance 在组内做」「回查找同组实例」才有着落。与组并列的另一个基础概念是 &lt;strong&gt;topic 与队列的关系&lt;/strong&gt;：&lt;/p&gt;
&lt;p&gt;topic 是客户端手里的逻辑名字，到了 broker 就成了一份配置，队列数记在这份配置里（读、写各一个数字，本文都填 4），&lt;strong&gt;每条队列都落在某台 broker 上&lt;/strong&gt;——本文只有一个 brokerName，所以这 4 条都在 broker-a 上。&lt;/p&gt;
&lt;p&gt;读与写是两个独立数字，因为它们在客户端各管一段：拉回路由时，发送那侧只取&lt;strong&gt;写&lt;/strong&gt;队列摊成发送列表，消费那侧只取&lt;strong&gt;读&lt;/strong&gt;队列摊成订阅列表，两个列表可以不一样（本文都填 4，看不出差别）。&lt;/p&gt;
&lt;p&gt;队列不是消息的容器，是消息的&lt;strong&gt;归属&lt;/strong&gt;：开 4 条队列，等于把「谁来写、谁来读、按什么顺序读」切成 4 份。发送时挑一份写进去；消费时每个实例领一份，&lt;strong&gt;同一个消费组内，一条队列同一时刻只归一个消费实例&lt;/strong&gt;。这里说的是集群消费模式；广播模式不同，每个实例都消费全部队列，位点也保存在本地（第七节）。&lt;/p&gt;
&lt;p&gt;它管归属，不管消息存在哪：磁盘上它只留下一个索引目录 &lt;code&gt;consumequeue/&amp;lt;topic&amp;gt;/&amp;lt;queueId&amp;gt;/&lt;/code&gt;，&lt;strong&gt;那个目录就是队列唯一的物理残留&lt;/strong&gt;，消息本体落在哪一层是第四节的事。&lt;/p&gt;
&lt;p&gt;&lt;img alt="同一个主题的 4 条队列在三处是三样东西：发送侧是 4 个写队列，客户端挑一条写入；消费侧是 4 个读队列，两个实例各领两条、一条同时只归一个实例；存储侧只剩 4 个索引目录和一份共用的 CommitLog" class="gallery-image" data-flex-basis="516px" data-flex-grow="215" decoding="async" height="316" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://zh.renxinblog.cn/images/gyd-c04-topic-and-queues.svg" width="680"&gt;&lt;/p&gt;
&lt;p&gt;图上把同一个主题的 4 条队列画成了三副样子：发送侧写、消费侧读、存储侧只剩目录。这个数还能拿来自查：主题建 4 条队列，发送侧就摊出 4 条并行车道；消费侧（同一消费组内）最多 4 个实例真正并行，第 5 个分不到队列、只能空转（第七节）；顺序消息的并发上限同样是 4。&lt;strong&gt;三个 4 是同一个 4&lt;/strong&gt;——队列数在消费侧就是并行度上限，它在第三节被定死，第八节收账。&lt;/p&gt;

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

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

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

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

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

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

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

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

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

 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;边注&lt;/strong&gt;：日志文件内部有三个独立游标（写入位置、已提交位置、已刷盘位置）。它们的差值决定掉电会丢哪一段，也正好是刷盘与复制那两个开关在等的东西，属于结构推导。&lt;/p&gt;

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

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

 &lt;/blockquote&gt;
&lt;h3 id="消费失败之后"&gt;消费失败之后
&lt;/h3&gt;&lt;p&gt;前面只讲了「监听器成功、位点提交」这条成功路径。监听器返回失败（&lt;code&gt;RECONSUME_LATER&lt;/code&gt;，或抛异常）时，消息不是被丢掉，而是进一条&lt;strong&gt;重试链路&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;第一次失败，消息被改投到该消费组的&lt;strong&gt;重试主题&lt;/strong&gt; &lt;code&gt;%RETRY%&amp;lt;消费组名&amp;gt;&lt;/code&gt;，投递次数写进消息属性，等一个退避间隔后再次投回原主题；&lt;/li&gt;
&lt;li&gt;重试主题也有自己的消费队列，这条链路继续由原消费者消费，位点在重试主题上按组记录；&lt;/li&gt;
&lt;li&gt;投递次数累计到一个上限（默认 16 次）后，消息被改投到&lt;strong&gt;死信主题&lt;/strong&gt; &lt;code&gt;%DLQ%&amp;lt;消费组名&amp;gt;&lt;/code&gt;，不再自动重试，等人手工介入。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这一小段为什么关键，因为它把「至少一次」和「消费端幂等」钉在了一起：重试会带来&lt;strong&gt;同一消息被多次投递&lt;/strong&gt;，业务处理必须幂等；第三节说「发送侧只给 at-least-once」，这里就是重复在消费侧的具体落点——不是发送重试造成的，是消费失败重投造成的。它也和第三节的问题 1 首尾相接：事务消息不解决重复，解决重复的是幂等，而幂等正是被这条重试链路逼出来的。&lt;/p&gt;
&lt;p&gt;顺序消费里，失败还有一层额外后果：顺序消费的队列被锁住、逐批串行，一批里的某条失败会让&lt;strong&gt;整条队列停在这个位置&lt;/strong&gt;，后续消息被挡住，直到这一条被处理成功或达到死信上限。所以顺序消费对「某一条能不能被可靠处理」的敏感度，比并发消费高得多。&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;边注&lt;/strong&gt;：重试的退避间隔和次数上限（&lt;code&gt;RETRY&lt;/code&gt; 主题的队列数、&lt;code&gt;delayLevelWhenNextConsume&lt;/code&gt; 等）都是消费组级的配置，默认行为是「逐次退避、16 次进死信」，具体档位可调。死信主题不会被普通消费者订阅，需要单独拉取或走运维通道。&lt;/p&gt;

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

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

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

 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;演示代码&lt;/strong&gt;：完整实现在 &lt;a class="link" href="https://github.com/renxin2024/GYD/tree/main/c04-rocketmq-architecture" target="_blank" rel="noopener"
 &gt;GYD 仓库 &lt;code&gt;c04-rocketmq-architecture&lt;/code&gt;&lt;/a&gt;，RocketMQ 集群的编排在仓库级 &lt;a class="link" href="https://github.com/renxin2024/GYD/tree/main/infra/rocketmq" target="_blank" rel="noopener"
 &gt;&lt;code&gt;infra/rocketmq/&lt;/code&gt;&lt;/a&gt;。正文里括注的名字分两类：&lt;code&gt;failmode&lt;/code&gt; / &lt;code&gt;orderly&lt;/code&gt; / &lt;code&gt;offsetprobe&lt;/code&gt; 是模块里的示例名，在 GYD 仓根用 &lt;code&gt;./gradlew :c04-rocketmq-architecture:run --args=&amp;quot;&amp;lt;示例名&amp;gt;&amp;quot;&lt;/code&gt; 跑（JDK 21 需显式指定 &lt;code&gt;JAVA_HOME&lt;/code&gt;）；&lt;code&gt;inspect-storage&lt;/code&gt; / &lt;code&gt;ha-failover&lt;/code&gt; 是三个 docker 层对照实验（端口映射、硬杀主节点、磁盘逐字节巡检）的巡检名。每个示例做了什么、各结论对应的完整输出记录、以及对照实验的做法与读数，都在该模块的 README 里。&lt;/p&gt;

 &lt;/blockquote&gt;</description></item><item><title>第三话｜跨行清算对账：跟你跑通一个对账流水线，亲手拆解分布式一致性的工程骨架</title><link>https://zh.renxinblog.cn/post/gyd-c03-reconciliation/</link><pubDate>Sun, 15 Feb 2026 00:00:00 +0000</pubDate><guid>https://zh.renxinblog.cn/post/gyd-c03-reconciliation/</guid><description>&lt;img src="https://zh.renxinblog.cn/images/gyd-c03-reconciliation-cover.png" alt="Featured image of post 第三话｜跨行清算对账：跟你跑通一个对账流水线，亲手拆解分布式一致性的工程骨架" /&gt;&lt;p&gt;A 从工行转 10,000 元给 B 的建行账户。工行 App 显示扣款成功，建行的到账短信也到了，两边客户看到的都是「成功」。&lt;/p&gt;
&lt;p&gt;第二天日终对账跑完，报表上多出一行差异：建行侧这笔交易有两条分录（账本上的记账记录），B 的账户多出一万块。钱只转了一次。那条多出来的分录是哪来的？谁负责把它消掉？能自动消掉吗？&lt;/p&gt;
&lt;p&gt;这三个问题，对账系统都得回答。而回答之前，得先知道这笔转账在哪几个地方各记了什么——它不是一个系统写一行数据，是三个互不信任的系统各写各的账。&lt;/p&gt;
&lt;p&gt;GYD 这个系列讲分布式系统里各种解决方案的实现方式。上一篇讲了消息队列的可靠投递——一条消息从发出到被处理，中间可能死在三段里。当消息可靠地到达了，新的问题浮现：两边的账还是可能对不上，你怎么发现、怎么分级处理、怎么修正？&lt;/p&gt;
&lt;p&gt;对账这件事，本质上不是在检查「账有没有记错」——它是在问一个更根本的问题：&lt;strong&gt;在没有全局一致性的分布式环境里，你用什么手段去证明系统的状态是对的？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;建一个最小可行的对账流水线：两个银行服务、各自的借贷账本、一个模拟结算服务、一个日终对账 Job。跑起来，亲手看到对账怎么发现差异、怎么分级决策、怎么追加式更正。不追求完整银行核心系统——那太大了——而是聚焦对账最核心的口径、匹配和分级处理。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="一一笔跨行转账动了三个账本"&gt;一、一笔跨行转账，动了三个账本
&lt;/h2&gt;&lt;p&gt;回到那笔 10,000 元。表面上看，就是工行的一个账户余额减了，建行的一个账户余额加了。但这个简化版本漏掉了最关键的一环：&lt;strong&gt;银行间的结算&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;先说清一件容易被跳过的事：工行不会用运钞车把一万块现金送到建行。银行之间的「钱」，是它们在央行开的账户里的数字。每家商业银行都在央行有一个&lt;strong&gt;结算账户&lt;/strong&gt;——好比公司里每个部门都在财务总部存了一笔备用金，跨部门的结算由总部在这个池子里挪数字。&lt;/p&gt;
&lt;p&gt;所以一笔跨行转账，实际有&lt;strong&gt;三个记账主体&lt;/strong&gt;，各自维护一个独立数据库，互相看不到对方的账：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;记账主体&lt;/th&gt;
					&lt;th&gt;账本上记的是什么&lt;/th&gt;
					&lt;th&gt;谁维护&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;工行核心系统&lt;/td&gt;
					&lt;td&gt;A 的客户存款 + 工行存放央行的钱&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;建行存放央行的钱 + B 的客户存款&lt;/td&gt;
					&lt;td&gt;建行&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id="三幕记账"&gt;三幕记账
&lt;/h3&gt;&lt;p&gt;时间线上分三幕，每一幕各写一组分录。分录（journal entry）就是账本上的一行记账记录，它的约束第二节细讲。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第一幕：A 点转账，工行记账。&lt;/strong&gt; 工行校验 A 余额充足，在自己的账本上写入两条：A 的客户存款减少一万，工行存放央行的钱减少一万。此时钱还没易手，只是工行单方面记下了「我要付一万出去」。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第二幕：工行把支付指令发给央行，央行记账。&lt;/strong&gt; 央行检查工行结算账户余额够不够，够则同时改两户：工行的结算账户减一万，建行的结算账户加一万。&lt;strong&gt;这一步完成，银行间资金交割就「最终」了&lt;/strong&gt;——在央行的制度里这叫结算最终性，记入央行账户后不可撤销。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第三幕：央行通知建行，建行记账。&lt;/strong&gt; 建行收到入账通知，确认 B 账户状态正常，在自己的账本上写入两条：建行存放央行的钱增加一万，B 的客户存款增加一万。之后建行回执、工行给 A 弹「转账成功」——那是通知，不是记账。&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;%%{init: {'theme':'neutral'}}%%
sequenceDiagram
 autonumber
 participant A as A（工行客户）
 participant ICBC as 工行核心系统
 participant PBC as 央行支付系统
 participant CCB as 建行核心系统
 participant B as B（建行客户）

 A-&gt;&gt;ICBC: 发起转账 10,000 元
 ICBC-&gt;&gt;ICBC: 记账：客户存款-A 减 / 存放央行 减
 ICBC-&gt;&gt;PBC: 支付指令
 PBC-&gt;&gt;PBC: 记账：结算存款-工行 减 / 结算存款-建行 加
 Note over PBC: 资金真正易手，结算最终性成立
 PBC-&gt;&gt;CCB: 入账通知
 CCB-&gt;&gt;CCB: 记账：存放央行 加 / 客户存款-B 加
 CCB--&gt;&gt;B: 到账短信
 ICBC--&gt;&gt;A: 转账成功&lt;/pre&gt;&lt;p&gt;三幕走完，四个账户余额发生了变化：A 的存款 −1 万、工行在央行的结算户 −1 万、建行在央行的结算户 +1 万、B 的存款 +1 万。钱没有消失也没有多出，只是欠条链条换了人。&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;p&gt;对账要跨的就是这种缝：三本账各记各的，同一个事实在不同账本里长得完全不一样。&lt;/p&gt;
&lt;h3 id="为什么裁判只能是央行"&gt;为什么裁判只能是央行
&lt;/h3&gt;&lt;p&gt;既然工行和建行各记各的账，为什么不能两边互相对一下就完事？&lt;/p&gt;
&lt;p&gt;假设只让两家互对：工行说「我转了」，建行说「我没收到」，信谁？两家都是这笔交易的利益相关方，漏记、错记的一方有动机把责任推给对方。更糟的情况是两边&lt;strong&gt;都&lt;/strong&gt;记错了——比如入账通知丢失导致两边都没入账，互对反而「对平了」，两家错得整整齐齐，谁也不知道出了问题。&lt;/p&gt;
&lt;p&gt;央行不一样：它在这笔交易里没有利益瓜葛，而且是&lt;strong&gt;资金真正动过的那个地方&lt;/strong&gt;。第一幕和第三幕都只是各家自己账本上的数字变化，第二幕才是钱真的易手。既然「钱到底转没转成」这个动作只发生在央行账上，这个问题的裁判也只能是央行账。&lt;/p&gt;
&lt;p&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;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;断在哪&lt;/th&gt;
					&lt;th&gt;后果&lt;/th&gt;
					&lt;th&gt;对账看到的现象&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;第一幕成功、指令没发出去&lt;/td&gt;
					&lt;td&gt;工行扣了 A 的钱，央行和建行都没动静&lt;/td&gt;
					&lt;td&gt;付款行有分录、结算侧无记录&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;第二幕成功、入账通知丢了&lt;/td&gt;
					&lt;td&gt;钱已到建行在央行的户头，但 B 没到账&lt;/td&gt;
					&lt;td&gt;结算已确认、收款行漏记&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;通知重复投递、消费端没做好幂等&lt;/td&gt;
					&lt;td&gt;B 到账两万&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;h3 id="demo-的架构"&gt;demo 的架构
&lt;/h3&gt;&lt;p&gt;三幕在 demo 里的落点：&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;%%{init: {'theme':'neutral'}}%%
flowchart LR
 ICBC["工行服务&lt;br/&gt;客户存款账本"]
 CCB["建行服务&lt;br/&gt;客户存款账本"]
 SETTLE["模拟结算服务&lt;br/&gt;清算结果记录"]
 MQ["RabbitMQ&lt;br/&gt;清算通道"]
 RECON["对账 Job&lt;br/&gt;日终逐笔匹配"]
 
 SETTLE --&gt;|"分别投递"| MQ
 MQ --&gt;|"消费记账"| ICBC
 MQ --&gt;|"消费记账"| CCB
 RECON --&gt;|"REST 拉取当日数据"| ICBC
 RECON --&gt;|"REST 拉取当日数据"| CCB
 SETTLE -.-&gt;|"对账 Job 同进程&lt;br/&gt;直接读本地库"| RECON&lt;/pre&gt;&lt;p&gt;模拟结算服务充当那个「央行的角色」，产生每笔转账的清算结果。RabbitMQ 只是传递这些指令的通道——它不代替结算服务的权威性。对账 Job 从两家银行各拉一份数据，再加上自己进程内的结算记录，凑齐三方才能进入真正的匹配逻辑。&lt;/p&gt;
&lt;p&gt;有一处和真实世界的差异要说清楚：现实中是「工行&lt;strong&gt;主动向&lt;/strong&gt;央行发指令，央行结算后&lt;strong&gt;通知&lt;/strong&gt;建行」，方向和顺序都固定；demo 里是结算服务生成结果后通过 RabbitMQ &lt;strong&gt;分别投递&lt;/strong&gt;给两家银行记账，两家都是被动消费。方向不同，但「三方各记各账、靠同一个交易流水号关联」的骨架一致。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="二单方账本的约束借贷记账法的边界"&gt;二、单方账本的约束：借贷记账法的边界
&lt;/h2&gt;&lt;p&gt;在讲跨行对账之前，先要弄清楚一件事：&lt;strong&gt;一家银行自己的账本，凭什么说自己是「自洽」的？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;答案是一套五百多年前发明的记账规则：借贷记账法（Double-Entry Bookkeeping）。&lt;/p&gt;
&lt;h3 id="先把三个词落地"&gt;先把三个词落地
&lt;/h3&gt;&lt;p&gt;上一节说「工行写入两条」，那两条长什么样？要看懂它，得先落地三个词。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;科目&lt;/strong&gt;：账本上的分类抽屉。银行账本上不写「张三」「李四」，而写「客户存款」「存放央行款项」这样的类别，每一类是一个科目，下面再挂具体账户。第一节里工行记的那两条，科目分别是「客户存款」和「存放央行款项」。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;资产和负债&lt;/strong&gt;：「存放央行款项」是资产——钱存在别人那里，是别人欠银行的。「客户存款」是负债——客户存进来的钱是银行欠客户的，客户随时可以取走。你存 1 万，是银行欠你 1 万，这笔钱在银行账上从来不是「银行的钱」。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;借和贷&lt;/strong&gt;：跟借钱、贷款没有任何关系。它们是会计里的两个方向符号，相当于左和右，五百年前意大利商人记账时定下的习惯，沿用至今。方向怎么定，规则只有两条：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;&lt;/th&gt;
					&lt;th&gt;增加记&lt;/th&gt;
					&lt;th&gt;减少记&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;资产&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;strong&gt;贷&lt;/strong&gt;。第三幕反过来：存放央行是资产，增加记&lt;strong&gt;借&lt;/strong&gt;；客户存款是负债，增加记&lt;strong&gt;贷&lt;/strong&gt;。&lt;/p&gt;
&lt;h3 id="借贷记账法做了什么"&gt;借贷记账法做了什么
&lt;/h3&gt;&lt;p&gt;每笔账务交易，必须至少产生两条分录——一条记借方（Debit），一条记贷方（Credit），这两条的总金额相等、方向相反。&lt;/p&gt;
&lt;p&gt;转账那笔 10,000 元，在工行侧产生的分录是：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;方向&lt;/th&gt;
					&lt;th&gt;科目&lt;/th&gt;
					&lt;th&gt;金额&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;借&lt;/td&gt;
					&lt;td&gt;客户存款（负债减少）&lt;/td&gt;
					&lt;td&gt;¥10,000&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;贷&lt;/td&gt;
					&lt;td&gt;存放央行款项（资产减少）&lt;/td&gt;
					&lt;td&gt;¥10,000&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;两条分录的金额相等，借贷平衡。把工行所有账务分录的借方加总、贷方加总，两者必须相等——这就是「试算平衡」。&lt;/p&gt;
&lt;p&gt;为什么钱必须记成对？因为钱不会凭空出现——它一定是「从某处来、到某处去」。只记「A 少了一万」而不记「这一万去了哪」，账本就没交代这笔钱的去向。借贷成对，等于强制每一笔都写清来源和去向。&lt;/p&gt;
&lt;p&gt;这个约束的价值在于：&lt;strong&gt;任何一笔账务操作，要么借方和贷方一起写入（交易提交），要么一起不写（交易回滚）。&lt;/strong&gt; 数据库的原子提交保证了这一点。所以「借记了一笔但忘了记对应的贷方」在正常运行的系统中不会发生——要么都记，要么都不记。&lt;/p&gt;
&lt;p&gt;在 demo 里，这个约束落在 &lt;code&gt;LedgerService.postEntries&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;// bank/LedgerService.java（节选）&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@Transactional&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&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;postEntries&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;JournalEntry&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;entries&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="kt"&gt;long&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;debitSum&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;entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&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="na"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&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="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getDirection&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;Direction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;DEBIT&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="na"&gt;mapToLong&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JournalEntry&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;getAmountCents&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="na"&gt;sum&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;creditSum&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;entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&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="na"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&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="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getDirection&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;Direction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CREDIT&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="na"&gt;mapToLong&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JournalEntry&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;getAmountCents&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="na"&gt;sum&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="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;debitSum&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;creditSum&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="k"&gt;throw&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;UnbalancedException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;debitSum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;creditSum&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="c1"&gt;// 借贷平衡 + 原子提交：所有分录在同一事务中写入&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;saveAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entries&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;/p&gt;
&lt;ol&gt;
&lt;li&gt;金额用的是 &lt;code&gt;long&lt;/code&gt;（最小货币单位「分」），不是 &lt;code&gt;BigDecimal&lt;/code&gt; 也不是浮点。这不是「贬低 DECIMAL」——DECIMAL 本身是精确的定点数，银行系统里也广泛使用。demo 选 &lt;code&gt;long&lt;/code&gt; 分是因为它足够表达整数金额、且避免了跨语言序列化时定点数精度不一致的坑。这是&lt;strong&gt;工程取舍&lt;/strong&gt;，不是「浮点不安全所以只能选 long」。&lt;/li&gt;
&lt;li&gt;借贷平衡的校验在应用层完成，PostgreSQL 的普通 &lt;code&gt;CHECK&lt;/code&gt; 约束无法跨行校验「借方合计 == 贷方合计」——&lt;code&gt;CHECK&lt;/code&gt; 只能约束单行的取值范围。这是数据库约束的边界，需要应用层补上。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;借贷平衡 + 原子提交，保证了单方账本不会出现「凭空多了一笔钱或凭空少了一笔钱」。&lt;strong&gt;但它的保护范围到此为止。&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id="借贷平衡不能发现的问题"&gt;借贷平衡不能发现的问题
&lt;/h3&gt;&lt;p&gt;试算平衡验证的是账务的内部自洽性，不是业务正确性。以下四类错误，账都是平的——借贷合计仍然相等：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;整笔漏记&lt;/strong&gt;：转账根本没入账。借方和贷方都没有，当然平衡。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;整笔重复入账&lt;/strong&gt;：同一笔转账被记了两次。第一次的借贷平衡，第二次的借贷也平衡，两次合起来仍然平衡。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;记错科目&lt;/strong&gt;：本该借记客户存款，却借记了另一个负债科目。借贷合计对得上，但客户的实际余额不对。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;两侧金额同时记错&lt;/strong&gt;：本来该记 10,000 元，两侧都记成了 1,000 元。借贷平衡，金额错误。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这四类正好覆盖第一节那三个断点：指令没发出去是整笔漏记，通知重复投递是整笔重复入账。&lt;strong&gt;它们全是异步链条的正常故障模式，而在单方账本上全都是「平的」——这就是为什么必须有跨方核对。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;试算平衡就像一道数学题的自检验算——能证明「我的计算过程没有低级算术错误」，但不能证明「我的算式列对了」。要证明「列对了」，需要和外部数据进行比对。这就是对账要解决的。&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;%%{init: {'theme':'neutral'}}%%
flowchart TB
 subgraph PASS["「平了」但有问题"]
 direction LR
 P1["整笔漏记"]
 P2["整笔重复"]
 P3["记错科目"]
 P4["金额同时记错"]
 end
 subgraph FAIL["「不平」才是报警"]
 direction LR
 F1["单侧多记"]
 F2["金额单侧偏差"]
 end
 CHECK["借贷平衡校验"] --&gt; PASS
 CHECK --&gt; FAIL&lt;/pre&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;strong&gt;什么时候对账？&lt;/strong&gt; 很多人把「清算的实时性」和「对账的时效」混为一谈。&lt;/p&gt;
&lt;p&gt;先用一个生活类比把三个词分开。你和同事这个月互相请客：算清楚「他欠我 80 块」是清算，他真的微信转你 80 是结算，月底翻聊天记录和账单确认「确实是 80，不是 180，也没重复算」是对账。&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;清算（Clearing）&lt;/td&gt;
					&lt;td&gt;计算各方的应收应付义务&lt;/td&gt;
					&lt;td&gt;HVPS（大额支付系统）逐笔实时，BEPS（小额支付系统）批量轧差&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;结算（Settlement）&lt;/td&gt;
					&lt;td&gt;通过资金账户完成义务清偿&lt;/td&gt;
					&lt;td&gt;大额实时，小额定时净额&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;对账（Reconciliation）&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;大额支付系统（HVPS）逐笔实时全额清算，每笔大额转账完成时就已经决定了各家银行的应收应付义务，银行间的结算也是实时的。小额支付系统（BEPS）用批量轧差——工行今天欠建行 80、建行欠工行 50，不必转两次，轧一下只转净差额 30；把一段时间里所有小额交易的借方和贷方分别汇总，只结算这个净额。&lt;/p&gt;
&lt;p&gt;但不管清算和结算是实时的还是批量的，对账这件事都不一定跟它们同步。&lt;strong&gt;对账的时效是一个独立的工程选择&lt;/strong&gt;，取决于你愿意在多大延迟内发现差异、差异出现时系统里堆积了多少未核对数据。&lt;/p&gt;
&lt;p&gt;网联清算平台的做法很有代表性：它的交易是实时转接的，但对账按小时切分成多个批次，每个批次独立处理。好处是把一天的量打散到 24 小时里分别核对，避免了日终集中处理的性能洪峰，也让差异能在小时内被发现、而不是等到第二天。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;我们这个 demo 选择日终批次。&lt;/strong&gt; 不是因为「银行都是这样做的」，而是因为日终批次是理解对账口径最清晰的模型：定义一个业务日的截止点，拉取那一天的完整数据，逐笔匹配，对所有差异做出分类和处理。至于这个「日」是 24 小时、1 小时还是一刻钟，口径不变，只改调度频率。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="四简化对账流水线的实现"&gt;四、简化对账流水线的实现
&lt;/h2&gt;&lt;p&gt;好了，原理讲完，开始跑代码。整个 demo 的结构如下：&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;th&gt;端口&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;gyd-c03-settlement&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;模拟结算服务：生成每笔转账的清算结果记录&lt;/td&gt;
					&lt;td&gt;央行（第二幕的记账者 + 权威记录源）&lt;/td&gt;
					&lt;td&gt;18080&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;gyd-c03-bank&lt;/code&gt;（bank-a profile）&lt;/td&gt;
					&lt;td&gt;付款行（工行）：维护客户存款账本，消费清算指令记账&lt;/td&gt;
					&lt;td&gt;工行（第一幕）&lt;/td&gt;
					&lt;td&gt;18081&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;gyd-c03-bank&lt;/code&gt;（bank-b profile）&lt;/td&gt;
					&lt;td&gt;收款行（建行）：维护客户存款账本，消费清算指令记账&lt;/td&gt;
					&lt;td&gt;建行（第三幕）&lt;/td&gt;
					&lt;td&gt;18082&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;ReconJob&lt;/code&gt;（结算服务内的组件）&lt;/td&gt;
					&lt;td&gt;对账任务：定时拉取三方数据，逐笔匹配，分级处理差异&lt;/td&gt;
					&lt;td&gt;事后核对，不属于三幕&lt;/td&gt;
					&lt;td&gt;随结算服务同进程（Spring Scheduler）&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;三个业务服务各自有独立的 PostgreSQL 数据库，中间通过 RabbitMQ 传递清算指令。对账 Job 随结算服务一起运行——它直接读本进程内的结算记录表（权威数据源），银行侧的分录则通过 REST API 拉取。&lt;/p&gt;
&lt;h3 id="数据库表设计"&gt;数据库表设计
&lt;/h3&gt;&lt;p&gt;每家银行的账本结构相同：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- 账务分录表（append-only，已入账的行永不修改）
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;journal_entry&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;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;BIGSERIAL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&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;entry_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;UNIQUE&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&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;trade_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 清算指令 ID（关联结算记录）
&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;account_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;direction&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;CHECK&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;direction&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;DEBIT&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;CREDIT&amp;#39;&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;amount_cents&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&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&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;trade_time&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&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&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;booked_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 记账时间（应用层 Instant.now() 写入）
&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;reversal_of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&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&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="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INDEX&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;idx_entry_trade&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;journal_entry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trade_id&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;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INDEX&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;idx_entry_booked&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;journal_entry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;booked_at&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;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&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;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;settlement_record&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;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;BIGSERIAL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&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;trade_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 清算指令 ID
&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;from_bank&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;to_bank&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;amount_cents&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;settled_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&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&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;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 结算状态，SETTLED = 已结算（应用层默认）
&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;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;金额用 &lt;code&gt;BIGINT&lt;/code&gt; 存分&lt;/strong&gt;（最小货币单位），不是浮点。浮点确实不能用于金额——多笔累加后的舍入误差会制造假差异。但 &lt;code&gt;DECIMAL&lt;/code&gt; 本身是精确的定点数，银行系统里也广泛使用，这里选 &lt;code&gt;BIGINT&lt;/code&gt; 分而不是 &lt;code&gt;DECIMAL&lt;/code&gt;，是为了让跨语言（Java ↔ SQL ↔ JSON）序列化时金额精度完全一致，减少一个出错面。这是取舍，不是说 &lt;code&gt;DECIMAL&lt;/code&gt; 不能用。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;entry_id&lt;/code&gt; 是业务身份&lt;/strong&gt;。这是对账匹配的核心——同一个 &lt;code&gt;trade_id&lt;/code&gt; 在付款行和收款行各自产生分录，各自的 &lt;code&gt;entry_id&lt;/code&gt; 不同，但都关联到同一个 &lt;code&gt;trade_id&lt;/code&gt;。对账靠 &lt;code&gt;trade_id&lt;/code&gt; 匹配。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;reversal_of&lt;/code&gt; 链&lt;/strong&gt;。冲正分录指向被冲正的原始 &lt;code&gt;entry_id&lt;/code&gt;，形成完整的更正链路。对账的闭环验收靠这个字段追溯。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id="对账-job-的核心逻辑"&gt;对账 Job 的核心逻辑
&lt;/h3&gt;&lt;p&gt;对账 Job 定时执行（demo 里用 &lt;code&gt;fixedDelay&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;// settlement/recon/ReconJob.java（节选，完整代码见仓库）&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@Scheduled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fixedDelayString&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="s"&gt;&amp;#34;${gyd.c03.recon.schedule-ms:60000}&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 class="n"&gt;initialDelayString&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="s"&gt;&amp;#34;${gyd.c03.recon.initial-delay-ms:30000}&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="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;run&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;LocalDate&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;today&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;LocalDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&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;Instant&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;start&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;today&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;atStartOfDay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ZoneOffset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;UTC&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="na"&gt;toInstant&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;Instant&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;end&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;today&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;plusDays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="na"&gt;atStartOfDay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ZoneOffset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;UTC&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="na"&gt;toInstant&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="c1"&gt;// 1. 拉取三方数据（银行通过 REST，结算记录直接查本地库）&lt;/span&gt;&lt;span class="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;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SettlementRecord&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;settlements&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;settlementRepo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findSettledBetween&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;end&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;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;JournalEntry&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;bankAEntries&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;fetchBankEntries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bankAUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;end&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;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;JournalEntry&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;bankBEntries&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;fetchBankEntries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bankBUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;end&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="c1"&gt;// 2. 以 trade_id 为键建立三方索引&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&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;JournalEntry&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;aByTrade&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;groupByTrade&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bankAEntries&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&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;JournalEntry&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bByTrade&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;groupByTrade&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bankBEntries&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;SettlementRecord&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;settlementMap&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;settlements&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&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="na"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Collectors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toMap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SettlementRecord&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;getTradeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s&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="n"&gt;s&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="n"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;String&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;allTradeIds&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;HashSet&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settlementMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;keySet&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;allTradeIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;aByTrade&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;keySet&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;allTradeIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bByTrade&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;keySet&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="c1"&gt;// 3. 逐笔匹配：结算侧无记录单独判，其余交给 checkBank 按科目核对&lt;/span&gt;&lt;span class="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;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ReconDiff&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;diffs&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;ArrayList&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;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;String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;tradeId&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;allTradeIds&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;SettlementRecord&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;settlement&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;settlementMap&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;tradeId&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;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;JournalEntry&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;aEntries&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;aByTrade&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getOrDefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tradeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;&lt;span class="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;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;JournalEntry&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;bEntries&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;bByTrade&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getOrDefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tradeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;&lt;span class="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="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;settlement&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="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="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;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;aEntries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&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;diffs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&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;ReconDiff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tradeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ReconDiffType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;NO_SETTLEMENT&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;Bank&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ICBC&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;entryIds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;aEntries&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="n"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="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="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;bEntries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&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;diffs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&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;ReconDiff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tradeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ReconDiffType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;NO_SETTLEMENT&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;Bank&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CCB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;entryIds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bEntries&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="n"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;continue&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="n"&gt;checkBank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;aEntries&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;settlement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Bank&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ICBC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;tradeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;diffs&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;checkBank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bEntries&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;settlement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Bank&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CCB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;tradeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;diffs&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="c1"&gt;// 4. 分级处理差异（processDiff 按类型决策）&lt;/span&gt;&lt;span class="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;ReconDiff&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;diff&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;diffs&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;processDiff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;settlementMap&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="c1"&gt;// 5. 闭环验收&lt;/span&gt;&lt;span class="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;verifyClosedLoop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;diffs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;settlementMap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;end&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;checkBank&lt;/code&gt; 里的匹配规则：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;结算侧有没有记录，在主循环里单独判&lt;/strong&gt;，不在 &lt;code&gt;checkBank&lt;/code&gt; 里：&lt;code&gt;findSettledBetween&lt;/code&gt; 已经过滤掉非 &lt;code&gt;SETTLED&lt;/code&gt; 的记录，所以「银行有分录、结算侧查不到」直接标 &lt;code&gt;NO_SETTLEMENT&lt;/code&gt;（待查）。&lt;code&gt;checkBank&lt;/code&gt; 只负责单侧的逐科目核对。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;本行的角色从结算记录的 &lt;code&gt;fromBank&lt;/code&gt; / &lt;code&gt;toBank&lt;/code&gt; 推导&lt;/strong&gt;，不写死「工行一定是借方」——反向交易（建行付给工行）时，两个科目的方向会整体反过来。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;按科目逐个核对&lt;/strong&gt;，不是只看一个方向：本行这笔交易应该在「客户存款」和「存放央行款项」上各有一条净分录，预期方向由 &lt;code&gt;account.directionFor(movement)&lt;/code&gt; 推导。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;金额以结算记录为准&lt;/strong&gt;（权威数据源）。某科目上没有分录 → &lt;code&gt;MISSING&lt;/code&gt;；多于一条净分录 → &lt;code&gt;DUPLICATE&lt;/code&gt;；方向与预期不符 → &lt;code&gt;DIRECTION_MISMATCH&lt;/code&gt;；金额不符 → &lt;code&gt;AMOUNT_MISMATCH&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只比「净分录」&lt;/strong&gt;——已被冲正的原始分录不参与匹配，这一步在银行侧的查询里就完成了（&lt;code&gt;findNetByBookedAtBetween&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;金额不作为唯一匹配依据&lt;/strong&gt;——如果金额匹配但 &lt;code&gt;trade_id&lt;/code&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;/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;有、状态=SETTLED&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;有、状态=SETTLED&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;有、状态=SETTLED&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;有、状态=SETTLED&lt;/td&gt;
					&lt;td&gt;方向与角色不符（金额对得上）&lt;/td&gt;
					&lt;td&gt;同左&lt;/td&gt;
					&lt;td&gt;方向记反（&lt;code&gt;DIRECTION_MISMATCH&lt;/code&gt;）&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;有、状态=SETTLED&lt;/td&gt;
					&lt;td&gt;重复（同一 trade_id 多笔）&lt;/td&gt;
					&lt;td&gt;—&lt;/td&gt;
					&lt;td&gt;付款行重复入账&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;有、状态=SETTLED&lt;/td&gt;
					&lt;td&gt;—&lt;/td&gt;
					&lt;td&gt;重复（同一 trade_id 多笔）&lt;/td&gt;
					&lt;td&gt;收款行重复入账&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;无或状态≠SETTLED&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;有、状态=SETTLED&lt;/td&gt;
					&lt;td&gt;在途（trade_time 在截止点附近）&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;p&gt;这里有一处代码与表格的落差，直说：&lt;code&gt;IN_FLIGHT&lt;/code&gt;（在途）作为差异类型和处理分支是存在的——&lt;code&gt;processDiff&lt;/code&gt; 对它只发 &lt;code&gt;[P2][在途]&lt;/code&gt; 告警、不动账，闭环验收也不把它算进失败——但 &lt;code&gt;checkBank&lt;/code&gt; 当前并不会主动产出这一类差异。它实际判定的是 &lt;code&gt;MISSING&lt;/code&gt;（漏记）、&lt;code&gt;DUPLICATE&lt;/code&gt;（重复入账）、&lt;code&gt;DIRECTION_MISMATCH&lt;/code&gt;（方向记反）、&lt;code&gt;AMOUNT_MISMATCH&lt;/code&gt;（金额不符）四类，主循环另判 &lt;code&gt;NO_SETTLEMENT&lt;/code&gt;（结算侧无记录）。主动判定在途需要一套「交易发起时间 vs 对账截止点」的口径规则，属于对账口径设计，留到讲口径的那一篇。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="五差异不是错误分级处理不是统一冲正"&gt;五、差异不是错误：分级处理，不是统一冲正
&lt;/h2&gt;&lt;p&gt;对账最容易被误解的地方是：&lt;strong&gt;发现了差异，不代表知道了问题。&lt;/strong&gt; 差异只是一个信号——两侧记录不一致。至于不一致的原因，是漏记、多记、记错、还是数据还没到，对账本身无法区分。&lt;/p&gt;
&lt;p&gt;回到开篇那个场景：报表上说建行侧多了一条分录。这条信息只告诉你「两边对不上」，它没告诉你该不该动、动哪一条、动了之后钱会不会退回去。&lt;/p&gt;
&lt;h3 id="分级决策模型"&gt;分级决策模型
&lt;/h3&gt;&lt;p&gt;对账 Job 对差异的处理不是「有一个差异就冲一笔」。它先判断这个差异的类型，再做对应动作：&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;%%{init: {'theme':'neutral'}}%%
flowchart TB
 DIFF["对账发现差异"]
 
 DIFF --&gt; Q1{"结算侧&lt;br/&gt;有对应记录？"}
 Q1 --&gt;|"无"| WAIT1["待查：标记为 pending&lt;br/&gt;下一批次重新核对"]
 Q1 --&gt;|"有"| Q2{"结算状态&lt;br/&gt;是 SETTLED？"}
 
 Q2 --&gt;|"否"| WAIT2["等待：交易仍在途&lt;br/&gt;下一批次重新核对"]
 Q2 --&gt;|"是"| Q3{"哪一侧&lt;br/&gt;不一致？"}
 
 Q3 --&gt;|"付款行缺分录"| ACT1["补记：根据结算记录&lt;br/&gt;补入借记分录"]
 Q3 --&gt;|"收款行缺分录"| ACT2["补记：根据结算记录&lt;br/&gt;补入贷记分录"]
 Q3 --&gt;|"金额不符"| ACT3["挂起：查证原指令&lt;br/&gt;人工裁决后修正"]
 Q3 --&gt;|"重复入账"| ACT4["挂起：无法判断哪组多余&lt;br/&gt;人工裁决后冲正"]&lt;/pre&gt;&lt;p&gt;这个决策树的每一层都依赖一个前提：&lt;strong&gt;结算记录是权威数据源。&lt;/strong&gt; 结算侧说这笔钱该付、该转，那它就是事实。银行分录如果缺了、多了、金额错了，以此为基准修正。&lt;/p&gt;
&lt;p&gt;但反过来，如果结算侧没有记录、或状态不是 &lt;code&gt;SETTLED&lt;/code&gt;，对账 Job &lt;strong&gt;不做任何自动修正&lt;/strong&gt;——它记一条 &lt;code&gt;NO_SETTLEMENT&lt;/code&gt; 差异，走 &lt;code&gt;[P1][待查]&lt;/code&gt; 告警，等下一个批次重新核对。权威源缺位时，宁可什么都不做也不猜。&lt;/p&gt;
&lt;h3 id="自动处理-vs-人工裁决"&gt;自动处理 vs 人工裁决
&lt;/h3&gt;&lt;p&gt;哪一类差异能自动处理？&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;漏记 + 结算已确认&lt;/strong&gt;：结算侧明确有一笔成功的清算结果，但某家银行没有对应的分录。这是最干净的情况——补记即可。补记的「正确动作」完全由结算记录决定（补哪一侧、补多少金额），没有歧义。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;哪些只能人工裁决？&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;重复入账&lt;/strong&gt;：同一 &lt;code&gt;trade_id&lt;/code&gt; 在同一侧出现了多条分录。&lt;code&gt;trade_id&lt;/code&gt; 相同说明这是重复，但无法判断「哪一组是正确的那组、哪一组该冲正」——自动冲正必须回答「冲哪一组」，而这个答案对账本身给不出来。开篇那个场景就落在这里：程序知道建行侧多了一条，但不知道该消哪条。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;金额不符&lt;/strong&gt;：两边金额对不上，而且结算结果看不出金额是对是错（结算结果本身的金额也是参与方上报的）。需要回溯原始支付指令确认。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;结算侧无记录&lt;/strong&gt;：可能是延迟、系统故障，也可能是欺诈交易。不自动处理。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;区分「能自动处理」和「只能人工裁决」的边界很清晰：&lt;strong&gt;当正确的修正动作能由权威数据源唯一确定时，可以自动处理；当存在多个可能的动作、或正确动作无法从数据里推断时，只能挂起等人裁决。&lt;/strong&gt; 漏记的补记动作由结算记录唯一确定（补、补多少都明确），所以能自动做；重复入账要冲正哪一组、金额不符到底哪边错，都存在歧义，所以只能人工。&lt;/p&gt;
&lt;p&gt;一个易混淆的点：&lt;strong&gt;本地账务的冲正和外部资金的退回是两件事。&lt;/strong&gt; 银行发现一笔跨行转账记重了，冲正的是本地账本里的多余分录——这不会自动把钱从对方银行退回来。那笔钱已经通过央行结算，结算最终性成立后不可撤销；要追回得走独立的退款流程，需要对方银行配合。如果资金还没完成结算，另一条路是止付——让央行拦下这笔尚未落地的资金，不再继续结算。对账 Job 只处理本地账务的一致性，不管外部资金的流向。&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;// settlement/recon/ReconJob.java（节选）：补记漏记分录&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;private&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;compensateMissing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bankUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ReconDiff&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;SettlementRecord&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s&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="k"&gt;try&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;restTemplate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;postForEntity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bankUrl&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="s"&gt;&amp;#34;/api/bank/compensate&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 class="n"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;tradeId&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;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tradeId&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;fromBank&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getFromBank&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;toBank&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getToBank&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;amountCents&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAmountCents&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;settledAt&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSettledAt&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="na"&gt;toString&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;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&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;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;[recon] 补记完成: tradeId={}, 银行={}, 科目={}, 金额=¥{}.{}&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 class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tradeId&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bankLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;bankCode&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;account&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="na"&gt;label&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAmountCents&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;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAmountCents&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;100&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 class="k"&gt;catch&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Exception&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 补记失败不能中断整轮对账：记下来，下一批次重新核出同一条差异再补&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;[recon] 补记失败: tradeId={}, 银行={}&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;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tradeId&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;bankCode&lt;/span&gt;&lt;span class="p"&gt;(),&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&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;BankController.compensate&lt;/code&gt;），它复用和消费记账相同的 &lt;code&gt;LedgerService&lt;/code&gt;，所以补记的分录同样受借贷平衡和幂等约束——补一笔漏记，也是补「一组」分录（客户存款 + 存放央行款项），而不是孤零零补一条。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="六冲正不是删除是追加式更正"&gt;六、冲正不是删除，是追加式更正
&lt;/h2&gt;&lt;p&gt;「发现错了，能不能把那条错的分录删掉？」这是很多工程师的第一反应。答案是：&lt;strong&gt;不能。&lt;/strong&gt; 已入账的分录一旦写入，本 demo 的设计是禁止后续修改或删除的。&lt;/p&gt;
&lt;p&gt;这不是保守，而是两道硬约束决定的：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;审计追溯&lt;/strong&gt;：外部审计师需要看到完整的账务变迁历史。原始记录消失，审计就失去了锚点。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;修复幂等&lt;/strong&gt;：如果允许删除原始记录然后重新写入，对账 Job 重复执行时就会把已修复的数据再次标记为差异，进入死循环。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;纸质账本上也是同一个道理：用圆珠笔记的账，写死的字划不掉，记错了只能在下面补记一笔反向的，两笔抵消，但两条笔迹都留在本子上。冲正的做法就是这一条：&lt;strong&gt;写一组金额相等、方向相反的新分录，来抵销原分录的影响。&lt;/strong&gt; 而且关键的是——冲正不是「逐条反」某一条 posting，而是按 &lt;code&gt;trade_id&lt;/code&gt; 找到这笔交易的&lt;strong&gt;整组&lt;/strong&gt;分录（客户存款 + 存放央行款项两条），一起反向。&lt;/p&gt;
&lt;p&gt;为什么必须整组反向？因为借贷平衡是对「一组分录」的约束。只反其中一条，这一组就不平了，&lt;code&gt;postEntries&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;// bank/LedgerService.java（节选）&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@Transactional&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;public&lt;/span&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="nf"&gt;reverseByTradeId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;tradeId&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="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;reverseEntries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findOriginalByTradeId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tradeId&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="cm"&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="kd"&gt;private&lt;/span&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="nf"&gt;reverseEntries&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;JournalEntry&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;originals&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="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;originals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&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="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;0&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="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;JournalEntry&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;reversals&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;ArrayList&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;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;JournalEntry&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;original&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;originals&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;String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;reversalEntryId&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="s"&gt;&amp;#34;REV-&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;original&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getEntryId&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;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;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByEntryId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reversalEntryId&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="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="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;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Direction&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;revDir&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;original&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getDirection&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="na"&gt;opposite&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;reversals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&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;JournalEntry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reversalEntryId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;original&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTradeId&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;original&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAccountId&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;revDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;original&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAmountCents&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;original&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTradeTime&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;original&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getEntryId&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="c1"&gt;// 整组反向分录同样走 postEntries，受借贷平衡校验&lt;/span&gt;&lt;span class="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;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;reversals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;postEntries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reversals&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;reversals&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&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;reverseByTradeId&lt;/code&gt;，人工裁决「只冲多余那组」走 &lt;code&gt;reverseByEntryIds&lt;/code&gt;——后者必须由人指定冲哪几条，因为重复入账时一个 &lt;code&gt;trade_id&lt;/code&gt; 下有两组分录，整组冲正会把合法的那组也冲掉。两个入口共用 &lt;code&gt;reverseEntries&lt;/code&gt;，所以幂等和借贷平衡的行为完全一致。&lt;/p&gt;
&lt;p&gt;注意三个要点：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;冲正必须保持借贷平衡。&lt;/strong&gt; 原分录是借记客户存款 ¥10,000、贷记存放央行款项 ¥10,000，冲正分录是贷记客户存款 ¥10,000（反向）、借记存放央行款项 ¥10,000（反向）。不是孤零零写一条 posting。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;reversal_of&lt;/code&gt; 指向原始分录。&lt;/strong&gt; 对账闭环验收时，通过这个链条确认「差异 → 原始分录 → 冲正分录 → 重新核对平了」。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;冲正只更正本地账务。&lt;/strong&gt; 前面说了，外部支付已经发生了，冲正不改外部事实。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;原始分录和冲正分录并排看（以工行付款侧为例，两条原始分录共享同一个 &lt;code&gt;trade_id&lt;/code&gt;，但各有独立的 &lt;code&gt;entry_id&lt;/code&gt;；&lt;code&gt;trade_id&lt;/code&gt; 是 &lt;code&gt;TRD-&lt;/code&gt; 加 8 位随机大写十六进制，&lt;code&gt;entry_id&lt;/code&gt; 的末尾 &lt;code&gt;CUST&lt;/code&gt; / &lt;code&gt;PBOC&lt;/code&gt; 是科目标记）：&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;分录类型&lt;/th&gt;
					&lt;th&gt;分录 ID&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;E-TRD-2B14449D-ICBC-CUST&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;借&lt;/td&gt;
					&lt;td&gt;客户存款&lt;/td&gt;
					&lt;td&gt;¥10,000&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;E-TRD-2B14449D-ICBC-PBOC&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;贷&lt;/td&gt;
					&lt;td&gt;存放央行款项&lt;/td&gt;
					&lt;td&gt;¥10,000&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;REV-E-TRD-2B14449D-ICBC-CUST&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;贷&lt;/td&gt;
					&lt;td&gt;客户存款&lt;/td&gt;
					&lt;td&gt;¥10,000&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;REV-E-TRD-2B14449D-ICBC-PBOC&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;借&lt;/td&gt;
					&lt;td&gt;存放央行款项&lt;/td&gt;
					&lt;td&gt;¥10,000&lt;/td&gt;
					&lt;td&gt;抵销上面那笔贷方&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;四条分录合在一起，净借贷合计仍然是 0。账平了，原始记录还在，审计师可以看到「发生过什么、怎么修正的」。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="七为什么对账不能替代幂等"&gt;七、为什么对账不能替代幂等
&lt;/h2&gt;&lt;p&gt;对账是事后发现，幂等是事前防护。很多人觉得「有了对账就不需要幂等」，这个想法有一个根本性的问题：&lt;strong&gt;对账只能发现「结果不一致」，它不知道「不一致的原因」。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;上一篇讲的消费端幂等，防的是「同一条消息被处理两次」；这里的对账，查的是「处理完之后三本账对不对得上」。两者在链路上前后相接：幂等让第一节的第三个断点（重复投递）尽量不发生，对账在它还是发生了之后把差异捞出来。&lt;/p&gt;
&lt;h3 id="有业务身份对账能做的远比人工裁决多"&gt;有业务身份，对账能做的远比「人工裁决」多
&lt;/h3&gt;&lt;p&gt;这个问题的关键不在于幂等键本身，而在于&lt;strong&gt;业务身份是否可追溯。&lt;/strong&gt; 如果每一笔交易都有一个唯一的 &lt;code&gt;trade_id&lt;/code&gt;，并且这个 ID 贯穿了从结算指令、银行分录、到对账匹配的整条链路，那么：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;对账可以准确判断两笔相同金额的记录是不是同一笔交易的重复执行（因为 &lt;code&gt;trade_id&lt;/code&gt; 相同）。&lt;/li&gt;
&lt;li&gt;对账可以区分「两笔金额一样的合法交易」和「同一笔交易记了两次」。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;反过来，如果系统里根本没有 &lt;code&gt;trade_id&lt;/code&gt; 这样的业务身份，记账时只写了金额、方向和时间戳，那对账面对两条 ¥10,000 的借记分录时，完全无法判断这是一笔重复记账还是两笔独立的合法转账。&lt;/p&gt;
&lt;p&gt;但即便有了 &lt;code&gt;trade_id&lt;/code&gt;，重复入账的自动修复也&lt;strong&gt;不是理所当然的&lt;/strong&gt;。&lt;code&gt;trade_id&lt;/code&gt; 相同只能说明「这是同一笔交易产生了多余的分录」，却不能告诉你「哪一组是正确的那组、哪一组该冲正」。这也是为什么 demo 里对重复入账（DUPLICATE）的处理是&lt;strong&gt;标记人工裁决&lt;/strong&gt;，而不是自动冲正——自动冲正必须回答「冲哪一组」，而这个答案对账本身给不出来。&lt;/p&gt;
&lt;h3 id="幂等键的三个必要条件"&gt;幂等键的三个必要条件
&lt;/h3&gt;&lt;p&gt;幂等键（Idempotency Key）只是第一步。要让对账能做到自动修复，还需要三个条件同时满足：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;消费端实际执行唯一性控制。&lt;/strong&gt; 不是生产端带了一个键就叫幂等——消费端必须在写入前检查这个键是否已经处理过。如果消费端收到带相同键的消息直接再次写入，键就是摆设。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;去重记录与账务写入在同一个事务中。&lt;/strong&gt; 先检查去重再记账，中间断开事务，竞态条件下仍然可能重复写入。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;同一键携带不同金额时能拒绝冲突。&lt;/strong&gt; 如果一个键第二次出现时带的金额和第一次不同，系统应该拒绝而不是覆盖。&lt;/li&gt;
&lt;/ol&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;// bank/LedgerService.java（节选）：消费端的幂等写入&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@Transactional&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;public&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;boolean&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;postIfNew&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;JournalEntry&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;entries&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;String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;tradeId&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;entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getFirst&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="na"&gt;getTradeId&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;// 幂等检查：这个 trade_id 已经记过账了？&lt;/span&gt;&lt;span class="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;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;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;existsByTradeId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tradeId&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="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 幂等跳过，不重复写入&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 记账：去重检查 + 分录写入在同一事务中完成&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;postEntries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entries&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="kc"&gt;true&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;BankSettlementConsumer.onSettlement&lt;/code&gt; 调用它，收到重复消息时返回 false 直接跳过——这是正常的幂等跳过，不是错误，所以不抛异常、照常 ack。&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;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;/td&gt;
					&lt;td&gt;可校验的内部一致性：任何系统都应该有办法自证「状态没乱」，但不等于状态正确&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;业务身份（&lt;code&gt;trade_id&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;/td&gt;
					&lt;td&gt;修正错误而不破坏审计链&lt;/td&gt;
					&lt;td&gt;不可变日志 + 补偿操作：Saga 的补偿动作、Event Sourcing 的逆事件&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;tr&gt;
					&lt;td&gt;对账 Job 的幂等设计&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;trade_id&lt;/code&gt; 把三幕串成同一笔交易，冲正让改错不毁历史，结算记录提供跨幕的裁判，分级决策决定每种断裂怎么收场。&lt;/p&gt;
&lt;h3 id="对账最大的工程价值分离发现和修复"&gt;对账最大的工程价值：分离「发现」和「修复」
&lt;/h3&gt;&lt;p&gt;很多人把对账理解成「一个定时任务，跑完报一堆差异」。但它的工程价值不在「发现差异」——任何简单的比对脚本都能做到。对账的真正价值在于：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;对账确定了「差多少」，分级处理确定了「怎么补回去」。&lt;/strong&gt; 这两个问题被拆成两个独立的步骤，各自有独立的执行逻辑、独立的幂等保证。&lt;/p&gt;
&lt;p&gt;这个分离是金融系统能在出错后继续运行的基石。如果发现差异的同时就「自动修正」，那修正错了怎么办？修正再修正？系统会陷入不可追溯的循环。但把它们分开——对账先给出一个&lt;strong&gt;不可变的事实陈述&lt;/strong&gt;（「2026 年 9 月 14 日的日终核对显示，trade_id=TRD-2B14449D 在工行的客户存款科目上缺失分录」），然后分级处理再给出&lt;strong&gt;可追溯的修正动作&lt;/strong&gt;（「根据结算记录补记分录 entry_id=COMP-TRD-2B14449D-ICBC-CUST」）——每一步的结果都可以被验证、被追溯、被重新评估。&lt;/p&gt;
&lt;h3 id="闭环验收没有重新核对就不算修好"&gt;闭环验收：没有重新核对就不算修好
&lt;/h3&gt;&lt;p&gt;修复动作完成后，不能以「生成了补记分录」或「写入了冲正记录」作为成功标准。真正的成功标准是：&lt;strong&gt;对当前这批数据重新跑一遍对账逻辑，差异消失了。&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="c1"&gt;// settlement/recon/ReconJob.java（节选）：闭环验收&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;private&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;verifyClosedLoop&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;ReconDiff&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;diffs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;SettlementRecord&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;settlementMap&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;Instant&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Instant&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;end&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="c1"&gt;// 它们本来就不该在这一轮被修好，算进失败会让「闭环失败」失去信号意义。&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ReconDiff&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;autoFixed&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;diffs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stream&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="na"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&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="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;type&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;ReconDiffType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;MISSING&lt;/span&gt;&lt;span class="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="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;settlementMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;containsKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tradeId&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="na"&gt;toList&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;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;autoFixed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&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;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;[recon] 本轮无自动处理的差异，跳过闭环验收&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 class="k"&gt;return&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="c1"&gt;// 重新拉取净分录（已被冲正的 journal 不再参与），复用 checkBank 重新核对&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&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;JournalEntry&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;aRecheck&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;groupByTrade&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fetchBankEntries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bankAUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&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;JournalEntry&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bRecheck&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;groupByTrade&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fetchBankEntries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bankBUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;end&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="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;String&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;remaining&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;ArrayList&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;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;ReconDiff&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;diff&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;autoFixed&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;SettlementRecord&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s&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;settlementMap&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;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tradeId&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;Bank&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bank&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;Bank&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fromCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;bankCode&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;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;JournalEntry&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;now&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;bank&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;Bank&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ICBC&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;aRecheck&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;bRecheck&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="na"&gt;getOrDefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tradeId&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;&lt;span class="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="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ReconDiff&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;recheckDiffs&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;ArrayList&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;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;boolean&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ok&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;checkBank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bank&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tradeId&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;recheckDiffs&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;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;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="n"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tradeId&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="s"&gt;&amp;#34;/&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;account&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="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="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;remaining&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&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;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;[recon] 闭环通过: {} 条自动处理的差异已重新核对为平&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;autoFixed&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;else&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;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;[recon] 闭环失败: {} 条差异补记后仍不平: {}&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;remaining&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="n"&gt;remaining&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;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;ol&gt;
&lt;li&gt;中国人民银行, &lt;em&gt;小额支付系统业务处理手续（试行）&lt;/em&gt;, 央行官网公开文档. &lt;a class="link" href="https://www.pbc.gov.cn/eportal/fileDir/history_file/files/att_19041_2.pdf" target="_blank" rel="noopener"
 &gt;https://www.pbc.gov.cn/eportal/fileDir/history_file/files/att_19041_2.pdf&lt;/a&gt; —— 冲正、止付与退回流程的官方定义&lt;/li&gt;
&lt;li&gt;金融时报, &lt;em&gt;网联平台荣获 2019 年度银行科技发展奖特等奖&lt;/em&gt;, 2020-09-25. &lt;a class="link" href="https://www.financialnews.com.cn/if/hydt/202009/t20200925_201999.html" target="_blank" rel="noopener"
 &gt;https://www.financialnews.com.cn/if/hydt/202009/t20200925_201999.html&lt;/a&gt; —— 网联「三地六中心」架构与小时级多批次对账设计&lt;/li&gt;
&lt;li&gt;ACCA, &lt;em&gt;Suspense accounts and error correction&lt;/em&gt;, Foundation Level Study Resources. &lt;a class="link" href="https://www.accaglobal.com/gb/en/student/exam-support-resources/foundation-level-study-resources/ffa/ffa-technical-articles/suspense-accounts-error-correction.html" target="_blank" rel="noopener"
 &gt;https://www.accaglobal.com/gb/en/student/exam-support-resources/foundation-level-study-resources/ffa/ffa-technical-articles/suspense-accounts-error-correction.html&lt;/a&gt; —— 试算平衡无法发现的六类错账&lt;/li&gt;
&lt;li&gt;Formance, &lt;em&gt;Transaction Reconciliation at Scale: An Engineering Guide&lt;/em&gt;. &lt;a class="link" href="https://www.formance.com/blog/engineering/transaction-reconciliation-at-scale-for-eng" target="_blank" rel="noopener"
 &gt;https://www.formance.com/blog/engineering/transaction-reconciliation-at-scale-for-eng&lt;/a&gt; —— 对账前的四项账本不变约束：复式记账、幂等、不可变性、双时态&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;

 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;演示代码&lt;/strong&gt;：完整实现在 &lt;a class="link" href="https://github.com/renxin2024/GYD/tree/main/c03-reconciliation" target="_blank" rel="noopener"
 &gt;GYD 仓库 &lt;code&gt;c03-reconciliation&lt;/code&gt;&lt;/a&gt;，PostgreSQL 编排在仓库级 &lt;a class="link" href="https://github.com/renxin2024/GYD/tree/main/infra" target="_blank" rel="noopener"
 &gt;&lt;code&gt;infra/&lt;/code&gt;&lt;/a&gt;。按各目录 README 的命令跑，不要复制这篇文章里零散的代码块。&lt;/p&gt;

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

 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;演示代码&lt;/strong&gt;：完整实现在 &lt;a class="link" href="https://github.com/renxin2024/GYD/tree/main/c02-mq-reliable-delivery" target="_blank" rel="noopener"
 &gt;GYD 仓库 &lt;code&gt;c02-mq-reliable-delivery&lt;/code&gt;&lt;/a&gt;，该目录的 README 列了环境、运行方式、端点与本文各节的对照，以及本文引用的完整输出记录；RabbitMQ 与 3 节点集群的编排在仓库级 &lt;a class="link" href="https://github.com/renxin2024/GYD/tree/main/infra" target="_blank" rel="noopener"
 &gt;&lt;code&gt;infra/&lt;/code&gt;&lt;/a&gt;，两个实测脚本在 &lt;code&gt;infra/rabbitmq/scripts/&lt;/code&gt; 与 &lt;code&gt;infra/rabbitmq-cluster/scripts/&lt;/code&gt;。&lt;/p&gt;

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

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

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

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

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

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

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

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

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

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

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

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

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