如何实现延迟任务?

1、延迟任务概述

延迟任务和定时任务的区别:

  • 定时任务:有固定周期的,有明确的触发时间
  • 延迟队列:没有固定的开始时间,它常常是由一个事件触发的,而在这个事件触发之后的一段时间内触发另一个事件,任务可以立即执行,也可以延迟。

业务场景:

  • 订单下单之后30分钟后,如果用户没有付钱,则系统自动取消订单;如果期间下单成功,任务取消
  • 接口对接出现网络问题,1分钟后重试,如果失败,2分钟重试,直到出现阈值终止
    下面介绍几种实现延迟任务的技术。

2、延迟任务实现技术

2.1、DelayQueue

JDK自带DelayQueue 是一个支持延时获取元素的阻塞队列, 内部采用优先队列 PriorityQueue 存储元素,同时元素必须实现 Delayed 接口;在创建元素时可以指定多久才可以从队列中获取当前元素,只有在延迟期满时才能从队列中提取元素。
在这里插入图片描述
DelayQueue属于排序队列,它的特殊之处在于队列的元素必须实现Delayed接口,该接口需要实现compareTo和getDelay方法:

  • getDelay方法:获取元素在队列中的剩余时间,只有当剩余时间为0时元素才可以出队列。
  • compareTo方法:用于排序,确定元素出队列的顺序。

2.1.1 实现示例

1:在测试包jdk下创建延迟任务对象DelayedTask,实现Delayed接口,重写compareTo和getDelay方法。
2:在main方法中创建DelayQueue并向延迟队列中添加三个延迟任务,
3:循环的从延迟队列中拉取任务

public class DelayedTask  implements Delayed{
    
    // 任务的执行时间,秒
    private int executeTime = 0;
    
    public DelayedTask(int delay){
        Calendar calendar = Calendar.getInstance();
        calendar.add(Calendar.SECOND,delay);
        this.executeTime = (int)(calendar.getTimeInMillis() /1000 );
    }

    /**
     * 元素在队列中的剩余时间
     * @param unit
     * @return
     */
    @Override
    public long getDelay(TimeUnit unit) {
        Calendar calendar = Calendar.getInstance();
        return executeTime - (calendar.getTimeInMillis()/1000);
    }

    /**
     * 元素排序,可以判断传入的delayed与当前的delayed排序先后关系
     * @param o
     * @return
     */
    @Override
    public int compareTo(Delayed o) {
        long val = this.getDelay(TimeUnit.NANOSECONDS) - o.getDelay(TimeUnit.NANOSECONDS);
        return val == 0 ? 0 : ( val < 0 ? -1: 1 );
    }


    public static void main(String[] args) {
        DelayQueue<DelayedTask> queue = new DelayQueue<DelayedTask>();
        queue.add(new DelayedTask(5));
        queue.add(new DelayedTask(10));
        queue.add(new DelayedTask(15));
        System.out.println(System.currentTimeMillis()/1000+" 定时任务开始 ");
        while(queue.size() != 0){
        // 弹出一个元素
            DelayedTask delayedTask = queue.poll();
            if(delayedTask !=null ){
                System.out.println(System.currentTimeMillis()/1000+" 消费了一个任务");
            }
            //每隔一秒消费一次
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }     
    }
}

DelayQueue虽然可以实现延迟任务,但是缺点也很明显:

  • 使用线程池或者原生DelayQueue程序,任务都是放在内存,挂掉之后需要考虑未处理消息的丢失带来的影响,如何保证数据不丢失,需要持久化(磁盘)。

2.2、MQ实现延迟任务

实现原理:
生产者在生产消息时,通过给消息设置消息存活时间,从而实现消息的延迟消费,当队列中的消息达到设置的存活时间时,就会转到死信队列,这时再通过消费者去消费私信对列中的消息即可。

  • TTL:Time To Live (消息存活时间)
  • 死信队列:Dead Letter Exchange(死信交换机),当消息成为Dead message后,可以重新发送另一个交换机(死信交换机)

