Redisson用于延时队列的选型与比较,附源码解析

1,018 阅读24分钟

项目背景

在电商、支付等系统中,一般都是先创建订单(支付单),再给用户一定的时间进行支付,如果没有按时支付的话,就需要把之前的订单(支付单)取消掉。这种类似的场景有很多,还有比如到期自动收货、超时自动退款、下单后自动发送短信等等都是类似的业务问题。因此,做了一次常见的中间件组件的汇总,供大家参考。

订单的到期关闭的实现有很多种方式,分别有:

  • 被动关闭(一般也要引入MQ做解耦修改订单状态)
  • 定时任务(推荐,适合时间精确度要求不高的场景)
  • DelayQueue(不推荐,基于内存,无法持久化)
  • 时间轮(不推荐,基于内存,无法持久化)
  • RocketMQ延迟消息(MQ方案不推荐,大量无效调度)
  • RabbitMQ死信队列(MQ方案不推荐,大量无效调度)
  • RabbitMQ插件(MQ方案不推荐,大量无效调度)
  • Redis的ZSet(不推荐,可能会重复消费)
  • Redisson(推荐,可以用)

一、实现方式介绍

1.1 被动关闭

队列是一种先进先出的数据结构,普通队列中的元素是有序的,先进入队列中的元素会被优先取出进行消费。

在解决这类问题的时候,有一种比较简单的方式,那就是通过业务上的被动方式来进行关单操作。简单点说,就是订单创建好了之后。我们系统上不做主动关单,什么时候用户来访问这个订单了,再去判断时间是不是超过了过期时间,如果过了时间那就进行关单操作,然后再提示用户。

优点:

这种做法是最简单的,基本不需要开发定时关闭的功能。

缺点:

  • 如果用户一直不来查看这个订单,那么就会有很多脏数据冗余在数据库中一直无法被关单。

  • 需要在用户的查询过程中进行写的操作,一般写操作都会比读操作耗时更长,而且有失败的可能,一旦关单失败了,就会导致系统处理起来比较复杂。

1.2 定时任务

优点:

这个方案的优点也是比较简单,实现起来很容易,基于Timer、ScheduledThreadPoolExecutor、或者像xxl-job 这类调度框架都能实现。

缺点:

  1. 时间不精准。一般定时任务基于固定的频率、按照时间定时执行的,那么就可能会发生很多订单已经到了超时时间,但是定时任务的调度时间还没到,那么就会导致这些订单的实际关闭时间要比应该关闭的时间晚一些。
  2. 无法处理大订单量。定时任务的方式是会把本来比较分散的关闭时间集中到任务调度的那一段时间,如果订单量比较大的话,那么就可能导致任务执行时间很长,整个任务的时间越长,订单被扫描到时间可能就很晚,那么就会导致关闭时间更晚。
  3. 对数据库造成压力。定时任务集中扫表,这会使得数据库在短时间内被大量占用和消耗,如果没有做好隔离,并且业务量比较大的话,就可能会影响到线上的正常业务。
  4. 分库分表问题。订单系统,一旦订单量大就可能会考虑分库分表,在分库分表中进行全表扫描,这是一个极不推荐的方案。

所以,定时任务的方案,适合于对时间精确度要求不高、并且业务量不是很大的场景中。如果对时间精度要求比较高,这种方案不适用。 但是一般来说,订单的到期关闭这种业务,对时间精确度要求并不高,所以定时任务也是使用的最广泛的一种方案。

1.3 JDK自带的DelayQueue

DelayQueue是一个无界的BlockingQueue,用于放置实现了Delayed接口的对象,其中的对象只能在其到期时才能从队列中取走。

基于延退队列,是可以实现订单的延退关闭的,首先,在用户创建订单的时候把订单加入到DelayQueue中,然后,还需要一个常驻任务不断的从队列中取出那些到了超时时间的订单,然后在把他们进行关单,之后再从队列中删除掉。

这个方案需要有一个线程,不断的从队列中取出需要关单的订单。一般在这个线程中需要加一个while(true)循环,这样才能确保任务不断的执行并且能够及时的取出超时订单

