Featured image of post 第五话|RabbitMQ 集群的架构与高可用:一条消息从发到收走了哪些节点

第五话|RabbitMQ 集群的架构与高可用:一条消息从发到收走了哪些节点

三台 RabbitMQ 连成集群后,「同步」「复制」这些词就开始含糊。这篇文章跟着一条消息,从生产者发出到消费者取走,看它经过哪些节点、集群内部同步了哪两类数据、哪些根本不进集群;再把接入节点故障、Leader 故障、失去多数派逐个拆开,记录发送与消费各自怎样失败或暂停。全文以 RabbitMQ 4.3.5 为准,集群是三台本机容器里的节点,结论只对这个实验环境成立。

系列 GYD(Get Your Distributed) 第 5 / 7 篇
主题 消息队列RabbitMQ分布式存储高可用

这篇文章讲 RabbitMQ 的集群架构,以及它如何靠这套架构实现高可用。要看清这套架构,得先拆掉「集群把消息复制了三份」这个最常见的误解——集群真正复制的其实只有两类:集群元数据和队列内容。 而客户端和某个节点之间的连接状态,根本不进集群,只握在客户端自己手里。搞清楚这三样东西各自由谁负责、出故障时各自怎么表现,就理解了这套高可用架构到底可靠在哪、又会在哪里失效。

第一话在单台节点上把一条消息的一生走了一遍,那时队列类型之间的差别只体现在消费语义上;连成集群之后,同一个差别会变成两种完全不同的故障表现——这正是集群高可用要解决的那一层。

正文里会碰到一个具体的矛盾:客户端连的是节点 A,队列的 Leader 在节点 B,消息为什么还能发成功? 这个问题在第四节和第六节会拆开讲,答案正是上面那三类数据的分工——客户端状态留在原地,队列内容在集群内部被定位与复制,两者各走各的通道。

全文以 RabbitMQ 4.3.5 为准(Erlang 27.3.4.17、amqp-client 5.30.0、JDK 21)。集群是三台容器里的节点,同宿主机、仅面向本机实验——它不构成任何生产容量或性能结论,文中给出的秒数与条数只对这个环境负责。凡是本机没有跑过的机制,正文会写明它来自官方文档或源码,不当成已证实的结论。

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


一、AMQP 模型:消息不直接进队列,而是先到交换机

讲集群之前,先把 RabbitMQ 在单机上的那套对象模型立住。生产者与消费者之间隔着三样东西:vhost、交换机(exchange)、队列(queue),消息要经过一次路由才会落进队列。

vhost 是逻辑隔离层。 一个 vhost 里有自己一套独立的交换机、队列和绑定,权限也按 vhost 划分。默认开箱即有一个 vhost(名字是 /)。本文的所有操作都发生在默认 vhost 里,不提 vhost 就是指它。

生产者把消息发到交换机,不是直接发到队列。 每发一条消息都要带一个 routing key(路由键),交换机根据自身类型和 binding(绑定)决定把它投给哪些队列。消息在交换机这一层完成路由,之后才落到队列里。

队列是消息最终落点,消费者从队列取。 队列通过 binding 挂到交换机上,binding 规定了「什么样的 routing key 投到这个队列」。消费者订阅的是队列,不是交换机。

