首页 / 资讯中心 / 文章详情

SpringBoot整合RabbitMQ死信队列:可靠性、延迟任务与避坑

SpringBoot整合RabbitMQ死信队列:可靠性、延迟任务与避坑 ★ FEATURED ARTICLE
上周帮一个做电商的朋友排查问题订单支付成功后本该 30 分钟自动关单结果有一批订单第二天还挂在待支付状态。翻日志才发现消费者处理时抛了异常basicNack里requeue传了true消息被反复塞回队头同一个坏消息把整个队列堵死了——后面几万条正常消息全在排队。这类事故在 SpringBoot 整合 RabbitMQ 的项目里太常见了根子上就是没设计好消息的失败出口。死信队列Dead Letter Queue简称 DLQ就是给消息准备的那条逃生通道消息被拒绝、过期、或者队列塞满的时候它不会凭空消失而是被投递到你指定的另一个交换机和队列里你可以慢慢分析、补偿、告警。这篇内容我会把 SpringBoot 整合 RabbitMQ 实现死信队列的完整链路拆开讲从拓扑设计、参数选型、代码落地到用 TTL 加死信做延迟任务再到我自己踩过的那些坑。适合已经用过 RabbitMQ、但还没把失败处理做扎实的同学也适合刚接手消息中间件模块、想搞清楚消息为什么不见了的开发者。1. 死信队列到底在解决什么问题1.1 从消息凭空消失说起很多人第一次接触死信是因为线上出现了消息丢了的错觉。生产者日志里明明convertAndSend成功了消费者那边却一条都没收到。你去查队列messages是 0messages_unacknowledged也是 0看起来干干净净但业务数据就是少了。这种情况十有八九是消息被消费了、抛异常了、然后nack或者reject之后没有requeueRabbitMQ 直接把它丢掉了。默认行为下被拒绝且不重回队列的消息就是销毁没有任何痕迹。我在早期项目里就吃过这个亏一个对账文件的解析消息因为对方某天字段格式变了消费者抛了NumberFormatException框架默认的AcknowledgeMode.AUTO在异常时走的是nack加requeuefalse消息瞬间消失对账直接断档事后只能靠数据库里的业务流水手工补。那次之后我才认真去读 RabbitMQ 的投递语义也才明白消息的失败路径必须显式设计不能指望默认行为。死信队列的价值就在这儿。它把处理失败这件事变成了一个可见、可查、可重放的状态。消息没有被销毁而是躺在另一个队列里等着你。你可以做个管理后台去看可以写脚本批量重投可以配监控做阈值告警。对于订单、支付、库存这类不允许丢消息的场景这个能力基本是刚需。1.2 三种触发死信的典型条件RabbitMQ 把消息投进死信交换机只有三种情况我把它们和实际业务场景对应起来方便你判断自己的项目需要哪一种。第一种消息被消费者拒绝也就是调用basic.reject或basic.nack并且requeue参数为false。这是最容易主动控制的一种业务代码里判断出这条消息重试也没用比如参数校验失败、依赖的数据已经被删除、签名不对就该走这条路。要注意requeuetrue不会产生死信它只是把消息塞回队列这也是开头那个事故的成因。第二种消息过期。这里又分两个层级队列级别的x-message-ttl整个队列的消息统一超时消息级别的expiration属性单条消息自己带 TTL。过期消息如果所在队列配置了死信交换机就会被投递过去。延迟任务就是靠这个特性做的后面第 4 章会细讲。第三种队列达到最大长度。通过x-max-length限制队列能存多少条或者用x-max-length-bytes限制字节数超出之后根据x-overflow的策略默认drop-head还有reject-publish处理。配合死信交换机被挤出来的消息就进了死信队列相当于一个溢出缓冲区不至于无声无息地丢。触发条件触发方式典型业务场景是否可主动控制消息被拒绝basicNack/basicReject且requeuefalse参数非法、依赖数据不存在、业务规则不满足完全可控最常用消息 TTL 过期队列x-message-ttl或消息expiration订单超时关单、优惠券到期提醒可控注意队头阻塞队列长度超限x-max-length/x-max-length-bytes削峰限流、防止内存被打爆可控属于兜底1.3 死信队列和普通重试不是一回事刚接触这块的同学容易把死信和重试混为一谈觉得反正失败了再试一次就行。这两者的定位完全不同。重试是我认为这次失败是暂时的过一会儿大概率能成比如下游服务短暂抖动、数据库连接超时死信是这次失败是确定性的再试也是浪费资源先存起来等人工介入。把这两条路径分开你的系统才不会陷入无休止的重试风暴。我在项目里通常这么分工消费者内部先做 2 到 3 次本地重试间隔用指数退避这类重试不碰队列纯在内存里循环本地重试还不行再决定是requeue回队列靠延迟队列做退避还是直接nack进死信。判断标准很简单——异常类型。TimeoutException、ConnectException这种归到可重试IllegalArgumentException、BusinessException归到不可重试直接进死信。注意requeuetrue的坑在于它会立刻把消息放回队头RabbitMQ 重新入队的位置接近队头如果这条消息本身有问题下一次消费还是它形成死循环CPU 和日志都会被刷爆。用requeue必须配重试次数上限。2. 整体设计与关键参数选型2.1 拓扑结构怎么画才不绕死信队列的本质是在业务队列上挂一个转发目标。业务队列声明两个额外参数——x-dead-letter-exchange和x-dead-letter-routing-key消息变成死信时RabbitMQ 会用这两个参数重新投递。注意这里走的是交换机的正常路由逻辑所以死信交换机必须真实存在并且死信队列要正确绑定到它上面路由键对不上照样会丢。我一般用的拓扑是这样业务交换机biz.order.exchangedirect 类型绑定业务队列biz.order.queue路由键order.create业务队列声明死信交换机为dlx.order.exchange死信路由键order.dead死信交换机再绑定死信队列dlx.order.queue。整条链路是单向的死信队列本身不再配死信参数避免出现环——虽然 RabbitMQ 不允许死信消息再次成为死信消息的x-death头里会记录且死信队列的消息即使过期也不会再次转发但拓扑上保持单向更利于排查。还有一个细节死信交换机可以用direct也可以用fanout。如果你希望一份死信同时被落库服务和告警服务两个消费者处理用fanout绑两个队列会更省事如果只是简单归档direct加固定路由键足够。我用direct居多因为死信的分类信息可以塞在路由键里比如order.dead.timeout、order.dead.reject后面消费时可以按前缀区分处理策略。2.2 TTL 的两种设置方式差别比你想的大队列级别的 TTL 用x-message-ttl参数单位毫秒声明队列时写死整个队列所有消息用同一个超时时间。好处是配置集中、不用生产者操心坏处是灵活性差而且有个容易被忽略的行为——RabbitMQ 只在消息到达队头时才判断是否过期。也就是说如果队头有一条 TTL 很长的消息后面那些已经过期的短 TTL 消息会一直卡着直到队头被消费掉。这个特性直接把队列 TTL 做延迟队列的方案限制在了所有消息 TTL 一致的场景。消息级别的 TTL 用消息属性expiration字符串形式的毫秒数发消息时逐条设置。它灵活但也踩过坑不同 TTL 的消息混在同一个队列里队头阻塞问题更严重。比如你先发一条 30 分钟的再发一条 10 秒的那条 10 秒的必须等 30 分钟那条被消费或过期之后才有机会被判定过期。这不是 bug是队列的 FIFO 特性决定的。我的建议很直接要做延迟任务一个延迟梯度一个队列。10 秒、30 秒、1 分钟、5 分钟、30 分钟每个梯度独立队列、独立 TTL不要图省事往一个队列里塞不同 TTL 的消息。梯度多的话队列数量会涨但排查起来清晰也不会互相阻塞。对比项队列级 TTLx-message-ttl消息级 TTLexpiration设置位置声明队列时发送消息时灵活性低整队列统一高逐条设置队头阻塞存在但同 TTL 下影响小明显混用 TTL 时最严重适用场景统一超时的延迟队列、消息兜底过期少量差异化超时需求Spring AMQP 写法QueueBuilder.ttl(int)message.getMessageProperties().setExpiration(30000)2.3 队列长度上限与溢出策略x-max-length这个参数我建议每个业务队列都设一个哪怕是拍脑袋定的值。它的作用是给队列兜底防止生产速度长期大于消费速度导致队列无限膨胀最后把 broker 的内存或者磁盘打满。设了上限之后超出部分按x-overflow策略处理drop-head是丢弃队头最老的消息默认reject-publish是拒绝新发布的消息并给生产者一个 nack需要开 publisher confirm 才能感知。用drop-head加死信交换机被挤掉的老消息就进了死信队列等于多了一层缓冲。但这里有个认知偏差要纠正死信队列不是无限容量。我见过有团队把业务队列上限设成 1 万死信队列不设上限结果某次故障死信队列涨到几百万条磁盘告警。死信队列同样要设x-max-length超出的部分该丢就丢同时必须有监控告警因为死信堆积本身就意味着业务出问题了。至于上限设多少我的经验公式是峰值 QPS × 可容忍积压秒数。比如峰值 200 QPS能容忍积压 5 分钟那就是 6 万条取个整 5 万到 10 万之间。配合监控看messages_ready的曲线跑一段时间再调。2.4 手动确认模式是死信能用的前提这一条是硬性前提。Spring Boot 里spring.rabbitmq.listener.simple.acknowledge-mode默认是auto这个auto不是自动 ack而是 Spring AMQP 帮你根据方法是否抛异常来决定 ack 还是 nack。它在大多数场景下够用但你想精细控制什么异常进死信、什么异常重回队列就必须切成manual。切成manual之后Channel会作为参数注入到监听方法里你需要自己调用basicAck、basicNack、basicReject。好处是控制力完全在你手上坏处是漏掉 ack 会导致消息一直处于 unacked 状态连接断开后才重新入队表现为消息重复消费。我现在的习惯是方法体用 try-catch-finally 包住ack 写在 try 的成功分支nack 写在 catch 的各个异常分支绝不留死角。还有一点prefetch在手动确认模式下尤其重要。它控制每个消费者最多预取多少条未确认消息设太大一次拉一堆放内存里消费慢的时候容易 OOM设太小吞吐上不去。我的起步值是 20然后压测调整。并发消费者数量concurrency和max-concurrency也一起配消费型任务一般 4 到 16 之间。3. SpringBoot 整合 RabbitMQ 的落地实现3.1 依赖与配置文件依赖就一个 starter版本跟着 Spring Boot 走不用单独指定 AMQP 客户端版本starter 里已经管理好了。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-amqp/artifactId /dependency配置文件的重点在三个地方确认机制、手动 ack、并发参数。publisher-confirm-type: correlated打开生产者确认配合publisher-returns: true能拿到路由失败的回调生产端的可靠性靠这两个兜底。监听容器这边把acknowledge-mode设成manualretry.enabled关掉——注意这个retry是 Spring 的拦截器重试开着的话它会在进入你的方法之前自己重试若干次和你代码里的逻辑打架容易出现你以为只消费了一次其实跑了三次的诡异现象。spring: rabbitmq: host: 10.0.0.11 port: 5672 username: app_user password: change_me virtual-host: /order publisher-confirm-type: correlated publisher-returns: true template: mandatory: true listener: simple: acknowledge-mode: manual prefetch: 20 concurrency: 4 max-concurrency: 16 retry: enabled: falsetemplate.mandatory: true这行容易被漏掉。不开的话消息路由不到任何队列时broker 会静默丢弃ReturnsCallback根本不会触发。开了之后路由失败的消息会退回给生产者你在回调里可以记日志或者转存这是发现路由键写错这类问题的最快手段。3.2 配置类把交换机和队列的绑定关系声明清楚Spring AMQP 的QueueBuilder提供了deadLetterExchange()和deadLetterRoutingKey()两个便捷方法底层就是往队列参数里塞x-dead-letter-exchange和x-dead-letter-routing-key。用 Java 配置类声明的好处是版本可控、可读性好比在管理后台手点强太多尤其是多环境部署的时候。Configuration public class RabbitDlxConfig { public static final String BIZ_EXCHANGE biz.order.exchange; public static final String BIZ_QUEUE biz.order.queue; public static final String BIZ_KEY order.create; public static final String DLX_EXCHANGE dlx.order.exchange; public static final String DLX_QUEUE dlx.order.queue; public static final String DLX_KEY order.dead; Bean public DirectExchange bizExchange() { return ExchangeBuilder.directExchange(BIZ_EXCHANGE) .durable(true) .build(); } Bean public Queue bizQueue() { return QueueBuilder.durable(BIZ_QUEUE) .deadLetterExchange(DLX_EXCHANGE) .deadLetterRoutingKey(DLX_KEY) .ttl(30 * 60 * 1000) .maxLength(50000) .build(); } Bean public Binding bizBinding(Queue bizQueue, DirectExchange bizExchange) { return BindingBuilder.bind(bizQueue).to(bizExchange).with(BIZ_KEY); } Bean public DirectExchange dlxExchange() { return ExchangeBuilder.directExchange(DLX_EXCHANGE) .durable(true) .build(); } Bean public Queue dlxQueue() { return QueueBuilder.durable(DLX_QUEUE) .maxLength(200000) .build(); } Bean public Binding dlxBinding(Queue dlxQueue, DirectExchange dlxExchange) { return BindingBuilder.bind(dlxQueue).to(dlxExchange).with(DLX_KEY); } }这里的Binding方法我用参数注入的方式拿Queue和Exchange而不是在方法体里直接调用bizQueue()。虽然Configuration类有 CGLIB 代理保证单例但显式注入可读性更好也避免有人把类改成Component之后出现多实例的坑。注意队列一旦创建参数就固定了。你后来把ttl从 30 分钟改成 10 分钟重启应用是不会生效的RabbitMQ 会报PRECONDITION_FAILED - inequivalent arg。要么删队列重建会丢消息要么换个队列名。这是很多人第一次改参数时踩的坑。3.3 生产端消息构造与确认回调生产端的代码不复杂重点是两件事消息要带唯一 ID用于死信排查时追溯以及确认回调要真正处理失败。Service RequiredArgsConstructor Slf4j public class OrderMessageProducer { private final RabbitTemplate rabbitTemplate; PostConstruct public void initCallback() { rabbitTemplate.setConfirmCallback((correlationData, ack, cause) - { String id correlationData null ? unknown : correlationData.getId(); if (ack) { log.debug(消息投递成功, id{}, id); } else { log.error(消息投递失败, id{}, cause{}, id, cause); // 落库标记失败交由定时任务补偿 } }); rabbitTemplate.setReturnsCallback(returned - { log.error(消息路由失败, exchange{}, routingKey{}, replyText{}, returned.getExchange(), returned.getRoutingKey(), returned.getReplyText()); }); } public void send(OrderMessage payload) { String msgId UUID.randomUUID().toString(); Message message MessageBuilder.withBody(JsonUtils.toBytes(payload)) .setContentType(MessageProperties.CONTENT_TYPE_JSON) .setMessageId(msgId) .setDeliveryMode(MessageDeliveryMode.PERSISTENT) .build(); CorrelationData data new CorrelationData(msgId); rabbitTemplate.convertAndSend( RabbitDlxConfig.BIZ_EXCHANGE, RabbitDlxConfig.BIZ_KEY, message, data); } }MessageDeliveryMode.PERSISTENT这个要显式设。虽然 Spring AMQP 默认就是持久化但一旦有人用RabbitTemplate的某些重载方法或者做了自定义转换器持久化标记可能就丢了。消息持久化加上队列持久化重启 broker 之后消息还在这是死信机制能兜底的前提。3.4 消费端用异常类型决定消息去向消费端是整个死信链路的核心。我把它拆成三层判断能正常处理就 ack属于可重试的临时故障走重试逻辑属于不可重试的业务异常直接 nack 进死信。Component Slf4j public class OrderConsumer { private static final int MAX_RETRY 3; RabbitListener(queues RabbitDlxConfig.BIZ_QUEUE) public void onMessage(Message message, Channel channel) throws IOException { long tag message.getMessageProperties().getDeliveryTag(); String msgId message.getMessageProperties().getMessageId(); try { OrderMessage payload JsonUtils.parse(message.getBody(), OrderMessage.class); processOrder(payload); channel.basicAck(tag, false); } catch (BusinessException e) { // 业务规则不满足重试无意义直接进死信 log.warn(业务校验失败, 转入死信, msgId{}, reason{}, msgId, e.getMessage()); channel.basicNack(tag, false, false); } catch (Exception e) { // 临时故障判断重试次数 int retry getRetryCount(message); if (retry MAX_RETRY) { log.warn(消费异常, 第{}次重试, msgId{}, retry 1, msgId, e); channel.basicNack(tag, false, true); } else { log.error(重试次数耗尽, 转入死信, msgId{}, msgId, e); channel.basicNack(tag, false, false); } } } private int getRetryCount(Message message) { Object v message.getMessageProperties().getHeaders().get(x-retry-count); int count v null ? 0 : Integer.parseInt(v.toString()); message.getMessageProperties().getHeaders().put(x-retry-count, count 1); return count; } private void processOrder(OrderMessage payload) { // 真正的业务处理 } }这里有个关键细节requeuetrue重新入队之后消息头会被保留所以可以靠自定义的x-retry-count计数。但要注意头部是跟着消息走的加完计数再 nack消息回到队列下次消费读到的就是加了 1 的值。这个方案简单缺点是计数不持久broker 重启后消息头还在因为是消息属性的一部分其实持久化是 OK 的另外多个消费者实例之间共享同一个计数语义上没问题。注意x-retry-count这种自定义头名不要和 RabbitMQ 保留前缀冲突。x-death、x-first-death-*、x-last-death-*这几个是 broker 写的你在消息真正变成死信时会看到它们里面记录了死因、原队列、时间。排查死信时先看x-death能省很多事。3.5 死信消费落库、告警、人工重投死信队列的消费者和业务消费者写法一样但处理逻辑不同。我的标准动作是三步先原文落库消息体、消息头、x-death全存再按死因分类做告警最后提供一个后台入口支持单条或批量重投。Component RequiredArgsConstructor Slf4j public class DeadLetterConsumer { private final DeadMessageRepository repository; private final AlertService alertService; RabbitListener(queues RabbitDlxConfig.DLX_QUEUE) public void onDeadMessage(Message message, Channel channel) throws IOException { long tag message.getMessageProperties().getDeliveryTag(); try { ListMapString, ? deaths (ListMapString, ?) message.getMessageProperties().getHeaders().get(x-death); DeadMessageEntity entity new DeadMessageEntity(); entity.setMessageId(message.getMessageProperties().getMessageId()); entity.setBody(new String(message.getBody(), StandardCharsets.UTF_8)); entity.setDeathReason(parseReason(deaths)); entity.setOriginQueue(parseOriginQueue(deaths)); entity.setCreatedAt(LocalDateTime.now()); repository.save(entity); if (rejected.equals(entity.getDeathReason())) { alertService.notifyBusiness(entity); } channel.basicAck(tag, false); } catch (Exception e) { log.error(死信落库失败, e); channel.basicNack(tag, false, false); } } }x-death是个数组每次消息死亡都会往里追加一条记录字段包括reasonrejected、expired、maxlen、queue、count、time。解析的时候取第一条基本就够那是最近一次的死因。如果你发现同一个消息 ID 在死信库里出现多次说明它被重投过又失败了这个信号很有价值通常意味着下游有个持续存在的 bug。重投的实现我没放在消费者里而是单独做一个管理接口从死信库查出记录用原路由键重新发到业务交换机成功后标记记录为已重投。这样做的理由是可审计、可回滚也避免了消费者里再搞一套生产逻辑。4. 用 TTL 加死信做延迟任务的实战4.1 为什么很多人不用延迟插件RabbitMQ 有个社区插件rabbitmq_delayed_message_exchange装完之后可以声明x-delayed-message类型的交换机发消息时设置x-delay头到点自动投递比 TTL 加死信优雅得多。但它有两个现实问题一是插件需要运维在 broker 上安装有些托管环境不给你装二是插件把延迟消息存在 Mnesia 里消息量大时对节点性能有影响官方文档也提过这一点。所以 TTL 加死信这个方案虽然土但在不能改 broker、延迟精度要求不高的场景里仍然是主流。它的原理很朴素消息进一个设了 TTL 的队列到点过期变成死信被转发到真正的业务队列业务消费者这时才处理。整个过程中消费者监听的是业务队列不是延迟队列延迟队列没有消费者纯粹当计时器用。4.2 一个延迟梯度一个队列前面说过队头阻塞的问题这里给一个实际可用的配置。假设关单场景需要 30 分钟延迟那就建一个延迟队列TTL 30 分钟死信交换机指向业务交换机。Bean public Queue orderDelayQueue() { return QueueBuilder.durable(delay.order.30m.queue) .ttl(30 * 60 * 1000) .deadLetterExchange(biz.order.exchange) .deadLetterRoutingKey(order.close) .maxLength(100000) .build(); } Bean public Binding orderDelayBinding(Queue orderDelayQueue, DirectExchange delayExchange) { return BindingBuilder.bind(orderDelayQueue) .to(delayExchange) .with(order.delay.30m); }生产者投递时往延迟交换机的order.delay.30m路由键发消息进入延迟队列静置 30 分钟过期后自动转到biz.order.exchange的order.close被监听业务队列的关单消费者拿到。这里有个容易写错的地方延迟队列的死信路由键要指向业务队列实际绑定的路由键不是延迟队列自己的路由键。我第一次配的时候把这两个搞混了消息过期后路由不到任何队列直接丢了排查了半天。如果业务需要多个延迟梯度比如 1 分钟、5 分钟、30 分钟、2 小时那就建四个延迟队列各配各的 TTL。队列数量多一点没关系声明成本很低。千万不要为了省队列把不同 TTL 的消息塞进同一个延迟队列队头阻塞会让短延迟的消息严重滞后表现就是说好 1 分钟提醒结果 2 小时后才到。4.3 延迟消息的可靠性补强TTL 加死信这个方案有个软肋延迟队列里的消息是静置的如果 broker 在这期间重启虽然持久化消息能恢复但 TTL 的计时是从消息入队时间算的恢复后该过期还是会过期问题不大。真正需要补的是业务侧的对账。我的做法是延迟消息发出时往业务库写一条待执行任务记录状态为 PENDING带执行时间。延迟消息到点后执行任务把状态改成 DONE。同时跑一个每 5 分钟一次的定时任务扫描执行时间已过但状态还是 PENDING的记录做补偿执行。这样即使延迟消息因为某种原因丢了业务也能自愈。这套组合拳的成本不高但能把可靠性从依赖中间件提升到依赖对账我心里踏实很多。还有一个细节延迟队列的maxLength要设得比业务队列大因为消息在里面待的时间长堆积是常态。100 万条以内一般没问题超过的话要考虑是不是业务量涨了、延迟时间设太长了。5. 常见问题排查与避坑清单5.1 死信不生效的排查路径这个问题我被问过不下十次绝大多数是配置细节没对上。按下面的顺序查基本能定位。第一看队列参数。进管理后台点开业务队列找Features或者Arguments确认x-dead-letter-exchange存在且值正确。如果没有说明配置类里的deadLetterExchange()没生效或者队列是之前手动创建的、参数没更新。已存在的队列改参数不生效这个坑前面提过排查时优先怀疑它。第二看交换机和绑定。死信交换机是否存在类型对不对有没有队列绑定到它上面绑定用的路由键和x-dead-letter-routing-key是否一致。这四项任何一项错消息都会在转发时被丢弃而且不报错——这是最坑的地方RabbitMQ 不会告诉你死信转发失败它只会默默丢掉。第三看消息是否真的成了死信。nack的参数顺序是(deliveryTag, multiple, requeue)第三个参数必须是false。我见过有人写反了以为在拒绝其实在无限重回队列。另外basicReject一次只能拒绝一条basicNack才能批量用错方法也会出问题。第四看消费者是不是根本没收到消息。如果业务队列的消费者压根没被触发那问题在前面的路由环节和死信无关。这时候查生产者的确认回调日志和ReturnsCallback八成是路由键错了。现象最可能的原因排查动作消息消失死信队列也没x-dead-letter-exchange未生效管理后台查看队列 Arguments死信交换机有消息进来又没了死信路由键与绑定不匹配检查 binding 的 routingKeynack 后消息一直重复消费requeue传了 true检查basicNack第三个参数改 TTL 参数后启动报错队列已存在参数不等价换队列名或删队列重建延迟消息不准时不同 TTL 消息混在同一队列拆分延迟队列一个梯度一个死信队列涨到几百万没人消费或消费失败循环查死信消费者日志加告警5.2 消息重复与幂等怎么处理死信重投、网络抖动导致的 ack 丢失、消费者重启这些都会造成消息重复。RabbitMQ 提供的是至少一次语义去重必须业务侧自己做。我的标准做法是用消息 ID 做唯一索引消费前先查一下有没有处理过。Transactional public void handle(OrderMessage msg) { try { consumeLogRepository.save(new ConsumeLog(msg.getMessageId())); } catch (DuplicateKeyException e) { log.info(消息已处理, 跳过, msgId{}, msg.getMessageId()); return; } // 真正的业务逻辑 }用数据库唯一索引是最简单可靠的方式缺点是每条消息多一次写。量大到扛不住的时候再考虑 Redis 的SETNX加过期时间但要注意 Redis 和数据库的一致性问题别为了解决重复引入了新的不一致。我在中小流量的项目里一直用数据库方案没出过问题。5.3 监控和日常运维的几个抓手死信队列最怕的是没人看。消息堆在那儿不告警等于没有。我一般配三个监控项死信队列的messages_ready超过阈值告警比如 100 条就报、业务队列的messages_unacknowledged持续高位告警说明消费者卡住了、死信消息的增长率告警短时间内暴增通常意味着下游挂了。命令行的快速查看用rabbitmqctl list_queues name messages messages_ready messages_unacknowledged consumers够用了。要做看板就接 Management API/api/queues/{vhost}/{queue}返回的结构很全。还有一点死信库要定期清理我设的是保留 30 天超期的归档到冷存储不然表会越来越大查询越来越慢。注意别给死信队列配自动过期清理除非你确定不需要回溯。有些团队为了省空间给消息设 TTL结果事故复盘时发现关键证据已经过期删掉了很尴尬。5.4 我踩过的几个真实坑第一个手动 ack 模式下忘了 ack。有段代码在if分支里 return 了没走到 ack消息一直 unacked消费者连接不断开就一直占着队列的messages_unacknowledged慢慢涨到 prefetch 上限整个消费者就假死了。后来我强制自己用 try-catch 包住ack 和 nack 必须在所有路径上出现。第二个死信消费者自己抛异常。死信队列没配死信消费者 nack 之后消息直接被丢弃等于白存。所以死信消费者的容错要做得比业务消费者更狠我现在的写法是 catch 住所有异常落一个处理失败的本地日志文件然后 ack 掉宁可丢在文件里也不能让它消失得无影无踪。第三个多环境共用一套 vhost。测试环境的死信和生产的混在一起排查时看串了数据。这个纯粹是规范问题每个环境独立 vhost队列名前缀带环境标识成本几乎为零。第四个x-death数组越滚越大。一条消息被反复死信头信息会越来越长极端情况下消息体积翻倍。正常业务里不该出现这种情况一旦出现说明有环或者重试逻辑有问题得从根上治别指望清理头部。这套东西搭下来其实核心就一句话让每一条失败的消息都有明确去处并且这个去处是你能看见、能操作的。Spring Boot 整合 RabbitMQ 的代码本身不复杂复杂的是对失败路径的设计和对边界情况的预判。我个人在实际项目里的体会是花在死信队列和监控上的时间最后都会以半夜不被叫起来的形式还回来。
阅读完成 · 觉得有帮助?
咨询建站