面试官灵魂拷问:Redis 如何实现延时队列?有遇到过什么坑吗?
面试官:“你项目中的 Redis 如何实现延时队列?有遇到过什么坑吗?” 这个问题是不是很熟悉?在订单超时取消、任务调度、延迟消息等场景中,延时队列是不可或缺的组件,而 Redis 作为高性能缓存和存储中间件,自然是实现延时队列的热门选择。
那么,Redis 到底有哪些方式可以实现延时队列?它们各自的优缺点是什么?哪种方式更适合你的业务场景?
🎯 基于 Redis 事件的延时队列实现
📌 Redis Keyspace Notifications 机制
Redis 提供了 Keyspace Notifications 机制,可以在 key 过期或被删除时触发通知事件。具体来说:
- 当 Redis 配置
notify-keyspace-events
选项后,其中 notify-keyspace-events 是 Redis 提供的 键空间通知配置项,它决定了 Redis 是否会广播 键的变更事件,例如 key 过期、删除、修改等,这样 Redis 就可以在__keyevent@0__:*
频道中发布 key 过期(expired
)或被删除(del
)的事件。 - 我们可以在应用中监听这些事件,并在事件触发时删除本地缓存中的对应 key,确保 Redis 和本地缓存的数据保持一致。
Redis 事件监听机制示意图
Redis 事件监听机制示例代码
下面是一段 Spring Boot 代码,我们使用 Redisson 监听 Redis 事件,并在 key 过期或被删除时,清除本地缓存中的数据。
Redisson 是一个 Redis 分布式工具库。Maven 依赖如下:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.23.0</version>
</dependency>
监听事件示例代码:
@Component
@DependsOn({"redissonConfig"})
public class RedisKeyEventListener {
private final Logger logger = LoggerFactory.getLogger(RedisKeyEventListener.class);
/**
* 监听所有 key 的事件
*/
private static final String TOPIC_PATTERN = "__keyevent@0__:*";
@Resource
private RedissonClient redissonClient;
@PostConstruct
public void init() {
try {
initListener();
} catch (Exception e) {
logger.error("Listening for Redis key events init error!");
}
}
public RedisKeyEventListener() {
}
public RedisKeyEventListener(RedissonClient redissonClient) {
this.redissonClient = redissonClient;
}
/**
* 初始化 Redis 事件监听器
*/
private void initListener() {
new Thread(() -> {
RedisKeyEventListener listener = new RedisKeyEventListener(redissonClient, caffeineCacheService);
listener.startListening();
}).start();
}
public void startListening() {
RPatternTopic topic = redissonClient.getPatternTopic(TOPIC_PATTERN);
if (topic == null) {
logger.error("Listening for Redis key events, topic is empty!");
return;
}
topic.addListener(String.class, (PatternMessageListener<String>) (pattern, channel, key) -> {
logger.info("Event received: {} - Key: {}", channel, key);
if (channel.toString().endsWith(":expired") || channel.toString().endsWith(":del")) {
// 订单取消
}
});
logger.info("Listening for Redis key events...");
}
}
通过上面的配置我们可以很轻松的实现 业务自动感知 Redis 过期和删除事件,及时触发取消订单。但是!真的没有问题吗?
💭 该方法是否真的能保证定时取消吗?是否有什么坑?
在实际应用中,即使使用了 Redis Key 过期事件监听,依然可能存在数据不一致的情况。让我们深入分析其中的 潜在风险 和 优化方案。
⚠️ Redis 过期策略的坑
Redis 采用 惰性删除 和 定期删除 机制处理 key 过期,但这两种方式可能导致 key 过期但事件未触发 的问题:
🔹 惰性删除(Lazy Expiration) :
- 只有当客户端访问某个 key 时,Redis 才会检查它是否过期并删除。
- 如果 key 一直没有被访问,那么它可能会在 Redis 中 滞留更长时间,不会立刻触发过期事件,导致本地缓存仍然持有过期数据。
🔹 定期删除(Active Expiration) :
- Redis 会每隔一段时间 随机 扫描部分 key,并删除过期数据。
- 由于扫描是 随机的,某些 key 可能在一段时间内没有被删除,从而导致未触发 key 过期事件。
🔍 Redis 过期策略可能带来的问题
✅ 最理想的情况:本地缓存监听到 Redis 过期事件后,及时触发任务。
❌ 实际情况可能是:过期 key 未及时触发事件,订单数据仍然有效,但 Redis 数据已过期,导致 数据脏读。
🛠 那么如何优化?
我们可以使用 Redisson 延时队列 ,Redisson 延时队列是 基于 Redis 实现的一种 定时任务队列,可以在指定时间后执行任务。它的作用类似于 Java 的 ScheduledExecutorService
,但是分布式的,不依赖单个 JVM。
⏳ Redisson 延时队列的实现
封装了一个 RedissonDelayQueue 类 :
@Component
public class RedissonDelayQueue {
private final Logger logger = LoggerFactory.getLogger(RedissonDelayQueue.class);
private final static String DELAY_QUEUE_NAME = "redissonDelayQueue";
@Resource
private RedissonClient redissonClient;
@Value("${platform.delay-queue.enable:false}")
private Boolean enableDelayQueue;
private RDelayedQueue<String> delayQueue;
private RBlockingQueue<String> blockingQueue;
private final ExecutorService executorService = Executors.newFixedThreadPool(4); // 初始化线程池
@PostConstruct
public void init() {
if(!enableDelayQueue) {
return;
}
initDelayQueue();
startDelayQueueConsumer();
}
private void initDelayQueue() {
blockingQueue = redissonClient.getBlockingQueue(DELAY_QUEUE_NAME);
delayQueue = redissonClient.getDelayedQueue(blockingQueue);
}
/**
* 延迟任务的消费线程
* 线程池避免队列同时阻塞导致由于消费者来不及处理完成耗时操作导致出现"延时"
*/
private void startDelayQueueConsumer() {
for (int i = 0; i < 4; i++) { // 启动多个消费线程
executorService.submit(() -> {
while (true) {
try {
// 从阻塞队列中取出任务并执行
String key = blockingQueue.take();
logger.info("接收到延迟任务: {}", key);
// 订单取消
} catch (Exception e) {
logger.error("延迟队列消费异常", e);
}
}
});
}
}
/**
* 接口添加任务
*
* @param task 任务名称 (key值)
* @param seconds 过期时间(延时时间)
*/
public void offerTask(String task, long seconds) {
logger.info("添加延迟任务:{} 延迟时间:{}s", task, seconds);
delayQueue.offer(task, seconds, TimeUnit.SECONDS);
}
}
这个类在创建的时候会去初始化延迟队列,创建一个 RedissonClient 对象,之后通过 RedissonClient 对象获取到 RDelayedQueue 和 RBlockingQueue 对象,传入的队列测试名字叫 redissonDelayQueue。
当延迟队列创建之后,会开启一个延迟任务的消费线程,这个线程会一直从 RBlockingQueue中通过 take 方法阻塞获取延迟任务。
添加任务的时候是通过 RDelayedQueue 的 offer 方法添加的。
启动项目,测试添加任务之后和延迟时间之后,会在指定时间获取到延迟任务。
🎯 Redisson 延时队列的实现原理
我们来看看 Redisson 延时队列整体的运行流程结构图:
Redisson 通过 有序集合(ZSet)+ Lua 脚本 + 发布订阅机制 实现了高可靠的延时队列,确保任务能够在指定时间点被准时转移到目标队列。
🌟 延时任务的存储与提交
-
任务提交
- 生产者将任务存入
redisson_delay_queue_timeout:redissonDelayQueue
,使用 任务到期时间戳(当前时间戳 + 延迟时间)作为 ZSet 分数。 - 这意味着 ZSet 中的任务按照到期时间排序,最早到期的任务排在前面。
- 生产者将任务存入
-
Lua 脚本定时执行
-
客户端的延迟任务(定时轮询)会触发一个 Lua 脚本,Redis 原子性地执行如下两个操作:
- 查找并移除 已经过期的任务,将其从
redisson_delay_queue_timeout:redissonDelayQueue
移动到 redissonDelayQueue 目标队列。 - 获取下一个最早到期任务的时间戳,并发布到
redisson_delay_queue_channel:redissonDelayQueue
频道(channel)。
- 查找并移除 已经过期的任务,将其从
-
🔄 客户端如何确保任务准时转移?
-
监听
redisson_delay_queue_channel:redissonDelayQueue
- 客户端监听
redisson_delay_queue_channel:redissonDelayQueue
,一旦接收到新的最早到期任务的时间戳,就会重新提交一个客户端延迟任务。 - 这个新的客户端延迟任务的执行时间 = 最早到期任务的时间戳 - 当前时间,确保任务能准时触发。
- 客户端监听
-
延迟任务到期触发 Lua 脚本
-
当客户端的延迟任务到期时,Lua 脚本会再次执行:
- 移除到期任务,放入目标队列。
- 找到新的最早到期任务的时间戳,并发布到 channel。
-
如此循环往复,确保任务准时进入目标队列。
-
🔍 Redisson 延时队列总结
Redisson 延时队列的核心逻辑:
- 任务存入有序集合(ZSet) ,按照到期时间排序。
- 客户端定时触发 Lua 脚本,检查并转移到期任务,同时发布新的最早到期时间。
- 监听 channel,动态调整下次触发时间,确保任务不会延迟执行。
- 通过项目启动时主动执行一次定时任务,防止因重启导致任务滞留。
这样,Redisson 依靠 Lua + ZSet + 发布订阅 实现了高可靠、高精度的延时队列,能够很好地适用于延时消息、任务调度、超时订单等场景。
🔹 Redisson 延时队列 VS 直接用 Redis 过期事件
Redis 过期事件: 通过监听 notify-keyspace-events 触发延时任务,实现简单,但不可靠(Redis 重启或故障会导致事件丢失)。
Redisson 延时队列: 基于 有序集合(ZSet) 实现,支持持久化、任务时间可调整,且适用于集群环境,更稳定可靠。
📚 总结:如何实现 Redis 延时队列?
在分布式系统中,延时任务 是常见的需求,比如订单超时取消、消息延迟发送、任务调度等。本文介绍了两种基于 Redis 的实现方式:
1️⃣ Redis Keyspace Notifications 监听 Key 过期事件(轻量但不可靠)
2️⃣ Redisson 延时队列(基于 ZSet
+ Pub/Sub
+ Lua 脚本,稳定且高效)
对于小型项目,Key 过期事件 方案足够,但它有任务丢失的风险(如 Redis 重启后任务消失)。
如果需要高可靠性、可修改延时时间、支持集群,建议使用 Redisson 延迟队列,它结合 ZSet
+ RBlockingQueue
,确保任务在正确的时间被消费。
如果你已经在项目中使用了延时队列,欢迎分享你的经验!