<?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%8F%AF%E9%9D%A0%E6%8A%95%E9%80%92/</link><description>Recent content in 可靠投递 on Renxin's Blog</description><generator>Hugo -- gohugo.io</generator><language>zh</language><lastBuildDate>Wed, 11 Feb 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://zh.renxinblog.cn/tags/%E5%8F%AF%E9%9D%A0%E6%8A%95%E9%80%92/index.xml" rel="self" type="application/rss+xml"/><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></channel></rss>