使用DelayQueue实现超时关单的方案,实现起来简单,不须要依赖第三方的框架和类库,JDK原生就支持了。

优点: 使用DelayQueue实现超时关单的方案,实现起来简单,不须要依赖第三方的框架和类库,JDK原生就支持了。

缺点:

  • 基于DelayQueue的话,需要把订单放进去,那如果订单量太大的话,可能会导致OOM的问题

  • DelayQueue是基于JVM内存的,一旦机器重启了,里面的数据就都没有了

所以,基于JDK的DelayQueue方案只适合在单机场景、并且数据量不大的场景中使用

1.4 Netty的时间轮

时间轮对比DelayQueue插入和删除操作的平均时间复杂度——O(nlog(n)),但是时间轮的方案可以将插入和删除操作的时间复杂度都降为O(1)。

时间轮可以理解为一种环形结构,像钟表一样被分为多个 slot。每个 slot 代表一个时间段,每个 slot 中可以存放多个任务,使用的是链表结构保存该时间段到期的所有任务。时间轮通过一个时针随着时间一个个 slot 转动,并执行 slot中的所有到期任务。

优缺点比较dealyQueue类似,实现简单,时间复杂度上面会更优秀。但是缺点是只适合在单机场景、数据量不大的场景中使用。

1.5 消息中间件实现延迟消息

引入消息中间件,当延迟消息写入到Broker后,不会立刻被消费者消费,需要等待指定的时长后才可被消费处理的消息,称为延时消息。

使用消息中间件之后,我们处理上就简单很多,只需要发消息,和接收消息就行了,系统之间完全解耦了。

1.5.1 RocketMQ

缺点: RocketMQ的延迟消息并不是支持任意时长的延迟的,它只支持:1s5s10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m30m1h2h这几个时长。 (商业版支持任意时长)

如果我们的业务上,关单时长刚好和RocketMQ延迟消息支持的时长匹配的话,那么是可以基于RocketMQ延迟消息来实现的。否则,这种方式并不是最佳的。

1.5.2 RabbitMQ死信队列

在RabbitMQ中,当一个消息变成死信之后,他就能被重新发送到死信队列中(交换机—exchange)。那么基于这样的机制,就可以实现延迟消息了。我们给一个消息设定TTL,但是并不消费这个消息,等他过期,过期后就会进入到死信队列,然后我们再监听死信队列的消息消费就行了。

优点: RabbitMQ中的这个TTL是可以设置任意时长的,这就解决了RocketMQ的不灵活的问题。

缺点: 死信队列的实现方式存在一个问题,那就是可能造成队头阻塞,如果死信队列中的队头的消息一直无法消费成功,那么就会阻塞整个队列,这时候即使排在他后面的消息过期需要处理了,那么也会被一直阻塞。

基于RabbitMQ的死信队列,可以实现延迟消息,非常灵活的实现定时关单,并且借助RabbitMQ的集群扩展性,可以实现高可用,以及处理大并发量。他的缺点第一是可能存在消息阻塞的问题,还有就是方案比较复杂,不仅要依赖RabbitMQ,而且还需要声明很多队列(exchange)出来,增加系统的复杂度。

1.5.3 RabbitMQ插件

基于RabbitMQ的话,可以不用死信队列也能实现延迟消息,那就是基于rabbitmq_delayed_message-exchange插件,这种方案能够解决通过死信队列 实现延迟消息出现的消息阻塞问题。但是该插件从RabbitMQ的3.6.12开始支持的,所以对版本有要求。

这个插件是官方出的,可以放心使用,安装并启用这个插件之后,就可以创建x—delayed—message类型的队列了。

基于死信队列的方式,是消息先会投递到一个正常队列,在TTL过期后进入死信队列。但是基于插件的这种方式,消息并不会立即进入队列,而是先把他们保存在一个基于Erlang开发的Mnesia数据库中,然后通过一个定时器去查询需要被投递的消息,再把他们投递到x—delayed—message队列中。

优点: 基于RabbitMQ插件的方式可以实现延迟消息,并且不存在消息阻塞的问题,但是这个插件他基于RabbitMQ实现,所以在可用性、性能方便都很不错

