Featured image of post 第二话|消息队列的可靠投递与可靠消费:不丢靠三段兜底,不重只能靠幂等

第二话|消息队列的可靠投递与可靠消费:不丢靠三段兜底,不重只能靠幂等

一笔订单的消息发送成功了,下游却没收到——它死在哪一段?在 RabbitMQ 上把生产者、Broker、消费者三段各跑一遍可复现的实验:confirm 返回 ack 时消息可能已经被丢弃,持久化要分清消息恢复和拓扑恢复,prefetch 决定消费者崩溃时最多重投多少条,重试用尽后为什么不能靠 nack 收尾。

系列 GYD(Get Your Distributed) 第 2 / 7 篇
主题 消息队列RabbitMQSpring Boot分布式可靠投递幂等

一张订单表里多了一行,下游的发货系统没有收到这条消息。

订单服务在创建订单成功后投递 order.created,发货、积分、通知三个下游各自消费它。这一笔的发送日志打着成功,订单表里有数据,下游的队列里没有消息。这条消息死在了哪一段?

消息队列从进入架构的第一天就带着「可靠」的承诺,但这句承诺不是某一个组件给的,而是生产者、Broker、消费者各兜一段才拼出来的。任何一段缺了兜底,都会出现「发送成功、下游没收到」。

这条链路的骨架:消息穿过生产者、Broker、消费者三个边界,Broker 里是交换机到队列的一段路由;路径上还有两个反向回执——Broker 回给生产者的 confirm、消费者回给 Broker 的 ack。

RabbitMQ 消息数据流与三段边界架构图

顺着这条消息的生命周期走一遍,它有三个可能消失的位置:

  • 发送时:生产者以为发出去了,消息却没进 Broker,或者进了 Broker 却没进任何队列。那笔订单的发送日志打的就是「成功」。
  • 存储时:Broker 收下了,回执也回了,消息还没挺过一次重启就从磁盘上消失。
  • 消费时:消费者读到了,处理到一半进程挂掉,重启后不知道这一笔该不该再处理。

这三个位置分别由生产者、Broker、消费者兜住,彼此没有替代关系:生产端再严谨也管不到消费者崩溃,Broker 副本再多也管不到生产者发错了路由键。

这条链路上有两类问题,经常被混在一起谈:

  • 不丢:一条消息从产生到被处理完,中途不能消失。
  • 不重:同一笔业务不能被处理两次。

在 Broker 这一层,这两类目标是冲突的:要保证不丢,就得允许重复投递。真正的「既不丢也不重」要到消费端用幂等去拼,Broker 给不了。

GYD 这个系列讲分布式系统里各种解决方案的实现方式。消息队列出现得早,也最容易被当成「基础设施,接上就行」的一环,而可靠性缺口通常就藏在这个默认判断里。

这三段在 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;完整实现、端点对照与复现方式在仓库 c02-mq-reliable-delivery,下面每处结论后面也各自带了落点。


一、生产端:confirm 和 return 是两件事

RabbitMQ 的投递路径是 生产者 → 交换机 → 绑定 → 队列。生产者把消息发给交换机,交换机再根据绑定关系决定它进哪些队列。

消息先到交换机这一层,意味着「到了 Broker」和「进了队列」是两个可以分开发生的事件。RabbitMQ 给了两个独立机制来报告它们:

机制报告的是什么触发条件
publisher confirm视路由结果分情况:可路由=路由到的队列已接收;不可路由=确认没有目标队列消息被交换机处理之后
publisher return消息没能路由到任何队列交换机找不到匹配的绑定,且投递时设置了 mandatory

这两道回执在配置里是两个分开的开关(publisher-confirm-type 收 confirm、publisher-returns 收退回),默认都不开;客户端代码侧还要给发送模板打上 mandatory 标记、并分别注册两个回调。缺了任何一处,等于少了一道回执。

把一条消息投到没有任何队列绑定的路由键上,日志会连出三件事:生产端记下自己把消息投了出去;Broker 退回一条 NO_ROUTE,说明没有任何队列接收它;紧接着是一条 confirm 的 ack,说 Broker 已确认。再去查队列深度,两个业务队列都是 0 条(端点 unroutable)。

confirm 返回了 ack,消息已经被丢掉了。

