RabbitMQ 里的死信队列很多人一开始都理解成“把消息扔进一个叫死信队列的地方”但真正配置下来才发现它其实是“过一遍死信交换机再决定去哪儿”。我最开始接触这个功能是在做订单超时未支付关单的需求时主队列里的消息过期后被转到了另一个队列再由另一个消费者去执行关单逻辑。那会儿我一边看着管理界面里的队列数据一边感叹原来消息“死掉”并不是被丢掉而是换了种方式继续活着。这篇文章打算把 RabbitMQ 死信队列的基础概念、创建配置、常见坑位一次性说清楚。内容不深但对刚开始接触消息队列或者已经写了几年业务代码但没细研究过死信机制的读者应该会有用。后面我会给出 Spring Boot 方式下完整可复用的配置代码也会讲管理界面和命令行两种手工声明方式最后把我在生产环境踩过的几个坑逐个复盘。整个链路走完你就能知道死信消息从“产生”到“被处理”的完整路径长什么样。1. 先搞清楚死信队列到底解决了什么问题1.1 什么条件下消息会变成死信RabbitMQ 官方对死信流程的定义是队列中的消息在满足某些条件后被重新发布到另一个交换机Dead Letter Exchange简称 DLX由这个交换机根据路由键把消息投递到指定队列。触发条件有三个第一消费者使用basicReject或basicNack明确拒绝消息并且设置requeuefalse。这是最常见的一种触发方式相当于消费端主动说“这条消息我处理不了也别再放回原队列了”。第二消息在队列中的存活时间超过设置的 TTL也就是过期了。这个机制既可以针对整个队列统一设置也可以针对单条消息单独设置。第三队列达到最大长度x-max-length新消息持续涌入时队头的旧消息会被挤出队列并转为死信。要特别注意第三种条件。我最开始就没理解这个设计消息本身并没有出错只是在队列里待得太久被“挤走”了。如果没有配置死信交换机这些被挤掉的消息会被直接丢弃配置了的话它们还有机会被收留并二次处理。那为什么需要这个机制核心原因是消息处理不应该“静默失败”。比如一个订单消息因为支付系统暂时不可用被拒绝如果直接把消息丢了事后对账就会发现订单数据凭空消失。死信队列提供了一条二次处理、补偿、人工介入的通道让系统有机会修复自己的问题。1.2 死信机制最常见的三种业务场景场景一订单超时关单。这也是大多数接触死信队列的人的第一个场景下单后 15 分钟未支付就自动取消。做法是把订单消息发到普通队列并设置 TTL到期后转成死信由专门负责关单的消费者处理。这种方案实现简单不用额外引入调度系统。场景二消费失败重试与补偿。消费端调用外部接口失败时消息被拒绝进入死信队列专门的重试服务再捞出消息做补偿。如果补偿了多次仍然失败就把消息落库或者报警等人工介入。场景三监控与审计。把死信消息统一收拢积累起来分析业务异常的原因。比如某个第三方接口连续失败了多少单、集中在哪个时段失败这些数据都可以通过死信队列沉淀下来。这个环节要建立的基本认知是死信队列不是一种特殊队列而是“死信交换机 一个普通队列 消费者”的组合。后面配置时你会发现我们在声明主队列时指定死信交换机等死信产生时消息才会被投递过去。2. 必须吃透的几个核心概念2.1 死信交换机DLX和死信路由键DLRKRabbitMQ 的死信机制里有两个关键参数平时配置的时候很多人会写错x-dead-letter-exchange指定消息变成死信后要转发到的交换机。x-dead-letter-routing-key指定消息转发到死信交换机时使用的路由键。如果不设置就沿用消息原来的 routing key。不少人在初次配置时会有一个误解以为声明队列时x-dead-letter-exchange直接指向死信队列就行。实际上这里必须填写交换机不能填队列。死信转发和正常消息投递链路是类似的——消息先到交换机再由交换机按路由键投递到队列。这样设计的灵活性在于一个死信交换机可以绑定多个队列根据不同的 routing key 把不同类型的死信消息分流每个队列的处理策略可以完全不同。我在项目里的习惯是给死信单独建一套交换机不跟业务交换机混用这样可以在管理界面上一眼看出哪些队列做了死信处理。routing key方面我建议要么保持和原队列绑定关系一致要么干脆不设置x-dead-letter-routing-key让它沿用原来的 key。只要死信交换机上绑定的路由键包含了原消息的 routing key消息就一定不会被卡在交换机里。这里有一个容易被忽略的坑消息进入死信交换机后如果没有任何队列绑定了匹配的 routing key消息就会在交换机里“迷路”结果是既没有进入死信队列也没有报错提示只在管理界面的某个指标里默默消失。所以不要以为“配置了死信交换机就万事大吉”绑定关系必须单独验证。2.2 死信队列本质上就是一个普通队列死信队列不是特殊的消息队列它就是普通队列加一个普通消费者。它和普通队列的唯一区别在于驱动消息进入这个队列的不是普通消息投递链路而是死信转发链路。这样的设计带来一个有意思的问题死信消息被消费后如果消费者又把它重新发布回原来的队列这个消息就会再次触发死信条件形成“死信循环”。死信循环往往表现为队列消息量持续增长、CPU 飙升、磁盘告警但业务日志里看不出明显异常。我后来养成了一个习惯在死信消费者的处理逻辑里加上一个自定义消息头比如retry-count每进一次死信队列就加一超过阈值后转入人工处理或者直接落库。简单说死信队列是个兜底机制但它不是垃圾回收机制也一样需要防重、幂等和限流。它只是给了你一个重新处理的机会并不保证处理结果一定成功。2.3 死信队列与延迟队列的关系很多文章把“死信队列”和“延迟队列”混在一起讲其实两者不是同一个东西。延迟队列的目标是“消息到了指定时间才被消费者看到”死信队列的目标是“消息没有被正常消费后的兜底”。但大家确实常用死信队列来实现延迟队列给主队列的消息设置 TTL并且不配置消费者消息存续到 TTL 时间后过期进入死信队列真正的业务消费者再去消费死信队列从而达成延迟效果。这种方案的好处是零额外依赖只靠 RabbitMQ 本身就能实现。缺点也明显队头阻塞。当队列头部的消息 TTL 很长排在它后面的短 TTL 消息必须等队头过期后才能被扫描导致延迟时间不准确。如果你对延迟精度有要求比如 10 分钟、30 分钟、1 小时多个级别建议每个延迟级别建一个独立队列或者直接使用覆盖延迟消息插件。我的经验是分钟级延迟用死信队列做没问题秒级、毫秒级的延迟还是别死磕这个方案用专业延迟组件更稳妥。3. 创建与配置全流程实操3.1 环境准备与依赖引入我习惯用的环境是 RabbitMQ 3.8Spring Boot 2.7消息客户端是spring-boot-starter-amqp。如果你用原生客户端也一样核心就是队列声明参数。起步先把依赖加上dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-amqp/artifactId /dependency连接配置在application.yml里最基本的 host、username、password 三件套。如果要测试手动确认需要把确认模式改成manualspring: rabbitmq: host: 127.0.0.1 port: 5672 username: guest password: guest listener: simple: acknowledge-mode: manual为什么建议用manual而不是默认的auto默认auto模式下如果消费者方法抛出异常Spring 会自动把消息拒绝并重回队列你很难控制它是否进入死信。manual模式下由你显式决定 ack、reject 还是 requeue。对于要验证死信机制的实验场景来说manual模式最接近真实生产行为而且能直观看到每条消息的处置结果。3.2 管理界面和命令行声明方式实际运维时不一定每次都用代码声明队列有时候直接在管理界面操作更快。步骤是在 Exchanges 页新增死信交换机比如dlx.exchange类型选 Direct。在 Queues 页新增死信队列比如dlx.queue持久化选 durable。在 Queues 页新增主队列比如order.queue在 Arguments 里填x-dead-letter-exchange为dlx.exchangex-dead-letter-routing-key为dlx.routing.key。分别给主交换机和死信交换机建绑定关系。如果服务器上面装了rabbitmqadmin也可以用命令声明。下面这套命令我实测过按顺序执行就是一套完整的死信环境rabbitmqadmin declare exchange namedlx.exchange typedirect durabletrue rabbitmqadmin declare queue namedlx.queue durabletrue rabbitmqadmin declare binding sourcedlx.exchange destinationdlx.queue routing_keydlx.routing.key rabbitmqadmin declare queue nameorder.queue durabletrue arguments{x-dead-letter-exchange:dlx.exchange,x-dead-letter-routing-key:dlx.routing.key} rabbitmqadmin declare exchange nameorder.exchange typedirect durabletrue rabbitmqadmin declare binding sourceorder.exchange destinationorder.queue routing_keyorder.routing.key这里要注意队列的参数一旦声明后不能修改如果想改x-dead-letter-exchange之类的 arguments只能删除重建队列。所以在生产环境声明队列前最好先把参数在开发环境验证好避免推上去以后重建带来的消息丢失风险。3.3 Spring Boot 代码配置完整示例下面是我在某跨平台系统的模拟项目里使用过的一组配置。项目里角色分明主交换机normal.exchange、主队列normal.queue死信交换机dlx.exchange、死信队列dlx.queue。通过一个配置类完成所有声明import org.springframework.amqp.core.*; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration public class DeadLetterRabbitConfig { public static final String NORMAL_EXCHANGE normal.exchange; public static final String NORMAL_QUEUE normal.queue; public static final String NORMAL_ROUTING_KEY normal.routing.key; public static final String DLX_EXCHANGE dlx.exchange; public static final String DLX_QUEUE dlx.queue; public static final String DLX_ROUTING_KEY dlx.routing.key; Bean public DirectExchange normalExchange() { return new DirectExchange(NORMAL_EXCHANGE); } Bean public DirectExchange dlxExchange() { return new DirectExchange(DLX_EXCHANGE); } Bean public Queue normalQueue() { return QueueBuilder.durable(NORMAL_QUEUE) .withArgument(x-dead-letter-exchange, DLX_EXCHANGE) .withArgument(x-dead-letter-routing-key, DLX_ROUTING_KEY) .build(); } Bean public Queue dlxQueue() { return QueueBuilder.durable(DLX_QUEUE).build(); } Bean public Binding normalBinding() { return BindingBuilder.bind(normalQueue()).to(normalExchange()).with(NORMAL_ROUTING_KEY); } Bean public Binding dlxBinding() { return BindingBuilder.bind(dlxQueue()).to(dlxExchange()).with(DLX_ROUTING_KEY); } }重点看normalQueue这个方法QueueBuilder.durable保证了队列持久化withArgument只是把x-dead-letter-exchange和x-dead-letter-routing-key塞进了队列参数。只要队列声明好后续发送消息逻辑什么都不用改死信机制对生产者是透明的消息该发还是照样发。Producer 端代码就一条rabbitTemplate.convertAndSend(DeadLetterRabbitConfig.NORMAL_EXCHANGE, DeadLetterRabbitConfig.NORMAL_ROUTING_KEY, 订单支付超时消息, message - { message.getMessageProperties().setExpiration(600000); return message; });这里setExpiration设置的是单条消息的 TTL单位是毫秒600000就是 10 分钟。到期后如果这条消息还留在主队列里就会被自动转成死信。消费者端可以写两个一个消费主队列一个消费死信队列Component public class NormalMessageConsumer { RabbitListener(queues DeadLetterRabbitConfig.NORMAL_QUEUE) public void onMessage(Message message, Channel channel) throws Exception { try { // 真实的业务处理逻辑 System.out.println(处理业务消息: new String(message.getBody())); channel.basicAck(message.getMessageProperties().getDeliveryTag(), false); } catch (Exception e) { // 关键第二个参数传 false表示不重回队列 channel.basicReject(message.getMessageProperties().getDeliveryTag(), false); } } }Component public class DeadLetterConsumer { RabbitListener(queues DeadLetterRabbitConfig.DLX_QUEUE) public void onMessage(Message message) { // 处理死信消息记录日志、落库、通知人工介入 System.out.println(收到死信: new String(message.getBody())); } }有几个参数必须说清楚basicReject/basicNack的 requeue 参数true表示重新放回原队列false表示不重回队列消息才会被投递到死信交换机。deliveryTag是消息在当前通道下的递增序号确认和拒绝都必须带上用来告诉服务端是哪条消息。如果使用RabbitListener默认的 auto 模式方法抛出异常时 Spring 会自动拒绝并重新入队这样死信队列是收不到消息的。3.4 三种死信触发条件的模拟实验为了验证配置是否正确我通常会在开发环境做三类实验。第一种手动拒绝。上面的消费者代码 catch 块里用basicReject并设置requeuefalse收到一条消息后立刻抛异常观察管理界面里normal.queue的消息数量瞬间变化dlx.queue多出一条消息。注意一点如果消费者在抛异常之前已经 ack 过了消息就不会进入死信这个顺序很关键。第二种TTL 过期。Producer 代码里设置setExpiration(10000)对应 10 秒。主队列不启动消费者等待 10 秒后死信队列出现消息。这个实验最直观我一般会把“TTL 过期进入死信队列”作为功能验收的必测项。第三种队列满溢出。声明主队列时在参数里增加x-max-length5然后连续发送 10 条消息观察前面 5 条是否被挤出队列进入死信队列。这个实验能帮你理解 RabbitMQ 的溢出策略它并不是等队列满了拒绝新消息而是把队头消息挤掉让新消息进来。实验做完以后在管理界面点开死信队列里的消息查看 Properties 区域会看到x-death头字段。这个字段记录了死信发生的时间、原队列、原交换机、死信原因排查问题的时候是最有力的第一手证据。4. 常见问题与排查技巧实录4.1 死信队列收不到消息时查什么这是我私藏的排查顺序每次遇到问题按照这个顺序走基本都能定位第一步先确认主队列有没有配置x-dead-letter-exchange参数。管理界面 Queues 页面选中队列展开 Arguments 一栏就能看到。参数为空说明声明队列时没带上代码里可能写错了 Bean。第二步确认死信交换机存在而且名称和参数里写的一致。有时候代码里定义的是 DirectExchange但管理界面里实际是 TopicExchange虽然多数情况不影响但容易给后续排查添乱。第三步确认死信交换机上有绑定关系且绑定的 routing key 能和消息进入死信交换机时携带的 key 匹配。不匹配的时候消息不会报错但死信队列就是收不到消息这是最隐蔽的一种情况。第四步确认消费者有没有把 requeue 设成true或者确认模式是不是 auto。这两种情况都会让消息重新回到原队列压根不会走到死信交换机这一步。第五步最后用命令行统计确认消息到底在哪。如果主队列没有消息死信队列也没有那消息大概率在交换机层面丢掉了优先检查绑定关系。4.2 TTL 过期时间不准是怎么回事这个坑有九成的人会踩。RabbitMQ 的死信机制扫描过期消息时只看队列头部的消息。如果队头消息 TTL 是 1 小时排在它后面的消息 TTL 是 10 分钟这 10 分钟不会先执行必须等 1 小时那条过期后后面的消息才会被逐个检查。这就是前面提到的队头阻塞。对于精确延迟需求我通常把队列设计成“一分一队列”的模式同一个延迟级别对应一个独立队列每个队列用固定的x-message-ttl参数互不影响。rabbitmqadmin declare queue nameorder.delay.10min durabletrue arguments{x-message-ttl:600000,x-dead-letter-exchange:dlx.exchange,x-dead-letter-routing-key:order.trade.expired} rabbitmqadmin declare queue nameorder.delay.30min durabletrue arguments{x-message-ttl:1800000,x-dead-letter-exchange:dlx.exchange,x-dead-letter-routing-key:order.trade.expired}如果你的业务就是“单队列加单条消息 TTL”的模型建议提前评估延迟精度的容忍度。分钟级没问题秒级和毫秒级的别死磕死信队列方案换成覆盖延迟消息插件或者独立调度组件更好。4.3 小心死信循环别让消息无限转圈死信循环是我在生产环境踩过最深的坑。当时某台节点的业务日志全是正常消费但磁盘占用一直涨。点开管理界面发现主队列和死信队列的消息数交替上涨原因很简单死信消费者把消息处理失败后重新convertAndSend回了原队列原队列的消费端再次拒绝再次进入死信如此往复。现在的做法是所有投递回原队列的操作都必须带上一个自定义头部字段retry-count每次进入死信消费时先读取该字段超过阈值就不再重投转而记录到异常表或发送告警。读取方式如下Integer retryCount (Integer) message.getMessageProperties().getHeaders().getOrDefault(retry-count, 0); if (retryCount 5) { // 重投回原队列并设置 retry-count 1 } else { // 落库或告警 }类似的场景也存在于手动确认模式下如果长时间不 ack消息不会死信但消费端内存会被未确认消息堆满最终导致消费者失联。遇到消息只增不减时可以先看管理界面里的 Unacked 列而不是 Ready 列。4.4 排查命令与管理界面速查开发阶段我最常用管理界面定位问题时也会配几条命令行一起看。整理成一张速查表用途命令查看所有队列消息数rabbitmqctl list_queues name messages查看队列未确认消息数rabbitmqctl list_queues name messages_unacknowledged查看交换机类型rabbitmqctl list_exchanges name type查看绑定关系rabbitmqctl list_bindings source_name destination_name routing_key查看队列参数rabbitmqctl list_queues name arguments命令行能看出管理界面不容易体现的细节。有一次死信队列迟迟收不到消息我用list_bindings发现dlx.exchange根本没有绑定dlx.queue消息全部卡在交换机内部这个问题在界面上需要一层层点开才能看到命令行一次解决。我个人的体会是死信队列不是一个很深的机制但它的配置链路长、隐蔽坑多。看上去只是两个参数实际上牵涉到交换机、绑定、确认模式、消息头、重试边界这些点。建议你第一次使用的时候不要直接在生产环境搭先在开发环境把三种触发条件完整跑一遍再把死信消费者的日志和告警配上这样至少能保证出了问题可以第一时间看到证据。另外一个小技巧是死信队列的消息最好单独落一份原始消息内容到数据库因为消息一旦被消费又没记录后期想复盘就只能抓瞎了。