Redis 如何实现延迟消息?

一则或许对你有用的小广告

欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论

  • 新开坑项目: 《Spring AI 项目实战(问答机器人、RAG 增强检索、联网搜索)》 正在持续爆肝中,基于 Spring AI + Spring Boot3.x + JDK 21...点击查看;
  • 《从零手撸:仿小红书(微服务架构)》 已完结,基于 Spring Cloud Alibaba + Spring Boot3.x + JDK 17...点击查看项目介绍; 演示链接: http://116.62.199.48:7070/;
  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/

Redis 如何实现延迟消息?Redis 如何实现延迟消息?

面试考察点

  1. 基础掌握度:面试官不仅仅是想知道你会用 Sorted Set,更是想知道你是否理解延迟消息的核心本质 —— "到时间才能被消费",以及如何利用 Redis 数据结构来实现时间排序 + 定时轮询。

  2. 方案设计能力:考察你是否能给出不止一种实现方案(Sorted Set、Keyspace Notification、Redisson 延迟队列),并清楚各自的优缺点和适用场景。

  3. 工程实践经验:是否能识别出轮询方案的空转问题、并发竞争问题、消息丢失风险,以及对应的优化策略。

核心答案

Redis 实现延迟消息主要有 3 种方案

方案核心原理优点缺点
Sorted Set(ZSET)score 存执行时间,定时轮询到期任务简单直观、易实现需要自己实现轮询逻辑,存在延迟偏差
Keyspace Notification监听过期 Key 的事件通知无需轮询,实时触发不保证可靠,消息可能丢失
Redisson 延迟队列基于 Sorted Set + BlockingQueue 封装开箱即用、成熟稳定依赖 Redisson 框架

一句话结论:生产环境推荐 Redisson 延迟队列(封装好、可靠性强)。如果不想引入框架,用 Sorted Set + 定时轮询 是最常见的自研方案。核心业务建议用 RocketMQ 等成熟消息中间件提供的延迟消息。

深度解析

一、方案一:Sorted Set + 定时轮询(最常用)

这是最经典的方案。利用 ZSET 的特性:score 可以排序,把消息的执行时间戳作为 score,定时任务轮询 score 小于当前时间的消息。

上图展示了 Sorted Set 实现延迟消息的核心思路:

  • 写入消息:调用 ZADD delay_queue <时间戳> <消息内容>,score 是消息应该被执行的时间戳。
  • 轮询消费:定时任务每隔一段时间(如 1 秒)调用 ZRANGEBYSCORE delay_queue 0 <当前时间戳>,取出所有到期的消息。
  • 先删后处理:先调用 ZREM 删除消息(保证不重复消费),再处理业务逻辑。

代码示例

// ==================== 生产者:发送延迟消息 ====================

public void sendDelayMessage(String topic, String message, long delaySeconds) {
    long executeTime = System.currentTimeMillis() / 1000 + delaySeconds;
    String msg = JSON.toJSONString(new DelayMessage(topic, message));
    // score = 执行时间戳,member = 消息内容
    jedis.zadd("delay_queue", executeTime, msg);
}

// 示例:30 分钟后取消未支付订单
sendDelayMessage("order", "取消订单#12345", 30 * 60);


// ==================== 消费者:定时轮询 ====================

@Scheduled(fixedDelay = 1000) // 每秒轮询一次
public void consumeDelayMessage() {
    long now = System.currentTimeMillis() / 1000;
    // 1. 取出到期的消息(score ≤ 当前时间)
    List<String> messages = jedis.zrangeByScore("delay_queue", 0, now);

    for (String msg : messages) {
        // 2. 先删除(保证只被消费一次)
        Long removed = jedis.zrem("delay_queue", msg);
        if (removed != null && removed > 0) {
            // 3. 删除成功,处理业务
            DelayMessage delayMsg = JSON.parseObject(msg, DelayMessage.class);
            handleMessage(delayMsg);
        }
    }
}

这个方案需要注意三个问题

  • 并发竞争:多个消费者同时 ZRANGEBYSCORE 拿到同一条消息,需要用 ZREM 的返回值判断是否删除成功,只有删除成功的那个才处理。
  • 空轮询浪费:大部分时间没有到期消息,轮询是空跑。优化方式是用 ZRANGEBYSCORE ... LIMIT 0 1 只取一条,配合 ZSCORE 检查最近一条消息是否到期,未到期就 sleep 差值时间。
  • ZRANGEBYSCORE + ZREM 非原子:可以用 Lua 脚本将 "查询 + 删除" 合并为原子操作。

Lua 脚本优化版

-- 原子地取出并删除一条到期消息
local msg = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1], 'LIMIT', 0, 1)
if #msg > 0 then
    redis.call('ZREM', KEYS[1], msg[1])
    return msg[1]
end
return nil

二、方案二:Keyspace Notification(过期事件通知)

Redis 2.8 引入了 键空间通知(Keyspace Notification),可以监听 Key 的过期事件。

上图展示了 Keyspace Notification 方案的流程:

  • 生产者:将延迟消息存为一个 Key,并设置过期时间为延迟时长。
  • Redis:Key 过期时自动发布一个事件到 __keyevent@<db>__:expired 频道。
  • 消费者:订阅该频道,收到过期事件后解析 Key 内容,执行对应业务。

