Bootstrap

延时任务调度设计

一、延迟队列介绍

延时任务的需求:

  • 生成订单30分钟未支付,则自动取消
  • 生成订单60秒后,给用户发短信

延时任务与定时任务区别:

  • 定时任务有明确的触发时间,比如在某个时刻执行,或者按照某个周期执行,延时任务没有
  • 定时任务一般执行的是批处理操作是多个任务,而延时任务一般是单个任务

二、延时队列设计方案

数据库轮询

比较简单的实现方式。所有的订单一般都会存储在数据库中,通过一个线程定时的去扫描数据库,通过订单时间来判断是否有超时的订单,然后进行update或delete等操作

优缺点:

  • 简单易行,使用相关开源框架,比如quartz,也可以支持集群操作
  • 对服务器内存消耗大
  • 存在延迟,比如你每隔5分钟扫描一次,那最坏的延迟时间就是5分钟,秒级调度实现可以减少延迟,但是数据量较大的情况,对数据库压力太大
使用DelayQueue

核心原理:BlockingQueue + PriorityQueue(优先级队列,堆排序)+ Delayed

  • DelayQueue中存放的对象需要实现compareTo()方法和getDelay()方法
  • getDelay方法返回该元素距离失效还剩余的时间,当<=0时元素就失效了,
    就可以从队列中获取到

代码:

//DelayQueue存放的元素对象,实现Delayed接口
public class OrderDelay implements Delayed {

    private String orderId;
    private long timeout;

    OrderDelay(String orderId, long timeout) {
        this.orderId = orderId;
        this.timeout = timeout + System.nanoTime();
    }

    //返回距离你自定义的超时时间还有多少
    @Override
    public long getDelay(TimeUnit unit) {
        return unit.convert(timeout - System.nanoTime(), TimeUnit.NANOSECONDS);
    }

    @Override
    public int compareTo(Delayed other) {
        if (other == this)
            return 0;
        OrderDelay t = (OrderDelay) other;
        long d = (getDelay(TimeUnit.NANOSECONDS) - t
                .getDelay(TimeUnit.NANOSECONDS));
        return (d == 0) ? 0 : ((d < 0) ? -1 : 1);
    }

    void print() {
        System.out.println(orderId+"编号的订单要删除啦。。。。");
    }
}

public class DelayQueueDemo {
    public static void main(String[] args) {
        SimpleDateFormat dateformat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        List<String> list = new ArrayList<String>();
        list.add("00000001");
        list.add("00000002");
        list.add("00000003");
        list.add("00000004");
        list.add("00000005");

        DelayQueue<OrderDelay> queue = new DelayQueue<OrderDelay>();

        long start = System.currentTimeMillis();

        for(int i = 0;i<5;i++){
            //延迟三秒取出
            queue.put(new OrderDelay(list.get(i),
                    TimeUnit.NANOSECONDS.convert(3, TimeUnit.SECONDS)));
            try {
                queue.take().print();
                System.out.println("After " +
                        dateformat.format(System.currentTimeMillis()-start) + " MilliSeconds");
            } catch (InterruptedException e) {
                // TODO Auto-generated catch block
                e.printStackTrace();
            }
        }
    }
}

JDK ScheduledExecutorService创建任务调度线程池,ScheduledThreadPoolExecutor内部对DelayQueue和优先级队列进行封装,扩展加强多线程任务调度部分功能,也可以使用这种方式。

优缺点:

  • 优点:效率高,任务触发时间延迟低。
  • 服务器重启后,数据全部消失,怕宕机
  • 集群扩展相当麻烦
  • 因为内存条件限制的原因,比如下单未付款的订单数太多,那么很容易就出现OOM异常
  • 代码复杂度较高
时间轮算法

时间轮是一种非常惊艳的数据结构。其在Linux内核中使用广泛,是Linux内核定时器的实现方法和基础之一

按使用场景,大致可以分为两种时间轮:原始时间轮和分层时间轮。分层时间轮是原始时间轮的升级版本,来应对时间“槽”数量比较大的情况,对内存和精度都有很高要求的情况。我们延迟任务的场景一般只需要用到原始时间轮就可以了。

原始时间轮:如图一个轮子,有8个“槽”,可以代表未来的一个时间。如果以秒为单位,中间的指针每隔一秒钟转动到新的“槽”上面,就好像手表一样。如果当前指针指在1上面,我有一个任务需要4秒以后执行,那么这个执行的线程回调或者消息将会被放在5上。那如果需要在20秒之后执行怎么办,由于这个环形结构槽数只到8,如果要20秒,指针需要多转2圈。位置是在2圈之后的5上面(20 % 8 + 1)。这个圈数需要记录在槽中的数据结构里面。这个数据结构最重要的是两个指针,一个是触发任务的函数指针,另外一个是触发的总第几圈数。时间轮可以用简单的数组或者是环形链表来实现

相关名词解释:

  • 时间格:环形结构中用于存放延迟任务的区块;
  • 指针(CurrentTime):指向当前操作的时间格,代表当前时间
  • 格数(ticksPerWheel):为时间轮中时间格的个数
  • 间隔(tickDuration):每个时间格之间的间隔
  • 总间隔(interval):当前时间轮总间隔,也就是等于ticksPerWheel*tickDuration

相比DelayQueue的数据结构,时间轮在算法复杂度上有一定优势。DelayQueue由于涉及到排序,需要调堆,插入和移除的复杂度是O(lgn),而时间轮在插入和移除的复杂度都是O(1)。

