延时任务解决方案

导读

首发于公众号:JAVA大贼船,原创不易,喜欢的读者可以关注一下哦!一个分享java学习资源,实战经验和技术文章的公众号!

前言

延时任务是指把需要延迟执行的任务叫做延迟任务。如红包 24 小时未被查收,需要延迟执退还业务;每个月账单日,需要给用户发送当月的对账单;订单下单之后30分钟未支付,系统自动取消订单。本文以订单下单超时支付为例。

DelayQueue处理延时订单

PriorityQueue(优先级队列)

在认识DelayQueue之前,我们得先了解PriorityQueue,这里只是简单聊一下,方便后面理解DelayQueue。

优先级队列的原理

PriorityQueue采用基于数组的平衡二叉堆实现,不论入队的顺序怎么样,take、poll出队的节点都是按优先级排序的。

PriorityQueue队列中的所有元素并不是在入队之后就已经全部按优先级排好序了,而是只保证head节点即队列的首个元素是当前最小或者说最高优先级的,其它节点的顺序并不保证是按优先级排序的。

PriorityQueue队列只会在通过take、poll取走head之后才会再次决出新的最小或者说最高优先级的节点作为新的head,其它节点的顺序依然不保证。所以通过peek拿到的head节点就是当前队列中最高优先级的节点!

DelayQueue简介

延迟队列DelayQueue是一个无界阻塞队列,它的队列元素只能在该元素的延迟已经结束或者说过期才能被出队,即是该队列只有在延迟期满的时候才能从中获取元素。

学习DelayQueue需要了解以下两个问题:

  • 它怎么判断一个元素的延迟是否结束呢?
  • 怎么按照延迟时间短优先出列
判断一个元素的延迟是否结束

