Skip to content

延迟消息实现方案对比:RabbitMQ TTL/DLX vs RocketMQ 定时消息 vs Redis ZSet

问题

消息队列中如何实现延迟消息?几种方案的优缺点和适用场景分别是什么?

分析

延迟消息(Delayed Message / Scheduled Message)是指消息发送后不立即投递给消费者,而是在指定时间后才投递。这在业务系统中非常常见:订单 30 分钟未支付自动取消、支付超时提醒、定时任务调度等等。

不同的消息中间件对延迟消息的支持能力差异很大,有的原生支持多种等级,有的需要靠 TTL + 死信队列迂回实现,还有的干脆不提供,得借助外部存储(如 Redis ZSet)来模拟。本文对比三种主流方案,分析各自的原理、优缺点和适用场景。

方案一:RabbitMQ TTL + DLX(死信队列)

原理

RabbitMQ 本身不提供延迟消息,但可以通过两条特性组合实现:

  1. TTL(Time-To-Live):给消息设置 expiration 属性,消息在 Queue 中存活超过 TTL 后变为"死信"。
  2. DLX(Dead Letter Exchange):死信消息被转发到指定的死信交换机(DLX),DLX 再绑定到目标 Queue,完成延迟投递。

流程:Producer → 原始 Queue(带 TTL)→ 消息过期 → DLX → 目标 Queue → Consumer。

代码示例

java
// 生产者:发送延迟消息到原始 Queue,设置 TTL 为 30 分钟
ConnectionFactory factory = new ConnectionFactory();
factory.setHost("localhost");
try (Connection connection = factory.newConnection();
     Channel channel = connection.createChannel()) {

    // 声明死信交换机
    channel.exchangeDeclare("dlx.exchange", "direct");
    // 声明目标队列,绑定到死信交换机
    channel.queueDeclare("target.queue", true, false, false, null);
    channel.queueBind("target.queue", "dlx.exchange", "target.key");

    // 声明原始队列,绑定死信交换机
    Map<String, Object> args = new HashMap<>();
    args.put("x-dead-letter-exchange", "dlx.exchange");
    args.put("x-dead-letter-routing-key", "target.key");
    channel.queueDeclare("delay.queue", true, false, false, args);

    // 发送消息,TTL = 30 分钟
    String message = "{\"orderId\": 12345, \"action\": \"cancel\"}";
    AMQP.BasicProperties props = new AMQP.BasicProperties.Builder()
        .expiration("1800000") // 30 分钟(毫秒)
        .build();
    channel.basicPublish("", "delay.queue", props, message.getBytes());
    System.out.println("延迟消息已发送,30 分钟后投递");
}

RabbitMQ 只检查 Queue 头部消息是否过期。如果头部消息的 TTL 很长(比如 30 分钟),后面 TTL 短的消息(比如 5 秒)不会提前过期,必须等头部消息过期后才能被检查到。这在同一条队列混用不同延迟时间时会导致延迟不准。解决方案:每条延迟时间单独建一个 Queue,但这会增加运维复杂度。

方案二:RocketMQ 定时消息

原理

RocketMQ 原生支持延迟消息,通过 18 个固定等级实现。Producer 调用 message.setDelayTimeLevel(level) 指定等级,消息写入 Broker 后先进入 SCHEDULE_TOPIC_XXXX(一个特殊的系统 Topic,包含 18 个 Queue 对应 18 个等级),定时线程扫描到期消息,投递到目标 Topic。

代码示例

java
// 生产者:发送延迟等级为 5 的消息(对应 30 分钟)
DefaultMQProducer producer = new DefaultMQProducer("delay_producer_group");
producer.setNamesrvAddr("localhost:9876");
producer.start();

Message msg = new Message("order_topic", "cancel", 
    "{\"orderId\": 12345}".getBytes());
// 延迟等级 5 对应 30 分钟
msg.setDelayTimeLevel(5);
SendResult result = producer.send(msg);
System.out.printf("延迟消息发送成功,等级=%d,msgId=%s%n", 
    5, result.getMsgId());

producer.shutdown();

18 个等级对照

等级延迟时间等级延迟时间
11s106m
25s117m
310s128m
430s139m
51m1410m
62m1520m
73m1630m
84m171h
95m182h

  • 只有 18 个固定等级,如果需要 3 小时 25 分钟,只能选 2h 或组合实现
  • 自定义等级需要修改 Broker 配置并重启
  • 所有延迟消息写入 SCHEDULE_TOPIC_XXXX 的 18 个 Queue,集中写入可能成为热点

