RabbitMQ 怎么实现延迟消息?


面试考察点

  1. 机制理解:面试官不仅仅是想知道你听过 "死信队列" 这个词,更是想确认你是否清楚 TTL 和 DLX 配合实现延迟消息的完整链路,能否画出消息流转过程。

  2. 方案对比:是否知道除了 TTL + DLX 方案之外,还有延迟插件方案,以及两者的优劣。

  3. 踩坑意识:是否了解 TTL + DLX 方案的 "队头阻塞" 问题,以及怎么解决——这个坑很多用过的人都不知道。

核心答案

RabbitMQ 实现延迟消息主要有 两种方案

方案原理优点缺点
TTL + 死信队列(DLX)消息过期后自动进入死信队列,消费死信队列即延迟消费无需插件,纯配置实现存在 "队头阻塞" 问题
延迟消息插件安装 rabbitmq_delayed_message_exchange 插件,Exchange 直接支持延迟使用简单,无队头阻塞需要额外安装插件,消息存在磁盘影响性能

深度解析

一、TTL + DLX 方案(经典方案)

这是最经典的实现方式,核心思路是:给消息设置 TTL(过期时间),消息过期后自动转发到死信交换机(DLX),消费者监听死信队列,从而实现延迟消费

上图的流转过程:

  • 生产者将消息发送到一个 "延迟队列"(delay.queue),这个队列没有消费者
  • 消息在 delay.queue 中 "躺" 着,等 TTL 过期
  • TTL 到期后,消息通过 DLX(dlx.exchange)自动转发到死信队列(dlx.queue
  • 消费者监听死信队列,收到消息时就已经延迟了指定的时间

代码配置如下(Spring Boot 示例):

// ==================== 延迟队列配置 ====================

// 1. 声明死信交换机和死信队列
@Bean
public DirectExchange dlxExchange() {
    return new DirectExchange("dlx.exchange");
}

@Bean
public Queue dlxQueue() {
    return new Queue("dlx.queue");
}

@Bean
public Binding dlxBinding() {
    return BindingBuilder.bind(dlxQueue())
        .to(dlxExchange()).with("dlx.routing.key");
}

// 2. 声明延迟队列(绑定死信交换机)
@Bean
public Queue delayQueue() {
    Map<String, Object> args = new HashMap<>();
    // 设置死信交换机:消息过期后转发到这个交换机
    args.put("x-dead-letter-exchange", "dlx.exchange");
    // 设置死信路由键
    args.put("x-dead-letter-routing-key", "dlx.routing.key");
    return new Queue("delay.queue", true, false, false, args);
}

// 3. 生产者发送消息时设置 TTL
rabbitTemplate.convertAndSend("normal.exchange",
    "delay.routing.key",
    message,
    msg -> {
        // 单条消息设置 TTL(单位:毫秒)
        msg.getMessageProperties().setExpiration("600000"); // 10 分钟
        return msg;
    });

二、队头阻塞——这个坑要知道

TTL + DLX 方案有一个致命问题:队头阻塞

上图的问题在于:RabbitMQ 的过期检查是只判断队头第一条消息。即使后面的消息已经过期了,只要队头消息没过期,后面的消息就会被 "堵住"。

解决方案:为每个延迟时间创建一个独立的延迟队列。比如 delay.queue.5mdelay.queue.10mdelay.queue.30m,同一队列里的消息 TTL 相同,就不会出现阻塞问题。

但这样队列数量会膨胀。所以如果延迟时间种类很多,建议直接用方案二——延迟消息插件。

三、延迟消息插件方案(推荐)

RabbitMQ 官方提供了一个插件 rabbitmq_delayed_message_exchange,让 Exchange 直接支持延迟投递,从根本上解决了队头阻塞问题。

# 安装插件
rabbitmq-plugins enable rabbitmq_delayed_message_exchange

原理:消息发送到 delayed.exchange 后,Exchange 不会立即路由,而是把消息暂存在本地的 Mnesia 数据库中,等延迟时间到了再投递到目标队列。

代码配置:

// 1. 声明延迟交换机(注意 type 是 x-delayed-message)
@Bean
public CustomExchange delayedExchange() {
    Map<String, Object> args = new HashMap<>();
    args.put("x-delayed-type", "direct"); // 底层路由模式
    return new CustomExchange("delayed.exchange",
        "x-delayed-message", true, false, args);
}

// 2. 生产者发送延迟消息
rabbitTemplate.convertAndSend("delayed.exchange",
    "order.routing.key",
    "订单已创建",
    msg -> {
        // 设置延迟时间(单位:毫秒)
        msg.getMessageProperties().setDelay(30 * 60 * 1000); // 30 分钟
        return msg;
    });

// 3. 消费者正常监听队列即可,跟普通消费没区别
@RabbitListener(queues = "order.queue")
public void handleOrder(String message) {
    log.info("收到延迟消息:{}", message);
}

比 TTL + DLX 方案简洁多了吧?不需要配死信交换机、不需要建延迟队列,一个 Exchange 搞定。

四、两种方案怎么选?

对比维度TTL + DLX延迟消息插件
队头阻塞有(需多队列规避)
架构复杂度较高(需要配 DLX + 多个队列)低(一个 Exchange 搞定)
额外依赖需安装插件
延迟精度队头阻塞导致不精确精确
性能消息在队列中,内存压力小消息存在 Mnesia 中,量大影响性能
适用场景延迟时间固定、种类少延迟时间灵活、种类多

我的建议:如果项目允许装插件,优先用插件方案。简单、无坑、好维护。如果运维不允许装插件,那就用 TTL + DLX,但一定要按延迟时间分队列,别一个队列塞不同 TTL 的消息。

面试高频追问

  1. RabbitMQ 延迟消息和 Redis 延迟队列怎么选?

    Redis 延迟队列(ZSET + 定时轮询)实现更轻量,但可靠性不如 RabbitMQ(Redis 宕机可能丢任务,需要持久化 + 补偿)。如果项目已经用了 RabbitMQ,就用 RabbitMQ;如果只是简单的延迟任务,Redis 方案更轻。

  2. 延迟消息插件的消息存在哪里?性能怎样?

    存在 Mnesia(Erlang 内置数据库)中,磁盘持久化。延迟消息量大的时候会有性能瓶颈,不适合做海量延迟任务(比如百万级延迟通知),那类场景建议用时间轮或专门的延迟队列中间件。

  3. 订单超时取消用什么方案实现?

    这道题本质上就是在问延迟消息。常见的有:RabbitMQ 延迟消息、Redis Key 过期通知、定时任务轮询数据库、时间轮算法。生产环境最常用的是 RabbitMQ 延迟消息或 Redis ZSET 方案。

常见面试变体

  • "RabbitMQ 如何实现定时消息?"
  • "订单 30 分钟未支付自动取消,怎么实现?"
  • "TTL + 死信队列实现延迟消息有什么问题?"

记忆口诀

两种方案:TTL + DLX 是经典(但有队头阻塞)、延迟插件是推荐(无阻塞但要装插件)。

队头阻塞:FIFO 只看队头,后面过期也出不去——按时间分队列可解决。

总结

一句话:RabbitMQ 原生不支持延迟消息,经典方案是 TTL + 死信队列(注意队头阻塞问题),推荐方案是 延迟消息插件x-delayed-message,简洁无坑)。面试时把两种方案都讲出来,再主动提队头阻塞问题和解决方案,这道题就是高分回答。