在现代分布式系统设计中,延迟队列作为一种重要的数据结构,广泛应用于消息延迟处理、任务调度、缓存失效、订单超时处理等场景。Redis,作为一个高性能的键值对存储系统,凭借其丰富的数据结构、原子操作、发布/订阅模式以及Lua脚本支持,成为了实现延迟队列的理想选择。
延迟队列(Delayed Queue)是一种特殊的队列,其中的元素不是立即被消费,而是等待一定的时间后才可被消费。这种机制在需要处理时间敏感的任务时非常有用,比如:
前段时间有个小项目需要使用延迟任务,谈到延迟任务,我脑子第一时间一闪而过的就是使用消息队列来做,比如RabbitMQ的死信队列又或者RocketMQ的延迟队列,但是奈何这是一个小项目,并没有引入MQ,我也不太想因为一个延迟任务就引入MQ,增加系统复杂度,所以这个方案直接就被pass了。
虽然基于MQ这个方式走不通了,但是可以使用Redis,所以我就想是否能够使用Redis来代替MQ实现延迟队列的功能。
Redis之所以适合实现延迟队列,主要得益于其以下几个特点:
INCR
、DECR
、LPUSH
、ZADD
等,保证了数据的一致性和完整性。Redis的有序集合是一种非常适合实现延迟队列的数据结构。在有序集合中,每个元素都会关联一个分数(score),Redis会根据这个分数对集合中的元素进行自动排序。通过将延迟时间戳作为分数,我们可以轻松实现延迟队列的功能。
在实现Redis延迟队列时,我们首先需要定义数据结构。对于有序集合,我们将消息ID作为成员(member),将消息应该被处理的时间戳(通常为Unix时间戳)作为分数(score)。
当需要添加一个新的延迟消息时,我们需要计算该消息应该被处理的时间点(即时间戳),并将其作为分数添加到有序集合中。
然后一个后台服务(或定时任务)来定期检查有序集合,获取所有已经到期的消息(即分数小于等于当前时间戳的消息),并处理它们。
public class RedisZsetDelayedQueue {
private static final String QUEUE_KEY = "delayed_jobs";
private Jedis jedis;
public DelayedQueue(Jedis jedis) {
this.jedis = jedis;
}
/**
* 添加任务到延迟队列
*
* @param jobId 任务ID
* @param delay 延迟时间,单位为秒
*/
public void addJob(String jobId, long delay) {
long score = System.currentTimeMillis() / 1000 + delay; // 转换为秒
jedis.zadd(QUEUE_KEY, score, jobId);
}
/**
* 处理队列中的任务
*/
public void processJobs() {
while (true) {
try {
// 获取当前时间戳(秒)
long now = System.currentTimeMillis() / 1000;
// 使用zrangeByScoreWithScores获取分数小于等于当前时间的所有任务
Set readyJobs = jedis.zrangeByScoreWithScores(QUEUE_KEY, 0, now);
if (!readyJobs.isEmpty()) {
for (Tuple job : readyJobs) {
String jobId = job.getElement();
// 从有序集合中移除已处理的任务
jedis.zrem(QUEUE_KEY, jobId);
// 执行任务逻辑
processJob(jobId);
}
}
// 适当的等待时间,减少CPU使用率
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}
private void processJob(String jobId) {
// 执行具体的任务处理逻辑
System.out.println("Processing job: " + jobId);
}
}
注意事项
在实现Redis延迟队列时,除了上述的基本实现步骤外,还需要注意以下几个方面,以确保系统的稳定性和效率:
并发与竞争条件
在多线程或多进程环境下,可能存在多个消费者同时尝试处理同一个延迟消息的情况。虽然Redis的ZREM
操作是原子的,但在实际的应用场景中,我们还需要确保任务处理逻辑的原子性。这通常可以通过在业务层加锁或者使用Redis的事务(multi/exec)来实现。
然而,对于大多数延迟队列的使用场景而言,直接在Redis层面处理并发已经足够,因为每个任务ID在队列中是唯一的,且ZREM
会确保只移除一个元素。但在某些复杂场景下,如任务需要基于其他数据状态来决策是否执行时,就需要在业务逻辑层面加锁了。
性能优化
processJobs
方法中,可以通过增加每次查询的时间范围(即增加zrangeByScoreWithScores
的max
参数),来一次性处理多个即将到期的任务,减少Redis的访问次数,提高性能。持久化与容错
时间精度
Redis的延迟队列依赖于系统时间,因此时间精度受限于操作系统的时钟精度。在大多数情况下,这并不会成为问题,但在对时间精度要求极高的应用场景中(如金融交易系统),可能需要考虑使用更精确的时间同步服务(如NTP)来确保时间的准确性。
清理机制
虽然Redis会定期清理过期的键,但在某些情况下(如Redis内存使用接近上限时),可能需要手动触发清理操作。对于延迟队列而言,可以定期运行一个脚本,检查并移除那些已经远远超过处理时间但尚未被消费的任务,以释放内存资源。
基于监听过期key的方式来实现延迟队列。
一谈到发布订阅模式,其实一想到的就是MQ,只不过Redis也实现了一套,并且跟MQ贼像,如图:
https://mmbiz.qpic.cn/mmbiz_png/B279WL06QYyBo7IXtcS6SKLQ8kQeRRfurpHhfzOC1ADRpbhwuv084GbXuiaZuDaLNcEplNP7wj2G44j4wJR5WNQ/640?wx_fmt=png
图中的channel的概念跟MQ中的topic的概念差不多,你可以把channel理解成MQ中的topic。
生产者在消息发送时需要到指定发送到哪个channel上,消费者订阅这个channel就能获取到消息。
在Redis的配置文件redis.conf
中,你可以设置notify-keyspace-events
参数来启用键空间通知。这个参数是一个字符串,由多个字符组成,每个字符代表一类事件。对于过期事件,你需要包含x
字符(表示过期事件)。例如,notify-keyspace-events Ex
表示启用对过期事件的通知,并且只针对数据库0(因为E
代表数据库0)。如果你想要对所有数据库都启用过期事件通知,可以使用A
代替数据库编号,如notify-keyspace-events Ax
。
在Redis中,有很多默认的channel,只不过向这些channel发送消息的生产者不是我们写的代码,而是Redis本身。当消费者监听这些channel时,就可以感知到Redis中数据的变化。
这个功能Redis官方称为key space notifications,字面意思就是键空间通知。
这些默认的channel被分为两类:
__keyspace@__:
为前缀,后面跟的是key的名称,表示监听跟这个key有关的事件。
举个例子,现在有个消费者监听了__keyspace@0__:
test这个channel,test就是Redis中的一个普通key,那么当test这个key被删除或者发生了其它事件,那么消费者就会收到test这个key删除或者其它事件的消息
__keyevent@__:
为前缀,后面跟的是消息事件类型,表示监听某个事件
同样举个例子,现在有个消费者监听了__keyevent@0__:expired
这个channel,代表了监听key的过期事件。那么当某个Redis的key过期了(expired),那么消费者就能收到这个key过期的消息。如果把expired换成del,那么监听的就是删除事件。具体支持哪些事件,可从官网查。
上述db是指具体的数据库,Redis不是默认分为16个库么,序号从0-15,所以db就是0到15的数字,示例中的0就是指0对应的数据库。
https://mmbiz.qpic.cn/mmbiz_png/B279WL06QYyBo7IXtcS6SKLQ8kQeRRfucX9TU5keBaiaISL9B28fP0kX2f5rv98pdbJIEibttZ6zqJs1fmVTl2vg/640?wx_fmt=png
通过对上面的两个概念了解之后,应该就对监听过期key的实现原理一目了然了,其实就是当这个key过期之后,Redis会发布一个key过期的事件到__keyevent@__:expired
这个channel,只要我们的服务监听这个channel,那么就能知道过期的Key,从而就算实现了延迟队列功能。
所以这种方式实现延迟队列就只需要两步:
__keyevent@__:expired
这个channel,处理延迟任务 org.springframework.boot
spring-boot-starter-data-redis
2.2.5.RELEASE
org.springframework.boot
spring-boot-starter-web
2.2.5.RELEASE
配置类
@Configuration
public class RedisConfiguration {
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory connectionFactory) {
RedisMessageListenerContainer redisMessageListenerContainer = new RedisMessageListenerContainer();
redisMessageListenerContainer.setConnectionFactory(connectionFactory);
return redisMessageListenerContainer;
}
@Bean
public KeyExpirationEventMessageListener redisKeyExpirationListener(RedisMessageListenerContainer redisMessageListenerContainer) {
return new KeyExpirationEventMessageListener(redisMessageListenerContainer);
}
}
KeyExpirationEventMessageListener实现了对__keyevent@*__:expired
channel的监听
https://mmbiz.qpic.cn/mmbiz_png/B279WL06QYyBo7IXtcS6SKLQ8kQeRRfuaGBESv9sLvShOvktQlgQiaE3WJnfAKEtTzO63qGG8MCmSvKM1uk9Tzw/640?wx_fmt=png
当KeyExpirationEventMessageListener收到Redis发布的过期Key的消息的时候,会发布RedisKeyExpiredEvent事件
https://mmbiz.qpic.cn/mmbiz_png/B279WL06QYyBo7IXtcS6SKLQ8kQeRRfus4WibnzS2dpYN58icV54WL72aO2DOh50ypKKVk2Mec2MDekLktUOMJOQ/640?wx_fmt=png
所以我们只需要监听RedisKeyExpiredEvent事件就可以拿到过期消息的Key,也就是延迟消息。
对RedisKeyExpiredEvent事件的监听实现MyRedisKeyExpiredEventListener
@Component
public class MyRedisKeyExpiredEventListener implements ApplicationListener {
@Override
public void onApplicationEvent(RedisKeyExpiredEvent event) {
byte[] body = event.getSource();
System.out.println("获取到延迟消息:" + new String(body));
}
}
不出意外的话,5s后MyRedisKeyExpiredEventListener应该可以监听到这个key过期的消息,也就相当于拿到了延迟任务,控制台会打印出获取到延迟消息
。
我查看控制台,但是控制台并没有按照预期打印出上面那句话。
为什么会没打印出?难道是代码写错了?正当我准备检查代码的时候,官网的一段话道出了真实原因。
https://mmbiz.qpic.cn/mmbiz_png/B279WL06QYyBo7IXtcS6SKLQ8kQeRRfurdrftnz7sWFQDhvqoribb0BdjQ289PXk5ByaNbrsia89Od25BTiaibj2kg/640?wx_fmt=png
上面这段话主要讨论的是key过期事件的时效性问题,首先提到了Redis过期key的两种清除策略,就是面试八股文常背的两种:
再后面那段话是核心,意思是说,key的过期事件发布时机并不是当这个key的过期时间到了之后就发布,而是这个key在Redis中被清理之后,也就是真正被删除之后才会发布。
到这我终于明白了,上面的例子中即使我设置了5s的过期时间,但是当5s过去之后,只要两种清除策略都不满足,没人访问这个key,后台的定时清理的任务也没扫描到这个key,那么就不会发布key过期的事件,自然而然也就监听不到了。
至于后台的定时清理的任务什么时候能扫到,这个没有固定时间,可能一到过期时间就被扫到,也可能等一定时间才会被扫到,这就可能会造成了客户端从发布到监听到的消息时间差会大于等于过期时间,从而造成一定时间消息的延迟,这就着实有点坑了。。
除了上面测试demo的时候遇到的坑之外,在我深入研究之后,还发现了一些更离谱的坑。
__keyevent@__:
开头的消息,那么会导致所有的key发生了事件都会被通知给消费者。
举个例子,某个消费者监听了__keyevent@*__:expired
这个channel,那么只要key过期了,不管这个key是张三还会李四,消费者都能收到。
所以如果你只想消费某一类消息的key,那么还得自行加一些标记,比如消息的key加个前缀,消费的时候判断一下带前缀的key就是需要消费的任务。
所以,综上能够得出一个非常重要的结论,那就是监听Redis过期Key这种方式实现延迟队列,不稳定,坑贼多!
Redisson他是Redis的儿子(Redis son),基于Redis实现了非常多的功能,其中最常使用的就是Redis分布式锁的实现,但是除了实现Redis分布式锁之外,它还实现了延迟队列的功能。
先来个demo,后面再来说说这种实现的原理。
org.redisson
redisson
3.13.1
封装了一个RedissonDelayQueue
类
@Component
@Slf4j
public class RedissonDelayQueue {
private RedissonClient redissonClient;
private RDelayedQueue delayQueue;
private RBlockingQueue blockingQueue;
@PostConstruct
public void init() {
initDelayQueue();
startDelayQueueConsumer();
}
private void initDelayQueue() {
Config config = new Config();
SingleServerConfig serverConfig = config.useSingleServer();
serverConfig.setAddress("redis://localhost:6379");
redissonClient = Redisson.create(config);
blockingQueue = redissonClient.getBlockingQueue("BLOCK_QUEUE");
delayQueue = redissonClient.getDelayedQueue(blockingQueue);
}
private void startDelayQueueConsumer() {
new Thread(() -> {
while (true) {
try {
String task = blockingQueue.take();
log.info("接收到延迟任务:{}", task);
} catch (Exception e) {
e.printStackTrace();
}
}
}, "test-Consumer").start();
}
public void offerTask(String task, long seconds) {
log.info("添加延迟任务:{} 延迟时间:{}s", task, seconds);
delayQueue.offer(task, seconds, TimeUnit.SECONDS);
}
}
这个类在创建的时候会去初始化延迟队列,创建一个RedissonClient
对象,之后通过RedissonClient
对象获取到RDelayedQueue
和RBlockingQueue
对象,传入的队列名字,这个名字无所谓。
当延迟队列创建之后,会开启一个延迟任务的消费线程,这个线程会一直从RBlockingQueue
中通过take方法阻塞获取延迟任务。
添加任务的时候是通过RDelayedQueue
的offer方法添加的。
controller类,通过接口添加任务,延迟时间为5s
@RestController
public class RedissonDelayQueueController {
@Resource
private RedissonDelayQueue redissonDelayQueue;
@GetMapping("/add")
public void addTask(@RequestParam("task") String task) {
redissonDelayQueue.offerTask(task, 5);
}
}
启动项目,在浏览器输入如下连接,添加任务
http://localhost:8080/add?task=test
静静等待5s,成功获取到任务。
一个延迟队列会在Redis内部使用到的channel和数据类型
BLOCK_QUEUE前面的前缀都是固定的,Redisson创建的时候会拼上前缀。
redisson_delay_queue_timeout:BLOCK_QUEUE
,sorted set数据类型,存放所有延迟任务,按照延迟任务的到期时间戳(提交任务时的时间戳 + 延迟时间)来排序的,所以列表的最前面的第一个元素就是整个延迟队列中最早要被执行的任务,这个概念很重要redisson_delay_queue:BLOCK_QUEUE
,list数据类型,也是存放所有的任务,但是研究下来发现好像没什么用。。BLOCK_QUEUE
,list数据类型,被称为目标队列,这个里面存放的任务都是已经到了延迟时间的,可以被消费者获取的任务,所以上面demo中的RBlockingQueue
的take方法是从这个目标队列中获取到任务的redisson_delay_queue_channel:BLOCK_QUEUE
,是一个channel,用来通知客户端开启一个延迟任务redisson_delay_queue_timeout:BLOCK_QUEUE
中,分数就是提交任务的时间戳+延迟时间,就是延迟任务的到期时间戳https://mmbiz.qpic.cn/mmbiz_png/B279WL06QYyBo7IXtcS6SKLQ8kQeRRfuyvzrdF7IWZjrxia9icLLwibYJKiavAVXJzoEAjSUh1iccry3dMQMBgyYGiag/640?wx_fmt=png
这段lua脚本主要干了两件事:
redisson_delay_queue_timeout:BLOCK_QUEUE
中移除,存到BLOCK_QUEUE
这个目标队列redisson_delay_queue_timeout:BLOCK_QUEUE
中目前最早到过期时间的延迟任务的到期时间戳,然后发布到redisson_delay_queue_channel:BLOCK_QUEUE
这个channel中当客户端监听到redisson_delay_queue_channel:BLOCK_QUEUE
这个channel的消息时,会再次提交一个客户端延迟任务,延迟时间就是消息(最早到过期时间的延迟任务的到期时间戳)- 当前时间戳,这个时间其实也就是redisson_delay_queue_channel:BLOCK_QUEUE
中最早到过期时间的任务还剩余的延迟时间。
此处可以等待10s,好好想想。。
这样,一旦时间来到了上面说的最早到过期时间任务的到期时间戳,redisson_delay_queue_timeout:BLOCK_QUEUE
中上面说的最早到过期时间的任务已经到期了,客户端的延迟任务也同时到期,于是开始执行lua脚本操作,及时将到了延迟时间的任务放到目标队列中。然后再次发布剩余的延迟任务中最早到期的任务到期时间戳到channe中,如此循环往复,一直运行下去,保证redisson_delay_queue_timeout:BLOCK_QUEUE
中到期的数据能及时放到目标队列中。
所以,上述说了一大堆的主要的作用就是保证到了延迟时间的任务能够及时被放到目标队列。
这里再补充两个特殊情况,图中没有画出:
第一个就是如果redisson_delay_queue_timeout:BLOCK_QUEUE
是新添加的任务(队列之前有或者没有任务)是队列中最早需要被执行的,也会发布消息到channel,之后就按时上面说的流程走了。
添加任务代码如下,也是通过lua脚本来的
https://mmbiz.qpic.cn/mmbiz_png/B279WL06QYyBo7IXtcS6SKLQ8kQeRRfuxicvOZYmIOMvialWn0eON911ah30icVdWsbz8ZtD6wf3sgdj6a9IicZg0Q/640?wx_fmt=png
第二种特殊情况就是项目启动的时候会执行一次客户端延迟任务。项目在重启时,由于没有客户端延迟任务的执行,可能会出现redisson_delay_queue_timeout:BLOCK_QUEUE
队列中有到期但是没有被放到目标队列的可能,重启就执行一次就是为了保证到期的数据能被及时放到目标队列中。
Redis延迟队列的使用场景非常广泛,主要适用于那些需要延迟处理任务,但又对实时性有一定要求的场景。以下是一些典型的使用场景:
在Redis中实现延迟队列,Redisson的延迟队列、Sorted Set(有序集合)以及Redis的过期key机制是三种常见的方法。每种方法都有其独特的优缺点,适用于不同的场景和需求。以下是对这三种方法的详细比较:
优点:
缺点:
优点:
缺点:
优点:
缺点:
对于需要分布式支持和高级功能的场景,推荐使用Redisson实现延迟队列。Redisson提供了完善的分布式支持、易用的API和丰富的功能,能够满足大多数复杂场景的需求。
对于对性能有较高要求且不需要分布式支持的场景,可以考虑使用**Sorted Set(有序集合)**来实现延迟队列。Sorted Set的高效性和有序性使得它成为实现延迟队列的理想选择之一。
Redis的过期key机制虽然简单易用,但由于其实时性不足和缺乏灵活性,通常不建议直接用于实现延迟队列。不过,在某些简单的场景下,也可以考虑通过结合其他机制(如监听key过期事件)来模拟延迟队列的行为。