官方文档在描述这件事时语气很平淡:「对不可路由的消息,Broker 会在交换机确认没有队列可路由之后发出 confirm;如果消息同时以 mandatory 方式发布,basic.return 会在 basic.ack 之前发给客户端。」文档没有单独警告不处理 return 会怎样,但把两者的定义摆在一起,结论是清楚的:confirm 的 ack 不是一律「成功」,它跟着路由结果走。路由到了队列,ack 表示队列已接收;路由不到,ack 只表示「确认了没有队列」,这条消息已经丢了。

只监听 confirm 而不管 return 的服务,等于在发送环节装了一个常亮的绿灯。

这里还有个更隐蔽的边界:return 的语义是「一个队列都没路由到」,所以「没有 return」只说明至少有一个队列接收了,不能证明发货、积分、通知三个预期订阅都配置正确。漏配了一个订阅,消息照样 confirm ack、照样不 return,只是那个下游安静地没收到。这个边界单靠发送端的回执是看不见的。

需要等回执再继续的场景,可以同步等确认结果(示例里给了 3 秒超时)。这里有一处容易写漏:不可路由时 confirm 依然返回 ack,此时消息已被退回、并未进队列,所以只查 ack 会把「路由不到队列」当成「发送成功」,必须再查一次退回结果。同步等回执会拖住发送方。文档里给了个量级:在持续负载下,ack 的延迟可以达到数百毫秒,因此它建议把 confirm 当作异步事件流处理,或者批量投递、统一等待整批回执。同步等待更适合这里这种单条验证,一眼就能看出这一条到底有没有被 Broker 收下。

生产端的可靠性到这里只解决了一半。ack 在「路由不到队列」时并不代表成功,不是回执说了假话,是应用把成功条件理解少了:只盯 confirm 不看 return,就把「没有队列」当成了「发送成功」。而一旦把发送失败当成功、靠重试兜底,「同一笔订单发了两遍」的概率就不再为零。可靠性问题的另一半,是从生产端的重试里长出来的。

二、Broker 端:持久化要分清「消息恢复」和「拓扑恢复」

消息进了队列,下一个问题是它会不会在 Broker 这一侧消失。这里要先把两件事分开:已经入队的消息能不能挺过重启,和重启后拓扑(交换机、绑定)还在不在。它们由不同的属性决定,不能混成一句话。

对一条 classic queue 里的消息,「能不能挺过重启」只看两个属性:

属性落点缺了会怎样
队列持久化声明队列时标 durable重启后队列连同消息一起消失
消息持久化投递时标 deliveryMode=2队列还在,队列里的这条消息不在

交换机的 durable(true) 是另一件事,管的是拓扑恢复:它决定重启后这个交换机还在不在、还能不能继续往它投,但不决定已经入队的消息能否恢复。所以别把交换机、队列、消息并列成「三个都要持久化」——交换机那条管的是「以后还能不能投」,跟「已经投进去的还在不在」是两码事。

官方文档对「能不能挺过重启」的说法是:durable queue 的元数据写磁盘,重启时会被恢复,「包括其中以 persistent 方式发布的消息。以 transient 方式发布的消息会在恢复过程中被丢弃,即使它们的队列是 durable 的。」

这两个属性里,消息持久化这条更容易被漏:队列的 durable 在声明时顺手就写了,消息的 deliveryMode 却要每次投递单独指定。

用一条不挂消费者的持久化探针队列做对照(这个实验只在 classic queue 上成立,quorum queue 的持久化不依赖 deliveryMode):投一条持久化、一条非持久化,两条都会收到 confirm 的 ack;重启 Broker 之后队列深度从 2 掉到 1,messages_persistent 一直是 1。再单独投一条非持久化消息复验:重启前深度 1,重启后归 0(端点 probe)。

ack 是否意味着落盘,取决于消息和队列的持久化属性。 文档给「持久化消息投到持久化队列」这种组合打了包票,那时 ack 意味着已经写盘。但只要消息属性没标持久化,ack 照样返回,重启后它无声消失。

单机持久化挡得住进程重启,挡不住磁盘损坏和机器故障。要扛机器级故障,只能用副本,在 RabbitMQ 4.x 上意味着 quorum queue——文档里那句「Classic queue mirroring was removed starting with RabbitMQ 4.0」,把过去的退路一并关掉了。