DelayQueue队列元素必须是实现了Delayed接口的实例,该接口有一个getDelay方法需要实现,延迟队列就是通过实时的调用元素的该方法来判断当前元素是否延迟已经结束.

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gLV4ETLb-1597737178123)(https://imgkr2.cn-bj.ufileos.com/25e2fd79-0327-45cd-af1e-f2e375c703bc.png?UCloudPublicKey=TOKEN_8d8b72be-579a-4e83-bfd0-5f6ce1546f13&Signature=dAmW0ZQzfnpjX9U4Su3M2Z3ulWc%253D&Expires=1597822197)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cbFlm84t-1597737178126)(https://imgkr2.cn-bj.ufileos.com/a670eef3-8f06-4760-b48c-0ac4079d2dec.png?UCloudPublicKey=TOKEN_8d8b72be-579a-4e83-bfd0-5f6ce1546f13&Signature=K9bh1UitbichPvqYwJ8153TG3MA%253D&Expires=1597822221)]

按照延迟时间短优先出列

DelayQueue就是基于PriorityQueue实现的,DelayQueue队列实际上就是将队列元素保存到内部的一个PriorityQueue实例中的。这就是为什么要简单聊一下PriorityQueue,如图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-O08NU2oP-1597737178130)(https://imgkr2.cn-bj.ufileos.com/4860574d-1b4d-4816-984c-ac989cca8d83.png?UCloudPublicKey=TOKEN_8d8b72be-579a-4e83-bfd0-5f6ce1546f13&Signature=JZ5o%252FQxWKVQ1WuOIcs4bEA3%252B7%252FY%253D&Expires=1597822242)]

既然DelayQueue是基于优先级队列来实现的,那么队列元素也要实现Comparable接口,这是因为Delayed接口继承了Comparable接口(可以看上上图),所以实现Delayed的队列元素也必须要实现Comparable的compareTo方法。

延迟队列就是以时间作为比较基准的优先级队列,基于延迟时间运用优先级队列并配合getDelay方法达到延迟队列中的元素在延迟结束时精准出队。

代码示例

实现Delayed接口
package com.ao.delaydemo.task;

import lombok.extern.slf4j.Slf4j;

import java.time.LocalDateTime;
import java.util.concurrent.Delayed;
import java.util.concurrent.TimeUnit;

@Slf4j
public class OrderDelay implements Delayed{
    private String orderId = "";
    private long start = 0;

    public OrderDelay(String orderId, long delayInMilliseconds){
        this.orderId = orderId;
        this.start = System.currentTimeMillis() + delayInMilliseconds;
    }

    /*getDelay 用于判断任务是否到期,如果是返回-1 表示任务已经到期*/
    @Override
    public long getDelay(TimeUnit unit) {
        long diff = this.start - System.currentTimeMillis();
//        返回距离你自定义的超时时间还有多少,延迟剩余时间,单位unit指定
        return unit.convert(diff, TimeUnit.MILLISECONDS);
    }

    @Override
    public int compareTo(Delayed o) {
        return (int)(this.getDelay(TimeUnit.MILLISECONDS) - o.getDelay(TimeUnit.MILLISECONDS));
    }

    /*模拟处理状态为已提交未支付的订单*/
    public void orderHandle(){
        log.info("系统在" + LocalDateTime.now() +"---处理延时任务---订单超时未付款---" + orderId);
    }


}

处理延时任务服务类

这里注意init()方法加了@PostConstruct注解,服务启动的时候就开始监听这个延时队列有没有任务。

通过take()或poll()获取一个任务,其中Poll():获取并移除队列的超时元素,没有则返回空;take():获取并移除队列的超时元素,如果没有则wait当前线程,直到有元素满足超时条件,返回结果。

package com.ao.delaydemo.service;

import com.ao.delaydemo.task.OrderDelay;
import com.ao.delaydemo.task.OrderTask;
import com.sun.org.apache.xml.internal.security.Init;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import java.time.LocalDateTime;
import java.util.concurrent.DelayQueue;
import java.util.concurrent.Executors;

@Component
@Slf4j
public class TaskService {
    private TaskService taskService;
    // 创建延时队列
    private  DelayQueue<OrderDelay> orderDelayQueue =  new DelayQueue<OrderDelay>();

    @PostConstruct
    private void init() {
        taskService = this;
        Executors.newSingleThreadExecutor().execute(new Runnable() {
            @Override
            public void run() {
                while (true){
                    try {
                        orderDelayQueue.take().orderHandle();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }

            }
        });
    }

    public void addTask(OrderDelay task){
        if(orderDelayQueue.contains(task)){
            return;
        }
        orderDelayQueue.add(task);
    }

    public void removeTask(OrderDelay task){
        orderDelayQueue.remove(task);
    }


}

编写测试controller

这里设置过期时间为3s。

package com.ao.delaydemo.web;

import com.ao.delaydemo.service.TaskService;
import com.ao.delaydemo.task.OrderDelay;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.time.LocalDateTime;
import java.util.ArrayList;


@Slf4j
@RestController
public class OrderController {
    @Autowired
    private TaskService taskService;

    @GetMapping("/submit")
    public void submit(){
        ArrayList<String> list = new ArrayList<>();
        list.add("订单10001");
        list.add("订单10002");
        list.add("订单10003");
        list.add("订单10004");
        list.add("订单10005");

        for (int i=0 ; i < list.size(); i++){
            taskService.addTask(new OrderDelay(list.get(i),3*1000));
            log.info(list.get(i)+"在"+ LocalDateTime.now() +"加入了延时任务");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

}

测试

访问http://localhost:10024/submit,查看控制台可以看到订单在3秒后执行了

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TH6cmbsZ-1597737178134)(https://imgkr2.cn-bj.ufileos.com/3e1a7a34-b924-49cb-8a8c-99e48c92640a.png?UCloudPublicKey=TOKEN_8d8b72be-579a-4e83-bfd0-5f6ce1546f13&Signature=R7WNCvt50drbgcaRVAYer3e9uYA%253D&Expires=1597822265)]

服务重启问题

因为延迟队列没有做持久化,那么服务重启之后,原来在队列的任务就丢失啦。所以,服务重启的时候要去扫描检测订单。

  • 已经过期了,添加到延迟队列,过期时间为0
  • 还没过期,添加到延迟队列,过期时间 = 订单创建时间+订单的延迟时间(本例就规定了是3s)- 当前时间

ApplicationRunner执行时机为容器启动完成的时候,实现run方法即可。或使用InitializingBean接口。

@Component
public class TaskStartupRunner implements ApplicationRunner {

    @Autowired
    private OrderService orderService;
    @Autowired
    private TaskService taskService;

    @Override
    public void run(ApplicationArguments args) throws Exception {
        /*查询已下单未支付的订单*/
        List<Order> orderList = orderService.queryUnpay();
        for(Order order : orderList){
            LocalDateTime add = order.getAddTime();
            LocalDateTime now = LocalDateTime.now();
            LocalDateTime expire =  add.plusMinutes(SystemConfig.getOrderDelayTime());
            if(expire.isBefore(now)) {
                // 已经过期,则加入延迟队列
                taskService.addTask(new OrderDelay(order.getId(), 0));
            }
            else{
                // 还没过期,则加入延迟队列
                long delay = ChronoUnit.MILLIS.between(now, expire);
                taskService.addTask(new OrderDelay(order.getId(), delay));
            }
        }
    }
}

使用Timer处理延时订单

当设置的延迟时间到了执行orderHandle方法

@Slf4j
public class OrderTimer {

    public static void main(String[] args) {
        ArrayList<String> list = new ArrayList<>();
        list.add("订单10001");
        list.add("订单10002");
        list.add("订单10003");
        list.add("订单10004");
        list.add("订单10005");

        for (int i=0; i < list.size();i++){
            String orderId = list.get(i);
            log.info(orderId+"在"+ LocalDateTime.now() +"加入了延时任务");
            Timer timer = new Timer();
            timer.schedule(new TimerTask() {
                @Override
                public void run() {
                    // 处理延时订单
                    orderHandle(orderId);
                }
            }, (3* 1000));
        }

    }

    /*模拟处理状态为已提交未支付的订单*/
    public static void orderHandle(String orderId){
        log.info("系统在" + LocalDateTime.now() +"---处理延时任务---订单超时未付款---" + orderId);
    }
}

测试

可以看到都是延迟3秒后执行
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vIihBRnu-1597737178142)(https://imgkr2.cn-bj.ufileos.com/858a1654-259a-44d9-b840-c0bf865dc545.png?UCloudPublicKey=TOKEN_8d8b72be-579a-4e83-bfd0-5f6ce1546f13&Signature=%252B6hHTvLBq4LwoV8qA7hNduLTYm8%253D&Expires=1597822307)]

服务重启问题

业务逻辑可参考上文delayQueue的服务重启问题。

存在问题

1、Timer在执行所有定时任务时只会创建一个线程。如果某个任务的执行时间长度大于其周期时间长度,那么就会导致这一次的任务还在执行,而下一个周期的任务已经需要开始执行了,当然在一个线程内这两个任务只能顺序执行,有两种情况:对于之前需要执行但还没有执行的任务,一是当前任务执行完马上执行那些任务(按顺序来),二是干脆把那些任务丢掉,不去执行它们。

2、Timer线程是不会捕获异常的,如果TimerTask抛出了未检查异常则会导致Timer线程终止,同时Timer也不会重新恢复线程的执行,他会错误的认为整个Timer线程都会取消。同时,已经被安排单尚未执行的TimerTask也不会再执行了,新的任务也不能被调度。故如果TimerTask抛出未检查的异常,Timer将会产生无法预料的行为。

针对上述问题,我们可以使用ScheduledExecutorService替代。但是由于ScheduledExecutorService是多线程处理,即不同任务会被分放到其线程池中的不同线程,因此当订单数据量稍微增长,随着线程的消耗,就容易出现无可用线程池甚至内存溢出等异常。

Redis之zset处理延时订单

zset的相关操作

利用redis的zset,zset是一个有序集合,每一个元素(member)都关联了一个score,通过score排序来取集合中的值。在代码演示之前先看一下zset的相关操作,如下:

#添加元素:ZADD key score member [[score member] [score member]]

redis:0>zadd order 10 10001

"1"

redis:0>zadd order 10 10002 9 10003

"2"

#按顺序查询元素:ZRANGE key start stop [WITHSCORES]

redis:0>zrange order 0 -1 withscores

 1)  "10003"

 2)  "9"

 3)  "10001"

 4)  "10"

 5)  "10002"

 6)  "10"

