广告位 · 页头(后台-模块管理-新建模块后替换此区域)

如何在 Spring Boot 中通过 RabbitMQ 死信队列实现延迟消息?

在订单支付超时、定时提醒、红包撤回等业务里,延迟消息几乎是绕不开的能力。RabbitMQ 没有单独的延迟队列类型,但可以把消息 TTL 和死信交换机组合使用,形成一条消息先停留、再转投、最后被消费的链路。理解这套组合之前,需要先分清 TTL 与 DLX 各自负责什么。TTL 决定消息可以存活多久,DLX 决定消息过期后去哪里。两者配合后,一个普通队列就具备了延迟队列的雏形。

如何在 Spring Boot 中通过 RabbitMQ 死信队列实现延迟消息?

一、TTL 与死信交换机是怎么把延迟消息串起来的

RabbitMQ 中的 TTL 可以设置在队列上,也可以在发送消息时指定。队列级别的 TTL 通过参数 x-message-ttl 设置,队列内所有消息共享同一个过期时间。消息级别的 TTL 通过 MessageProperties 的 expiration 字段设置,每条消息可以拥有不同的存活时间。无论哪种方式,消息过期后并不会立刻被清理,而是当它到达队列头部时才会被 RabbitMQ 丢弃或转发。

死信交换机并不是一种特殊交换机,它只是一个普通的交换机,被某个队列通过 x-dead-letter-exchange 参数指定后,专门接收该队列产生的死信。RabbitMQ 判定一条消息为死信的条件有三个:消息被消费者拒绝且 requeue 设置为 false;消息 TTL 到期;队列达到最大长度发生溢出。对于延迟队列方案,主要使用第二条。

把这两个机制连接起来,流程就是:生产者把消息发到一个没有消费者的延迟队列;消息在延迟队列里等待 TTL 到期;到期后被投递到指定的死信交换机;死信交换机根据 routing key 把消息路由到实际消费队列;最终消费者从实际消费队列取到消息。由于消息在延迟队列中停留了一段时间,对最终消费方来说就产生了延迟效果。

二、Spring Boot 中的队列与交换机配置

Spring Boot 项目只需引入 spring-boot-starter-amqp 依赖。以 Maven 为例,核心依赖如下:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>

连接信息可以放在 application.yml 中,同时建议开启生产者确认和手动 ACK。手动 ACK 可以避免消费者处理失败后消息被自动删除,是延迟队列可靠性的重要保障。

spring:
  rabbitmq:
    host: 127.0.0.1
    port: 5672
    username: guest
    password: guest
    virtual-host: /
    publisher-confirm-type: correlated
    publisher-returns: true
    listener:
      simple:
        acknowledge-mode: manual

核心配置类使用 @Configuration 注解,通常定义两个交换机、两个队列和两条绑定关系。延迟队列需要携带三个参数:x-message-ttl 设置队列级过期时间,x-dead-letter-exchange 指向死信交换机,x-dead-letter-routing-key 指定转投后的路由键。实际消费队列只需要通过 routing key 绑定到死信交换机即可。

@Configuration
public class RabbitConfig {
    public static final String DELAY_EXCHANGE = "delay.exchange";
    public static final String DELAY_QUEUE = "delay.queue";
    public static final String DEAD_LETTER_EXCHANGE = "dead.letter.exchange";
    public static final String DEAD_LETTER_ROUTING_KEY = "dead.letter.routing.key";
    public static final String ACTUAL_QUEUE = "actual.queue";
    public static final String DELAY_ROUTING_KEY = "delay.routing.key";
    @Bean
    public DirectExchange delayExchange() {
        return new DirectExchange(DELAY_EXCHANGE, true, false);
    }
    @Bean
    public Queue delayQueue() {
        Map<String, Object> args = new HashMap<>();
        args.put("x-message-ttl", 10000);
        args.put("x-dead-letter-exchange", DEAD_LETTER_EXCHANGE);
        args.put("x-dead-letter-routing-key", DEAD_LETTER_ROUTING_KEY);
        return QueueBuilder.durable(DELAY_QUEUE).withArguments(args).build();
    }
    @Bean
    public Binding delayBinding(Queue delayQueue, DirectExchange delayExchange) {
        return BindingBuilder.bind(delayQueue).to(delayExchange).with(DELAY_ROUTING_KEY);
    }
    @Bean
    public DirectExchange deadLetterExchange() {
        return new DirectExchange(DEAD_LETTER_EXCHANGE, true, false);
    }
    @Bean
    public Queue actualQueue() {
        return QueueBuilder.durable(ACTUAL_QUEUE).build();
    }
    @Bean
    public Binding actualBinding(Queue actualQueue, DirectExchange deadLetterExchange) {
        return BindingBuilder.bind(actualQueue).to(deadLetterExchange).with(DEAD_LETTER_ROUTING_KEY);
    }
}

