RabbitMQ TTL 与死信队列详解
在消息队列的实际使用中,消息并不总是越快被消费越好。有些业务天然需要"到点再处理",也有些消息因为过期、拒收或队列容量限制,无法继续留在原队列中。RabbitMQ 提供的 TTL 和死信队列,正好可以处理这类场景。
TTL 负责控制消息的存活时间,死信队列负责接收那些无法被正常消费的消息。两者经常组合使用,比如订单超时未支付自动取消、退款申请超时自动同意、消费失败后进入异常处理队列等。
TTL 是什么
TTL 是 Time To Live 的缩写,表示存活时间。在 RabbitMQ 中,可以给消息或队列设置过期时间。如果一条消息到达设定的存活时间后仍然没有被消费,它就会被自动清除,或者在绑定了死信交换机的情况下,被转发到死信队列。
典型场景包括:
- 用户下单后超过指定时间仍未支付,系统自动取消订单。
- 用户申请退款后,超过指定时间仍未处理,系统自动触发后续流程。
- 短信、通知、验证码等具有时效性的消息,过期后不再需要投递。
RabbitMQ 中设置 TTL 主要有两种方式:给单条消息设置 TTL,以及给整个队列设置 TTL。
给单条消息设置 TTL
给消息设置 TTL 时,每条消息都可以拥有自己的过期时间。发送消息时,通过消息属性中的 expiration 指定过期时间,单位是毫秒。
先准备交换机和队列常量:
java
public static final String TTL_QUEUE = "ttl_queue";
public static final String TTL_EXCHANGE_NAME = "ttl_exchange";
声明交换机、队列以及绑定关系:
java
@Bean("ttlExchange")
public Exchange ttlExchange() {
return ExchangeBuilder
.fanoutExchange(Constant.TTL_EXCHANGE_NAME)
.durable(true)
.build();
}
@Bean("ttlQueue")
public Queue ttlQueue() {
return QueueBuilder
.durable(Constant.TTL_QUEUE)
.build();
}
@Bean("ttlBinding")
public Binding ttlBinding(@Qualifier("ttlExchange") FanoutExchange exchange,
@Qualifier("ttlQueue") Queue queue) {
return BindingBuilder.bind(queue).to(exchange);
}
发送消息时指定过期时间:
java
@RequestMapping("/ttl")
public String ttl() {
String ttlTime = "10000";
rabbitTemplate.convertAndSend(
Constant.TTL_EXCHANGE_NAME,
"",
"ttl test...",
message -> {
message.getMessageProperties().setExpiration(ttlTime);
return message;
}
);
return "发送成功!";
}
这里的 10000 表示 10 秒。消息进入队列后,如果 10 秒内没有被消费,就会过期。
需要注意两点:
- 如果不设置 TTL,消息默认不会因为时间原因过期。
- 如果 TTL 设置为
0,只有在消息能够立刻投递给消费者时才会被投递,否则会被立即丢弃。
这种方式适合不同消息需要不同过期时间的业务。例如同一个队列中,有些消息 10 秒过期,有些消息 30 秒过期,就可以在发送时分别设置。
给队列设置 TTL
队列 TTL 的含义是:进入该队列的所有消息,都使用相同的过期时间。它通过队列参数 x-message-ttl 实现,单位同样是毫秒。
例如新增一个队列:
java
public static final String TTL_QUEUE2 = "ttl_queue2";
使用 Spring AMQP 的构建方法设置 20 秒过期:
java
@Bean("ttlQueue2")
public Queue ttlQueue2() {
return QueueBuilder
.durable(Constant.TTL_QUEUE2)
.ttl(20 * 1000)
.build();
}
@Bean("ttlBinding2")
public Binding ttlBinding2(@Qualifier("ttlExchange") FanoutExchange exchange,
@Qualifier("ttlQueue2") Queue queue) {
return BindingBuilder.bind(queue).to(exchange);
}
也可以使用参数 Map 的方式:
java
@Bean("ttlQueue2")
public Queue ttlQueue2() {
Map<String, Object> arguments = new HashMap<>();
arguments.put("x-message-ttl", 20000);
return QueueBuilder
.durable(Constant.TTL_QUEUE2)
.withArguments(arguments)
.build();
}
发送普通消息即可:
java
@RequestMapping("/ttl")
public String ttl() {
rabbitTemplate.convertAndSend(
Constant.TTL_EXCHANGE_NAME,
"",
"ttl test..."
);
return "发送成功!";
}
如果采用发布订阅模式,一个交换机同时绑定了 ttl_queue 和 ttl_queue2,那么两个队列都会收到这条消息。区别在于:没有配置 TTL 的队列会一直保留消息,而设置了队列 TTL 的队列,会在指定时间后让消息过期。
消息 TTL 和队列 TTL 的区别
两种 TTL 都能让消息过期,但 RabbitMQ 对它们的处理方式并不完全相同。
队列 TTL 的过期判断更直接。因为队列中的消息使用同一个过期时间,RabbitMQ 可以从队头开始判断消息是否过期,一旦发现过期消息,就可以直接删除。
消息 TTL 的处理更特殊。由于每条消息的过期时间可能不同,如果 RabbitMQ 为了删除过期消息而不断扫描整个队列,成本会比较高。因此,单独设置消息 TTL 时,消息即使已经过期,也不一定会立刻从队列中删除,通常会在即将投递给消费者之前再判断是否过期。
如果两种 TTL 同时存在,最终以较短的过期时间为准。比如队列设置 20 秒过期,某条消息自己设置 10 秒过期,那么这条消息会按 10 秒处理。
完整 TTL 配置示例
下面是一个包含两个队列的配置示例,一个队列不设置过期时间,另一个队列设置 20 秒过期时间:
java
public static final String TTL_QUEUE = "ttl_queue";
public static final String TTL_QUEUE2 = "ttl_queue2";
public static final String TTL_EXCHANGE_NAME = "ttl_exchange";
java
@Bean("ttlExchange")
public FanoutExchange ttlExchange() {
return ExchangeBuilder
.fanoutExchange(Constant.TTL_EXCHANGE_NAME)
.durable(true)
.build();
}
@Bean("ttlQueue")
public Queue ttlQueue() {
return QueueBuilder
.durable(Constant.TTL_QUEUE)
.build();
}
@Bean("ttlQueue2")
public Queue ttlQueue2() {
Map<String, Object> arguments = new HashMap<>();
arguments.put("x-message-ttl", 20000);
return QueueBuilder
.durable(Constant.TTL_QUEUE2)
.withArguments(arguments)
.build();
}
@Bean("ttlBinding")
public Binding ttlBinding(@Qualifier("ttlExchange") FanoutExchange exchange,
@Qualifier("ttlQueue") Queue queue) {
return BindingBuilder.bind(queue).to(exchange);
}
@Bean("ttlBinding2")
public Binding ttlBinding2(@Qualifier("ttlExchange") FanoutExchange exchange,
@Qualifier("ttlQueue2") Queue queue) {
return BindingBuilder.bind(queue).to(exchange);
}
发送消息:
java
@RequestMapping("/ttl")
public String ttl() {
rabbitTemplate.convertAndSend(
Constant.TTL_EXCHANGE_NAME,
"",
"ttl test..."
);
return "发送成功!";
}
死信队列是什么
死信可以理解为无法被正常消费的消息。当一条消息在原队列中变成死信后,RabbitMQ 可以把它重新发送到另一个交换机,这个交换机称为 DLX,也就是 Dead Letter Exchange。绑定到这个交换机的队列,就是死信队列。
死信交换机和普通交换机没有本质区别,死信队列和普通队列也没有本质区别。它们特殊的地方在于:正常队列通过参数指定了死信交换机和死信路由键,当消息变成死信时,RabbitMQ 会自动把消息转发过去。
消息变成死信通常有三种原因:
- 消息被消费者拒绝,并且
requeue设置为false。 - 消息超过 TTL,已经过期。
- 队列达到最大长度,无法继续容纳新的消息。
声明正常队列和死信队列
先定义常量:
java
public static final String DLX_EXCHANGE_NAME = "dlx_exchange";
public static final String DLX_QUEUE = "dlx_queue";
public static final String NORMAL_EXCHANGE_NAME = "normal_exchange";
public static final String NORMAL_QUEUE = "normal_queue";
然后声明死信交换机、死信队列和绑定关系:
java
@Configuration
public class DLXConfig {
@Bean("dlxExchange")
public Exchange dlxExchange() {
return ExchangeBuilder
.topicExchange(Constant.DLX_EXCHANGE_NAME)
.durable(true)
.build();
}
@Bean("dlxQueue")
public Queue dlxQueue() {
return QueueBuilder
.durable(Constant.DLX_QUEUE)
.build();
}
@Bean("dlxBinding")
public Binding dlxBinding(@Qualifier("dlxExchange") Exchange exchange,
@Qualifier("dlxQueue") Queue queue) {
return BindingBuilder
.bind(queue)
.to(exchange)
.with("dlx")
.noargs();
}
}
再声明正常交换机、正常队列和绑定关系:
java
@Bean("normalExchange")
public Exchange normalExchange() {
return ExchangeBuilder
.topicExchange(Constant.NORMAL_EXCHANGE_NAME)
.durable(true)
.build();
}
@Bean("normalQueue")
public Queue normalQueue() {
return QueueBuilder
.durable(Constant.NORMAL_QUEUE)
.build();
}
@Bean("normalBinding")
public Binding normalBinding(@Qualifier("normalExchange") Exchange exchange,
@Qualifier("normalQueue") Queue queue) {
return BindingBuilder
.bind(queue)
.to(exchange)
.with("normal")
.noargs();
}
正常队列绑定死信交换机
要让正常队列中的死信进入死信队列,需要给正常队列配置两个关键参数:
x-dead-letter-exchange:指定死信交换机。x-dead-letter-routing-key:指定转发到死信交换机时使用的路由键。
写法如下:
java
@Bean("normalQueue")
public Queue normalQueue() {
Map<String, Object> arguments = new HashMap<>();
arguments.put("x-dead-letter-exchange", Constant.DLX_EXCHANGE_NAME);
arguments.put("x-dead-letter-routing-key", "dlx");
return QueueBuilder
.durable(Constant.NORMAL_QUEUE)
.withArguments(arguments)
.build();
}
也可以使用更简洁的链式写法:
java
return QueueBuilder
.durable(Constant.NORMAL_QUEUE)
.deadLetterExchange(Constant.DLX_EXCHANGE_NAME)
.deadLetterRoutingKey("dlx")
.build();
配置完成后,只要正常队列中有消息变成死信,RabbitMQ 就会把它投递到 dlx_exchange,再根据路由键 dlx 路由到 dlx_queue。
制造死信产生的条件
为了观察死信效果,可以给正常队列添加 TTL 和最大长度:
java
@Bean("normalQueue")
public Queue normalQueue() {
Map<String, Object> arguments = new HashMap<>();
arguments.put("x-dead-letter-exchange", Constant.DLX_EXCHANGE_NAME);
arguments.put("x-dead-letter-routing-key", "dlx");
arguments.put("x-message-ttl", 10000);
arguments.put("x-max-length", 10);
return QueueBuilder
.durable(Constant.NORMAL_QUEUE)
.withArguments(arguments)
.build();
}
链式写法如下:
java
return QueueBuilder
.durable(Constant.NORMAL_QUEUE)
.deadLetterExchange(Constant.DLX_EXCHANGE_NAME)
.deadLetterRoutingKey("dlx")
.ttl(10 * 1000)
.maxLength(10L)
.build();
这段配置表示:
- 正常队列中的消息 10 秒未被消费就会过期。
- 正常队列最多保留 10 条消息。
- 过期或超出长度限制的消息,会被转发到死信交换机。
发送消息测试过期进入死信队列
发送一条消息到正常交换机:
java
@RequestMapping("/dlx")
public void dlx() {
rabbitTemplate.convertAndSend(
Constant.NORMAL_EXCHANGE_NAME,
"normal",
"dlx test..."
);
}
消息会先进入 normal_exchange,再根据路由键 normal 进入 normal_queue。如果 10 秒内没有消费者消费这条消息,它会因为 TTL 过期而变成死信。由于正常队列已经绑定了死信交换机,消息会被重新投递到 dlx_exchange,再根据路由键 dlx 进入 dlx_queue。
这个流程可以概括为:
text
生产者 -> normal_exchange -> normal_queue -> 消息过期
-> dlx_exchange -> dlx_queue
发送多条消息测试队列长度限制
如果正常队列设置了最大长度为 10,而生产者一次发送 20 条消息:
java
@RequestMapping("/dlx")
public void dlx() {
for (int i = 0; i < 20; i++) {
rabbitTemplate.convertAndSend(
Constant.NORMAL_EXCHANGE_NAME,
"normal",
"dlx test..." + i
);
}
}
正常队列最多只能保留 10 条,超出容量限制的消息会进入死信队列。之后,如果正常队列中剩余的消息继续超过 TTL,也会陆续进入死信队列。
这种能力很适合保护系统资源,避免某个队列无限堆积消息。
消费者拒收消息进入死信队列
第三种常见情况是消费者拒绝消息,并明确设置不重新入队。示例代码如下:
java
@Component
public class DlxQueueListener {
@RabbitListener(queues = Constant.NORMAL_QUEUE)
public void listenerQueue(Message message, Channel channel) throws Exception {
long deliveryTag = message.getMessageProperties().getDeliveryTag();
try {
System.out.printf(
"接收到消息: %s, deliveryTag: %d%n",
new String(message.getBody(), "UTF-8"),
deliveryTag
);
int num = 3 / 0;
channel.basicAck(deliveryTag, true);
} catch (Exception e) {
channel.basicNack(deliveryTag, true, false);
}
}
@RabbitListener(queues = Constant.DLX_QUEUE)
public void listenerDLXQueue(Message message, Channel channel) throws Exception {
System.out.printf(
"死信队列接收到消息: %s, deliveryTag: %d%n",
new String(message.getBody(), "UTF-8"),
message.getMessageProperties().getDeliveryTag()
);
}
}
这里的关键代码是:
java
channel.basicNack(deliveryTag, true, false);
第三个参数 requeue 设置为 false,表示拒绝后不重新放回原队列。如果原队列配置了死信交换机,这条消息就会进入死信队列。
如果把 requeue 设置为 true,消息会重新进入原队列,可能导致同一条异常消息被反复消费,甚至形成无意义的重试循环。因此在实际项目中,通常需要结合重试次数、异常类型、业务状态来决定是否重新入队。
死信队列的常见应用
死信队列不是为了简单地"丢垃圾消息",更重要的是给系统保留一个异常处理入口。它常见的使用方式包括:
- 记录消费失败的消息,方便排查问题。
- 把异常消息交给专门的补偿服务处理。
- 配合 TTL 实现延迟任务,例如订单超时取消。
- 对无法处理的消息做告警、工单或人工确认。
- 在达到最大重试次数后,把消息转入异常队列,避免阻塞正常消费。
以支付业务为例,支付系统通知订单系统支付结果时,消息非常关键,不能轻易丢失。如果订单系统消费失败,可以将消息转入死信队列,再由专门的消费者进行补偿处理,例如重新查询支付状态、创建工单、发送告警等。
小结
TTL 解决的是消息"能活多久"的问题,死信队列解决的是消息"无法正常处理后去哪里"的问题。
TTL 可以设置在消息上,也可以设置在队列上。消息 TTL 更灵活,适合不同消息拥有不同过期时间;队列 TTL 更统一,适合同一类消息使用相同的过期策略。如果两者同时存在,会以更短的时间为准。
死信队列依赖正常队列上的死信交换机配置。当消息过期、被拒绝且不重新入队,或者因为队列长度限制无法继续留在原队列中时,就会成为死信,并被转发到指定的死信队列。
在真实项目中,TTL 和死信队列经常一起出现。一个负责时间控制,一个负责异常流转。用好这两个特性,可以让消息系统在面对超时、失败、积压等情况时更加可控,也更容易做补偿和排查。