#查询元素score:ZSCORE key member

redis:0>zscore order 10001

"10"

#移除元素:ZREM key member [member …]

redis:0>zrem order 10001

"1"

那么怎么去处理延时订单呢?我们可以将订单超时时间戳与订单号分别设置为score和member,系统扫描第一个元素判断是否超时。在handle()里面如果当前的时间大于等于订单的过期时间,我们才处理订单的业务逻辑。

代码演示

public class ZsetTest {
    private static final String HOST = "xxxx";
    private static final int PORT = 6379;
    private static final String PASSWORD = "xxxxx";
    private static JedisPool jedisPool = new JedisPool(new GenericObjectPoolConfig(),HOST, PORT,3000,PASSWORD);

    public static Jedis getJedis() {
        return jedisPool.getResource();

    }

    /*模拟用户提交了订单*/
    public void submit(){
        for(int i=10001;i<10005;i++){
            //延迟3秒
            Calendar now = Calendar.getInstance();
            now.add(Calendar.SECOND, 3);
            int start = (int) (now.getTimeInMillis() / 1000);
            ZsetTest.getJedis().zadd("OrderId", start,"orderId:"+i);
            System.out.println(LocalDateTime.now() +"-redis加入了一个订单任务:订单ID为"+i);
        }
    }

    /*系统处理超时订单*/
    public void handle(){
        Jedis jedis = ZsetTest.getJedis();
        while(true){
            Set<Tuple> items = jedis.zrangeWithScores("OrderId", 0, 1);
            if(items == null || items.isEmpty()){
                continue;
            }
            int  score = (int) ((Tuple)items.toArray()[0]).getScore();
            Calendar now = Calendar.getInstance();
            int nowSecond = (int) (now.getTimeInMillis() / 1000);
            if(nowSecond >= score){
                String orderId = ((Tuple)items.toArray()[0]).getElement();
                jedis.zrem("OrderId", orderId);
                System.out.println(LocalDateTime.now() +"--redis处理了一个任务:处理的订单OrderId为"+orderId);
            }
        }
    }