quorum queue 基于 Raft,副本关系里有一个 leader 和若干 follower,可用性由「多数派是否在线」决定。单节点验证不了这件事,所以另起了一套 3 节点集群(infra/rabbitmq-cluster/,端口与单节点错开,可以同时运行)。观察到的是这样一串:声明出来的 quorum queue 副本组实为 3 个成员、leader 落在 rmq1;投 3 条持久化消息全部收到 confirm;停掉 leader 所在节点后重新选主(两次运行分别落在 rmq2 与 rmq3),2/3 在线时继续投 2 条都成功,而且能取回全部 5 条,原 3 条一条不少;再停一个、只剩 1/3 时投递被拒,15.0s 后返回 NackError: 0 message(s) NACKed;把停掉的节点恢复回来,队列自愈到 3/3、投递恢复,而被 NACK 的那条并没有留在队列里(脚本 quorum-majority.py)。

quorum queue 的可用性由多数派决定:2/3 在线可以读写,掉到 1/3 就无法提交写入。 这不是丢消息,是 Raft 为避免脑裂做的取舍:宁可拒绝写入,也不让两个分区各自接受写入。客户端看到的表现不一定是即时报错,可能是等待、超时,也可能是明确的 NACK(本次实测是 15s 后返回 NackError)。

顺带一个观测上的坑:失去多数派时,连读队列视图本身都会超时,list_queues 时好时坏。故障期间的监控图表断点,有一部分来自这里。

维度单机 durable queuequorum queue
数据存活范围单节点磁盘多数派副本
扛得住什么进程重启、节点重启节点重启、单机磁盘损坏、少数派节点丢失
可用性前提节点在线多数派在线(3 节点可容忍 1 个)
代价—每次投递要复制到多数派,写入路径更长

quorum queue 的 confirm 语义也跟着变严:只有在消息被复制到多数派成员之后,Broker 才会发出 publisher confirm。 这一点和单机队列不同,单机队列上「收到 ack」离「数据安全」还有距离,quorum queue 上这句话的分量更重。

副本组本身也有几条固定约束:quorum queue 必须是持久化队列(Raft 的副本机制不建立在内存队列上);官方建议副本数为奇数,默认 group size 是 3;文档明确写了「不要在多过 7 个 RabbitMQ 节点上运行 quorum queue」,也提醒少于 3 个节点的集群拿不到 quorum queue 的完整保证。本次实测的 3 节点加 group size 3,正好是能观察到选主、多数派可用性和少数派拒写的最小配置。

三、消费端:ack 时机与 prefetch 决定崩溃后重投多少条

消息安全躺在队列里,最后一个缺口在消费者手里。ack 的时机就是一次投票:投给「不丢」,还是投给「不重」。

处理成功后再确认、还是先确认再处理,是两个方向相反的取舍:先处理成功后 ack,中途崩溃消息会重投,于是可能重复处理;先 ack 再处理,中途崩溃时消息已删除,于是可能丢失。autoAck 是后者的极端形式,消息一交给消费者就当作已确认,跑得最快,也最不设防。

这里要插一句和 Spring 的边界:上面两种只是 AMQP 层的 manual / autoAck;Spring 还有一个默认的 AUTO 模式,它是容器在监听方法成功返回后替你 ack,比 autoAck 保守,比 manual 省事。这套 demo 用的是 manual,配置里关掉框架自动确认,把 ack 时机完全交给业务代码:业务成功才 ack,失败则带 requeue=false 拒绝,交给重试链路(不重回原队列,否则会原地死循环)。

选完 ack 时机,还有一个必须显式决定的参数:prefetch。

RabbitMQ 的消费模型是「未确认消息的集合」,不是 Kafka 那种记「读到哪了」的位点。Broker 把消息推给消费者,在收到 ack 之前一直记着这些消息属于这个消费者,没有 ack 就没有「位点前进」,只有「这条还没结清」。于是 prefetch(协议里的 basic.qos)决定了一个消费者最多同时持有多少条未确认消息。

这个参数不影响单条消息的处理速度,它影响的是消费者崩溃时有多少条要重投。用 50 条慢消息(每条处理 200ms)只改 prefetch 来对照:设成 1 时未确认条数恒为 1,设成 20 时恒为 20,而 50 条排空都花约 10s(端点 burst)。两者的吞吐相同,差别在同时在途的未确认条数。 如果消费者在任一时刻崩溃,prefetch=1 最多重投 1 条,prefetch=20 最多重投 20 条。

