做分布式事务做到第三篇前面聊了生产者如何可靠投递、消息如何通过事务消息保证最终一致性今天终于轮到消费端。很多时候我们把消息发出去就以为万事大吉实际上真正容易翻车的恰恰是消费这一环。消费端一旦丢消息、重复处理、失败后没有兜底前面投递做得再稳也是白搭。所以这篇我重点拆解RabbitMQ可靠消费的完整套路手动确认、重试与死信、幂等设计再配一个积分发放的完整案例把踩过的坑一并讲清楚。1. 可靠消费的核心问题与整体设计思路1.1 为什么消费端是分布式事务的最后一道关分布式事务的最终目标是数据一致但消息从生产者发出到消费者真正改库中间隔着网络、队列、应用进程、数据库事务。任何一个环节失败都可能造成数据不一致。前面两篇解决的是“消息一定能从生产者到达Broker”的问题但Broker把消息推给消费者之后消费者本地事务失败怎么办消息被重复投递怎么办消费者处理到一半宕机了消息算成功还是失败这些问题的答案全部落在消费端。可靠消费本质上要回答三件事消息有没有被正确处理、处理失败后有没有重试机会、重复投递时业务数据会不会被搞乱。这三个问题不解决哪怕Broker再怎么高可用分布式事务依然会漏洞百出。从实际项目看不少团队在生产环境出过类似事故消费者里直接调用业务方法没有手动确认进程一重启消息丢了或者失败后直接throw new RuntimeException没有重试机制消息进入死信队列后无人处理再或者下游接口超时重试导致同一笔订单被处理了两遍。这些问题的根源都是没有把消费端当成一个独立的可靠性系统来设计。1.2 可靠消费的目标与关键点拆解可靠消费的最终目标可以概括为一句话每条消息至少被正确处理一次且重复处理不会产生业务影响。这里有两个关键短语一个是“至少一次”一个是“不产生业务影响”。前者靠消息确认机制来保证后者靠幂等设计来兜底。拆解下来可靠消费有三个核心环节消息确认消费者处理完成后必须显式通知Broker告知消息可以删除处理失败则通知Broker重新投递或转移。重试机制失败的消息不能直接丢弃需要有策略地重试重试仍失败则进入死信或人工处理流程。幂等控制无论消息被投递多少次业务结果只能生效一次这需要业务侧配合用唯一键或状态机来拦截重复请求。这三个环节缺一不可。确认机制解决“消息丢没丢”重试机制解决“失败怎么办”幂等机制解决“重复会不会错”。把它们串起来才是一条完整的可靠消费链路。1.3 技术选型手动ACK 重试队列 幂等表我个人的做法是消费端坚持三件套关闭自动确认autoAckfalse使用手动ACK。消费失败后不无限重试而是将消息转入延迟队列/死信队列通过TTL死信交换机实现延迟重试。业务数据库中建立幂等表用业务唯一键约束保证重复消息不会重复生效。这套组合的好处是不依赖任何外部中间件纯RabbitMQ原生能力即可实现每个环节责任单一排查问题非常直观而且能覆盖绝大多数业务场景。当然如果你用的是Spring Cloud Stream或RocketMQ可能有事务消息等更高级的玩法但RabbitMQ生态下这三件套就是最稳的落地姿势。2. 消息确认机制从自动ACK到手动ACK的关键转变2.1 自动确认的隐患RabbitMQ消费者默认是自动确认模式。所谓自动确认就是Broker把消息推给消费者后不管消费者的业务逻辑是否执行成功立即把这条消息标记为已消费并删除。这个模式下消费者进程在收到消息和真正处理消息之间只要有任何异常——比如网络闪断、OOM、代码Bug——消息就永远丢了。我们线上曾经出过一个问题消费者监听一个支付回调队列代码里先用Thread.sleep模拟处理耗时结果某次发布变更后代码抛了空指针但消息已经被自动ACK掉了。支付成功了积分没发放用户找过来才知道出了问题。这就是自动确认最大的坑它只保证“消息被投递”不保证“消息被成功处理”。自动确认唯一的优点是吞吐高少一次网络往返。但分布式事务场景里可靠性优先级远高于吞吐。所以但凡涉及资金、积分、订单状态变更这类核心数据必须切手动ACK。2.2 手动ACK的正确姿势basicAck、basicNack、basicReject切到手动确认后消费者代码里需要显式调用三种方法之一basicAck(deliveryTag, multiple)确认消息处理成功Broker删除该消息。basicNack(deliveryTag, multiple, requeue)否定消息可以选择是否重新入队。推荐将requeue设为false避免无限循环。basicReject(deliveryTag, requeue)拒绝单条消息等价于basicNack的简化版但没有multiple参数。这里要特别注意deliveryTag。它是每个消费者Channel内自增的编号从1开始。multiple参数表示是否批量确认如果设为true会确认所有小于等于该deliveryTag的消息。日常开发建议multiple设为false逐条确认逻辑更清晰。还有一个容易忽略的细节手动ACK必须与业务事务在同一个边界内。要么先处理业务成功后再ACK要么先ACK再处理业务——但绝不推荐后者因为ACK后进程崩溃消息就丢了。正确的顺序是业务逻辑执行成功 → 提交本地事务 → 发送ACK。如果业务逻辑失败走异常分支调用basicNack(requeuefalse)把消息转死信。2.3 配置与消费者代码骨架如果你用Spring Boot Spring AMQP配置非常简洁。首先在配置里关掉自动确认spring.rabbitmq.listener.simple.acknowledge-modemanual spring.rabbitmq.listener.simple.prefetch10prefetch是每次从Broker预取的消息数量。这里我设置为10表示消费者本地缓存10条消息避免一次拉取太多导致内存压力。如果业务处理慢可以把prefetch调小到3~5让消息更均匀地分布在多个消费者上。消费者代码骨架如下RabbitListener(queues order.pay.success.queue) public void onMessage(Message message, Channel channel) throws Exception { long deliveryTag message.getMessageProperties().getDeliveryTag(); try { // 1. 幂等判断 if (idempotentService.check(message)) { // 2. 核心业务处理 businessService.handle(message); // 3. 业务成功后确认 channel.basicAck(deliveryTag, false); return; } // 幂等已处理过直接确认 channel.basicAck(deliveryTag, false); } catch (Exception e) { log.error(消费失败消息进入死信, e); // 不重新入队让消息进入死信交换机 channel.basicNack(deliveryTag, false, false); } }这段代码看起来简单但有几个关键点必须说清楚。幂等判断要放在最前面确认check方法本身要支持并发。basicNack的requeue一定要传false否则失败消息会不断重回队头卡死消费者。另外如果重试策略是“本地重试N次后再Nack”那catch块里还要加重试逻辑后面单独讲。2.4 实际踩坑消息丢失与重复的边界情况手动确认模式同样有边界问题最常见的两个先ACK后处理导致消息丢失。有人为了减少消息积压先basicAck再执行业务。如果业务执行到一半宕机消息就丢了而且没有任何补偿机会。正确做法始终是先处理后确认哪怕ACK延迟一点也不能本末倒置。网络抖动导致重复投递。消费者执行完业务在发送ACK的过程中网络断开了Broker没有收到ACK会认为消息未消费重新投递给其他消费者。这时候同一笔业务被执行两次。所以我说只要用消息队列重复消费就无法完全避免必须用幂等来兜底。还有一点手动确认模式下如果消费者抛出异常而没有调用basicNack或basicAck该消息会一直处于Unacked状态直到消费者连接断开才会重新入队。所以RabbitListener方法内部不要轻易吞异常要么处理掉要么明确Nack。3. 消费失败后的重试策略与死信处理3.1 消费重试的三种方案对比消息处理失败后不能直接丢弃也不能无限重试。业界常见三种方案本地重试在消费者代码里用for循环或Retryable注解重试几次。优点是简单缺点是一旦进程重启重试状态丢失而且重试期间消息一直Unacked容易阻塞其他消息。延迟队列重试把失败消息发送到一个带TTL的延迟队列过期后自动转入原消费队列重新消费。优点是不会阻塞主队列且重试间隔可控。死信队列重试利用RabbitMQ死信交换机把重试N次仍失败的消息转入死信队列由单独程序扫描处理或人工介入。我推荐的组合是消费者本地重试2~3次处理瞬时抖动仍然失败就Nack进死信同时设置死信队列的消费者进行定期扫描重投或人工补偿。这样既不会无限重试拖垮系统也不会让失败消息石沉大海。3.2 基于死信队列的重试机制实现RabbitMQ有一个非常实用的特性消息被basicNack(requeuefalse)时如果声明队列时绑定了x-dead-letter-exchange消息会被路由到死信交换机。利用这一点我们可以构建一个“失败消息 - 延迟 - 重新消费”的闭环。具体做法是定义三个队列主消费队列biz.queue绑定死信交换机biz.dlx并设置x-dead-letter-routing-keyretry.延迟队列biz.retry.queue设置了x-message-ttl3000030秒并绑定死信交换机biz.dlx死信路由键为main.主队列的消费者失败后basicNack(requeuefalse)消息进入死信交换机被路由到延迟队列。延迟队列里的消息等待30秒后过期由于它自己也绑定了死信交换机过期后再次进入死信交换机最终路由回biz.queue实现延迟重试。用Spring AMQP声明这些队列时代码大概是这样的Bean public Queue mainQueue() { return QueueBuilder.durable(biz.queue) .withArgument(x-dead-letter-exchange, biz.dlx) .withArgument(x-dead-letter-routing-key, retry) .build(); } Bean public Queue retryQueue() { return QueueBuilder.durable(biz.retry.queue) .withArgument(x-message-ttl, 30000) .withArgument(x-dead-letter-exchange, biz.dlx) .withArgument(x-dead-letter-routing-key, main) .build(); } Bean public DirectExchange deadLetterExchange() { return new DirectExchange(biz.dlx); } Bean public Binding mainBinding() { return BindingBuilder.bind(mainQueue()).to(deadLetterExchange()).with(main); } Bean public Binding retryBinding() { return BindingBuilder.bind(retryQueue()).to(deadLetterExchange()).with(retry); }这样消息每次消费失败都会在延迟队列里待30秒再回来形成间隔重试。如果不想无限循环可以在消息属性里增加重试次数标记或者使用不同的TTL实现退避。3.3 重试参数配置次数、间隔与退避策略重试参数不是拍脑袋定的要结合业务容忍延迟和下游系统能力。我建议首次失败本地重试2次间隔1秒和3秒解决网络抖动、数据库连接池暂时不可用等问题。本地重试仍失败进入延迟队列TTL设置30秒让消息过30秒再重试。延迟重试次数限制给每条消息带上重试次数比如用messageProperties.getHeaders()存一个retryCount消费时1超过5次则转入人工死信队列不让它无限循环。为什么要限制次数因为如果下游接口永久故障比如业务逻辑有Bug无限重试只会反复打日志、占用线程资源而且消息堆积在延迟队列里还会影响其他正常消息。更合理的方式是设定上限后转人工。退避策略上可以 简单 使用固定TTL比如每次30秒。如果要更精细可以用多个延迟队列TTL分别设为30秒、60秒、120秒根据retryCount路由到不同延迟队列。实现上稍微复杂但能避免数据积压。3.4 重试告警与人工补偿重试机制不是终点还要有监控和人工兜底。我习惯在所有消费失败消息进入最终死信队列人工处理队列时发送一条监控告警。比如监听biz.dead.manual.queue的消费者收到消息后通知运维和开发人员同时在管理后台生成一条待处理记录。人工补偿流程要有明确的台账消息ID、业务唯一键、失败原因、重试次数、进入时间。建议把异常堆栈存储到独立的日志表或文件方便排查。实际操作中我发现很多团队只做了重试却忽略了人工补偿这一环结果死信队列越积越多数据不一致问题一拖再拖。可靠消费的“可靠”二字一定包含最终有人能兜底。4. 幂等性设计分布式事务的最终一致性基石4.1 为什么必须做幂等消息重复的三类来源即使你用了手动确认消息依然可能重复投递。归纳起来有三类来源生产者重试生产端发送消息时网络超时导致消息其实已到Broker但生产者没收到确认重试发送造成重复消息。消费者重复投递消费者处理成功后ACK丢失Broker重新投递。集群故障转移某个Broker节点宕机消息可能被重新路由或恢复消费者可能会收到重复消息。所以不要把幂等当成可选项而是消费端的第一道防线。在分布式事务场景下消息重复率可能很低但一旦发生业务数据被污染影响往往是灾难性的。比如用户购买订单自动发券如果重复发放优惠券系统直接亏损。4.2 基于业务唯一键的幂等表方案目前最通用、最稳的方案是“业务唯一键 唯一索引”幂等表。具体做法在消费者处理消息前先往一个幂等表中插入一条记录记录的主键或唯一键由业务ID和事件类型组合而成。比如订单支付成功事件可以用order_id _PAY_SUCCESS作为唯一键。如果插入成功说明这条消息从未处理过继续执行后续业务如果插入时唯一索引冲突说明之前已经处理过直接ACK跳过。表结构可以这样设计CREATE TABLE msg_idempotent ( id BIGINT AUTO_INCREMENT PRIMARY KEY, biz_key VARCHAR(128) NOT NULL COMMENT 业务唯一键如订单ID_事件类型, msg_id VARCHAR(64) NOT NULL COMMENT 消息ID, handler VARCHAR(64) NOT NULL COMMENT 处理方标识, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_biz_key (biz_key) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里的关键是uk_biz_key唯一索引。利用数据库的原子性天然支持并发安全。插入和业务处理最好在同一事务里保证“幂等记录”和“业务变更”要么同时成功要么同时失败。如果业务处理失败整个事务回滚幂等记录也被回滚下次重试可以重新插入并处理。如果业务成功但事务提交前消息重复投递由于另一个线程插入相同biz_key会触发唯一索引错误从而跳过重复处理。4.3 幂等判断的时序与并发问题使用幂等表要注意两个关键点插入与业务同事务以及并发下唯一索引的兜底。第一种正确姿势开启数据库事务 → 尝试插入幂等表 → 如果插入成功执行业务更新 → 提交事务 → ACK。如果插入时发生DuplicateKey异常说明已处理过直接ACK。第二种正确姿势先查幂等表判断是否存在但这种“先查后插”的方式在并发下有窗口期。两个线程同时查到不存在同时执行业务仍然产生重复。所以不要依赖查询来判断一定要用插入的唯一索引来判断。查询只能作为无脑前置过滤最终判断必须交给数据库约束。还有一个细节幂等表不要和业务表放得太近避免业务表锁竞争。但插入操作又在事务里所以实际上会有短暂的锁。我通常将幂等表独立出来放在单独的数据库中或者至少单独建表降低耦合。4.4 其他幂等方案对比数据库约束、Redis SETNX、状态机除了幂等表常见方案还有三种可以按场景选择数据库唯一约束如果业务表本身有唯一键比如订单号唯一那么直接用这个唯一键做幂等。适用于“一条消息对应一条业务记录”的场景实现最简单。Redis SETNX在消费前执行SETNX biz_key timestamp返回1则处理返回0则重复。优点是性能高但Redis不是关系型数据库key可能过期需要谨慎设置TTL且Redis故障时会影响消费判断。适合对性能要求极高、允许短时间重复检测失效的业务。状态机业务状态从“待处理”变为“处理中”再由“处理中”变为“已完成”。消费者处理前先检查状态只有“待处理”才允许更新。这个方案需要业务表有状态字段适合订单、任务类场景能有效防止重复但要求业务表结构和流程设计支持状态流转。我的经验是核心资金级业务一定要用数据库幂等表或唯一约束非核心、对一致性要求稍低的可以结合Redis做第一道快速过滤数据库做最终兜底。不要只用Redis做唯一判断Redis挂了会直接放行所有重复请求风险很大。5. 完整案例订单支付成功后的积分发放可靠消费5.1 业务场景与消息链路假设一个电商系统用户下单并支付成功后订单服务发送一条pay.success消息积分服务消费该消息后给用户增加积分。这个场景是典型的分布式事务。订单支付和积分发放不在同一个数据库事务里必须通过消息最终一致。消息链路如下订单服务在支付成功本地事务中写一条order_pay_event事件表同时发送消息到MQ。积分服务监听order.pay.success.queue消费消息后先幂等判断再增加用户积分。这里“幂等”尤其重要因为支付成功消息如果被重复消费可能给用户重复加积分。所以必须用到前面的幂等表方案。5.2 消费者核心代码实现积分服务消费者完整代码逻辑如下基于Spring Boot MyBatis-Plus为例略去部分模板代码Component public class PointsConsumer { Autowired private IdempotentService idempotentService; Autowired private PointsService pointsService; RabbitListener(queues order.pay.success.queue) public void handlePaySuccess(Message message, Channel channel) throws Exception { long deliveryTag message.getMessageProperties().getDeliveryTag(); String bizKey null; try { String body new String(message.getBody(), StandardCharsets.UTF_8); JSONObject json JSON.parseObject(body); String orderId json.getString(orderId); Long userId json.getLong(userId); Integer points json.getInteger(points); bizKey orderId _PAY_SUCCESS; // 1. 幂等检查尝试插入幂等表冲突则说明已处理 boolean firstTime idempotentService.tryInsert(bizKey, message.getMessageProperties().getMessageId()); if (!firstTime) { // 已经处理过直接确认 channel.basicAck(deliveryTag, false); return; } // 2. 核心业务给用户增加积分 pointsService.addPoints(userId, points); // 3. 提交事务后确认 idempotentService.confirmSuccess(bizKey); // 如果tryInsert里的事务未提交这里确认 channel.basicAck(deliveryTag, false); } catch (DuplicateKeyException e) { // 并发下唯一索引冲突说明其他线程已在处理直接确认 log.warn(重复消费直接确认: {}, bizKey); channel.basicAck(deliveryTag, false); } catch (Exception e) { log.error(消费异常转入死信: {}, bizKey, e); // 请求重新入队进入延迟队列 channel.basicNack(deliveryTag, false, false); } } }你会发现代码里幂等判断放在最前面但具体实现tryInsert需要和业务操作在同一个事务内。这里我给出的tryInsert返回false的情况包含了两种一种是之前已经成功处理过一种是另一个线程正在处理中。两种都应该直接ACK吗如果另一个线程正在处理但还没提交我们ACK了会不会导致另一个线程失败后无人重试这里要仔细说。如果并发AB两个线程同时拿到同一订单消息A先插入幂等成功B插入时唯一冲突。B应该怎么处理两种选择A方案B直接ACK信任A最终成功B方案B不ACK而是抛出异常让消息重新入队等下次再查。通常我们选A因为B和A处理的是同一笔业务只要A成功了B没有存在意义。但B直接ACK有一个前提必须确认A已经“尽力而为”。更稳妥的做法是B捕获冲突后查询幂等表的状态。如果状态是“处理成功”则ACK如果是“处理中”则延迟一下重新入队例如Nack requeuetrue避免过早ACK。不过大多数业务场景直接ACK加上人工补偿也够用。我在实际代码中会优先选择查询状态减少极端情况下的事务风险。为了让事务边界清晰tryInsert服务可以这样写Transactional public boolean tryInsert(String bizKey, String msgId) { // 尝试插入 try { idempotentMapper.insert(new MsgIdempotent(bizKey, msgId, POINTS_CONSUMER)); return true; } catch (DuplicateKeyException e) { return false; } }注意tryInsert的Transactional里没有业务操作业务操作是pointsService.addPoints两者要能在同一个事务里提交。所以实际的写法应该是把幂等插入和积分更新放在同一个方法/同一个事务中。你可以这样设计Transactional public void handleWithTransaction(String bizKey, String msgId, Long userId, Integer points) { boolean first tryInsert(bizKey, msgId); if (!first) { throw new DuplicateKeyException(重复); } pointsService.addPoints(userId, points); }然后消费者调用这个事务方法。如果tryInsert抛重复异常事务回滚但幂等记录不会插入此时消费者捕获DuplicateKeyException直接ACK。如果事务成功幂等记录和积分发放一起提交。这样做最严谨。5.3 队列与交换机配置完整配置需要三部分组成正常队列、延迟队列、死信交换机以及对应的Binding。Configuration public class RabbitConfig { public static final String MAIN_QUEUE order.pay.success.queue; public static final String RETRY_QUEUE order.pay.success.retry.queue; public static final String DEAD_LETTER_QUEUE order.pay.success.manual.queue; public static final String DLX order.pay.success.dlx; public static final String RETRY_ROUTING_KEY retry; public static final String MAIN_ROUTING_KEY main; Bean public Queue mainQueue() { return QueueBuilder.durable(MAIN_QUEUE) .withArgument(x-dead-letter-exchange, DLX) .withArgument(x-dead-letter-routing-key, RETRY_ROUTING_KEY) .build(); } Bean public Queue retryQueue() { return QueueBuilder.durable(RETRY_QUEUE) .withArgument(x-message-ttl, 30000) .withArgument(x-dead-letter-exchange, DLX) .withArgument(x-dead-letter-routing-key, MAIN_ROUTING_KEY) .build(); } Bean public Queue manualQueue() { return QueueBuilder.durable(DEAD_LETTER_QUEUE).build(); } Bean public DirectExchange dlxExchange() { return new DirectExchange(DLX); } Bean public Binding mainRetryBinding() { return BindingBuilder.bind(retryQueue()).to(dlxExchange()).with(RETRY_ROUTING_KEY); } Bean public Binding retryMainBinding() { return BindingBuilder.bind(mainQueue()).to(dlxExchange()).with(MAIN_ROUTING_KEY); } Bean public Binding manualBinding() { return BindingBuilder.bind(manualQueue()).to(dlxExchange()).with(manual); } }这套声明方式靠死信交换机把主队列和延迟队列串起来。消息在主队列消费失败Nack(requeuefalse)进入死信交换机路由到延迟队列等待30秒后过期再通过延迟队列自己的死信路由回到主队列。这样完成一轮重试。如果重试多次后仍失败我们可以在业务代码中根据头信息决定是否手动将消息投递到manual队列。5.4 测试验证与常见效果本地测试时我习惯先模拟两个场景积分服务首次启动正常消费观察到消息被ACK数据库多了积分流水和幂等记录。消费时直接抛异常观察消息在管理后台的流向主队列消息减少延迟队列先增加消息30秒后延迟队列消息消失主队列再次收到同一消息。用管理后台可以看到消息在队列之间的流转。如果你加一个测试消费者监听manual队列可以快速验证死信路径是否走通。实际测试中我发现一个容易忽略的点延迟队列里的消息如果不触发任何消费者它不会自动过期。因为RabbitMQ的TTL是基于“消息在队列中的存活时间”只有消息在队列头部的时候才会被检查过期并进入死信交换。如果延迟队列积压了很多消息排在后面的消息即使TTL到了也要等前面的消息被消费或过期才会轮到它。所以不要指望延迟队列能精确控制每条消息的重试时间如果重试消息量大需要考虑用不同TTL的队列做分级。6. 常见问题与排查技巧实录6.1 典型问题速查表问题现象可能原因解决方案消费者重启后消息丢失使用自动确认或先ACK后处理切换手动确认先处理再ACK消息一直处于Unacked状态消费者进程卡住或代码未调用Ack/Nack检查消费者线程阻塞情况确认try-catch覆盖所有分支失败消息无限循环重试Nack时设置了requeuetrue或延迟队列死信路由回原队列且无次数限制将requeue设为false增加重试次数限制同一订单被处理两次业务未做幂等或ACK丢失导致重新投递引入幂等表用唯一索引拦截延迟队列消息迟迟不进入主队列延迟队列积压排队消息未过期严格保持延迟队列消费速度或分多个TTL队列消费者处理慢但内存飙升手动确认模式下prefetch过大降低prefetch控制本地缓存消息数死信队列堆积无人处理没有设置人工消费逻辑增加死信监听告警并接入工单6.2 排查思路从队列监控到日志定位遇到消费问题我习惯按这个顺序排查看RabbitMQ管理后台的Queues页面关注Ready、Unacked、Total三个数字。Unacked突然增大说明消费者卡住了Ready长期不为0说明消费速度跟不上。看消费者日志重点搜basicNack、basicAck和异常堆栈。我会在Ack/Nack前后都打一行日志带消息ID和业务唯一键这样能精确追踪每条消息的命运。看幂等表查询对应biz_key是否存在判断是“重复消费”还是“首次消费失败”。如果幂等表有记录但业务表没有说明事务没控制好。看延迟队列和死信队列的流量结合时间点判断是否因为代码Bug导致大量消息进入重试循环。还有一个非常实用的技巧给每个消费者设置一个唯一的消费组标识同时在消息header里记录x-original-queue、x-death信息。RabbitMQ死信消息会自动带上x-death头里面记录了被死信的原因如rejected、expired和次数。排查死信问题时直接查看消息体的headers能快速定位是被谁拒收的。6.3 独家避坑Unacked消息导致队列卡死有一次线上事故某个消费者抛异常没有Nack也没有Ack所有消息堆积成Unacked。因为Spring AMQP默认行为是发生异常后消息会被自动恢复吗并不是。RabbitListener如果方法抛出异常会默认调用basicNack并带入requeuetrue导致消息重新入队单条消息反复失败形成死循环。如果不设置重试上限这个循环会一直跑下去。后来我改了配置在SimpleRabbitListenerContainerFactory里设置defaultRequeueRejected(false)让失败消息直接进死信而不是无限requeue。配置方法Bean public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory( ConnectionFactory connectionFactory) { SimpleRabbitListenerContainerFactory factory new SimpleRabbitListenerContainerFactory(); factory.setConnectionFactory(connectionFactory); factory.setAcknowledgeMode(AcknowledgeMode.MANUAL); factory.setDefaultRequeueRejected(false); factory.setPrefetchCount(10); return factory; }这样Spring的异常兜底逻辑会Nack且requeuefalse不会再无限循环。但要注意如果我们在catch里已经手动Nack就不会走到兜底逻辑。6.4 关于重试与死信的几个原则基于这些年的经验我总结几条原则供你参考重试不是越多越好。通常本地重试2~3次延迟重试最多5次超过就转人工。消费失败与事务回滚必须一致。手动ACK的时机是业务事务提交后而不是业务代码执行完。幂等表要和业务同库同步。如果跨库插入幂等记录事务就不是原子的容易产生“幂等记录成功但业务失败”的脏状态。不要让普通消息和重试消息混在同一个队列否则TTL消息积压会影响正常消费。从设计上就考虑延迟重试的时间成本比如30秒重试意味着用户体验上最多延迟30秒需要评估业务可接受程度。这些原则看起来简单但真正执行到位需要团队形成共识而不是靠某个开发同学的自觉。结尾的话写到这RabbitMQ可靠消费的完整链路基本讲透了。我个人在实际操作中的体会是可靠消费真正难的不是某个单一技术点而是把确认、重试、幂等、死信、告警这些环节串成一个闭环并且每个环节都要有日志、有监控、有兜底。很多项目出事往往不是败在方案不够先进而是败在某个环节“想当然不会出错”。比如默认开了自动确认、失败了没有转死信、重复消费不幂等——这些看似不起眼的选择最终都可能在数据一致性的考卷上扣分。最后再分享一个小技巧上线前一定要做一次“故障演练”。可以手动往队列里批量塞入重复消息、制造消费者抛异常、把消费者进程kill掉然后观察消息流转和最终数据是否一致。我每次负责这类项目都会写几个脚本来模拟这些场景。如果演练之后幂等表无脏数据、死信队列能正常告警、人工补偿能及时处理那么这个消费端才算是真正“可靠”了。这套方法也推荐你同样试试。