    public static void main(String[] args) {
        ZsetTest ZsetTest =new ZsetTest();
        ZsetTest.submit();
        ZsetTest.handle();
    }

}
测试

可以看到结果是符合预期的

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TrIjTqf3-1597737178144)(https://imgkr2.cn-bj.ufileos.com/5bb4d02d-fa86-47fe-9cb1-58005a20ff2c.png?UCloudPublicKey=TOKEN_8d8b72be-579a-4e83-bfd0-5f6ce1546f13&Signature=%252F7O2EeMFSBT8umwS1Kk0Kx6Yefo%253D&Expires=1597822329)]

高并发存在的问题

上面的代码看着结果没有问题,在高并发下就会存在一条记录被消费多次。下面用CountDownLatch演示一下,小扩展一下CountDownLatch的知识点,有点类似join(),相比CountDownLatch会更灵活。

CountDownLatch中主要用到两个方法一个是await()方法,调用这个方法的线程会被阻塞,
另外一个是countDown()方法,调用这个方法会使计数器减一,当计数器的值为0时,因调用await()方法被阻塞的线程会被唤醒,继续执行。

代码演示
public class CountDownLatchTest {
    private static final int threadCount = 5;
    private static CountDownLatch countDownLatch = new CountDownLatch(threadCount);
    static class orderClose implements Runnable{
        public void run() {
            try {
                //调用await()方法的线程会被挂起,它会等待直到count值为0才继续执行
                countDownLatch.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            ZsetTest zsetTest =new ZsetTest();
            zsetTest.handle();
        }
    }

    public static void main(String[] args) {
        ZsetTest zsetTest =new ZsetTest();
        zsetTest.submit();
        for(int i=0;i<threadCount;i++){
            new Thread(new orderClose()).start();
            //将count值减1
            countDownLatch.countDown();
        }
    }

}
测试

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kVqWYJeC-1597737178146)(https://imgkr2.cn-bj.ufileos.com/184bfa37-af0c-4daf-83dc-24767e5a6270.png?UCloudPublicKey=TOKEN_8d8b72be-579a-4e83-bfd0-5f6ce1546f13&Signature=c%252BPOXHnyKIH0h09hJHNfMUcv69o%253D&Expires=1597822345)]

解决方案

handle()方法中修改一下,当从redis删除对应的订单号成功之后才进行订单超时业务逻辑。

  			if(nowSecond >= score){
                String orderId = ((Tuple)items.toArray()[0]).getElement();
                Long num = jedis.zrem("OrderId", orderId);
                if (num != null && num > 0){
                    System.out.println(LocalDateTime.now() +"--redis处理了一个任务:处理的订单OrderId为"+orderId);
                }
            }
测试

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FrdUK0gj-1597737178147)(https://imgkr2.cn-bj.ufileos.com/b86dbb61-d7bf-4ebc-a9d2-80a6396c5212.png?UCloudPublicKey=TOKEN_8d8b72be-579a-4e83-bfd0-5f6ce1546f13&Signature=Vy4BAXNXplcLBFPeYqThqhxQjCw%253D&Expires=1597822358)]

扩展

还可以用redis键空间机制分布式锁解决。

Rabbitmq之延迟队列处理延时订单

rabbitmq之springboot详细介绍可看我这篇文章传送门,这里可直接看文末最后的延迟队列
在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值