方案三:Redis ZSet + 轮询

原理

不依赖 MQ 的延迟消息能力,用 Redis 的有序集合(ZSet)作为延迟队列。消息的 score 设为期望执行时间戳,后台定时任务轮询 ZSet 中 score <= 当前时间的消息,取出后投递到 MQ 或直接执行。

代码示例

java
// 延迟消息生产者:写入 Redis ZSet
@Service
public class DelayedMessageProducer {
    @Autowired
    private StringRedisTemplate redisTemplate;

    private static final String DELAY_QUEUE_KEY = "delay:order_cancel";

    public void sendDelayMessage(String orderId, long delayMs) {
        long executeTime = System.currentTimeMillis() + delayMs;
        // 消息体可以是 JSON 字符串
        String message = "{\"orderId\":\"" + orderId + "\",\"action\":\"cancel\"}";
        // ZSet 的 score 是执行时间戳
        redisTemplate.opsForZSet().add(DELAY_QUEUE_KEY, message, executeTime);
    }
}

// 延迟消息消费者:轮询扫描
@Component
public class DelayedMessageConsumer {
    @Autowired
    private StringRedisTemplate redisTemplate;

    private static final String DELAY_QUEUE_KEY = "delay:order_cancel";
    private static final long BATCH_SIZE = 100;

    @Scheduled(fixedDelay = 1000) // 每秒轮询一次
    public void pollAndProcess() {
        long now = System.currentTimeMillis();
        // 取出 score <= 当前时间的消息,按 score 升序
        Set<String> messages = redisTemplate.opsForZSet()
            .rangeByScore(DELAY_QUEUE_KEY, 0, now, 0, BATCH_SIZE);

        if (messages == null || messages.isEmpty()) {
            return;
        }

        for (String msg : messages) {
            try {
                // 处理延迟任务(这里模拟发送到 MQ 或直接执行)
                processMessage(msg);
                // 处理成功后从 ZSet 删除
                redisTemplate.opsForZSet().remove(DELAY_QUEUE_KEY, msg);
            } catch (Exception e) {
                log.error("处理延迟消息失败: {}", msg, e);
                // 失败不删除,下次轮询重试
            }
        }
    }

    private void processMessage(String message) {
        // 发送到 MQ 或直接调用业务逻辑
        System.out.println("执行延迟任务: " + message);
    }
}

  • Redis 宕机丢数据:RDB 或 AOF 持久化可以部分缓解,但 Redis 宕机到重启期间的延迟数据可能丢失
  • 轮询间隔精度有限:1 秒轮询一次,延迟精度在秒级,毫秒级延迟不适用
  • 大量消息时 ZSet 性能下降O(log N) 的插入和查询,百万级消息时延迟增加

对比总结

维度RabbitMQ TTL+DLXRocketMQ 定时消息Redis ZSet
延迟精度秒级(受队列头部阻塞影响)固定等级(秒/分级)秒级(受轮询间隔限制)
任意延迟时间支持(但多延迟混用不准)仅 18 个固定等级支持任意毫秒值
吞吐能力中等高(ZSet O(log N))
数据可靠性高(RabbitMQ 持久化)高(RocketMQ 刷盘)低(Redis 宕机丢数据)
运维复杂度中等(需要额外建 Queue)低(原生支持)中等(需要额外维护 Redis)
适用场景延迟时间固定、精度要求不高的场景延迟等级固定的订单超时等灵活延迟时间、可接受少量丢失

总结

延迟消息的实现没有银弹。RocketMQ 原生定时消息是最省心的方案,订单超时 30 分钟、支付提醒 15 分钟这些固定延迟场景直接选它。RabbitMQ TTL + DLX 适合已经重度使用 RabbitMQ 的团队,但要避开多条延迟时间混用的坑,最好每条延迟时间单独建队列。Redis ZSet 适合需要灵活延迟时间且可以接受少量数据丢失的场景,或者作为辅助方案实现 MQ 不支持的延迟等级。

如果延迟精度要求高(毫秒级)且消息量不大(< 10 万级),可以考虑 Netty HashedWheelTimer 时间轮算法,完全在内存中实现,精度可达毫秒级。延迟消息体量极大(百万级)且需要任意延迟时间,Pulsar 的 DelayedDeliveryTracker 是更好的选择,原生支持任意延迟且性能优于 RocketMQ。

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。