这个方案有致命缺陷,不推荐生产使用

  • 消息可能丢失:过期事件通过 Pub/Sub 发送,如果消费者离线,事件直接丢弃。
  • 只能拿到 Key 名:过期事件只通知 Key 的名称,不携带 Value,所以需要把业务数据编码到 Key 中(或用 Key 去 Redis 查 Value,但 Key 已过期了)。
  • 不保证实时:Redis 过期清理是惰性的,不保证 Key 到期后立刻触发事件。

三、方案三:Redisson 延迟队列(生产推荐)

Redisson 提供了 RDelayedQueue,是对 Sorted Set 方案的生产级封装,开箱即用。

// ==================== Redisson 延迟队列使用 ====================

// 1. 获取队列
RBlockingQueue<String> blockingQueue = redissonClient.getBlockingQueue("order_delay_queue");
RDelayedQueue<String> delayedQueue = redissonClient.getDelayedQueue(blockingQueue);

// 2. 生产者:发送延迟消息(30 分钟后取消订单)
delayedQueue.offer("取消订单#12345", 30, TimeUnit.MINUTES);

// 3. 消费者:阻塞等待到期消息
new Thread(() -> {
    while (true) {
        try {
            // take() 会阻塞,直到有到期消息
            String message = blockingQueue.take();
            System.out.println("处理延迟消息:" + message);
            handleOrderCancel(message);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            break;
        }
    }
}).start();

Redisson 延迟队列的内部原理:

  • 将消息放入一个 ZSETredisson_delay_queue_timeout),score 为到期时间戳。
  • 通过 ZRANGEBYSCORE 检测到期消息,转移到 Listredisson_delay_queue)。
  • 消费者通过 BLPOP 阻塞读取 List,实现 零空转 的高效消费。

四、三种方案对比

对比维度Sorted Set 轮询Keyspace NotificationRedisson 延迟队列
可靠性✅ 高(消息持久化)❌ 低(Pub/Sub 丢消息)✅ 高(消息持久化)
实时性取决于轮询间隔取决于过期清理延迟✅ 高(秒级)
实现复杂度中等(需自研轮询)低(几行代码)低(开箱即用)
空转问题❌ 有(需优化)✅ 无(事件驱动)✅ 无(阻塞等待)
消息确认需自己实现内置支持
适用场景自研轻量方案不推荐生产使用生产首选

终极选型

  • 简单场景 / 自研:Sorted Set + 定时轮询,配合 Lua 脚本保证原子性。
  • 生产环境:直接用 Redisson 的 RDelayedQueue,省心省力。
  • 核心业务:RocketMQ 延迟消息(支持 18 个固定延迟等级)或 RabbitMQ 死信队列,消息可靠性更有保障。

面试高频追问

  1. 追问一:Sorted Set 轮询方案如何避免空轮询浪费 CPU? 优化思路是 智能 sleep:每次轮询时,先用 ZRANGE ... LIMIT 0 1 取 score 最小的那条消息,如果它的 score 大于当前时间,说明队列中没有到期消息,就 sleep(score - 当前时间),醒来后再检查。这样既不浪费 CPU,又能保证及时处理。

  2. 追问二:RocketMQ 的延迟消息和 Redis 方案的区别? RocketMQ 原生支持延迟消息(开源版支持 18 个固定等级:1s/5s/10s/.../2h),消息可靠性高、支持消费重试。Redis 方案优势是轻量、灵活(任意延迟时间),但需要自己保证可靠性。核心业务(订单超时取消、支付回调)建议用 RocketMQ。

  3. 追问三:订单超时取消用哪种方案? 取决于业务规模:

    • 小规模:Redis Sorted Set + 轮询,简单够用。
    • 中等规模:Redisson 延迟队列,生产级封装。
    • 大规模 / 核心业务:RocketMQ 延迟消息,可靠性最强。

常见面试变体

  • 变体一:"如何用 Redis 实现订单超时自动取消?"
  • 变体二:"Redis 如何实现定时任务?"
  • 变体三:"Redis 过期 Key 的回调机制了解吗?"
  • 变体四:"延迟队列有哪些实现方案?"

记忆口诀

Sorted Set 轮询:score 存时间戳,定时捞到期 —— "闹钟响了再去拿信"。

Keyspace Notification:Key 过期触发通知 —— "信件自动燃烧,看到烟就来"(不可靠)。

Redisson:封装好的 ZSET + 阻塞队列 —— "快递柜,到了自动通知你来取"。

选型:自研用 ZSET,生产用 Redisson,核心业务上 RocketMQ。

总结

Redis 实现延迟消息的 最佳实践 是使用 Redisson 的 RDelayedQueue(内部基于 ZSET + 阻塞队列)。如果需要自研,用 Sorted Set + 定时轮询 + Lua 脚本原子操作 是最经典的方案。Keyspace Notification 方案因不可靠,不推荐生产使用。核心业务的延迟消息(如订单超时取消)建议使用 RocketMQ 等专业消息中间件。