缺点 这个插件设计用于延迟消息发布数秒、数分钟或数小时。最多一两天。这不是一个长期的调度解决方案。如果需要将发布延迟几天、几周、几个月或几年,请考虑使用适合长期存储的数据存储和某种外部调度工具。

1.6 Redis的ZSET

zset是一个有序集合,每一个元素(member)都关联了一个score,可以通过score 排序来取集合中的值。

我们将订单超时时间的时间戳(下单时间+超时时长)与订单号分别设置为 score 和member。这样redis会对zset按照score延时时间进行排序。然后我们再开启redis扫描任务,获取“当前时间>score”的延时任务,扫描到之后取出订单号,然后查询到订单进行关单操作即可。

优点: redis数据结构比较熟悉,实现简单并且还具有完善的redis的持久化、高可用机制。

缺点: 那就是在高并发场景中,有可能有多个消费者同时获取到同一个订单号,一般采用加分布式锁解决,但是这样做也会降低吞吐型。

1.7 Redisson实现

Redisson是一个在Redis的基础上实现的框架,它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务。

Redisson中定义了分布式延迟队列RDelayedQueue,这是一种基于上面介绍过的zset结构实现的延时队列,它允许以指定的延迟时长将元素放到目标队列中。

其实就是在zset的基础上增加了一个基于内存的延迟队列。当我们要添加一个数据到延迟队列的时候,redisson会把数据+超时时间放到zset中,并且起一个延时任务,当任务到期的时候,再去zset中把数据取出来,返回给客户端使用。

二、 汇总比对

2.1 使用场景

1. 延时需求

在开发应用程序时,可能需要定期调度某些任务。例如,在商城中,可以使用Redisson来实现订单的延时任务,即当订单创建时,把订单ID放入Redis的延时队列中,在订单失效时间到达时,自动触发对应的失效操作。此外,还有用户注册后的欢迎邮件、每日的数据备份任务等等。

2. 流量控制

对于一些高并发场景,可能需要限制访问量,以保证系统的稳定性和安全性。使用延时队列可以很好地实现流量控制功能,将请求添加到队列中,并在一段时间内按照预设速率逐一处理请求,避免服务器瞬时访问量过高导致系统崩溃。

2.2 常用方案对比

方案集成优点缺点
JDK自带延时器1)JDK自带的Timer和TimerTask简单易用,适合小规模和简单任务的延时。
2)可以方便地控制任务的延时和周期性。
1)不支持分布式环境下的消息发送和消费。
2)当任务执行时间过长或者出现异常时,容易导致定时器线程崩溃,从而影响整个系统的稳定性。
3)自带的延时器可靠性较差,容易出现消息丢失的情况
Rabbitmq延时队列1)RabbitMQ是一种可靠的消息队列,通过持久性和可靠的传输,确保消息不会丢失。
2)延时队列是RabbitMQ的一个核心功能,通过使用RabbitMQ的死信队列来实现。使用RabbitMQ进行消息的延迟队列,可以确保消息的可靠性和延迟时间的稳定性。
3)有比较清晰的消息队列管理方式和监控工具,方便使用和维护。

1)RabbitMQ需要单独搭建MQ服务器,本身的易用度要依赖于rabbitMq的运维。所以复杂度和成本变高
2)RabbitMQ的需要进行额外配置,添加死信消息队列TTL和DLX(死信转发)
3)在已经使用kafka情况下,会浪费成本。
Redis+zset+TimerWheel1)集成简单,实现了集群环境下延迟队列
2)高性能,可处理大规模的延时任务
3)支持多个定时器共享内存,资源利用率高
1)Redis+zset+TimerWheel需要自己手动实现,配置和维护成本较高
2)TimerWheel算法决定了时间粒度由节点间隔确定,所有的任务的时间间隔需要以同样的粒度定义,比如时间间隔是1小时,则我们定义定时任务的单位就为小时,无法精确到分钟和秒
Redisson1)Redisson基于Redis,所以具有 Redis 功能使用的封装,功能齐全,拥有主从,集群等各种部署架构
2)Redisson 内置了延时队列功能,提供了灵活的参数配置和使用方式
3)Redisson 实现了分布式锁机制,使用了lua脚本保证原子性,在分布式环境下的消息处理更加可靠
Redisson 对 Redis 有依赖,需要对 Redis 做相应的配置和部署