这里有个边界要说清楚:本实验的瓶颈在消费处理本身(每条固定 200ms),所以两者耗时相同。如果消费逻辑很快、网络往返占比高,prefetch 取值会明显影响吞吐,那是另一个结论,不能从这组数据外推。表里的「恒为」也只成立在积压充足阶段,消息源源不断时未确认条数稳定在 prefetch 上限;排空的最后一段,队列里剩不足 prefetch 条,未确认条数会掉下来。另外,实验只测了单消费者,多消费者共用一个队列时的负载分摊没有测。

还有一件事值得和 prefetch 分开看:消费者崩溃后,那些未确认的消息会被重新投递,但不一定回到同一个消费者。Broker 把它们放回队列等重新分配,多个消费者在场时通常落到别人手里。这就是为什么扩容之后「同一笔业务被两个消费者各处理一次」会更常见,幂等在那里不再是理论问题。

至于 prefetch 该定多少,没有一个可以照抄的数。它取决于单条消息的处理耗时、下游能承受的并发、以及你愿意接受崩溃时重投多少条。文档里 0 表示不设上限,等于把未确认集合交给内存和连接去决定,生产环境一般不会这么配。Broker 侧另有一个「消费者没指定时生效」的默认 prefetch,可在高级配置里调整(文档示例给的值是 250),所以不显式设置并不等于没有限制,只等于这个数由别人替你定了。

四、不丢和不重分别落在两层:Broker 管投递,幂等管效果

前三段各自解决了一类丢失,代价是把重复引了进来:生产端的重试会重发,消费端的 ack 时机(先处理再确认)会重投。要理解这件事的边界,得先看清 Broker 能承诺什么。

官方可靠性文档把话说得很直白:「使用 acknowledgements 保证 at least once 投递。不使用 acknowledgements 时,发布和消费过程中都可能丢消息,只能保证 at most once。」至于 exactly-once,文档在同一页给出了它的现实形态:生产者重传、确认丢失时消息可能重复,「因此消费端应用需要做去重,或者以幂等的方式处理收到的消息。」

Broker 能给的只有 at-least-once 和 at-most-once。exactly-once 不是队列的承诺,而是「at-least-once 投递 + 消费端幂等」拼出来的效果,而且这个 exactly-once 只覆盖被幂等机制管住的那些业务效果。

投递语义谁提供结果
at-most-once关掉 ack / autoAck不重,可能丢
at-least-once手动 ack + 失败重投不丢,可能重
exactly-once队列不提供由 at-least-once + 消费端幂等拼出

这里要分清三个层次,它们经常被混成一句话。投递可以重复:Broker 重投、生产者重发都会让同一条消息到两次。执行可以重复:处理函数被调用两次。业务副作用能不能只生效一次,取决于消费端有没有幂等,而且幂等只覆盖它自己管得住的那些副作用。数据库去重表能让「改库」只发生一次,但它不会自动让发货、短信、外部扣款也只发生一次;那些副作用要各自有幂等键(发货单号、短信去重、扣款流水号),或者接受「至少一次 + 人工对账」。

注意幂等这一层必须落在业务侧。Broker 手里也有一些计数,比如 quorum queue 会把一条消息失败的投递次数记在 x-delivery-count 头里,配合 delivery-limit 策略,投递超过上限的消息会被死信掉,而它解决的是「一条毒消息别在队列里无限打转」,不是「你的业务代码只跑一次」。这两个问题在不同的层,别指望前一层替后一层兜住。(delivery-limit 的计数规则在 4.3 有调整,不是所有显式 nack 都计入失败次数,与第六节讲的 delayed retry 相关。)

落回订单场景:这一笔订单可能被处理两次。幂等的最小形态是让消息带上业务唯一键,消费端用它去重,这套 demo 里就是消息体上的 orderId。它由订单服务生成,重发时不变;消费端拿到它,先查这一步业务是否已经做过,再决定要不要执行。但要小心并发:去重记录和业务写入必须原子提交(比如唯一索引 + 同库事务),否则两个消费者同时「查无记录」、都往下执行,幂等就失效了。去重表、唯一索引、状态机校验各有各的适用场景,本 demo 没有落到那一层,orderId 只负责把「同一笔业务」这个身份带过去。

五、兜底:死信与延迟重试链路

三段兜底之外,还有一类问题没有解决:消费一直失败怎么办。失败一次就重投,会得到一个高速空转的循环;失败三次就扔掉,会丢掉一条本该被处理的消息。