配置中延迟队列没有绑定普通消费者,它的作用就是让消息停留到 TTL 到期。队列级 TTL 为 10000 毫秒,也就是 10 秒。真正处理业务的是 actualQueue,它通过 dead.letter.routing.key 与死信交换机绑定。消息一旦在延迟队列过期,就会自动带上这个 routing key 转投到死信交换机。

三、发送延迟消息与消费死信队列

发送消息时可以复用队列级 TTL,也可以在每次发送时动态指定延迟时间。动态指定更灵活,适合不同业务有不同延迟要求的场景。RabbitTemplate 提供了 MessagePostProcessor 接口,可以在发送前修改 MessageProperties。下面示例中根据传入的 delay 毫秒值设置 expiration。单位是毫秒,不是秒,配置错会让延迟时间差 1000 倍。

@Component
public class DelayMessageSender {
    private final RabbitTemplate rabbitTemplate;
    public DelayMessageSender(RabbitTemplate rabbitTemplate) {
        this.rabbitTemplate = rabbitTemplate;
    }
    public void send(String content, long delay) {
        rabbitTemplate.convertAndSend(
            RabbitConfig.DELAY_EXCHANGE,
            RabbitConfig.DELAY_ROUTING_KEY,
            content,
            message -> {
                message.getMessageProperties().setExpiration(String.valueOf(delay));
                return message;
            }
        );
    }
}

发送的消息会先进入 delay.queue。如果发送时设置的 expiration 小于队列级 TTL,则消息会在更短时间后过期;如果大于队列级 TTL,则仍以消息自己的 TTL 为准。实际场景中建议保持队列级 TTL 为默认值或一个较大的兜底值,主要依赖消息级 TTL 控制延迟时间。

消费端监听实际队列,使用手动 ACK 模式可以避免消息在业务处理失败时丢失。RabbitMQ 默认自动确认会在消息投递后立即删除,一旦业务逻辑抛异常,消息已经没了。改成手动确认后,只有业务处理成功才调用 basicAck,失败则 basicNack 并让消息重新入队或转入其他处理队列。下面示例中消费失败重新入队,重试次数需要业务自行控制,否则会形成无限重试。

@Component
public class ActualConsumer {
    @RabbitListener(queues = RabbitConfig.ACTUAL_QUEUE)
    public void handle(Message message, Channel channel) throws IOException {
        String body = new String(message.getBody(), StandardCharsets.UTF_8);
        try {
            System.out.println("收到延迟消息:" + body);
            // 执行业务逻辑,成功后再确认
            channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
        } catch (Exception e) {
            channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, true);
        }
    }
}

手动确认时需要注意 deliveryTag 是当前 channel 内递增值,不同 channel 之间互不通用。如果业务处理时间过长,可能出现 channel 被关闭的情况,此时要捕获 IOException 或连接异常,避免消费者线程中断。对于支付超时关闭这类场景,最好在消费者入口处做幂等判断,防止重复消费导致重复关闭订单。

四、方案局限与优化思路

这套方案最大的限制是延迟时间不精确。如果一个延迟 10 秒的消息前面排队了一条延迟 60 秒的消息,RabbitMQ 只会在 60 秒消息到达队头并过期后,才继续检查后面的 10 秒消息。此时 10 秒消息的实际延迟可能接近 60 秒。要缓解这个问题,可以为不同延迟级别创建独立的队列,或者直接安装 rabbitmq_delayed_message_exchange 插件。插件的交换机可以精确调度任意时间的延迟消息,但需要额外运维。

另一个容易忽略的点是死信不只来源于 TTL 超时。消费者拒签且不重新入队、队列溢出也会产生死信。如果一条消息业务处理失败被拒绝,可能混入正常的延迟消息路径,造成业务误判。设计死信队列时最好区分系统级超时死信和业务级拒收死信,使用不同的 routing key 或队列。尤其是在订单关闭场景,消费端必须判断订单当前状态是否可关闭,做好幂等处理。

可靠性方面,延迟消息比普通消息多了一段等待期,这期间的 Broker 重启、队列删除、消息积压都会影响最终投递。生产环境建议开启生产者确认和持久化,队列、交换机、消息都设为 durable。消费者幂等可以通过数据库唯一键、Redis 锁或版本号实现。监控上关注延迟队列的消息数、过期未消费比例和实际消费延迟,避免延迟队列堆积过多拖垮整个 RabbitMQ 节点。如果延迟时间跨度较大,还可以考虑按时间范围分队列,例如 10 秒队列、1 分钟队列、10 分钟队列,这样既能分散积压,也能减少队头阻塞对短延迟消息的影响。

广告位 · 文章底部(后台-模块管理替换此区域)