2.2.2 性能比较

方案性能扩展性延时可靠性
JDK自带延时器小规模性能高DK自带的延时器扩展性有限,不适合大规模和高可用的场景支持秒级、毫秒级延时。可靠性差,不适合用于分布式场景
Rabbitmq延时队列较高RabbitMQ具有非常好的扩展性和灵活性,能够实现集群和高可用。支持秒级、毫秒级延时。RabbitMQ作为一款可靠的消息队列,通过持久化机制保证消息的可靠性和不重复消费。
Redis+zset+TimerWheel可以使用Redis的分布式进行分片、复制等,提高可扩展性。支持秒级、毫秒级延时。Redis+zset+TimerWheel基于Redis的数据持久化机制,有可靠性高、不丢失消息的特点。
RedissonRedisson 通过实现各种锁机制和队列机制,提供了很好的扩展性支持秒级、毫秒级延时。Redisson基于Redis的数据持久化机制,有可靠性高、不丢失消息的特点。

2.2.3 延时方案的对比

不同的场景中也适合不同的方案

  • 单体应用,业务量不大:Netty的时间轮、JDK自带的DelayQueue、定时任务
  • 分布式应用,业务量不大:RabbitMQ死信队列、Redis的zset、定时任务
  • 分布式应用,业务量大、并发高:Redisson、RabbitMQ插件、RocketMQ延迟消息、定时任务
  • 业务量特别大:定时任务

总体考虑的话,考虑到成本,方案完整性、以及方案的复杂度,还有用到的第三方框架的流行度来说,我们平台使用的是redission方法实现的延时关闭功能。

三、 Redisson简介

3.1 特性

1. 支持多种数据结构

Redisson 的延时队列支持多种数据结构,包括队列、优先队列、双端队列、阻塞队列等,具有更好的适配性和灵活性。

2. 高级定时功能

Redisson 的延时队列支持在任务未到期前取消、修改、查看任务,并提供了批量取消、批量查询等高级功能,使用更加便捷。

3. 多线程安全

Redisson 的延时队列是多线程安全的,能够确保在并发环境下执行准确无误。

4. 高性能

Redisson 的延时队列基于 Redis 实现,继承了 Redis 的高性能和高可靠性,并支持异步操作提升执行效率。

5. 高可靠性

Redisson 的延时队列具有高可靠性,当 Redis 断线或者发生异常时,可以保证任务的执行不会受到影响。

6. 高扩展性

Redisson 的延时队列支持分布式部署,并且能够快速扩展集群中的节点,支持高并发场景的需求。

7. 丰富的 API

Redisson 的延时队列接口简单易用,提供了丰富的 API,能够满足不同场景下的使用需求。

3.2 单节点模式下本地测试性能

本地线程数CPU数量内存添加1w条消息用时消费1w条消息用时QPS
4416g/1.51min76.3
8416g54秒55s181.8
12416g38秒38s263
16416g/28s357

3.3 多节点模式下本地测试性能

本地线程数CPU数量内存添加1w条消息用时消费1w条消息用时消费节点数
8416g54秒18s3个

四、 Redisson 实践

引入Redisson

        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson-spring-boot-starter</artifactId>
            <version>3.16.8</version>
        </dependency>

Redisson配置

    @Bean
    public RedissonClient redissonClient(RedisProperties redisProperties) throws IOException {

        Config config = new Config();
        //配置单Redis节点模式

        SingleServerConfig singleServerConfig = config.useSingleServer()
                .setAddress("redis://" + redisProperties.getHost() + ":" + redisProperties.getPort())
                .setPassword(redisProperties.getPassword())
                .setClientName(serverName)
                .setKeepAlive(true)
                //连接超时时间(毫秒)
                .setConnectTimeout(connectionTimeout)
                //Redis命令的执行超时时间(毫秒)
                .setTimeout(timeout);

        return Redisson.create(config);
    }

