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 如何实现延迟消息?
面试考察点
-
基础掌握度:面试官不仅仅是想知道你会用 Sorted Set,更是想知道你是否理解延迟消息的核心本质 —— "到时间才能被消费",以及如何利用 Redis 数据结构来实现时间排序 + 定时轮询。
-
方案设计能力:考察你是否能给出不止一种实现方案(Sorted Set、Keyspace Notification、Redisson 延迟队列),并清楚各自的优缺点和适用场景。
-
工程实践经验:是否能识别出轮询方案的空转问题、并发竞争问题、消息丢失风险,以及对应的优化策略。
核心答案
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 延迟队列的内部原理:
- 将消息放入一个 ZSET(
redisson_delay_queue_timeout),score 为到期时间戳。 - 通过
ZRANGEBYSCORE检测到期消息,转移到 List(redisson_delay_queue)。 - 消费者通过
BLPOP阻塞读取 List,实现 零空转 的高效消费。
四、三种方案对比
| 对比维度 | Sorted Set 轮询 | Keyspace Notification | Redisson 延迟队列 |
|---|---|---|---|
| 可靠性 | ✅ 高(消息持久化) | ❌ 低(Pub/Sub 丢消息) | ✅ 高(消息持久化) |
| 实时性 | 取决于轮询间隔 | 取决于过期清理延迟 | ✅ 高(秒级) |
| 实现复杂度 | 中等(需自研轮询) | 低(几行代码) | 低(开箱即用) |
| 空转问题 | ❌ 有(需优化) | ✅ 无(事件驱动) | ✅ 无(阻塞等待) |
| 消息确认 | 需自己实现 | 无 | 内置支持 |
| 适用场景 | 自研轻量方案 | 不推荐生产使用 | 生产首选 |
终极选型:
- 简单场景 / 自研:Sorted Set + 定时轮询,配合 Lua 脚本保证原子性。
- 生产环境:直接用 Redisson 的
RDelayedQueue,省心省力。 - 核心业务:RocketMQ 延迟消息(支持 18 个固定延迟等级)或 RabbitMQ 死信队列,消息可靠性更有保障。
面试高频追问
-
追问一:Sorted Set 轮询方案如何避免空轮询浪费 CPU? 优化思路是 智能 sleep:每次轮询时,先用
ZRANGE ... LIMIT 0 1取 score 最小的那条消息,如果它的 score 大于当前时间,说明队列中没有到期消息,就 sleep(score - 当前时间),醒来后再检查。这样既不浪费 CPU,又能保证及时处理。 -
追问二:RocketMQ 的延迟消息和 Redis 方案的区别? RocketMQ 原生支持延迟消息(开源版支持 18 个固定等级:1s/5s/10s/.../2h),消息可靠性高、支持消费重试。Redis 方案优势是轻量、灵活(任意延迟时间),但需要自己保证可靠性。核心业务(订单超时取消、支付回调)建议用 RocketMQ。
-
追问三:订单超时取消用哪种方案? 取决于业务规模:
- 小规模: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 等专业消息中间件。