RabbitMQ 在这一层的原语是死信。消息在四种情况下会被重新投递到死信交换机:消费者以 requeue=false 拒绝(basic.nack / basic.reject)、消息因 TTL 到期(per-message TTL,或队列级 message-ttl)、队列超过长度上限、以及 quorum queue 上超过 delivery-limit 的投递次数。

这里有两个容易踩到的地方。如果队列上配了死信交换机,但消息需要死信时那个交换机并不存在,消息会被静默丢弃,没有回执,没有异常。拓扑声明因此不能只声明队列,交换机、绑定、队列的生命周期要一起管。

另一个和队列类型有关:classic queue 的死信过程默认不带 publisher confirm,消息从原队列删除后直接投给死信目标,目标不可用时它就丢了。quorum queue 可以做得更强,但默认仍是 at-most-once,要显式配置 dead-letter-strategy=at-least-once、overflow=reject-publish 并配好死信交换机,才切到带 confirm 的 at-least-once 死信。同一套死信拓扑,队列类型和策略配置不同,兜底强度并不一样。本 demo 没有显式配置这套策略,死信转移仍是 at-most-once 语义。

要做「重试 N 次再进死信」,标准做法是让业务队列的死信出口指向一条重试链路:业务队列 → nack → 重试交换机 → TTL 重试队列 → 到期回业务交换机 → 业务队列。这套 demo 的拓扑里,业务队列的死信出口不是最终死信队列,而是重试交换机;重试队列上配了 2000ms 的队列级 TTL,到期后经自身的死信出口回到业务交换机;最终死信交换机只接重试用尽以后由应用显式投递的消息。

重试计数不一定非要应用自己维护,本 demo 读 Broker 写在 x-death 头里的死信记录。 应用自己维护一个进程内计数器,进程重启后就归零;要可靠就得把次数持久化到数据库、任务记录或消息头里,那是另一套存储。本 demo 选择读 x-death,它跟着消息走,避免了另建一套重试计数存储。官方文档对它的定义是:每条记录里 count 表示「这条消息因为这个原因从这个队列被死信了多少次」,并且记录会按 {队列, 原因} 压缩,同一个 (队列, 原因) 组合只保留一条,count 累加。

还要说清一个边界:x-death 记的是「死信」次数,不是「投递」次数。消费者崩溃后消息被直接重投、没走死信的那一次,不会在 x-death 里留记录。所以「rejected 次数 + 1 = 累计投递次数」这个等式,只在「每次失败都走 nack 死信」的本实验路径下成立。

实测一条一直失败的消息(orderId 里带 fail),队列级 TTL 2000ms、最多重试 3 次。第一次失败时 x-death 还是空的;第二次时里面已经有两条记录,业务队列上一条 rejected 计数 1、重试队列上一条 expired 计数 1;第三次两边各加 1;到第 4 次投递仍失败,消息转入最终死信队列,此时累计投递 4 次,也就是首次加上 3 次重试。相邻两次处理的间隔实测 2004 / 2003 / 2003ms,与配置的 2000ms 对得上(重跑会有几毫秒浮动)。

计数时要挑对条目:同一次失败会留下两条记录,业务队列上一条 reason=rejected,重试队列上一条 reason=expired,两个都算就重复计数了。示例里的计数只挑与业务队列同名的 rejected 记录求和。

重试用尽之后怎么收尾,有一个容易写错的地方。这里的业务队列死信出口是重试交换机,所以不能再用 nack 结束,它会再一次进入重试循环。正确做法是显式投到最终死信交换机,再 ack 掉原消息,同时把尝试次数写进消息头备查。

六、单个 TTL 重试队列的限制:混合延迟会出现队头阻塞

重试链路跑通之后,一个自然的想法是让间隔递增:1 秒、10 秒、60 秒,给下游留出恢复时间。这个想法在 RabbitMQ 的 TTL 上会撞到一件不那么直观的事。

队列级的 x-message-ttl 是队列的属性,同一条队列里的消息共用一个延迟,所以它天然只能做固定间隔,要递增就得给每一级配一条队列。另一条路是 per-message TTL:投递时给每条消息带上自己的 expiration。直觉上「谁先到期谁先走」,实测是「队头说了算」。

同一条队列投 3 条消息、各带不同的 expiration,只换入队顺序(脚本 ttl-head-of-line-blocking.py):先投 6000ms 再投两条 1000ms,三条全部在 +6.0s 才死信;把顺序换成两条 1000ms 在前、6000ms 在后,前两条 +1.0s 就走,第三条 +6.0s。后两条的 expiration 明明只有 1000ms,却等到 +6.0s 才死信,被队头那条 6000ms 的消息卡了约 5 秒。