交换机类型决定路由规则,最常见的是四种:

  • direct:routing key 和 binding key 精确相等才投;
  • topic:用 . 分段的通配匹配(* 匹配一段、# 匹配零或多段);
  • fanout:忽略 routing key,广播给所有绑定队列;
  • headers:按消息头匹配,几乎不用。

四种里本文只会在两处用到 fanout——第六节「一条消息路由命中多个队列」和第八节「confirm 由所有命中队列共同决定」,都依赖 fanout 把一条消息同时投进多个队列。其余三种类型与本文的集群主题无关。

一句话立住这个模型:生产者 → 交换机 →(binding 路由)→ 队列 → 消费者。集群要做的事,就是把这条链上「交换机、队列、绑定的定义」和「队列里的消息」这两样,在多个节点之间正确地复制与同步。下面先看复制靠什么机制,再回到这条链本身。


二、三种队列类型:classic、quorum 与 stream

第一节里的「队列」在 RabbitMQ 里不是一个铁板一块的东西,而是分三种类型。它们共享同一套 AMQP 模型(都要挂到交换机上、都由消费者订阅),差别在于一份数据存几份、以什么方式存。后面全文反复出现的「classic / quorum 两类」指的就是其中两种,这里先一次说清,免得读到中途才去猜。

classic queue:一份数据,只存在属主那台节点上。 这是 RabbitMQ 最老、默认的队列类型,消息只在属主(owner)节点落一份。它适合不需要跨节点复制、追求简单与低延迟的场景;代价是一旦属主节点离线,队列就不可用——因为数据没有第二份。

quorum queue:三份数据,靠 Raft 复制到多数节点。 它把队列实现为一组 Raft 副本(典型三副本,也可配其它奇数),消息被复制到多数节点,靠多数派保证可用性与不丢。它适合要求「挂掉一个节点还能继续收发」的高可用场景;代价是每次写入要走一轮复制确认,比 classic 多一次网络往返。

stream:按追加日志存,适合大吞吐的连续流。 它面向的不是经典的生产/消费队列语义,而是「可以反复重放、按偏移量读」的日志型数据,消息不会被消费即删。它的副本也是多数派复制,但副本管理、确认边界都和 quorum 有细微差别。

三种类型在应用里只差一个 x-queue-type 参数,其余声明姿势完全一样(amqp-client 的 Channel 接口):

// 三种队列的声明只差 x-queue-type 这一个参数
channel.queueDeclare("orders.classic", true, false, false, null);
channel.queueDeclare("orders.quorum", true, false, false,
        Map.of("x-queue-type", "quorum"));
channel.queueDeclare("orders.stream", true, false, false,
        Map.of("x-queue-type", "stream"));

不写 x-queue-type,拿到的是默认的 classic。类型在声明那一刻就锁死——之后再用一个不同的 x-queue-type 去声明同一个队列,broker 会拒绝这次声明,而不会把队列改掉。

三种类型落在同一套 AMQP 模型上,差别只在「一份数据存几份、怎么存」:classic 只有一份数据,属主节点离线队列就不可用;quorum 把数据复制成三份,靠多数派保证可用;stream 也是三份,但按追加日志存、可反复重放。副本数是「高可用」的地基,这个地基在这一节先立住,后面几节再讲它怎么被多数派与选举用起来。

三种类型里,本文的主线只有 classic 和 quorum 两类——因为这一话的主题是「集群高可用」,而 classic 与 quorum 正好是一副最干净的对照:一份数据 vs 三份数据、单点 vs 多数派。stream 只在文末第十三节选型时出现一次,作为第三种选项补全决策维度,不进入正文的机制叙述。需要知道的是,两类队列在 AMQP 模型上是完全等价的:同一个 fanout 可以把一条消息同时投进一个 classic 和一个 quorum,两边各自按自己的规则接不接受、各确认各的,互不相干。

有了这个分类,后面每一节里「classic 一个副本 / quorum 三个副本」的说法就有了出处。下面先看这些副本靠什么机制复制——也就是 Raft。


三、Raft 的最小模型:角色、任期、日志、多数派与选举

RabbitMQ 集群的高可用,底层靠的是一套叫 Raft 的共识算法:把一份状态复制成多份,靠内部选举与复制,保证其中几台挂了整体还能服务。要理解它,不需要读完 Raft 教科书,只需要下面这个最小模型——一个副本组内部在做什么。模型到「能推出后续行为」为止,术语只保留最少的几个。

一个 Raft 副本组的最小模型:写操作到 Leader,追加为日志条目并复制给 Followers,多数派确认后提交

角色。 一个组里有一个 Leader 和若干 Followers。所有写操作先到 Leader。这里的「操作」是泛称:任何需要多个副本达成一致的变更都算,不必限定成某一种。

任期与日志。 Leader 更替会更换任期。选举只允许日志足够新的候选者获胜,所以新 Leader 本身就带着既有日志,不是从零开始;它要处理的是上任期可能遗留的未提交尾部,把这些尾部在新任期里提交或覆盖,组内就「已提交到哪」达成一致之后继续服务。每个操作以日志条目的形式追加在 Leader 上,Leader 负责把条目复制给 Followers。

多数派。 这是模型里最要紧的一条:Raft 组只在多数副本可达时可用。三成员组里一个节点故障还剩两个,多数派成立;再故障一个就只剩一个,多数派不成立。多个 Raft 组可以并存,各自独立地选举与判定多数派。

提交点。 多数派接受并确认之后,这条日志才算提交,此时才向客户端返回。所以确认的语义是「多数派已经收下」,它并不要求每个副本都写完。

组大小与容错。 三个成员支撑一个节点故障,五个支撑两个。为什么用奇数:四成员和它小一号的三成员一样只能容忍一个故障,却要多复制一份,纯亏。也正因如此,两节点集群不可取——两个成员里任何一个故障都会让多数派消失,容错能力为零,代价却照付。

成员不共享节点:同一个组的几个成员分布在几台不同节点上,否则一台机器挂掉会同时带走两个成员。规模也不是越大越好,超过五个成员之后性能下降、不建议超过七个。

四、三台节点连成集群:一条消息从发到收,走了哪些节点

三台 RabbitMQ 节点组成集群后,客户端连接其中任意一台即可声明队列、收发消息,无论这个队列实际由哪台节点持有。本节沿一条消息的完整路径,说明发布、消费各经过哪些节点,以及集群在节点间同步了哪些数据。

发布:客户端只连一台,其余由集群内部完成。 客户端连接的那台节点称为接入节点,它只作为入口,不必然持有目标队列。队列的写操作由 Leader 承担(classic queue 中称属主),Leader 可以位于集群内任意节点。发布时,消息先到达接入节点上的 Channel 进程;Channel 根据队列元数据,在 broker 内部直接定位到队列 Leader(而不是先投到一个本地队列再等转发)。Leader 将其追加为日志条目,再复制给该队列的其余副本(Follower)。多数派副本确认后,确认沿原路返回客户端。整个过程客户端只与接入节点通信,无需感知 Leader 的位置——「接入节点」与「Leader」之间的那一步是 broker 内部寻址,不是客户端另建连接。

同步:集群复制的只有两类数据。 上述路径中,真正在节点间复制的数据分两类,机制相互独立:

  • 元数据:队列定义、binding 等声明性内容,由 Khepri 维护,全体节点共享同一份。在任一节点声明队列,其余节点立即可见。
  • 队列内容:消息本体。副本数量取决于队列类型——classic queue 仅 Leader 持有一份;quorum queue 有 Leader 与两个 Follower 共三份。

除此以外的状态——客户端与某节点的连接、信道上未完成的命令——不进入集群,只保留在客户端侧。因此「部署三台节点」不等于「消息自动复制三份」:元数据确有三份,队列内容按类型而定,客户端状态则一份都没有。

元数据、队列内容、客户端状态三个范围各自的副本份数

消费:同样先到 Leader。 消费并不从接入节点直接读取。消费者连接某接入节点后,投递由队列的 Leader 发起,Leader 将消息推送(或由消费者拉取),消费者确认后再返回 Leader。因此无论发布还是消费,队列的读写都由 Leader 仲裁,接入节点上的 Channel 进程只负责在 broker 内部把请求直接定位到 Leader,本身不持有队列数据。

一句话收口:发布与消费都经过「接入节点上的 Channel → 队列 Leader」这条 broker 内部路径——客户端始终只连接入节点这一台,节点间定位 Leader 是集群内部完成的;集群所做的是把 Leader 上的变更同步给 Follower,同步范围仅限元数据与队列内容两类。

读完本文,读者应能对一个具体队列自行判断:

这个队列是什么类型、它当前有几个副本在线、是否仍有多数派、以及 Publisher Confirm 在这个队列类型上确认到了哪一步。

边界在这里可以直接量一下:只连一个节点声明两个队列,三个节点都查到了同一份定义(两条),exchange 与 binding 也一样;但两类队列的成员数立刻分开了——classic 只有一个成员,quorum 有三个(raw-scope-of-replication.log)。

本文所有实测读数来自同一套三节点集群,先交代它的边界:三个节点跑在同一台宿主机上,都是 RabbitMQ 4.3.5。同宿主机意味着节点间没有真实网络分区,只有进程被杀与容器被停两种故障,所以本文能说明「节点消失时集群怎么反应」,不能说明「网络抖动或分区时怎么反应」,也不构成任何生产容量或性能结论。节点间的 net_ticktime 在本机设成 10 秒(默认 60),后面出现的超时数字都基于这个值。集群的起停步骤收在 GYD 仓库该模块的 README 里,正文不复现;跑不通或读数可疑时,翻文末的排错索引。凡本机没有跑过的机制,正文会写明它来自官方文档或源码,不当成已证实的结论。

五、元数据:集群里唯一全局共享的一份

元数据是最容易被顺手想成「也是数据、也是复制三份」的那个范围。它确实在复制,但它复制的是「有哪些队列」,不是「队列里有什么」。

4.x 里这套元数据存储的实现叫 Khepri,基于 Raft,保存的是定义类的东西:用户与权限、vhost、exchange 和 queue 的定义、binding、policy。它不保存消息,也不保存队列的运行状态。一条消息是否已落盘,在它这里查不到。

元数据与队列内容是两个独立的 Raft 组。这一点在磁盘上看得最直接:同一个数据目录下 coordination/ 与 quorum/ 并列,前者装集群级元数据,后者装每个队列自己的 Raft 日志。既然是两个组,各自的 Leader 也可以不是同一台节点——本机实测里元数据 Raft 的 Leader 落在 rabbitmq2,而集群名节点是 rabbitmq1,两者没有必要一致。

这套元数据存储在 4.3 里由 Khepri 独家接管。演进线是:3.13 实验性支持,4.2 成为新集群默认,到 4.3 它已是唯一受支持的元数据存储——Mnesia 连同配套的分区处理策略一起被彻底移除。本机实测印证了这一点:数据目录里没有 schema.DAT,mnesia 相关的 schema 命令数为零(env-baseline.log)。

六、一次发布从发出到确认,经历了什么

前面提出过一个具体矛盾:客户端连在节点 A,队列 Leader 在节点 B,消息仍然发送成功。这一节把一次发布从头到尾走一遍,说明它为什么成立。

第一步,客户端确定目标:Channel 持有的是 Leader 的位置,不是队列的位置。 连接建立后,客户端从集群取得每个目标队列的 Leader 位置,发布时直接按这个位置投递。关键在于「直接寻址」发生在节点 A 内部:客户端仍只连 A 这一台,但 A 上的 Channel 进程根据队列元数据,在 broker 内部直接定位到队列 Leader 所在节点 B,并不经过「先投到一个本地队列、再等转发」这一步。所以「Leader 在 B、客户端连的是 A」并不构成障碍——消息走的是 A 内 Channel 到 B 的 broker 内部通道,不是客户端另建一条网络连接。Leader 换届会改变这个位置,客户端在下一次操作时取得新值,按新值继续发送。

第二步,路由可能分叉:一个 fanout 会同时命中多个队列。 消息到达后先做路由。这一步可能把一条消息复制为多份、投给多个队列,此后每一份走各自的目标队列,各自决定接不接受。整条路径上不存在「整条消息落一次盘」这样的全局动作:一个 fanout 同时绑了 classic 与 quorum 时,同一条消息分别进入两个队列的判定,两边的确认也互不相干。

第三步,写入是异步的:客户端不逐条等待确认。 客户端不是「发一条、等确认、再发下一条」,而是连续发出,确认异步返回。多数派副本确认后,confirm 沿原路返回客户端。这是跨节点发布仍能维持吞吐的原因。

发布这一侧在应用里就是「发消息」+「开 confirm」两件事。发消息用 basicPublish,开 confirm 用 confirmSelect,确认结果由回调异步送达:

// 打开 publisher confirm,之后每条 basicPublish 都会收到一个 ack 或 nack
channel.confirmSelect();

channel.basicPublish("", "orders.quorum", null, "一条订单".getBytes());

channel.addConfirmListener(
        (sequenceNumber, multiple) -> {
            // 成功:这条(或这几条)消息已被 broker 确认接收
            System.out.println("confirmed: " + sequenceNumber);
        },
        (sequenceNumber, multiple) -> {
            // 失败:这条消息没有落地,需要重发
            System.out.println("nack-ed: " + sequenceNumber);
        });

confirmSelect 之后发的每一条消息都会对应一个确认序号(sequenceNumber),回调里拿到这个序号就能知道是哪一条被确认或拒绝。这就是后面第八节要展开的「消息不丢」的第一块拼图——没有 confirm,消息在落盘前丢了,生产者完全无从察觉。

这套步骤不依赖接入节点与 Leader 的关系。实测中,把 Leader 置于不同节点、把客户端连到不同节点轮换,九种排列组合全部拿到确认;其中客户端所连节点既非 Leader、也非副本成员时,发布依然成功。也就是说,客户端连到集群里任意一台节点,消息都能走完上面三步、到达目标队列。

边注:这组实验的一个变体使用两个成员的队列(与前面默认的三成员队列不同),让客户端所连节点落在副本组之外,单独验证。每批 100 条、单组 0.099–0.136 秒(raw-non-leader-publish.log)。


七、两类队列在磁盘上各是什么样子

前面说 classic queue 一个副本、quorum queue 三个副本,落到磁盘上是什么样,把三台节点各自的数据目录摆在一起就看得清。

三台节点的数据目录对比:元数据各一份、classic 消息只一份、quorum 日志各一份

三类目录并列在同一个数据目录下,各装一种东西,各自的「有几份」也一眼可见:coordination/ 装集群元数据(Khepri 存储),每台节点各一份;msg_stores/ 装 classic queue 的消息本体,只在属主节点 1 有一份(本轮约 252 KB,另外两台根本不存在这个目录);quorum/ 装每个 quorum queue 自己的 Raft 日志,每台节点各一份(各约 552 KB)。这就是「元数据全体共享、队列内容按类型而定、客户端状态一份都没有」在磁盘目录上的落点。

classic queue 的消息只有一份,且只在属主节点上。 声明在节点 1 的 classic 队列,消息目录就只在节点 1 出现,另外两台节点的数据目录里找不到它。这就是「一个副本」在磁盘上的样子:不是「复制了三份但只标了一份」,是根本没有第二份。

quorum queue 的 Raft 日志每台节点各一份。 同一个队列在三台节点上各有一份日志目录。每份目录的路径形如 quorum/<节点名>/<前缀_编码名>/,节点名那一层就说明了它是谁持有的副本。

边注:msg_stores/ 与 quorum/ 管的是「消息在哪里」。队列自己的定义在另一处,即元数据里(Khepri 那一份)。一个队列因此在两个地方同时留下痕迹:定义在 Khepri 的 Raft 组里,内容在由队列类型决定的那个组里。

classic 的落盘目录内部还能看到更细的形态:属性文件、索引文件与段文件分开,索引按队列内的序号命名。把队列里的消息消费干净之后,大部分文件会消失,只剩属性文件(raw-classic-owner-failure.log)。

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

八、发布的可靠性:RabbitMQ 拿什么保证消息不丢

一条消息从生产者发出,到它在 broker 里「真正安全」落地,中间可能在哪几处丢?RabbitMQ 给出的不是单一机制,而是四个可以叠加的手段,每个手段防住一类丢失。这一节把这四个手段讲清,再说明把它们全用上之后,仍然有两种丢失要生产者自己兜底。

手段一:路由退回(mandatory),防「消息根本没进队列」。 默认情况下,一条消息如果路由不到任何队列,broker 会直接丢弃它,生产者拿不到任何提示。发布时带上 mandatory 标志,broker 就会在发现消息不可路由时,用 basic.return 把它退回给生产者,附带原因码。这样「消息没进队列」这件事不再静默。

手段二:持久化(队列 durable、消息 persistent),防「broker 重启后消息没了」。 队列要声明成 durable(重启后队列本身还在),消息要以 persistent 投递模式发出(重启后消息还在)。两者缺一不可:只持久化队列,重启后消息丢;只持久化消息,重启后队列连同消息一起没。持久化消息投到非持久化队列时,按非持久化处理——所以「队列 durable」是前提,不是可选项。即便两者都设了,消息也只在落盘之后才算持久;broker 为了减少 fsync 次数会批量写盘,落盘前节点挂掉,消息仍会丢。这正是它必须和手段三配合的原因。

手段三:Publisher Confirm,防「生产者不知道消息丢了」。 Confirm 是一条消息拿到一个确认的机制,有肯定结果 basic.ack 和否定结果 basic.nack。它解决的是上一段那个「落盘前挂掉」的问题:不用 Confirm,消息在落盘前丢失时生产者无从得知;用了 Confirm,broker 会在消息真正落地后才回 basic.ack,落地前就挂掉的消息不会收到 ack,生产者因此能察觉并决定是否重发。同一个 basic.ack,在不同路径上「落地」的含义不同:

同一个 basic.ack 在不可路由、classic、quorum、多队列四种路径下的不同含义

手段四:副本(quorum queue 的多数派),防「单台节点挂掉消息就没了」。 前三个手段保证的是「消息在某一台节点上落稳了」,但经典队列只有一个副本,那台节点一旦永久损坏,落盘的消息照样丢。quorum queue 把消息复制到多数派副本,basic.ack 意味着多数派已经接受,单台节点故障不影响消息存在。这就是「发布可靠性」和前面几章讲的「集群高可用」交汇的地方:副本机制让持久化的消息不再依赖单一节点。

四个手段在应用里不是四段分散的代码,而是落进同一次声明、同一条发布调用里:

// 手段二 + 四:队列 durable=true、类型选 quorum(多数派副本)
channel.queueDeclare("orders.quorum", true, false, false,
        Map.of("x-queue-type", "quorum"));

// 手段三:打开 publisher confirm
channel.confirmSelect();
channel.addConfirmListener(
        (seq, multiple) -> { /* 落地成功,可清理重发缓存 */ },
        (seq, multiple) -> { /* 落地失败,按 seq 重发这条消息 */ });

// 手段一 + 二:mandatory=true(不可路由则退回)、deliveryMode=2(持久化)
AMQP.BasicProperties props =
        new AMQP.BasicProperties.Builder().deliveryMode(2).build();
channel.basicPublish("", "orders.quorum", true, props,
        "一条订单".getBytes());
channel.addReturnListener(returned -> {
    // mandatory 退回:这条消息没有进任何队列
    System.out.println("returned: " + new String(returned.getBody()));
});

四行注释各对上一个手段:queueDeclare 里的 durable=true 和 x-queue-type=quorum 是手段二和四,confirmSelect 是手段三,basicPublish 里的 mandatory=true 和 deliveryMode=2 是手段一和二。缺任何一样,「消息不丢」就断掉一环。

四个手段各有覆盖,但没有一个是「消费确认」。 上面四件事做完,basic.ack 保证的是「这条可路由消息已被所有命中队列接受、且按各自类型的确认边界落了地」。它不代表消息被消费——本文几组发布实验全程没有消费者,确认数与队列留存条数一致,消息一直躺在队列里,直到收尾阶段才被逐条取回。多队列那组的基线 10 条在阶段 1 就拿到了 basic.ack,到阶段 5 才被消费掉,中间五十多秒无人处理。消费侧的确认与幂等是另一篇的事。

两个手段合起来仍有两个洞,要生产者自己兜。 一是重复:多队列场景下 basic.ack 是 AND——只要一个命中队列没接受,整条消息就拿不到 ack。实测让一个 fanout 同时绑 classic 与 quorum,把 quorum 的 Leader 摆到节点 2,SIGKILL 节点 1(classic 的属主),再从节点 2 发布 10 条:

  • 客户端侧:10 条全部失败,客户端库报 NackError,耗时 0.00–0.01 秒;
  • 服务端侧同时读 quorum queue 的条数:从 10 涨到 20,正好增加 10;
  • 同一时刻只往 quorum 发 5 条(对照):5/5 拿到确认,排除「客户端连不上」或「quorum 不可用」这两个解释;
  • 节点 1 恢复后:classic queue 里仍然只有基线那 10 条,故障中那 10 条一条也没进去(raw-fanout-per-queue.log)。

这几组读数说明三件事,缺一句都会误判:basic.ack 是 AND 不是 OR(一个队列没接受就整体 nack);basic.nack 不等于哪儿都没进去(quorum 侧已收下 10 条,只是不能替 classic 回答);客户端从结局里看不出是哪个队列拒绝的(返回的只是一个 NackError,没按队列拆开)。后两条合起来就是重复的来源:拿到 basic.nack 就整条重投,会在已经接受它的那些队列里再写一遍,而 Confirm 机制本身不消重。

二是不可路由的语义陷阱:不可路由消息同样会收到 basic.ack(交换机为它做了判定),但这条路径上的 basic.ack 说的不是「进了队列」。这条本文没有实测,只引官方口径——对 quorum queue 而言,成功返回 basic.ack 的可路由消息,在托管该队列的多数节点没有永久不可用的前提下,RabbitMQ 才给出相应保证;这个保证不覆盖不可路由消息,不覆盖 classic queue。

classic 侧还有一个更硬的证据:属主节点被 SIGKILL 之后,队列里那 51 条持久化消息在节点恢复后一条不少,逐条消费取回验证通过;而故障期间往同一个队列发的 20 条一条都没拿到 basic.ack,恢复后队列里也没有多出这 20 条(raw-classic-owner-failure.log)。本节与相邻几节的读数分别出自 raw-non-leader-publish.log、raw-failover-timeline.log、raw-classic-owner-failure.log、raw-fanout-per-queue.log。

九、消费的可靠性:ack、prefetch 与交付语义

第八节讲消息如何从生产者侧可靠进入,这一节讲它如何从队列侧可靠出去。两侧用的是两套完全独立、互不知晓的机制:Publisher Confirm 只关心消息是否被所有命中队列接受,不关心是否有消费者;Consumer Ack 只关心消费者是否处理完成,不关心消息如何进来。消息不丢,靠的是两侧各自守住自己的一端。

消费确认:basic.ack、basic.nack、basic.reject。 消费者处理完一条消息,需要通过消费确认(consumer acknowledgement)把结果回给 broker,broker 据此决定删除还是保留。三种方法分别表达三种结果:basic.ack 是正向确认,表示处理成功、消息可删除;basic.nack 是否定确认,表示处理失败,可携带 requeue 参数决定是否放回队列,并支持批量;basic.reject 同样是否定确认,但每次只能拒绝一条,不能批量。basic.nack 的能力覆盖了 basic.reject,因此多数实现只用 ack 与 nack。

消息在 broker 侧有两种状态。 队列统计里的 ready 和 unacknowledged 是消息的两种状态:刚入队、尚未投递给任何消费者的消息处于 ready;已投递给消费者、消费者尚未 ack 的处于 unacknowledged,两者之和是队列的总消息数 total。发布时消息从无到有进入 ready;投递时从 ready 转入 unacknowledged;消费者 ack 后才从 unacknowledged 移除。异常路径则往回走:消费者崩溃或连接断开,未 ack 的消息自动从 unacknowledged 退回 ready,等待再次投递。unacknowledged 持续增长而 ready 不涨,通常说明消费者收到消息却不 ack——这是排查消费异常时最先看的一个信号。

消费确认在两类队列里,走的是两套不同的底层机制。 classic queue 的持久化层由「队列索引 + 消息存储」两个组件构成:队列索引记录每条消息的位置、是否已投递、是否已确认,消费者 ack 一条消息,broker 就在队列索引里把它标记为已确认,随后由消息存储的 GC 压缩把这条消息的数据真正回收。quorum queue 则不同——它把消息发布和消费确认都记录为 Raft 日志条目:消费者 ack 一条消息,Leader 就把这条 ack 追加进日志、复制给副本,与消息发布走同一条复制路径。所以「消息被消费」在 quorum queue 磁盘上的表现不是「那条消息被删掉」,而是「多了一条 ack 记录」,已 ack 消息的数据仍留在 segment 文件里,靠日志压缩逐步回收。这个回收机制在 4.3 前有一个缺陷:一条乱序的未确认消息就能挡住整段日志的截断,导致磁盘无界增长;4.3 引入的 Ra v3 压缩允许乱序 ack 与不同速率的确认共存,仍能正常回收空间。两类队列的差别正好落在第七节的磁盘结构上:classic 在 msg_stores/ 里按消息存储的 GC 回收,quorum 在 quorum/ 的 Raft 日志里按日志压缩回收。

确认模式决定交付语义。 交付语义回答的问题是:一条消息保证被投递几次。订阅时选择自动确认还是手动确认,直接给出两种不同的保证:

模式保证消息何时从队列移除
自动确认(auto-ack)至多一次broker 把消息写入 TCP 后立即移除,不等待消费者反馈
手动确认(manual-ack)至少一次消费者显式 basic.ack 后移除;未 ack 的消息在连接或信道关闭时自动重新入队

两种保证各有代价。自动确认下,消费者还没处理完就崩溃,消息已经没了,这是丢失;手动确认下,消费者处理完了、但 ack 还没发出去就断连,消息会重新投递,这是重复。官方文档对自动确认的定性是「应视为不安全」,它只适用于消息丢失可容忍的场景(指标、遥测)。可靠消费必须采用手动确认。

prefetch 限制在途消息数。 手动确认下,未确认的消息会一直留在消费者一侧。若不加限制,broker 会把积压消息全部推给消费者,既可能耗尽消费者内存,也让其他消费者无消息可处理。basic.qos 的 prefetch 值规定一个信道上最多同时未确认多少条:达到上限后 broker 停止投递,直到消费者 ack 一条、腾出一个位置。quorum queue 会把 prefetch 上限强制限制为 2000。取值依任务而定:计算密集型取小值(如 1,让多个消费者均匀分摊),IO 密集型取较大值(抵消网络往返延迟)。

可靠消费在应用里就是「设 prefetch + 手动确认」两件事,消费逻辑写在投递回调里:

// 手动确认 + prefetch=10:同一时刻最多 10 条消息未确认
channel.basicQos(10);

DeliverCallback handler = (consumerTag, delivery) -> {
    long tag = delivery.getEnvelope().getDeliveryTag();
    boolean redelivered = delivery.getEnvelope().isRedeliver();
    try {
        process(new String(delivery.getBody()));
        channel.basicAck(tag, false);          // 处理成功,确认删除
    } catch (Exception e) {
        channel.basicNack(tag, false, true);   // 处理失败,重新入队
    }
};
// 第二个参数 autoAck=false:由上面的回调显式确认,而不是 broker 自动删
channel.basicConsume("orders.quorum", false, handler, consumerTag -> {});

两处要盯住:basicConsume 的第二个参数必须是 false(否则变成自动确认,ack 逻辑形同虚设);basicAck 的第二个参数 multiple 设为 false,只确认当前这一条,避免误把还没处理完的批量消息一起确认掉。delivery.getEnvelope().isRedeliver() 返回 true 时,说明这是一条之前投递过、但没来得及 ack 就被重投的消息——下面要讲的「重复」正是从这里来的。

重复是可靠消费需要应用侧自行消解的代价。 至少一次的代价是重复:处理成功、basic.ack 尚未发出时连接断开,该消息会被重新投递,并携带 redelivered=true 标记。RabbitMQ 不提供 exactly-once,只保证「至少一次」并标记重投,把「消除重复」交给应用侧:用消息中的业务唯一 ID 去重、用数据库唯一约束兜底、或用版本号与状态机做前置校验。

重复在本实验里有直接记录:失去多数派恢复后,3000 条预置消息共投递 3001 次,多出的一次是消费者被强杀时未及 ack 的那条被重新投递,队列剩余为 0、无丢失——即至少一次交付的粒度(读数出自 raw-quorum-consumption.log,机制细节见下一节)。

至此,消费可靠性的边界明确:手动确认与 prefetch 保证消息不丢、消费者不被压垮;余下的重复由应用以幂等消解。下一节将发布与消费置于同一批故障中,看这两套机制在故障时的表现。


十、故障分轻重:程度不同,RabbitMQ 的应对也不同

故障不只有「有」和「没有」两档,严重程度是逐级上升的。这一节按严重程度从轻到重走一遍:每一级故障下,发布和消费分别会发生什么,RabbitMQ 的架构又是靠哪一层把它扛住或放弃的。三级的差异本身就是信息——同样是节点停掉,停的节点角色不同,架构给出来的结局就完全不同。

三级故障各自的结局:从最轻到最重,每级的发布与消费表现

最轻的一级:接入节点故障(该节点不承载目标队列成员)。 客户端连的那台节点停了,而且这台节点不承载目标队列的任何成员,既不是属主,也不是 quorum 副本。发布端的表现:broker 强制关闭连接,客户端收到 CONNECTION_FORCED (320);实测 76 次尝试里 75 次异常,只有 1 次落在线路断开前的窗口里。把客户端改连到存活节点之后,178 次发布连续全部成功(raw-failover-timeline.log)。

消费端的表现:连在这台节点上的消费者会断链一次,异常原文是 StreamLostError: Transport indicated EOF,改连存活节点后 0.5 秒内恢复投递。队列侧什么都没发生:定义、副本、Leader 都不受影响,需要做恢复动作的只有客户端自己。

「只断连接」有一个必须交代的前提:普通集群节点往往同时是接入节点和多个队列的副本节点。一旦停掉的节点恰好还是某个队列的属主或 quorum 成员,它就升级成更严重的中间一级,不再是单纯的最轻一级。

对维护者来说,这一级几乎不需要动手:客户端换节点重连就能继续,队列侧没有状态要修。真正要做的只有一件事——先判断这次停掉的节点到底是不是某个队列的属主或 quorum 成员。判断方法就是看这个队列的成员列表(rabbitmqctl list_queues 的 members 字段)里有没有这台节点;没有,就按最轻一级处理,等客户端自愈;有,就得按下面的中间一级或最重一级处置,而不是停在「反正能重连」的错觉里。

中间的一级:Leader 故障,多数派仍在。 Leader 被 SIGKILL,剩下两个副本还在,多数派成立,组内重新选举。发布端的表现分两层:机制层,官方口径是选举窗口内写操作会被推迟、多数已发布的消息会保留并转交给新 Leader,但部分场景会以 basic.nack 拒绝;观察层,这一轮 3206 次发布全部拿到确认、0 异常,单条最长阻塞 348 毫秒,没有出现 basic.nack。两层不矛盾——前者说的是「可能」的范围,后者只是其中一次观察落在「全保留」这一端,所以这一条不能写成「只停顿、永不失败」的固定保证。

消费端的表现:重新选举期间投递暂停,选出新 Leader 后自动恢复;连在故障节点上的那个消费者断链一次后改连存活节点,前后投递 346 条,连在存活节点的对照消费者投递 348 条,最长无投递窗口 A 0.55 秒、B 0.24 秒。客户端在整个过程中不需要知道 Leader 换了人,它只感觉到一次停顿。

停顿的长短与「怎么停」有关。换成 SIGTERM(正常终止)再测一遍,4507 次发布同样全部成功,单条最长阻塞只有 41 毫秒,比硬杀小一个量级:正常终止的节点有机会先把状态交代清楚,硬杀让组内直接从「失联」开始判定。两种停法各只跑了一轮,这两个数字按单样本口径看。

对维护者来说,这一级是最该忍住别动手的一级。多数派还在,架构会自己重新选举、自己恢复,此刻最不该做的就是急着往组里加成员、删成员或强制改 Leader——成员变更是要等应用落地、且超时会回滚的操作,故障当口去动它,反而可能把本来还能自愈的局面搅乱。维护者该做的是两件事:盯住选举窗口里的写停顿(发布端最长阻塞、消费端最长无投递这两条时长指标),确认新 Leader 真的选出来了(rabbitmqctl list_queues 看 leader 字段是否指向存活节点)。停顿是预期内的、短暂的,只要多数派还在,不需要人工接替。

最重的一级:失去多数派。 三副本里再停一个,只剩一个。需要共识的操作无法继续推进——请求可能被保留、等多数派恢复,也可能在超时后失败。发布端的两种表现必须分开写,否则会得出「失去多数派也不报错」这种错结论:

  • 已登记的信道会被挂住。 故障前就用过这个队列的信道,它的发布不会立刻失败,而是被保留着等多数派回来。实测里这条发布挂了 37.7 秒仍未返回,直到观察窗结束。
  • 新连接会明确失败。 一个新连接的首条发布在 15.01 秒后以 basic.nack 结束,拿到的是明确的否定结局。

差别来自超时预算:信道持有的队列状态里有一个命令超时,值等于 net_ticktime 加 5 秒。本机 net_ticktime 是 10 秒,所以超时是 15 秒,15.01 秒正好对上。已登记那条挂得更久,是因为它在等待期间被反复重试,不是一次判定。

失去多数派之下消费端的表现最反直觉:静默停止。两个消费者做对照,A 连故障节点、B 连存活节点,预置 3000 条积压并限速消费(否则基线就会把积压吃光,分不清「停了」还是「没东西可投」)。失去多数派期间两者都是 0 条投递,连接未断、无异常、不重连,最长无投递 50.59 秒,等于整个观察窗。与发布侧「挂住 / 明确失败」不同,消费侧连「失败」这个信号都没有。应用侧只能靠投递停滞时长监控发现它,靠「连接是否存活」什么也看不出来。

多数派恢复之后,两侧都继续工作:恢复后 A、B 各投递 245 条,最长无投递窗口缩到 2.85 秒。3000 条预置消息最后投递了 3001 次,多出的那一条是消费者 A 被强杀时手里没来得及 ack 的那条被重新投递,队列剩余为 0、没有丢失。这是 at-least-once 的粒度实测,数字如实留档、不做归一化(消费侧读数出自 raw-quorum-consumption.log)。

对维护者来说,这一级没有任何「在线操作」能补救:队列的数据已经不足多数派,加成员、删成员、切 Leader 都救不回一个没有多数派的组。唯一有效的动作是尽快让离队的副本重新上线,多数派一旦回来,两侧就会按上面说的那样自动恢复。所以这一级的运维重心不在「怎么修」,而在「怎么早点发现」——因为消费侧是静默停止、连接还活着,靠连接存活探测什么也看不出来,只能靠投递停滞时长这条监控在 50 秒级别内报警,而不是等应用侧先崩了才后知后觉。

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

三级故障的全部读数出自 raw-failover-timeline.log 与 raw-quorum-consumption.log,完整输出在模块 README 里逐条可查。


十一、同一个故障,quorum 如何实现高可用

第十节按严重程度讲了「故障下会发生什么」,但没正面回答「quorum 为什么能在 Leader 挂了之后继续服务」。这一节就回答这个:quorum 的高可用,靠的是多副本、多数派、自动选举三样机制叠在一起,而不是某一个节点特别可靠。用 classic 队列作反衬——它只有一份数据,同样的故障下就不可用,正好把 quorum 靠「副本数」这一件事说清楚。

第一样:多副本,数据不在一台节点上。 一个 quorum 队列的每一条消息,不是只落在 Leader 一台节点,而是被复制到组内所有成员。Leader 挂了,消息还在其它副本手里,没有跟着那台节点一起消失。这是高可用的地基——没有多副本,后面两样机制无从谈起。classic 队列就是反面:它只有一个副本,数据只在属主那一台,属主一停,数据也跟着「离线」,谁也读不到。

第二样:多数派,少一个副本不致命。 quorum 的成员数是奇数(典型是三副本),一致性只要求「多数」在线,不要求全部。三个副本坏一个,还剩两个,仍是多数,队列照常可用;坏两个,只剩一个,不再是多数,队列就停摆。所以 quorum 的「高可用」不是「永不坏」,而是「容忍少数副本故障」——这个容忍度直接由副本数决定:三副本容忍一个,五副本容忍两个。classic 没有这个余地:一个副本就是唯一一份,坏一个就是全部,容忍度是零。

第三样:自动选举,Leader 换了人而客户端无感。 Leader 挂掉后,剩下的 follower 自己发起一轮选举,选出一个新 Leader 接替。选举的窗口很短,期间写操作会短暂停顿(第十节实测的单条最长阻塞是 348 毫秒),选出之后发布和消费照常继续;客户端全程不需要知道 Leader 换了人,它只感觉到一次停顿。没有这第三样,多副本和多数派只是「数据还在」,却没有人能继续处理写入。

三样机制叠起来,才是这一节标题那句话的完整答案:quorum 靠「数据复制到多台 + 只需多数在线 + 自动换 Leader」实现高可用,缺一不可。

反过来看 classic,就能把这件事看得更清楚。做一次对照:把 quorum 的 Leader 和 classic 的属主摆到同一台节点 1,再 SIGKILL 节点 1——对 classic 来说它失去了唯一副本,对 quorum 来说它只失去了三份里的 Leader。

同一个 SIGKILL 下 classic 与 quorum 两条队列的对照时间线

classic(唯一副本离线)quorum(Leader 离线,2/3 在线)
故障中发布旧信道与新信道各 10 条,全部被立即拒绝10 条全部成功确认
故障中消费挂 67.4 / 68.2 / 68.3 秒后才以 404 失败正常取到,没有阻塞
属主恢复后51 条一条不少,逐条消费验证通过55 条,online 回到 3/3

同一个 SIGKILL 落在两类队列上,结局分岔在副本数这一个变量:classic 一份数据离线就是不可用,quorum 少一个副本仍有多数派。这个对照不是要讲 classic 有多差,而是用「没有多副本会怎样」反证 quorum 高可用到底靠的是什么。

对照里还有一个不对称值得一提,但只是支线,不展开:classic 那条路径上发布 0.01 秒就被拒,消费却挂到约 68 秒才有结果,方向性的原因是发布与消费走了两套不同的队列访问封装。完整归因和故障期间管理 API 的观测陷阱,见文末排错索引。

(对照读数出自 raw-classic-owner-failure.log 与耗时归因探针 raw-phase2-timing.log。)

十二、故障之后:RabbitMQ 怎样一步步恢复

第十一节讲的是故障当口 quorum 靠什么不倒下;这一节讲故障之后——离队的节点回来,RabbitMQ 从「少一个副本」走回「全量副本」的完整过程。这个过程不是节点一起来就瞬间完成,而是分几步,每一步都有它自己的判断点。

图里有两条岔路:上面那条是「少数副本暂时离队」的正常恢复,一步一步走到重新参与多数派;下面那条是「多数节点永久丢失」,续传没有对象,直接跌入永久不可用、只能删重建。这一节按上面这条主路走,最后再回到那条岔路。

第一步:节点重启,进程先回来,数据还没追平。 容器重新起来,只说明这个 RabbitMQ 进程活了,不代表它的日志已经和组里其它成员对齐。一个离队很久的副本,落下的是它离开期间产生的所有新条目,重启那一刻它还停在旧的日志位置。所以「进程起来」和「副本追平」是两件事,不能划等号。

第二步:follower 从它离开的地方续传。 这是恢复路径上自动发生、不需要人工干预的一侧。重新上线的副本向 Leader 报告自己的日志位置,Leader 把落下的条目一批批补给它,直到它追到最新。这段时间里集群看起来是健康的,但这个副本还没有真的跟上——如果它还没追平就又发生故障,它的「多数派」贡献仍是空的。

第三步:追平验收,用官方指标,不用容器状态。 追平没有完成,得有一条可复核的判据。最直接的两个是三份 Last Log Index 是否一致、online 副本数是否回到全量;实测里追平后的三份 Last Log Index 是一致的(一轮是 10516、另一轮是 72)。check_if_node_is_quorum_critical 的退出码则是节点级关停检查,它回答的是「再把目标节点关掉,会不会让某个组件或队列失去多数派」,不是「某个队列是否已经追平」,所以它适合在关停前做风险评估,不适合当追平验收。容器报 healthy 只说明进程活着,不说明日志追上了——这一步的判据必须落在日志与副本数上,而不是容器状态。

第四步:追平后重新参与多数派。 副本一旦追到最新,就重新成为多数派里的有效一员。第十一节讲过多数派才是可用性的关键——一个离线副本在追平之前不贡献多数派,追平之后才真正「回来」了。这一步完成,队列才回到故障前的可用状态。

追不平的另一侧:永久不可用。 如果多数节点是永久丢失,而不是暂时离队,那么数据已经不足多数派,续传没有对象、追平无从谈起。这个队列就永久不可用,任何自动化都救不回来,只能强制删除重建。所以恢复过程有一个前提:丢失的只是少数副本。多数一旦没了,走的就是「删重建」而不是「恢复」。

十三、如何选择队列类型和副本位置

前面把机制讲完了,这里落到两个实际决定:选哪种队列类型,副本放在哪。

队列类型,按「要多少可靠性」选。 三种类型本质上是三档不同的可靠性:

  • classic:数据只在属主节点一份。属主离线队列就不可用,但写入最快、最简单。适合能接受「节点故障时短暂不可用」的场景。
  • quorum:数据复制到多数节点(典型三副本)。容忍少数节点故障,写入要多一轮复制确认。适合要求「挂掉一个节点还能继续收发」的场景。
  • stream:按追加日志存、可反复重放,面向大吞吐流。副本也是多数派复制,但不显式 fsync,不受控关机可能丢数据,所以比 quorum 的保证弱一档——RabbitMQ 自己在文档里都说「要更安全就用 quorum queue」。

一句话记住这个选择:classic 是单点、quorum 是多数派、stream 是带重放的多数派(但可靠性略降)。要「不丢且高可用」就 quorum,要「极致简单或能容忍单点」就 classic,要「日志型重放」才考虑 stream。三档的关键不是「谁最好」,而是「代价和可靠性一起涨」:classic 写入最快,代价是属主一挂就不可用;quorum 靠多数派把可靠性顶上去,代价是每次写入多一轮复制确认;stream 在多数派之外加了日志重放,但「不显式 fsync」让它的可靠性比 quorum 弱一档。选型时先定「能接受多大的不可用窗口、能承受多大的写入代价」,再对着这三档取。

副本位置,靠 queue-leader-locator 控制,但只能「建议」不能「指定」。 这个配置决定声明队列时副本落在哪些节点:

  • 两个有效取值:client-local(默认)与 balanced。另外三个已弃用的取值(random、least-leaders、min-masters)在内部都映射到 balanced。
  • 成员怎么挑:声明队列时,声明它的客户端所连的那台节点一定被放上一个副本;其余成员在队列总数少于 1000 时按「哪个节点同类队列成员最少」来挑,达到 1000 后改随机。
  • 关键限制:client-local 对 quorum、stream 只是建议——它保证副本落在声明节点,但不保证这个节点一直是 Leader,因为 Leader 由之后的 Raft 选举决定。balanced 则去均衡 Leader 分布。

由此,那句「Leader 位置影响跨节点流量与热点」要这样理解:热点确实存在,但配置只能把副本放过去、不能把 Leader 钉死。要治理热点,靠的是选节点,不是指望一条配置锁住 Leader。

副本位置之外,还有成员变更的边界。 副本数不是定死就不能动的,声明之后运维还能手动加删成员,但有两条要记住:

  • 加成员默认是 promotable(非投票成员),声明时创建的才是 voter;只加不提升,就不参与多数派计算。
  • 删不掉最后一个成员(返回 last_node);成员变更 20 秒超时会回滚。

(成员变更更细的底层行为——比如 ra:add_member 要等 applied 才真正落地——见排错索引与源码,这里不展开,不影响选型这个主线。)


十四、收口:整条链的分工与保证边界

把整条链各方的分工摆在一起看:Publisher Confirm 负责把「消息已被接受」这件事如实告诉你,它不负责不重复;客户端重连负责把「接入节点没了」变成一次可恢复的断线,它不负责不中断;消费端幂等负责兜住 at-least-once 语义下的重复投递;而队列类型与副本放置,决定上面每件事发生在哪种故障模型里。

几个「安全」类说法,都成立于一个具体的故障模型,脱离那个模型就不成立:

  • 「quorum queue 能容忍一个节点故障」成立于三副本、且任一时刻仍有多数派在线。关键不是故障是否「同时」发生,而是第一个副本尚未恢复之前,第二个故障到来同样会失去多数派。
  • 「已确认的消息不会丢」成立于「托管该队列的多数节点不是永久不可用」。一旦多数节点永久丢失,这个保证就不复存在,队列只能强制删除重建。
  • 「客户端不需要知道 Leader 在哪」成立于客户端能连上任意一台存活节点。接入节点故障时,需要换节点的是客户端自己。
  • 「消费不会丢」——本文实验只验证到 at-least-once:3000 条投递了 3001 次,没有丢失,但有重复。

所以 quorum queue 并不保证「不丢消息」,也不保证有多数派就等于 exactly-once。basic.ack 保证的是「这条可路由消息已被所有命中队列接受」;消费侧的补偿与幂等是另一件事。

最后跨产品对照一句:RabbitMQ 与 RocketMQ 在这类系统上的差别是副本粒度——RocketMQ 复制的是整份日志,RabbitMQ 在 quorum queue 上的复制单位是队列。这一句的依据是本系列第四话的副本粒度结论,加上本文实测到的 RabbitMQ 侧副本范围。


排错索引

遇到下面这些现象,按「判据 → 处置」对一遍。最后一列指向原始日志或证据条目,每一条都能回溯。

现象判据 / 原因处置证据
读到的队列条数比 basic.ack 成功的条数少条数指标按周期上报(quorum queue 上是 Ra 的 tick 回调,默认 5 秒),读到的是上一份快照等两次读数一致再取值;不要据此判定丢消息raw-failover-timeline.log 末尾
管理 API 查不到刚建立的连接连接表是周期性采集的轮询到出现为止,别把「查不到」当成「落在别的节点」raw-classic-owner-failure.log
故障期间管理 API 仍报 state = 'running',node 指向已离线节点state 反映的是元数据里的记录,不是可用性用 quorum_status 与 list_queues 判断;但这两个查询本身也可能读不到(见下一条)raw-classic-owner-failure.log
transfer_leadership 返回非零目标已是 Leader 时退出码为 69,且结果头在 stdout、错误在 stderr接受 already the leader 为成功;判定文本要两路都读raw-classic-owner-failure.log 首行
list_queues 在失去多数派时可能失败或超时Khepri 的缓存读与线性一致读行为不同,两轮实测结果不一致,归因未定(见第十节边注)别把一次查询结果当成确定的队列状态判据;先看元数据存储状态再下结论raw-failover-timeline.log、raw-quorum-consumption.log
消费挂住很久才报 404classic 的读取路径有 2000 × 30 毫秒的重试预算这是 broker 侧的耗时尺度(约 1 分钟量级);应用侧的超时按自己的 SLA、幂等与重试策略设,不必照抄这个预算raw-phase2-timing.log
节点重启后立刻读写容器起来 ≠ 副本追平用 quorum_status 的 Last Log Index 与 online 验收raw-failover-timeline.log、raw-classic-owner-failure.log
发布拿到 basic.nack,以为这条消息哪儿都没进去basic.ack 是所有命中队列的 AND;被拒绝的那一个决定了结局,别的队列可能已经收下了重投前先按消息 id 去重,或把多队列命中改成显式检查;不要假设「basic.nack = 没写入」raw-fanout-per-queue.log 阶段 3 / 3b
消费者「连着但收不到消息」,日志里什么都没有失去多数派时消费是静默停止:连接不断、不报错、不重连,只是不再投递用「投递停滞时长」而不是「连接是否存活」做告警raw-quorum-consumption.log 阶段 3
重启节点后出现少量重复消费消费者所在节点被强杀时,手里未 ack 的消息会被重投;这是 at-least-once 的粒度消费侧做幂等;不要期望重连后不重复raw-quorum-consumption.log 阶段 5(3001 / 3000)

参考资料

  1. RabbitMQ, Clustering Guide. https://www.rabbitmq.com/docs/clustering —— 集群复制什么与不复制什么、客户端操作的路由、奇数节点与 Leader 放置
  2. RabbitMQ, Metadata Store. https://www.rabbitmq.com/docs/metadata-store —— Khepri 保存的范围,及其与 Mnesia 的关系(与 partitions 页口径冲突,见正文第五节)
  3. RabbitMQ, Quorum Queues. https://www.rabbitmq.com/docs/quorum-queues —— Leader 与 Follower 的角色、「操作」的定义、Publisher Confirm 的确认边界、组大小与容错、成员与放置
  4. RabbitMQ, Reliability Guide. https://www.rabbitmq.com/docs/reliability —— 选举期间投递暂停、消费者是否要自己恢复
  5. RabbitMQ, Publisher Confirms. https://www.rabbitmq.com/docs/confirms —— 三种路径分别何时发 confirm、没有 confirm 就没有保证
  6. RabbitMQ, Network Partitions. https://www.rabbitmq.com/docs/partitions —— 4.3.0 移除了什么、失去多数派时哪些操作停摆
  7. RabbitMQ, Streams. https://www.rabbitmq.com/docs/streams —— stream 的副本管理、确认边界与恢复动作(本文选型表的官方依据)
  8. RabbitMQ Server v4.3.5 源码(rabbit_quorum_queue.erl、rabbit_queue_location.erl、rabbit_fifo_client.erl、rabbit_amqqueue.erl 等)—— 成员变更的三条边界、Leader 放置的实现、classic 读取路径的重试预算
  9. 本系列:第一话《RabbitMQ 的架构与特性》、第四话《RocketMQ 4.x 的架构与机制》—— 前者是本文的队列类型基础,后者的副本粒度结论是本文末节跨产品对照的依据

演示代码:三节点集群的编排与全部实测脚本在 GYD 仓库 infra/rabbitmq-cluster/。集群的起停、运行顺序、完整命令、原始输出与每一步的预期观察点,都在该模块的 README 里;正文里每个读数的出处也在那里。这篇文章不复现命令与输出——它负责机制,复现交给仓库。

留言

欢迎分享你的想法。评论提交后会在审核通过后显示。