示意图:
在这里插入图片描述

2.3、Redis实现延迟任务

Redis有五种数据类型:string,list,set,hash,zset;其中zset带分值的去重有序队列,使用zset数据类型的去重有序(分数排序)特点进行延迟;例如:时间戳作为分值score进行排序。
实现思路示意图:
在这里插入图片描述
思路解答:

  • 为什么任务需要存储在数据库中?

延迟任务是一个通用的服务,任何需要延迟得任务都可以调用该服务,需要考虑数据持久化的问题,存储数据库中是一种数据安全的考虑。

  • 为什么redis中使用两种数据类型,list和zset?

效率问题,算法的时间复杂度

  • 在添加zset数据的时候,为什么不需要预加载?

任务模块是一个通用的模块,项目中任何需要延迟队列的地方,都可以调用这个接口,要考虑到数据量的问题,如果数据量特别大,为了防止阻塞,只需要把未来几分钟要执行的数据存入缓存即可。
综上所述,使用Redis实现延迟任务是比较可靠的选择。

3、Redis实现延迟任务代码示例

按照2.3介绍的Redis实现延迟任务的思路,给出代码示例。

3.1、添加延迟任务

 /**
     * 把任务添加到redis中,已省略保存任务到数据库的步骤
     *
     * @param task
     */
    private void addTaskToCache(Task task) {
        String key = task.getTaskType() + "_" + task.getPriority();
        //获取5分钟之后的时间  毫秒值
        Calendar calendar = Calendar.getInstance();
        calendar.add(Calendar.MINUTE, 5);
        long nextScheduleTime = calendar.getTimeInMillis();
        //2.1 如果任务的执行时间小于等于当前时间,存入list
        if (task.getExecuteTime() <= System.currentTimeMillis()) {
            stringRedisTemplate.opsForList().leftPush( key, JSON.toJSONString(task));
        } else if (task.getExecuteTime() <= nextScheduleTime) {
            //2.2 如果任务的执行时间大于当前时间 && 小于等于预设时间(未来5分钟) 存入zset中
            stringRedisTemplate.opsForZSet().add( key, JSON.toJSONString(task),task.getExecuteTime());
        }
    }

3.2、消费任务

/**
     * 按照类型和优先级拉取任务
     * @return
     */
@Override
public Task poll(int type,int priority) {
    Task task = null;
    try {
        String key = type+"_"+priority;
        String task_json = stringRedisTemplate.opsForList().rightPop(key);
        if(StringUtils.isNotBlank(task_json)){
            task = JSON.parseObject(task_json, Task.class);
            .......此处省略执行任务相关代码
            //更新数据库信息为已执行
            updateDb(task.getTaskId(),ScheduleConstants.EXECUTED);
        }
    }catch (Exception e){
        e.printStackTrace();
        log.error("poll task exception");
    }
    return task;
}

3.3、待执行任务数据定时刷新

由于待执行的任务数据,是通过zset的数据结构保存到Redis,所以如何取出待执行的数据有以下两种方式:

3.3.1 批量查询:reids key值匹配

  • 方案1:keys 模糊匹配
    keys的模糊匹配功能很方便也很强大,但是在生产环境需要慎用!开发中使用keys的模糊匹配却发现redis的CPU使用率极高,redis是单线程,会被堵塞,所以有的公司的redis生产环境将keys命令禁用了。
  • 方案2:scan
    SCAN 命令是一个基于游标的迭代器,SCAN命令每次被调用之后, 都会向用户返回一个新的游标, 用户在下次迭代时需要使用这个新游标作为SCAN命令的游标参数, 以此来延续之前的迭代过程。
    在这里插入图片描述
    代码示例:
@Test
public void testKeys(){
// 模糊匹配
    Set<String> keys = stringRedisTemplate.keys("future_*");
    System.out.println(keys);
    // scan扫描主键
    Set<String> scan = stringRedisTemplate.execute((RedisCallback<Set<String>>) connection -> {
            Set<String> result = new HashSet<>();
            try (Cursor<byte[]> cursor = connection.scan(new ScanOptions.ScanOptionsBuilder()
                    .match(patten).count(10000).build())) {
                while (cursor.hasNext()) {
                    result.add(new String(cursor.next()));
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
            return result;
        });
    System.out.println(scan);
}

3.3.2 批量写入:Redis管道

普通redis客户端和服务器交互模式:
在这里插入图片描述
Pipeline请求模型:
在这里插入图片描述
官方测试结果数据对比:

  • 横坐标:数据量
  • 纵坐标:用时(毫秒)

在这里插入图片描述
代码示例:

//耗时6151 使用普通方式
@Test
public  void test1(){
    long start =System.currentTimeMillis();
    for (int i = 0; i <10000 ; i++) {
        Task task = new Task();
        task.setTaskType(1001);
        task.setPriority(1);
        task.setExecuteTime(new Date().getTime());
        stringRedisTemplate.opsForList().leftPush("1001_1", JSON.toJSONString(task));
    }
    System.out.println("耗时"+(System.currentTimeMillis()- start));
}

 //使用管道技术
@Test
public void test2(){
    long start  = System.currentTimeMillis();
    List<Object> objectList = stringRedisTemplate().executePipelined(new RedisCallback<Object>() {
        @Nullable
        @Override
        public Object doInRedis(RedisConnection redisConnection) throws DataAccessException {
            for (int i = 0; i <10000 ; i++) {
                Task task = new Task();
                task.setTaskType(1001);
                task.setPriority(1);
                task.setExecuteTime(new Date().getTime());
                redisConnection.lPush("1001_1".getBytes(), JSON.toJSONString(task).getBytes());
            }
            return null;
        }
    });
    System.out.println("使用管道技术执行10000次自增操作共耗时:"+(System.currentTimeMillis()-start)+"毫秒");
}

3.3.3 完成代码

综合上面介绍的scan扫描,和管道技术,完整的待执行任务数据定时刷新代码如下:

@Scheduled(cron = "0 */1 * * * ?")
public void refresh() {
    // 获取所有未来数据集合的key值
    Set<String> futureKeys = stringRedisTemplate.execute((RedisCallback<Set<String>>) connection -> {
            Set<String> result = new HashSet<>();
            try (Cursor<byte[]> cursor = connection.scan(new ScanOptions.ScanOptionsBuilder()
                    .match("future_" + "*").count(10000).build())) {
                while (cursor.hasNext()) {
                    result.add(new String(cursor.next()));
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
            return result;
        });
    for (String futureKey : futureKeys) { 
    	// 获取待执行任务list的key
        String listKey = ScheduleConstants.TOPIC + futureKey.split(ScheduleConstants.FUTURE)[1];
        //获取该组key下当前需要消费的任务数据,分值是小于当前时间的任务数据
        Set<String> tasks = stringRedisTemplate.opsForZSet().rangeByScore(futureKey, 0, System.currentTimeMillis());
        if (!tasks.isEmpty()) {
            //将这些任务数据添加到list中
            cacheService.refreshWithPipeline(futureKey, topicKey, tasks);
stringRedisTemplate.executePipelined(new RedisCallback<Object>() {
            @Nullable
            @Override
            public Object doInRedis(RedisConnection redisConnection) throws DataAccessException {
                StringRedisConnection stringRedisConnection = (StringRedisConnection)redisConnection;
                String[] strings = tasks.toArray(new String[tasks.size()]);
                stringRedisConnection.lPush(topicKey,strings);
              //  Redis Zrem 命令用于移除有序集中的一个或多个成员,不存在的成员将被忽略。
                stringRedisConnection.zRem(futureKey,strings);
                return null;
            }
        });
        }
    }
}

以上就是如何实现延迟任务的技术实现方式,介绍了比较多的Redis的技术运用,欢迎大家点赞收藏,评论区交流。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

TheChainsmokers-

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值