添加任务,延时10s添加到目标队列

 @Autowired
    private RedissonClient redissonClient;

    private ExecutorService executorService = Executors.newFixedThreadPool(12);


    public void addTaskToDelayQueue(String orderId) {
        // RBlockingDeque的实现类为:new RedissonBlockingDeque
        RBlockingDeque<String> blockingDeque = redissonClient.getBlockingDeque(RedissonKeyConstants.getRedissonKey(orderId));
        // RDelayedQueue的实现类为:new RedissonDelayedQueue
        RDelayedQueue<String> delayedQueue = redissonClient.getDelayedQueue(blockingDeque);
        log.info("创建订单号:{}的消息队列", orderId);
        for (int i = 0; i < 10000; i++) {
            final int ii = i;
            executorService.submit(new Runnable() {
                @Override
                public void run() {
                    String mission = "第" + ii + "个消息来自订单id:" + orderId;
                    delayedQueue.offer(mission, 10, TimeUnit.SECONDS);
                }
            });
        }
}


消费任务

    @Autowired
    private RedissonClient redissonClient;

    private ExecutorService executorService = Executors.newFixedThreadPool(8);

    public void testRedissonDelayQueue(String orderId) {

        RBlockingDeque<Object> blockingDeque = redissonClient.getBlockingDeque(RedissonKeyConstants.getRedissonKey(orderId));

        RDelayedQueue<Object> delayedQueue = redissonClient.getDelayedQueue(blockingDeque);

        for (int i = 0; i < 8; i++) {
            executorService.submit(new Runnable() {
                @Override
                public void run() {
                    while (true) {
                        Long oneStartTime = System.currentTimeMillis();
                        String message = null;
                        try {

                            message = blockingDeque.take().toString();

                            log.info(Thread.currentThread().getName() + "消费消息:" + message + "耗时:" + (System.currentTimeMillis() - oneStartTime) + "毫秒");

                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                }
            });

        }

    }

五、源码解析

5.1 源码

5.1.1 offer()

构建队列的时候,需要通过创建两个队列,最后添加value是从延时队列的offer方法,点进offer会发现最终都会调用offerAsync()这个方法,因此对此方法进行剖析。

public RFuture<Void> offerAsync(V e, long delay, TimeUnit timeUnit) {
    long delayInMs = timeUnit.toMillis(delay);
    //此处转为毫秒,并加上系统当前时间,计算出来的过期时间
    long timeout = System.currentTimeMillis() + delayInMs;
     //生成唯一标识:生成一个随机的长整型数值作为该任务的唯一标识。
    long randomId = PlatformDependent.threadLocalRandom().nextLong();
    //返回脚本,通过过evalWriteAsync方法将Lua脚本发送给Redis服务器执行,同时传递相关的键名和参数。
    return commandExecutor.evalWriteAsync(getName(), codec, RedisCommands.EVAL_VOID,
        
            "local value = struct.pack('dLc0', tonumber(ARGV[2]), string.len(ARGV[3]), ARGV[3]);" 
          + "redis.call('zadd', KEYS[2], ARGV[1], value);"
          + "redis.call('rpush', KEYS[3], value);"
          // if new object added to queue head when publish its startTime 
          // to all scheduler workers 
          + "local v = redis.call('zrange', KEYS[2], 0, 0); "
          + "if v[1] == value then "
             + "redis.call('publish', KEYS[4], ARGV[1]); "
          + "end;"
             ,
          Arrays.<Object>asList(getName(), timeoutSetName, queueName, channelName), 
          timeout, randomId, encode(e));
}

具体看脚本内容,剖析核心命令:

redis.call('zadd', KEYS[2], ARGV[1], value)

这条命令翻译过来就是:zadd timeoutSetName timeout value,timeoutSetName是存储超时时间的有序集合键。将value添加到有序集合timeoutSetName中,分数为ARGV[1] (超时时间)。

通过使用zadd命令将用户的延迟任务存放在Redis的SortedSet数据结构里面,score的值为延迟任务的过期时间。Redis的SortedSet会自动按照score(延迟任务的过期时间)从小到大自动排序,最先过期的任务排在最上面。

redis.call('rpush', KEYS[3], value)

将value添加到队列queueName的尾部。

 local v = redis.call('zrange', KEYS[2], 0, 0)

获取有序集合timeoutSetName中的第一个元素。

 if v[1] == value then redis.call('publish', KEYS[4], ARGV[1])

如果第一个元素等于value,则说明新元素位于队列头部,并且同时也说明之前队列里面的优先级有变化,此时向 Redis通道channelName发布超时时间timeout给所有订阅者,通知延迟任务的优先级发生变化了,可以来取新的延迟任务了。 至此,我们往延迟队列里面添加延迟任务的工作就已经彻底做完了。

5.1.2 take()

image.png

take()方法是从Redisson的RDelayedQueue延迟队列里面取出延迟任务的。getName()队列名字就是在创建阻塞队列中 redissonClient.getBlockingDeque(RedissonKeyConstants.getRedissonKey(orderId))这个方法里面传入的名字。这里用orderQueue代替,blpop orderQueue 0,0 代表不限时,一直阻塞下去。Redisson会把所有已经过期的任务,都存放在这个List里面,所以只有这个List里面有数据,就代表这个数据已经过期了,消费者可以消费了。Redisson往这个List里面放的时候使用的rpush命令,rpush命令的意思是往List的右边放。比如有A和B俩个任务,先放A,再放B,List里面的数据为:AB。但是注意先放进去的肯定是最先过期的,所以我们消费的时候要先消费A,再消费B。 而blpop这个命令就是从List的左边开始消费的。

5.1.3 队列的初始化作用分析

先写总结: 无论是调用offer还是take方法都要写上这两行代码,主要说一下作用是 防止客户端挂掉之后,缺少搬运者将【消息延时队列】里的到期数据移动到【消息目标队列】

再来看代码:

image.png

image.png

通过点进构造方法发现,构造方法内部实现了QueueTransferTask这个接口,其中需要实现两个接口方法, pushTaskAsync()getTopic()

为了避免混淆,解释下其中几个key的用途

KEYS[1]:orderQueue,阻塞队列,用于存储待处理的任务。

KEYS[2]:timeoutSetName,延时队列,用于存储带有延迟时间的任务。

KEYS[3]:queueName,辅助列表,用于记录已处理的任务。

各个键的作用

  • orderQueue: 将过期任务推入此队列。

  • timeoutSetName:获取过期任务,并从有序集合中移除这些任务。

  • queueName: 从这个列表中移除已处理的任务。

了解各个key的作用后,再来看lua脚本就很简单了。

5.1.3.1 pushTaskAsync()解析

具体看脚本内容,剖析核心命令:

redis.call('zrangebyscore', KEYS[2], 0, ARGV[1], 'limit', 0, ARGV[2]);

翻译过来为:zrangebyscore timeoutSetName 0 System.currentTimeMillis() limit 0 100。意思就是从timeoutSetName这个SortedSet数据结构里面中获取分数在0到系统当前时间的元素,限制一次最多只取100条数据。别忘了,我们之前调用offer方法存数据的时候,延迟任务就是存储在这里的,score的值我们当时存储的是延迟任务的过期时间。所以,如果score的值小于系统当前时间,说明这个延迟任务已经过期了,可以让消费者取出来了。

"if #expiredValues > 0 then  
    "for i, v in ipairs(expiredValues) do "
        "local randomId, value = struct.unpack('dLc0', v);"
        "redis.call('rpush', KEYS[1], value);"
         "redis.call('lrem', KEYS[3], 1, v);"
    "end; 
    "redis.call('zrem', KEYS[2], unpack(expiredValues));"
    "end; "

这段代码总体是检查是否有过期值,如果有,则遍历每个过期值,rpush orderQueue value就是将其解析后添加到队列中,这个List里面存放的全是已经过期的数据,take方法就是从这里获取数据的。过期的数据就是在这里放进去的,这个是最核心的代码了。第二条redis命令就是从queueName里面删除此数据。因为此处已经向阻塞队列添加完了元素,所以这里要在辅助队列中把它删除掉。直到过期值都被处理完毕此处循环才会结束。

循环结束之后,又执行了一条命令:zrem KEYS[2] unpack(expiredValues) => zrem timeoutSetName 所有取出来过期的数据,从SortedSet这个数据结构中删除掉。

"local v = redis.call('zrange', KEYS[2], 0, 0, 'WITHSCORES'); "
+ "if v[1] ~= nil then "
   + "return v[2]; "
+ "end "
+ "return nil;",

最后这段代码就是获取最早任务的时间:从有序集合timeoutSetName(KEYS[2])中获取第一个元素及其分数。

然后检查结果:如果存在第一个元素,则返回其分数;否则返回nil。

5.2 定时器的调用时机

image.png 顺着构造方法代码能看到最后调用到了schedule方法,此方法会触发任务的调度,在这个任务里面会动态的触发定时任务的执行,这些定时任务会在任务过期时调用pushTaskAsync()方法,执行上面的Redis命令,将过期数据放入目标延迟队列供消费者消费。

随着调用链,可以找到start()方法:

image.png

此处会在这里注册两个监听时间,从而触发pushTask方法;另一个onMessage会在收到消息时候触发scheduleTask方法。而pushTask实际上调用的就是上面我们解析过的pushTaskAsync(),调用此方法会返回最先过期的任务过期时间,并回调操作完成的结果,是用调度任务处理返回值,如下图:

image.png

解析我们最后核心的调度任务方法,scheduleTask(final Long startTime)方法:

//此处传入的就是pushTaskAsync()返回的最先过期的任务时间
private void scheduleTask(final Long startTime) {
TimeoutTask oldTimeout = lastTimeout.get();
if (startTime == null) {
    return;
}
//取消上一个定时任务
if (oldTimeout != null) {
    oldTimeout.getTask().cancel();
}
/**
此处大致逻辑:
如果任务过期时间减去当前系统时间大于10ms则说明任务没有过期,会创建一个定时任务,定时时间就是剩余的过期时间
*/
long delay = startTime - System.currentTimeMillis();
if (delay > 10) {
    Timeout timeout = connectionManager.newTimeout(new TimerTask() {                    
        @Override
        public void run(Timeout timeout) throws Exception {
            pushTask();
            
            TimeoutTask currentTimeout = lastTimeout.get();
            if (currentTimeout.getTask() == timeout) {
                lastTimeout.compareAndSet(currentTimeout, null);
            }
        }
    }, delay, TimeUnit.MILLISECONDS);
    if (!lastTimeout.compareAndSet(oldTimeout, new TimeoutTask(startTime, timeout))) {
        timeout.cancel();
    }
} else {
//如果小于10ms,就立刻调用pushTask方法去执行redis命令去搬运过期数据
    pushTask();
}

5.2.1 调用流程

QueueTransferTask这个类的start() 方法->

getTopic() 注册2个Listener监听事件: 1.onSubscribe(订阅监听)2.onMessage(消息监听) ->

addListener() 这个方法在添加订阅监听的同时也会主动去订阅一下,订阅完直接就触发onSubscribe监听事件了,然后就会调用一次pushTask方法去执行redis命令去搬运过期数据到阻塞队列,此处作用是避免延时队列中有以前没调度完的过期任务,把他全都一次性flush~。

至此,整个延时队列底层代码大致都梳理完毕了,我个人总结绘制了一个架构流程图,方便理解~

5.3 架构流程示意图

5.3.1 架构流程示意图

image.png

5.3.2 定时任务模块示意图

image.png

最后说一句(求关注,别白嫖我)

这篇是我在这里写的第一篇文章,源码读了两三天才读懂,其中有些不足之处希望各位看官可以给点意见~有问题的地方我会及时改正!

如果这篇文章对您有所帮助,或者有所启发的话,帮忙扫描下发二维码关注一下,您的支持是我坚持写作最大的动力。

求一键三连:点赞、分享、然后观看。