Netty时间轮开源实现:

public class NettyHashedWheel {
    public static void main(String[] args) {
         Timer timer = new HashedWheelTimer();
         
         timer.newTimeout(timeout -> System.out.println("定时器1:10s后执行"), 10, TimeUnit.SECONDS);

         timer.newTimeout(timeout -> System.out.println("定时器2:5s后执行"), 5, TimeUnit.SECONDS);

         timer.newTimeout(timeout -> System.out.println("定时器3:10s后执行"), 10, TimeUnit.SECONDS);
    }
}

在使用HashedWheelTimer的过程中,延迟任务的实现最好使用异步的,HashedWheelTimer的任务管理和执行都在一个线程里面。如果任务比较耗时,那么指针就会延迟,导致整个任务就会延迟。集群扩展比较麻烦,数据也都是保存在内存中,不能宕机,同时因为内存空间有限,如果数据量较大,会给内存带来比较大的压力。

Redis zset

Redis中的ZSet是一个有序的Set,内部使用HashMap和跳表(SkipList)来保证数据的存储和有序,HashMap里放的是成员到score的映射,而跳跃表里存放的是所有的成员,排序依据是HashMap里存的score,使用跳跃表的结构可以获得比较高的查找效率,并且在实现上比较简单

思路:

  • 将订单超时时间戳与订单号分别设置为score和value,利用Sorted Set天然的排序特性,执行时刻越早的会排在越前面
  • 开起一个或多个定时线程,每隔一段时间去查一下这个Sorted Set中score小于或等于当前时间戳的元素(使用zrangebyscore获取最小值),再执行元素对应的任务即可
  • 为了避免任务重复执行,即多个线程拿到相同任务,通过zrem命令来实现,只有删除成功了,才能执行任务

代码实现:

//生产者
public class DelayTaskProducer {

    public void produce(String newsId,long timeStamp){
        Jedis client = RedisClient.getClient();
        try {
            client.zadd(Constants.DELAY_TASK_QUEUE,timeStamp,orderId);
        }finally {
            client.close();
        }
    }
}

将订单号和时间戳放到zset中

public class DelayTaskConsumer {

    private ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();

    public void start(){
        scheduledExecutorService.scheduleWithFixedDelay(new DelayTaskHandler(),1,1, TimeUnit.SECONDS);
    }

    public static class DelayTaskHandler implements Runnable{

        @Override
        public void run() {
            Jedis client = RedisClient.getClient();
            try {
                Set<String> ids = client.zrangeByScore(Constants.DELAY_TASK_QUEUE, 0, System.currentTimeMillis(),
                        0, 1);
                if(ids==null||ids.isEmpty()){
                    return;
                }
                for(String id:ids){
                    Long count = client.zrem(Constants.DELAY_TASK_QUEUE, id);
                    if(count!=null&&count==1){
                        System.out.println(MessageFormat.format("删除订单。id - {0} , timeStamp - {1} , " +
                                "threadName - {2}",id,System.currentTimeMillis(),Thread.currentThread().getName()));
                    }
                }
            }finally {
                client.close();
            }
        }
    }
}

首先看start方法。在这个方法里面我们利用Java的ScheduledExecutorService开了一个调度线程池,这个线程池会每隔1秒钟调度DelayTaskHandler中的run方法。

DelayTaskHandler类就是具体的调度逻辑了。主要有2个步骤,一个是从Redis Sorted Set中拉取到期的延时任务,另一个是执行到期的延时任务。拉取到期的延时任务是通过zrangeByScore命令实现的,处理多线程并发问题是通过zrem命令实现的

public class DelayTaskTest {

    public static void main(String[] args) {
        DelayTaskProducer producer=new DelayTaskProducer();
        long now=new Date().getTime();
        System.out.println(MessageFormat.format("start time - {0}",now));
        producer.produce("1",now+ TimeUnit.SECONDS.toMillis(5));
        producer.produce("2",now+TimeUnit.SECONDS.toMillis(10));
        producer.produce("3",now+ TimeUnit.SECONDS.toMillis(15));
        producer.produce("4",now+TimeUnit.SECONDS.toMillis(20));
        for(int i=0;i<10;i++){
            new DelayTaskConsumer().start();
        }
    }

}

任务确实能够在相应的时间点左右被执行,不过有少许时间误差,这个是因为我们拉取到期任务是通过定时任务拉取而不是实时推送的,而且拉取任务时有一部分网络开销,再者,我们的任务处理逻辑是同步处理的,需要上一次的任务处理完,才能拉取下一批任务,可以把任务处理与调度切割开,执行不影响调度。

RabbitMQ延时队列
  • RabbitMQ可以针对Queue和Message设置 x-message-tt,来控制消息的生存时间,如果超时,则消息变为dead letter
  • RabbitMQ的Queue可以配置x-dead-letter-exchange 和x-dead-letter-routing-key(可选)两个参数,用来控制队列内出现了deadletter,则按照这两个参数重新路由

利用RabbitMQ的分布式特性轻易的进行横向扩展,消息支持持久化增加了可靠性。本身的易用度要依赖于rabbitMq的运维.因为要引用rabbitMq,所以复杂度和成本变高

小结

分布式任务调度系统开源组件众多,比如阿里的分布式任务调度服务SchedulerX,有赞延迟队列设计方案,爱奇艺任务调度服务JCrontab,可以参考设计。

参考

https://www.cnblogs.com/rjzheng/p/8972725.html

;