做消息中间件这一行最怕的事情不是并发高不是延迟大而是悄无声息地丢消息。用户下单成功结果支付回调没触发库存扣减了结果订单服务没收到通知积分加了结果异步日志没落库。这些看起来“偶发”的问题背后往往都是消息投递链路中某个环节出了问题。而RabbitMQ作为目前使用最广泛的消息队列之一虽然本身提供了不少可靠性保障机制但很多人并没有把这些机制串起来导致消息还是会在特定场景下丢失。这篇内容我准备从“可靠性投递”这个角度完整拆解一套可落地的实现方案从生产端到消费端再从存储端到兜底补偿把每一步的原理、配置和坑都讲清楚。如果你正在负责一个基于RabbitMQ的核心链路改造或者你的系统已经出现过消息丢失、重复消费这类问题这篇文章应该能帮你少走不少弯路。方案涉及生产者Confirm确认机制、Return回退机制、消息持久化、消费者手动ACK、幂等去重、死信队列、延迟重试以及最终的数据对账属于一套比较完整的工程化落地思路。1. 先理清楚消息到底是在哪个环节丢的1.1 一次消息投递要经过的四个位置在聊实现方案之前我建议先花两分钟把消息的完整链路在脑子里过一遍。RabbitMQ中一条消息从业务系统发出到被最终处理要经过四个关键位置生产者应用、RabbitMQ Broker、交换机与队列存储和消费者应用。流程大致是这样的生产者把消息发给交换机交换机根据路由键把消息投递到绑定的队列队列把消息存储下来然后消费者从队列拉取或者由Broker推送消息进行消费。你仔细想一下这四个位置中任何一个环节出问题消息都可能丢。比较典型的场景包括生产者发消息时网络闪断消息根本没到Broker消息到了交换机但路由不到任何队列消息已经在队列里了但Broker宕机并且消息没有持久化消费者收到消息后处理逻辑报错或者消费者进程在确认之前崩溃。每一类问题对应的解决方案完全不同如果只是笼统地加一个“手动确认”或者“消息持久化”根本覆盖不了所有丢消息的场景。1.2 可靠性投递的完整目标所谓“可靠性投递”不应该只是某一个开关或者某一项配置而是要从端到端的视角打一套组合拳。我的目标很明确所有发送成功并完成确认的消息最终一定会被业务方处理且只被处理一次。注意这里面有两个关键词一个是“不丢失”另一个是“不重复”。后者在分布式系统中往往比前者更难解决因为消息重复在大多数情况下难以彻底避免我们能做的只能是让消费端具备幂等处理能力。这个目标拆解下来对应四层工作生产端的发送确认、服务端的存储保障、消费端的确认机制以及最后兜底的补偿对账。下面每一层我都会详细展开。2. 生产端第一道防线Confirm确认机制和Return回退2.1 为什么默认的发送方式是不安全的很多刚开始用RabbitMQ的同学最容易犯的一个错误就是发完消息不检查结果。代码写得很简单声明一个队列、一个交换机然后调用一下基础发送方法消息发出去了就认为万事大吉。实际上默认情况下生产者是“发完即忘”的状态只要连接不断消息就算发送出去了。但问题是发送操作成功并不代表Broker真的收到了哪怕一个字节。举个例子生产者发送消息时如果RabbitMQ服务端发生了网络分区或者集群中的某个节点发生了故障切换客户端连接断开那这条消息很可能在TCP缓冲区里没有真正送达服务端。在同步发送场景下客户端会抛异常但异步发送场景或者框架封装了缓冲队列时这条消息可能就被当成“已发送”直接吞掉了。所以在实现可靠性投递方案时第一步就是给生产端加上发送确认机制。2.2 开启Publisher Confirm后的核心逻辑RabbitMQ在AMQP 0-9-1协议基础上提供了一个扩展能力叫作Publisher Confirm。原理很简单生产者把信道设置成Confirm模式之后每发送一条消息Broker在成功接收并持久化如果消息是持久化的之后会给生产者返回一个确认BasicAck如果内部发生了异常导致消息没有接收成功则返回BasicNack。实际开发中我建议直接把整个链路都开Confirm。使用Spring Boot的RabbitTemplate时可以通过配置文件开启Confirm回调核心配置如下spring: rabbitmq: publisher-confirm-type: correlated publisher-returns: true template: mandatory: true这三行配置分别对应什么呢publisher-confirm-type: correlated是说开启Confirm机制并且采用Correlated模式也就是每条消息会携带一个CorrelationData对象用来唯一标识这条消息的确认结果。publisher-returns: true和template.mandatory: true合在一起是让消息在“无法被路由到任何队列”时能够通过Return回调把消息退回给生产者。否则默认情况下一条发不到队列的消息会在交换机这里被静默丢弃。配置完成后还需要在代码里注册对应的回调比如rabbitTemplate.setConfirmCallback((correlationData, ack, cause) - { if (!ack) { log.error(消息发送失败correlationId: {}, cause: {}, correlationData.getId(), cause); // 这里将消息标记为待重发或直接写入本地失败记录表 } else { log.info(消息发送成功correlationId: {}, correlationData.getId()); } }); rabbitTemplate.setReturnsCallback(returned - { log.error(消息路由失败exchange: {}, routingKey: {}, replyText: {}, returned.getExchange(), returned.getRoutingKey(), returned.getReplyText()); // 处理无法路由的消息 });这里有个很重要的细节Confirm回调只能证明消息到达了Broker不能证明消息被正确路由到了队列。也就是说如果交换机找不到匹配的队列Broker还是会返回Ack给生产者只是额外触发一次Return回调。所以这两个回调必须同时处理消息才算是真正安全地“入了队”。关于这一点我是踩过坑的。早期项目里只做了消息持久化和Confirm确认有一段时间订单消息偶尔会消失排查了很久后来定位到原因是路由键在某个配置中心里被改动了消息发到交换机后发现没有匹配的绑定交换机直接丢弃了消息。因为当时没有配置mandatory和Return回调日志里完全看不出来生产端还认为发出去了实际上消息根本没进队列。2.3 Confirm模式下如何选择同步等待与异步回调在非Spring Boot环境下如果你使用的是原生RabbitMQ客户端也可以通过channel.confirmSelect()开启Confirm模式。之后有两种处理方式一种是每条消息发送后同步调用waitForConfirms()阻塞等待确认结果另一种是批量发送后用waitForConfirmsOrDie()统一检查还有一种是基于监听器的异步处理。我个人比较推荐的方式是在瞬时发送量大但又不希望丢任何一条消息的场景下采用回调式异步处理因为同步确认会显著降低生产者的吞吐量。尤其是在单条消息RTT往返时延在毫秒级以上的情况下同步等待会让发送能力出现很大损耗。当然同步等待也有它的好处代码逻辑简单不需要维护状态。如果是低频次且对吞吐不敏感的场景完全可以用同步方式。这两者的本质是一样的都是在发送端确认Broker已经收到消息区别只在于等待模型不同。实际项目中我需要的是“既要吞吐又要可靠”所以最终选的是Correlated异步确认并且配合本地消息表记录发送状态。3. 存储端保障持久化、镜像队列与存储策略3.1 三个持久化缺一不可消息到达Broker之后如果Broker进程突然崩溃或者所在的物理机宕机这时候消息是否安全取决于你做了多少持久化工作。这里有三个必须同时满足的条件交换机持久化、队列持久化、消息本身持久化。交换机持久化是在声明交换机时设置durable参数为true。队列持久化同样是在声明队列时设置durable为true。如果交换机或队列没有持久化Broker重启后相关的定义会消失后续即使消息持久化了存储的队列都没了消息也无处安放。消息持久化是在发送消息时设置deliveryMode为PERSISTENT值为2在Spring AMQP中对应MessageDeliveryMode.PERSISTENT。比如Message message MessageBuilder.withBody(payload.getBytes()) .setDeliveryMode(MessageDeliveryMode.PERSISTENT) .build(); rabbitTemplate.send(exchange, routingKey, message);需要特别注意这三个地方必须全部持久缺任何一个都可能丢消息。有些同学只设置了durabletrue的队列然后发送消息时没有指定持久化模式默认情况下Spring Boot的RabbitTemplate会根据消息属性生成默认的deliveryMode如果你没有显式设置它可能是非持久的。非持久消息只要Broker内存中还有就不会丢但一重启就没了。列出这几个持久化配置在Spring Boot中的设置位置持久化对象设置方式缺失风险交换机声明时durabletrue或配置类中设置durable重启后交换机定义丢失消息无法路由队列声明时durabletrue或配置类中设置durable重启后队列定义丢失消息无法存储消息发送时deliveryModePERSISTENT值为2重启后消息内容丢失3.2 单机持久化够不够聊聊镜像队列和仲裁队列持久化解决的是“进程重启后数据还在”的问题但它解决不了“机器彻底宕机”的问题。如果你部署的是单节点RabbitMQ磁盘损坏这种极端情况仍然可能丢数据。所以在生产环境里高可用部署是可靠性投递的基本要求。传统方案是配置镜像队列Mirrored Queue用镜像策略把主队列的数据同步到其他节点。主节点负责读写从节点作为热备份。旧版本中这是比较成熟的模式但在高并发下镜像队列同步存在性能开销而且主从切换时可能会有短暂的不可用。新版本推荐使用仲裁队列Quorum Queue它基于Raft协议实现数据在多个节点上形成副本具备更强的数据一致性保证。仲裁队列要求至少三个节点才能发挥出容错作用适合对数据可靠性要求极高的核心链路。如果项目已经用了5.x版本的RabbitMQ我建议直接切换到仲裁队列尤其是在处理订单、支付、库存这类核心消息时。它的网络开销比镜像队列更高但换来的是更干净的故障切换体验。如果你只是测试环境或者业务量不大的系统单节点加持久化也可以跑但不要把单节点方案直接照搬到生产环境这是很多小团队后来不得不做架构改造的原因。3.3 持久化对性能到底有多大影响聊了这么多可靠性保障肯定有人关心性能问题。先说结论只要开启持久化和Confirm性能肯定比“发完即忘”模式差但差得不至于不能用。持久化消息的落盘过程本质上就是把消息写入磁盘日志文件。RabbitMQ的持久化消息会先写入内存队列同时异步写入磁盘不会每条消息都同步刷盘所以它是有一个批量写入机制的。根据我自己做的压测结果单节点环境下持久化消息加Confirm回调的模式吞吐量大约是非持久化消息的50%到70%。听起来降得挺多但在绝大多数业务场景下每秒几十条到几百条的消息量这点损耗完全可以接受。真正需要关注的不是吞吐量而是延迟。如果你的业务要求毫秒级响应每条消息都同步等待磁盘刷盘那延迟会变得不稳定。对于这种场景建议把消息的可靠性交给对账和补偿机制而不是完全依赖RabbitMQ本身的持久化。这个思路后面我会讲到。4. 消费端可靠性手动ACK、死信队列与幂等消费4.1 自动ACK为什么是万恶之源消费端丢消息九成以上都是因为默认开启了自动确认。在Spring AMQP的监听容器中如果把acknowledgeMode设置为AUTO或NONE消费者在收到消息后立即向Broker返回确认不管业务逻辑是否执行成功。这样一来如果消费方法里抛了异常或者消费者进程在业务处理到一半时崩溃这条消息已经被标记为“已确认”Broker就认为你已经成功消费了于是直接把它从队列中删除。消息就这样静悄悄地丢了。正确做法是使用手动ACK模式也就是acknowledgeMode: MANUAL。在这个模式下业务逻辑执行成功后才主动调用basicAck方法确认执行失败则调用basicNack或basicReject根据参数决定是丢弃、重新入队还是进入死信队列。配置方式如下spring: rabbitmq: listener: simple: acknowledge-mode: manual对应的消费代码可以这样写RabbitListener(queues order.queue) public void onMessage(Message message, Channel channel) throws Exception { long deliveryTag message.getMessageProperties().getDeliveryTag(); try { // 业务逻辑处理 process(message); channel.basicAck(deliveryTag, false); } catch (Exception e) { // 根据异常类型决定是否重试尽量不要无条件重新入队 channel.basicNack(deliveryTag, false, false); } }这里有一个非常关键的坑basicNack的第三个参数requeue如果设置为true消息会被重新放回原队列然后立刻再次投递给消费者形成一个死循环。如果消费逻辑里有抛异常的bug消息就会一直在消费者和队列之间来回弹跳CPU飙高日志刷屏还会把正常消息堵在后面。所以我在处理这个参数时会非常谨慎普通业务异常直接basicNack并且requeuefalse让消息进入死信队列由专门的补偿任务去处理。只有网络抖动、数据库连接池暂时不可用这类瞬时故障才考虑重新入队并配合次数限制。4.2 重试策略的设计不要裸奔式无限重试手动ACK只是解决了“消费失败不丢失”的问题但接下来会面对另一个问题失败的这条消息怎么处理最简单的做法是配合Spring Retry在RabbitListener外层加上重试机制。比如spring: rabbitmq: listener: simple: retry: enabled: true max-attempts: 5 initial-interval: 1000 multiplier: 2.0 max-interval: 10000这样消费失败后会由Spring框架在内存中做指数退避重试重试次数超过上限后消息会被丢弃或者进入我们指定的异常处理逻辑。这个方案适合处理那些因为数据库临时锁、外部接口瞬时超时等原因导致的短暂不可用。但是要注意Spring Retry的重试是发生在消费者进程内的如果消费者进程在重试过程中重启内存中的重试状态就没了。另一种更可靠的方案是使用RabbitMQ本身的死信队列机制。核心思路是定义一个业务队列绑定一个死信交换机。当消息被拒绝且requeuefalse或消息过期或队列长度超限时消息会被自动转发到死信交换机再由死信交换机路由到死信队列。专门写一个消费者监听死信队列把消息取出来做延迟处理比如保存到数据库、发送告警、或者转入定时任务等待后续重发。死信队列的声明代码大致是这样Bean public Queue businessQueue() { return QueueBuilder.durable(business.queue) .deadLetterExchange(dlx.exchange) .deadLetterRoutingKey(dlx.routing.key) .build(); }使用死信队列带来的一个额外好处是你有了一个“消息事故现场”可以随时去查看哪些消息是处理失败的失败时的原始内容是什么方便排查和审计。这是纯内存重试不具备的优势。4.3 消费端必须自己做幂等不要指望消息只来一次哪怕我们把前面所有可靠性机制都做对了依然要面对一个现实消息在分布式环境下是有可能重复投递的。举个例子消费者处理完消息后业务数据已经写库了但在返回确认给Broker的过程中网络闪断Broker没有收到确认于是过一会儿又把同一条消息投递了过来。这时候如果消费端不做幂等处理就会出现重复扣减库存、重复加积分、重复发短信之类的严重故障。幂等设计最常用的方案是“唯一业务键已处理记录表”。在消息体中带上一个业务唯一ID比如订单号、支付流水号。消费者在处理消息前先查一下本地数据库看看这个ID是否已经处理过。如果已经处理直接确认消息并跳过业务逻辑。为了避免并发场景下两个线程同时查到“未处理”还需要在记录表中对唯一键加数据库唯一约束插入失败就说明重复了。代码里一般是这样一个套路String msgId message.getMessageProperties().getMessageId(); if (processedMsgMapper.exist(msgId)) { channel.basicAck(deliveryTag, false); return; } try { // 在事务内同时插入 msgId 与执行业务逻辑利用唯一约束防重 processBusiness(message); processedMsgMapper.insert(msgId); channel.basicAck(deliveryTag, false); } catch (DuplicateKeyException e) { channel.basicAck(deliveryTag, false); }需要说明的是幂等不应该只依赖数据库唯一键因为某些业务场景下消息清理或表归档后唯一约束就没了。更保险的做法是业务上本身就具备天然幂等性比如“订单状态流转已支付状态下再次收到支付成功回调直接忽略”这种设计对应的代码应该写在业务逻辑内部而不是完全依赖于消息中间件的去重。4.4 手动ACK和幂等是配套的别拆开做我见过一些团队手动ACK做了但是消费端没有幂等另一些团队接口幂等做了但用的是自动ACK。这两种方案单独拿出来都有各自的漏洞。手动ACK没有幂等重复消费时业务数据会出错自动ACK配合幂等虽然业务上不会出大问题但消息可能因为消费者进程崩溃而丢失毕竟Broker在你收到消息后立刻就把消息删掉了。所以我的建议是老老实实把这一整套都配上手动ACK保证“至少一次”投递消费者业务幂等保证“最多一次”的业务影响两者合在一起才能达到“有效一次”的效果。这也是目前几乎所有的消息中间件可靠性方案里最核心的共识。5. 兜底补偿本地消息表与定时对账5.1 聊完实时链路还得聊聊终极兜底前四节讲的各种机制已经可以应对绝大多数故障场景了。但做过程序的人都知道再完备的链路也可能出现“意外中的意外”。比如RabbitMQ集群彻底崩溃且备份也丢了比如消息在某个未知环节被手动删除比如代码逻辑上存在并发问题导致部分消息没有走到发送逻辑。纯靠消息中间件本身的可靠性机制没法兜住所有问题。因此我一般会在核心业务链路上加一层数据最终一致的补偿机制。这套机制的核心是本地消息表也叫事务消息表。思路是这样的在业务数据库里建一张message_record表业务操作和消息记录写入在同一个本地事务中完成。事务提交之后再通过一个独立的消息发送程序把消息从表里捞出来发送到RabbitMQ。发送成功后更新消息状态为“已发送”。5.2 表结构和发送程序怎么设计消息表通常包含这几个核心字段消息ID、业务类型、业务主键、消息内容JSON格式、状态待发送、已发送、发送失败、已确认、重试次数、创建时间、最后修改时间。在这里消息ID整个producer端的唯一标识也就是前面Confirm回调里用到的CorrelationData对应的ID。发送程序可以做成一个定时任务每隔几秒扫描一次状态为“待发送”且重试次数未超上限的消息调用RabbitTemplate发送然后等待Confirm回调更新状态。如果回调里返回失败就把消息状态改成“发送失败”等待下一次扫描重试。这个方案的巧妙之处在于消息的可靠性从“依赖RabbitMQ不丢消息”变成了“依赖业务系统自己有一个可以反复重放的待发送队列”。只要数据库不丢消息总能发出去。伪代码如下Scheduled(fixedDelay 5000) public void scanAndSend() { ListMessageRecord records messageRecordMapper.scanPending(); for (MessageRecord record : records) { CorrelationData correlationData new CorrelationData(record.getMessageId()); try { rabbitTemplate.convertAndSend(record.getExchange(), record.getRoutingKey(), record.getMessageContent(), correlationData); } catch (Exception e) { record.setRetryCount(record.getRetryCount() 1); messageRecordMapper.updateStatus(record.getId(), SEND_FAILED); } } }定时任务发送之后还需要按计划定期清理已经完成的消息记录避免表无限膨胀。根据消息量的大小可以保留最近7天到30天超期的按照消息ID归档或者直接删除。5.3 补偿机制能不能代替Confirm机制这个问题经常有人问。既然有了本地消息表和定时任务是不是可以不开Confirm了答案是不行。本地消息表和定时任务解决的是“消息发送前”的可靠性它保证了消息一定会被发送程序尝试发送但不保证发送之后Broker是否真的收到。如果发送程序把消息交给了RabbitTemplate但由于网络分区等原因Broker没有收到RabbitTemplate内部已经把消息包装好了而发送程序又因为逻辑异常退出了消息状态仍然停留在“待发送”。这没问题下次扫描还会重发。但如果发送程序在逻辑上认为“发送成功”了实际上Broker没收到那这条消息就会卡在“已发送”状态再也不会被扫描到。所以仍然需要Confirm回调来更新真正的确认状态。就算你不开Confirm也要至少检查发送结果或使用同步发送的异常捕获机制否则本地消息表就形同虚设了。从可靠性角度排序最完善的是本地消息表负责“一定发得出去”Confirm机制负责“发出去了没”持久化负责“Broker存储不丢”手动ACK负责“消费者处理不丢”幂等设计负责“重复了也没事”。五层各司其职缺一层都有漏洞。6. 常见问题排查实录与避坑技巧6.1 消息丢失的经典排查路径日常运维和开发过程中遇到消息丢失问题最忌讳的是直接去翻代码而是应该按链路一层层排查。我一般会按照下面这个顺序来先看生产端有没有发送日志消息是否真的调用了RabbitTemplate的发送方法。再看Confirm回调有没有触发返回的是Ack还是Nack有没有Return回调。如果Ack返回了那就看Broker端队列深度是否增加消息是否在队列中滞留。如果队列有消息但消费者没消费检查消费者的日志和监听容器状态。如果队列没消息但确认已经消费了看消费端有没有成功执行业务、有没有手动Ack。如果手动Ack了但没有业务数据多半是消费逻辑里异常被吞了这类问题需要仔细看链路日志。在实际定位中我会依赖RabbitMQ管理界面的队列状态来辅助判断。例如一条消息发出去后队列深度不变说明消息可能没有进队列队列深度增加了但消费者一直没消费说明消费端可能在报错或者网络不通队列深度为零但数据库没有数据说明消费端Ack了但业务逻辑有问题。这种逐层推理的方式比单纯看代码更高效。6.2 高频踩坑场景速查表下面这些场景在我参与和接触过的项目中出现的频率非常高我整理成了一张速查表问题现象可能原因解决思路消息确认回调返回Nack交换机或队列不存在或Broker内部IO异常检查交换机队列是否声明检查落盘空间Confirm回调一直不触发CorrelationData未设置或回调线程异常检查是否设置publisher-confirm-type回调异常是否被吞消息进了队列但消费者不消费消费者应用未启动或监听容器线程数耗尽查看消费者连接状态扩容并发线程数消费端报错后消息消失使用了自动Ack或手动Ack前抛异常且未进入Nack分支改为手动Ack异常时统一进入Nack/死信逻辑重复消费导致数据重复消费端没有幂等处理增加唯一键去重业务幂等处理消息被无限重新入队循环requeuetrue且消费一直失败设置requeuefalse配合死信队列队列一直堆积消费者CPU飙升单条消息处理时间过长或者消费死循环查看消费者日志优化处理耗时必要时拆分队列消息丢失且发送日志显示成功RabbitMQ持久化未开启或消息未持久化确认三个持久化点尤其是消息deliveryMode确认模式下发送吞吐量下降明显使用了同步确认或持久化日志刷盘频繁改异步Confirm回调优化刷盘策略6.3 每次上线前必做的消息链路检查可靠性方案做得再好如果上线时无意中改错了一个配置照样会出事。我自己在项目上线前会强制要求团队完成几个检查项。确认所有核心业务队列声明时都设置了durabletrue不能因为开发环境没开持久化就默认照搬配置。确认消费者监听容器设置的是acknowledge-mode: manual这是代码配置层最容易漏掉的地方。确认生产端RestTemplate或生产连接工厂上开启了Confirm和Return生产环境不能用测试环境的纯内存模式。确认死信队列消费者存在不然消息进入死信后没人处理堆积起来毫无意义。确认关键业务链路有对账或者告警机制比如队列积压超过一定阈值时钉钉或者企业微信要能收到告警。如果能做到这几点可靠性投递方案基本上就比较扎实了。6.4 追查消息轨迹的实践建议最后再分享一个我自己在项目中用的技巧。RabbitMQ本身没有内置消息轨迹追踪的效果但我们可以自己给每一条消息打上全局唯一的消息ID同时在所有生产和消费的关键节点打印结构化日志。日志里至少包含消息ID、业务ID、交换机、路由键、队列、时间戳、操作类型发送/确认/入队/消费/Ack/Nack。比如rabbitTemplate.convertAndSend(exchange, routingKey, message, new MessagePostProcessor() { Override public Message postProcessMessage(Message message) throws AmqpException { message.getMessageProperties().setMessageId(UUID.randomUUID().toString()); return message; } });当消息在某个环节丢失时拿着这个消息ID去日志平台搜索就能快速定位它最后出现在哪个节点。这个办法比任何监控面板都直观尤其是在排查跨团队协作的系统时日志里有没有消息ID是能否高效扯皮、快速定位问题的关键。我强烈建议在核心链路中把消息ID做为一个必填字段。7. 我的最后几点总结写到这里可靠性投递方案的核心内容差不多讲完了。回顾一下最核心的要点发送端开启Confirm和Return机制确认消息真的到达了队列存储端做好交换机、队列、消息三重持久化部署尽量使用镜像队列或仲裁队列消费端改手动ACK结合重试机制和死信队列消费逻辑一定要具备幂等能力兜底层使用本地消息表加定时任务确保“即使RabbitMQ全挂也能通过重放数据库记录把消息找回来”。这里要特别注意不要觉得“配置多、步骤多”就觉得麻烦而偷懒。在核心业务上省掉的每一步可靠性工作后面都会以线上故障的形式让你加倍偿还。我见过不少系统初期跑得很顺等用户量和业务复杂度上来了消息丢失引发的数据不一致问题集中爆发最后不得不返工加各种补偿机制风险和成本都高得多。也别过度设计比如那些对数据一致性要求不高的通知类消息比如“用户浏览记录统计”“推荐位点击数据”这些场景丢了也无所谓就不需要付出额外的性能代价。可靠性是分级别的核心链路和非核心链路要有不同的方案这一点也是很多年在生产环境里实践出来的体会。如果你正准备改造消息这块不要一口吃成胖子先把Confirm、手动ACK、持久化和幂等这几件事做了再把死信队列和本地消息表逐步加上一点点往“最终一致”的方向靠拢系统的稳定性自然会慢慢提上来。这应该是当前成本最低、见效最快的一条路径了。