在本实验这种「同一条 classic 队列、只靠 per-message TTL 触发死信」的场景下,过期检查只在队头进行。 队头那条没到期,排在它后面、已经过期的消息也不会被处理。

官方文档写得很清楚:classic queue 与 quorum queue 都是「过期消息到达队头时才被死信掉」,并且「设置 per-message TTL 后,已过期的消息可能排在未过期消息后面,直到后者被消费或过期;这些过期消息占用的资源不会被释放,也会被计入队列的消息数」。

所以递增退避的「手搓」做法有两条:每一级一条队列(1s 队列、10s 队列、60s 队列各配一条,用路由键区分级别),或者装延迟消息插件。本 demo 用固定 2000ms 间隔,正是因为它只需要一条重试队列。

不过这两条都不是唯一的答案了。RabbitMQ 4.3 起给 quorum queue 提供了原生 delayed retry:通过 x-delayed-retry-type / x-delayed-retry-min / x-delayed-retry-max 三个参数,消息被拒绝(reject/nack)后按投递次数自动线性退避,延迟 = min(min_delay × delivery_count, max_delay)。它把「递增退避」做成了队列的内建能力,不用再叠一堆 TTL 队列或插件。注意三点:它只对 quorum queue 生效;默认 x-delayed-retry-type=disabled,要显式开启;本 demo 用的是 classic queue + 单条 TTL 重试队列,走的是上面那条「手搓」路径,正好能看清队头阻塞这个底层限制。

七、一致性补偿:Broker 之外的那一段

上面讲的都是「消息发出去之后」的可靠性。还有一类问题在更早的地方:订单写进了数据库,消息没发出去,或者反过来。

在 RabbitMQ 上这件事要靠应用层补偿。AMQP 的 channel 事务在这里帮不上忙,它的硬伤首先是事务边界:channel 事务只能包住「发消息」这一个动作,包不住「数据库写 + 发消息」这对跨系统的操作,所以它根本解决不了原子提交;顺带它还有吞吐代价(文档给的是降到约 1/250)且和 confirm 模式互斥。RabbitMQ 也没有 RocketMQ 那种 broker 级的事务消息(半消息 + 回查)。可行的模式是本地消息表(也叫 outbox):在写订单的同一个本地事务里,往一张 outbox 表插一条待投递记录;事务提交后由后台任务把 outbox 里的记录投递出去,投递成功再标记完成;消费端仍然靠幂等兜底。

这个模式的关键在「同一个本地事务」,数据库事务保证订单和待发消息一起落库,消息投递则从「必须成功」降级成了「迟早成功」。本 demo 没有实现本地消息表,这里只说明它在链路里的位置:它是把「改库 + 发消息」这对跨系统操作,压回一个本地事务里的做法。

三个角色可以分开看:订单表和 outbox 表写在同一个事务里,解决「改库成功但消息没发出去」;后台投递任务负责重试,解决「投递时下游不可用」;消费端幂等兜最后一道,解决「投递重试带来的重复」。三处缺一处,一致性就漏一处。

这个模式也有自己的代价:outbox 表需要清理策略,否则它会跟着业务表一起无限增长;投递任务要独立控制并发和失败告警,它本身变成一条需要被监控的链路;投递逻辑和业务表放在同一个库时,事务的粒度会变大。它适合「本地写库、且要求通知最终一定送到」的场景,不适合拿来兜一个本来就不要求一致的通知。

八、Boot 4 与 RabbitMQ 4.3 上的几处版本差异

这套代码跑在 Spring Boot 4.1.1 + Spring AMQP 4.1.1 + amqp-client 5.30.0 + JDK 21 上,和大多数 Boot 3 时代的 RabbitMQ 教程有几处对不上的地方:

现象原因处理
Jackson2JsonMessageConverter 找不到依赖Boot 4 用 Jackson 3(tools.jackson),类路径上只有 com.fasterxml.jackson 的注解包,没有 databind改用 JacksonJsonMessageConverter
消费侧抛 The class ... is not in the trusted packages转换器默认只信任 java.util / java.lang按消息类的包名授权,不要写成 "*"
Confirm.isAck() 报弃用Spring AMQP 4 起确认结果改成了 record用 ack() / reason()
死信监听器反序列化失败参数写成 org.springframework.amqp.core.Message 会走 typeId 反推,撞上信任包检查参数直接用具体的消息类型
改完队列参数后应用启动失败,报 inequivalent arg队列的 x-dead-letter-exchange 等参数不允许原地修改先 rabbitmqctl delete_queue 再重启,让应用重新声明(只适用于可清空的实验环境,删除队列会连消息一起删;生产环境应改用 policy 动态配置)
脚本里声明非持久化的非排他队列报 transient_nonexcl_queues is deprecatedRabbitMQ 4.3 起默认不再允许这类队列一律声明为持久化队列

最后一条是 4.3 引入的:官方文档把「transient(非持久化)且非排他的 classic queue」标为弃用,4.3.0 起默认不允许声明。写实验脚本时如果沿用了老教程里的 durable=False,会直接撞上这个报错。

九、收口:三段各兜住什么,以及它划出的边界

回到开篇那三个可能消失的位置,每一处现在都能对上兜底的一方,也能看清它兜到哪为止:

  • 发送时:生产端的 confirm 加 return。两道回执都要看——confirm 跟着路由结果走,不可路由时它照样返回 ack,只监听 confirm 等于在发送环节装一盏常亮的绿灯;回执也覆盖不到订阅配置,没有 return 只说明至少有一个队列接收了。
  • 存储时:Broker 端的持久化与副本。两个持久化属性回答「重启后还在不在」,副本回答「机器坏了还在不在」,后者把可用性前提从「节点在线」换成「多数派在线」,故障期间连读队列视图都会超时。
  • 消费时:消费端的 ack 时机与 prefetch。前者决定崩溃时消息是重投还是丢失,后者决定重投的规模,但都没有改变「可能重复」这个结果。

重复这件事在三段里没有归宿:Broker 侧的 x-delivery-count 与 delivery-limit 管的是毒消息别在队列里无限打转,不是业务代码只跑一次;消掉重复的位置在消费端的业务幂等里,而幂等只覆盖它自己管得住的那几个副作用。三段拼出来的承诺是「不丢」加 at-least-once,不是「既不丢也不重」。

代价和上面三处一一对应:生产端要处理两道回执,并决定同步等还是异步收,同步等会把发送方拖进回执时延里(文档给的量级是持续负载下数百毫秒);Broker 端要接受「多数派不在就拒绝写入」这个前提;消费端先处理再 ack 把重复引了进来,扩容之后同一笔业务落到两个消费者手里会更常见;重试链路上固定间隔只占一条 TTL 队列,改成递增退避,要么每级一条队列,要么接受过期检查只在队头这一条限制。

还有一段不在这三段的视野里:订单写进数据库、消息没发出去(或者反过来)发生在第七节的位置。三段兜底保证消息不消失,保证不了业务状态和消息一起生效;把这件事压回一个本地事务里的做法是本地消息表,它的代价是 outbox 的清理策略、投递链路自己的监控,以及变大的事务粒度。

参考资料

  • Consumer Acknowledgements and Publisher Confirms:publisher confirm 与 consumer ack 的定义与触发时机,mandatory 与 basic.return 的先后顺序。
  • Reliability Guide:at-least-once / at-most-once 的边界,以及消费端去重与幂等的必要性。
  • Queues:队列持久性与消息持久化的关系,transient 非排他 classic queue 的弃用说明。
  • Quorum Queues:Raft 副本、多数派可用性、publisher confirm 的发出时机、x-delivery-count 与 delivery-limit、at-least-once 死信(dead-letter-strategy/overflow)、4.3 原生 delayed retry。
  • Dead Letter Exchanges:死信的四种触发条件,x-death 头部结构与按 {队列, 原因} 压缩的规则。
  • Time-To-Live and Expiration:per-queue 与 per-message TTL 的区别,过期检查发生在队头的行为。
  • Consumer Prefetch:basic.qos 与未确认消息上限。
  • Metadata store:Khepri 自 4.2.0 起成为默认元数据存储,与 Mnesia 的关系。

演示代码:完整实现在 GYD 仓库 c02-mq-reliable-delivery,该目录的 README 列了环境、运行方式、端点与本文各节的对照,以及本文引用的完整输出记录;RabbitMQ 与 3 节点集群的编排在仓库级 infra/,两个实测脚本在 infra/rabbitmq/scripts/ 与 infra/rabbitmq-cluster/scripts/。

留言

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