【笔记】下单但未支付的订单倒计时自动取消逻辑实现_订单倒计时实现方案-程序员宅基地

技术标签: java  笔记/经验  分布式  

平常我们都用过淘宝,京东这些电商平台,同时肯定也在这些平台上面下过单,这种情况不保证大家都有遇到过,但做开发的,肯定也知道有这个环节的存在:确认货品配置无误之后,我们都会点击购买,随之而来的就是一个结算页,让你确认商品信息、收货地址、价格等信息,但是如果这个时候,我们退出这个结算页,那此时,这个订单就属于生成但未支付的状态,对应的电商平台就会对这样的订单做延时任务取消操作,这样就可以稳定商品库存和提供用户一定的犹豫时间,那从技术的角度来看,这个延时任务是怎么实现的?


疑问1

看着上面的案例,有的人会问:这个简单,我们直接用定时任务,做个半小时后的查询数据库来判断是该取消了还是怎么样不就行了吗?

解答

其实这个场景,我们就不能说是定时任务了,准确来说是延时任务,那什么是延时呢?延时就是从一个点开始后的多久后再执行,比方说:火车八点到站,但是晚点十分钟,是那就是八点后的十分钟

疑问2

上面的解答,概念有点模棱两可,和定时任务不是特别能区分开,那接下来我们就介绍一下定时和延时任务的区别


定时和延时区别

1、定时任务有明确的触发事件,而延迟任务没有

举例:火车行程表
进入火车站,我们都会看到大屏上显示各车次的出发时间、始发地、终点站等等信息

定时任务:火车半小时一列,出发时间就是每天0点开始算,0:30一班,1:00一班,
哪怕第一趟火车0:10到站,那第二趟火车依旧是0:30到站

延时任务:第二趟火车是在第一趟火车到站之后的半小时后一班,比如第一班火车晚点了,
是0:10分到站,那第二趟火车就是0:40到站,由前面那趟火车到站时间为起点后的半小时作为第二趟火车的到站时间

2、定时任务有执行周期,而延时任务是在条件满足之后的一段时间后再执行,没有执行周期

举例:站岗换班

定时任务:晚上22点开始站岗,每半小时换一次人,那接下来就是22:30、23:00、23:30...没半小时换一个人,周期是固定的,半小时一换,硬性要求,半小时必换班

延时任务:同样是晚上22点开始站岗,每半小时换一次人,假设第一个人站岗已经两个小时了,需要第二个人来替班,这个时候第二个人才会来替班,第二个人替班的前提是:第一个人满足半小时站岗时间并且累了要求换人,而且第二个人替班时间是:0点,并非22:30,要求是半小时一换,但是有个前提,如果第一个人超过半小时而且不换,那就没有执行周期了,第二个人就可以摸鱼了

3、定时任务一般执行的都是多个任务,而延时任务一般来说都是单个任务

举例:订单取消

定时任务:如果用定时任务来取消订单,如果定死是30分钟整,不考虑秒的话,那可能到了半小时之后,会取消多个不同秒下单的订单,这个场景用代码来实现的话,最常见的就是把数据库订单的创建时间批量查出来,只精确到分,>30分钟的都要取消的,这取消的可能是大批量的数据和任务

延时任务:同样是半小时整,但因为上面1和2的条件,延时任务针对的是每笔订单下单未支付后的半小时内的订单取消,他自然而然会把秒考虑进去,比如,9:00:55这个时间下单未支付,那取消订单会在9:30:55取消,同时我9:01:02下了另一个单,但未支付,那取消这笔订单的时间就是9:31:02


那接下来我们正式来介绍一下有多少种常见的延时方案

数据库轮询

实现思路 

一般来说会直接用定时框架quartz来实现,通过一个线程定时的去请求数据库,通过订单的创建时间计算出有没有超过规定时间没支付的订单,如果有就update,没有就等下一轮轮询

【优点】

相对简单,支持集成多线程

【缺点】

1、对服务器消耗比较大

2、可能会存在延迟

3、扫描的如果是订单这种大量数据的表,那频繁的请求数据库,数据库的压力也不小


JDK延迟队列 

实现思路  

利用JDK提供的DelayQueue队列来实现,它是一个无界阻塞队列,啥意思呢,就是说这个队列只有在延迟期满了之后才能从里面获取元素

代码Demo

1、定义订单OrderDelay来继承Delayed

public class OrderDelay implements Delayed {

    private String orderNo;

    private long timeout;

    OrderDelay(String orderNo, long timeout) {
        this.orderNo = orderNo;
        //规定超时的时间+订单创建时间(现在)时间戳
        this.timeout = timeout + System.nanoTime();
    }

    /**
     * @Description: 时间比对
     */
    public int compareTo(Delayed other) {
        if (other == this) return 0;
        OrderDelay t = (OrderDelay) other;
        //设定的有效时间-当前系统时间是否是小于等于0
        long d = (getDelay(TimeUnit.NANOSECONDS) - t.getDelay(TimeUnit.NANOSECONDS));
        return (d == 0) ? 0 : ((d < 0) ? -1 : 1);

    }

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

    public void print() {
        System.out.println("订单号为:" + orderNo + "的订单即将被取消支付");
    }
}

2、编写main方法

 public static void main(String[] args) {
        List<String> list = new ArrayList<String>();
        //模拟订单号
        list.add("DD111111");
        list.add("DD222222");
        list.add("DD333333");
        list.add("DD444444");
        list.add("DD555555");

        //存入队列
        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("删除时间:" +(System.currentTimeMillis() - start) + "s");
                System.out.println("=====================================================");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

3、观察控制台输出

订单号为:DD111111的订单即将被取消支付
删除时间:3005s
=====================================================
订单号为:DD222222的订单即将被取消支付
删除时间:6018s
=====================================================
订单号为:DD333333的订单即将被取消支付
删除时间:9027s
=====================================================
订单号为:DD444444的订单即将被取消支付
删除时间:12042s
=====================================================
订单号为:DD555555的订单即将被取消支付
删除时间:15058s
=====================================================

看时间其实是可以知道的,两个相邻的订单被取消的时间都在3秒,每3秒取消一笔订单

【优点】

效率高,延迟低

【缺点】

1、服务器重启或者宕机,数据会全部丢失

2、集群拓展困难

3、如果下单未付款的订单数太多,会出现OOM的异常

4、代码复杂度较高,上面的案例较为简单,可以实现一些指定的简单场景


时间轮-算法

实现逻辑 

可以用时钟来理解,我们看一下较长的时针,时针按照某一个方向按固定的速度或频率转动,每一次刻度是一个tick,所以就引出了时间轮的三个概念:ticksPerWheel(一轮的tick数),tickDuration(一个tick的持续时间)和timeUnit(时间单位),怎么理解这三个概念呢?比如:如果ticksPerWheel=60,tickDuration=1,timeUnit=秒,那就和我们平常认知里的时钟的时针转动的速度是一样的了

结合案例说一下:如果指针指到2那个刻度,同时一个任务需要4秒以后执行,那么这个执行的线程回调会放在6上面,如果需要20s之后执行会咋样呢,由于这个始终只有12个,如果是20s,就转一圈再转一圈,20个刻度停止,也就是10点的那个位置

代码Demo

1、使用netty提供的HashedWheelTimer来实现。先添加Netty依赖

<dependency>
    <groupId>io.netty</groupId>
    <artifactId>netty-all</artifactId>
    <version>4.1.42.Final</version>
</dependency>

2、创建一个定时任务

public class HashedWheelTimerTest {
    static class MyTimerTask implements TimerTask {
        boolean flag;

        public MyTimerTask(boolean flag) {
            this.flag = flag;
        }
        //要执行延时的逻辑
        public void run(Timeout timeout) {
            System.out.println("正在取消订单的路上...");
            this.flag = false;
        }

    }

    public static void main(String[] argv) {
        MyTimerTask timerTask = new MyTimerTask(true);
        Timer timer = new HashedWheelTimer();
        //三个参数分别是,定时要实现的定时任务/延时时长/时间单位(这儿是秒)
        timer.newTimeout(timerTask,          5,     TimeUnit.SECONDS);
        int i = 1;
        while (timerTask.flag) {
            try {
                //延时1s,1s打印一次,上面的延时是五秒后
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("已经过去了" + i + "秒");
            i++;
        }
    }
}

3、Timer中的newTimeout讲解

public Timeout newTimeout(TimerTask task, long delay, TimeUnit unit) {
        if (task == null) {
            throw new NullPointerException("task");
        } else if (unit == null) {
            throw new NullPointerException("unit");
        } else {
            //任务数,相当于线程池的大小
            long pendingTimeoutsCount = this.pendingTimeouts.incrementAndGet();
            //不能超过最大任务数
            if (this.maxPendingTimeouts > 0L && pendingTimeoutsCount > this.maxPendingTimeouts) {
                this.pendingTimeouts.decrementAndGet();
                throw new RejectedExecutionException("Number of pending timeouts (" + pendingTimeoutsCount + ") is greater than or equal to maximum allowed pending timeouts (" + this.maxPendingTimeouts + ")");
            } else {
                //开始上面demo中的任务
                this.start();
                //计算是否超时
                long deadline = System.nanoTime() + unit.toNanos(delay) - this.startTime;
                if (delay > 0L && deadline < 0L) {
                    deadline = 9223372036854775807L;
                }
                //添加到timeouts队列,这是一个MPSC队列,MPSC队列是多生产者单消费者无锁的并发队列,worker线程会对队列进行消费
                HashedWheelTimer.HashedWheelTimeout timeout = new HashedWheelTimer.HashedWheelTimeout(this, task, deadline);
                this.timeouts.add(timeout);
                return timeout;
            }
        }
    }

4、最后打印

已经过去了1秒
已经过去了2秒
已经过去了3秒
已经过去了4秒
已经过去了5秒
正在取消订单的路上...
已经过去了6秒

第五秒之后会去执行取消订单的操作

【优点】

1、效率比较高,整体来说比JDK提供的DeplayQueue效率要好

【缺点】

1、服务重启或宕机,数据会丢失

2、对集群拓展不太友好

3、和JDK一样,会出现OOM异常


Redis缓存 

实现思路 

使用Redis五大数据类型中的zset类型,它是一个有序集合,每一个元素都有一个score,通过这个score排序来获取集合中的值,就取消订单来说,我们将订单超时的时间戳与订单号分别存放到score和member,然后让系统扫描第一个元素是否超时,这个相对简单,不讲了,说一个更好玩的思路

代码Demo

1、编写代码

public class ZsetTest {
    //创建redis实例
    private static JedisPool jedisPool = new JedisPool("127.0.0.1", 6379);

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

    //生产者,生成5个订单放进去
    public void productionDelayMessage(){
        for(int i=0;i<5;i++){
            Calendar cal1 = Calendar.getInstance();
            //延迟3秒
            cal1.add(Calendar.SECOND, 3);
            int second3later = (int) (cal1.getTimeInMillis() / 1000);
            //                          //ket       score                   member
            ZsetTest.getJedis().zadd("OrderNo",second3later,"ORDER0000001"+i);
            System.out.println(System.currentTimeMillis()+"ms:Redis生成了一个订单任务:订单号为"+"ORDER0000001"+i);

        }

    }

    //消费者,获取订单
    public void consumerDelayMessage(){
        Jedis jedis = ZsetTest.getJedis();
        while(true){
            //扫描key下面所有数据
            Set<Tuple> items = jedis.zrangeWithScores("OrderNo", 0, 1);
            if(items == null || items.isEmpty()){
                System.out.println("当前没有需要等待执行的任务");
                try {
                    //等待500ms
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                continue;
            }
            //获取score
            int  score = (int) ((Tuple)items.toArray()[0]).getScore();
            Calendar cal = Calendar.getInstance();
            //计算现在的时间戳
            int nowSecond = (int) (cal.getTimeInMillis() / 1000);
            //对比,如果创建的时间戳已经不再规定的时间范围内,就是过期了
            if(nowSecond >= score){
                String orderNo = ((Tuple)items.toArray()[0]).getElement();
                //移除订单
                jedis.zrem("OrderNo", orderNo);
                System.out.println(System.currentTimeMillis() +"ms:Redis消费了一个订单任务:消费的订单号为"+orderNo);
            }
        }
    }

    public static void main(String[] args) {
        ZsetTest zsetTest =new ZsetTest();
        //生成订单
        zsetTest.productionDelayMessage();
        //获取订单,并取消订单
        zsetTest.consumerDelayMessage();

    }

}

2、打印结果

1650851642873ms:Redis生成了一个订单任务:订单号为ORDER00000010
1650851642875ms:Redis生成了一个订单任务:订单号为ORDER00000011
1650851642876ms:Redis生成了一个订单任务:订单号为ORDER00000012
1650851642877ms:Redis生成了一个订单任务:订单号为ORDER00000013
1650851642878ms:Redis生成了一个订单任务:订单号为ORDER00000014
1650851645000ms:Redis消费了一个订单任务:消费的订单号为ORDER00000010
1650851645001ms:Redis消费了一个订单任务:消费的订单号为ORDER00000011
1650851645001ms:Redis消费了一个订单任务:消费的订单号为ORDER00000012
1650851645001ms:Redis消费了一个订单任务:消费的订单号为ORDER00000013
1650851645002ms:Redis消费了一个订单任务:消费的订单号为ORDER00000014
当前没有需要等待执行的任务
当前没有需要等待执行的任务

3、我这里可能打印结果体验不出来接下来要说的问题,那就是多线程会消费同一个资源,那怎么解决嘞?方案就是:对zrem返回值进行判断,>0的时候消费数据,否则不消费,我们修改一下上面demo代码中取消订单的if判断中的逻辑,修改后的代码如下:

if(nowSecond >= score){
    String orderNo = ((Tuple)items.toArray()[0]).getElement();
    //移除订单
    Long no = jedis.zrem("OrderNo", orderNo);
    if(no!=null && no>0){
        System.out.println(System.currentTimeMillis() +"ms:Redis消费了一个订单任务:消费的订单号为"+orderNo);
    }
}

【优点】

1、集群拓展相当友好

2、时间准确度较高

3、数据不易丢失,如果服务出现重启或宕机,当服务启动的时候,会有重新处理数据的可能

【缺点】

需要对应的Redis维护的能力


RabbitMQ队列 

实现原理

RabbitMQ自身是可以实现延迟队列,RabbitMQ可以针对Queue和Message设置 x-message-tt,来控制消息的生存时间,如果超时,则消息变为dead letter

代码Demo

具体的延时队列的使用介绍和代码,请移驾~到下面的链接,专门为这一个需求写的文章

【RabbitMQ】延迟队列_阿小冰的博客-程序员宅基地【RabbitMQ】延迟队列https://blog.csdn.net/qq_38377525/article/details/124989700

【优点】

1、效率较高

2、有关RabbitMQ的横向拓展也十分友好

3、支持数据持久化

【缺点】

需要对RabbitMQ的安装配置和API要有一定的功底

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/qq_38377525/article/details/124385040

智能推荐

C++中创建对象的时候加括号和不加括号的区别(转)-程序员宅基地

文章浏览阅读214次。c++创建对象的语法有-----1 在栈上创建 MyClass a;2 在堆上创建加括号 MyClass *a= new MyClass();3 不加括号 MyClass *a = new MyClass;4.---------------MyClass a();声明了一个返回值为MyClass类型的无参函数。 1 #include <iostream> 2 class..._创建类对象加大括号会怎么样c++

UiAutomator2+Pytest+Allure+PO模型实现Android自动化测试_atx-agent po模式-程序员宅基地

文章浏览阅读3.9k次,点赞5次,收藏51次。介绍uiautomator2 是一个可以使用Python对Android设备进行UI自动化的库。其底层基于Google uiautomator,Google提供的uiautomator库可以获取屏幕上任意一个APP的任意一个控件属性,并对其进行任意操作环境搭建安装JDK,请参考此文章安装Android SDK,构建工具版本需大于24,下载并安装工具包时请注意版本,SDK配置请参考此文章安装uiautomator2pip install uiautomator2_atx-agent po模式

关于win7 下面 jlink 固件 修复-程序员宅基地

文章浏览阅读155次。从keil4过渡到MDK5后,遇到的第一个问题就是下载时MDK提示需要将keil的固件升级,一想环境不一样,估计估计需要升级一下,于是手残点了升级,于是就悲剧了,项目中断,倒腾固件修复倒腾了一天,下面说下遇到的问题。固件修复后,jlink上灯就不亮了,不亮就不亮,固件升级原来也做过,不难,可是好像忘了一个问题,原来的开发环境在winxp下,后来换成了win7 64位,就是这个环境让我..._win7 sp1补丁包和jlink

Task 4 基于深度学习的文本分类1_基于tf的文本分类-程序员宅基地

文章浏览阅读185次。Task 4 基于深度学习的文本分类1与传统机器学习不同,深度学习即提供特征提取功能,也可以完成分类的功能。4.1 学习目标1.学习FastText的使用和基础原理2.学会使用验证集进行调参4.2 现有文本表示方法的缺陷上一章节介绍了几种文本表示方法:1.One-hot2.Bag of Words3.N-gram4.TF-IDF上述方法或多或少都存在一定的问题:转换得到的向量维度很高,需要较长的训练时间;没有考虑单词与单词之间的关系,只是进行了统计。与上述方法不同,深度学习也可以用于_基于tf的文本分类

session一些基本的东西_session rolling-程序员宅基地

文章浏览阅读160次。sessionsession是什么session是因为cookie的弊端(存放在客户端,容易被客户修改伪造,数据量大也有纯传输问题) 才被做出来的,用session存储在服务器中,这让他的安全性也相对高一点session是一次浏览器和服务器的交互的会话,session也是一种存储方案服务器建立一个session,会在客户端建立一个唯一的识别(目的是为了只有这个客户端才能获得这个sessionsession的标识也会根据浏览器有关系 不同的浏览器的同一用户是不同的标识session 的运作通过_session rolling

Duilib的简单使用(四、换肤)_dulib皮肤-程序员宅基地

文章浏览阅读1.4k次。一、前言在Duilib的简单使用(一、duilib demo)中我们介绍了利用duilib简单的构造一个项目在Duilib的简单使用(二、xml实现界面与业务分离)中我们介绍了XML在duilib中的使用在Duilib的简单使用(三、界面逻辑交互)中我们已经知道如何简单的进行界面的交互这一篇,我们来提一下,Duilib强大的一键换肤功能。二、函数介绍Duilib是一个以贴图为主要表现手段的界面库,实现换肤非常简单,可以通过给控件设置不同的图片来实现换肤,比如给需要换肤的控件调用CControlU_dulib皮肤

随便推点

thinkphp3.2在php7下运行,出现错误"系统不支持:mysql"-程序员宅基地

文章浏览阅读2.7w次,点赞4次,收藏5次。原因是thinkphp里面的数据库链接配置里面在php7里面,已经不能用mysql_connect,只能把mysql改为mysqli这样就可以正常使用thinkphp3.2链接数据库了._系统不支持:mysql

前端传参数,java后端接收为null_前端传参,java接收为null-程序员宅基地

文章浏览阅读1.6w次,点赞6次,收藏8次。开发接口时,出现个离奇问题。在对接第三方接口时,按对方接口参数进行传参,却一直接收不到,为null值,检查所有代码,并不是代码问题。我将代码整理并创建简单接口进行测试,如图:测试实体类测试实现类postman测试参数debug接收参数为null在测试时,无论如何参数都为接收都为null,在折腾许久,终于发现时实体类里的@Data注解的原因一下是我截取出的get/set方法;最终原因就是出在参数名命名不规范,造成@Data注解在生产class文件时出现问题。造成所生成的get/set方_前端传参,java接收为null

小米6能读取卡信息无服务器,MIUI官方回应:米6公交开卡系服务升级、NFC功能可正常使用...-程序员宅基地

文章浏览阅读592次。原标题:MIUI官方回应:米6公交开卡系服务升级、NFC功能可正常使用近日,有消息称小米6因为系统升级暂时关闭了NFC公交开卡服务,或涉及虚假宣传。对此,小米官方也给予了回应,以下为小米MIUI官方在论坛上的回应:NFC(近场通信)功能拥有三种工作模式:读卡器模式(Reader/Writer mode)、点对点模式(P2P mode)、卡模拟模式(Card emulation mode)。读卡器模..._小米6读卡器

报错The code generator has deoptimised the styling of ...as it exceeds the max of 500KB.-程序员宅基地

文章浏览阅读3.1w次,点赞4次,收藏3次。先贴出报错:字面意思是:babel警告,代码生成器已经将这块js去除了styling, 因为他超过了500KB.解决方案:{ test: /.js$/, exclude: /node_modules/, use: 'babel-loader'},..._the code generator has deoptimised the styling of undefined as it exceeds th

MEMZ木马病毒-程序员宅基地

文章浏览阅读1.3w次,点赞3次,收藏32次。今天做蠕虫弄个memz病毒,把虚拟机搞崩了,重装了一下才解决,详细的了解了一下这种病毒。MEMZ病毒:MEMZ病毒又称彩虹猫病毒,在运行时,该病毒会不断弹窗导致系统无法正常运行,如果尝试结束MEMZ进程或重启系统,桌面会弹出无数个包含“火星文”的消息对话框,随后计算机进入蓝屏状态。重启后,会在屏幕顶部出现一段英文(译文:你的电脑已经被MEMZ病毒损坏,现在一起来欣赏彩虹猫吧),最后出现一个跳跃的“彩虹猫”动画。运行环境:虚拟机!!!!源代码:Github上有源码,不过好像是越南语的注释,不太_memz

python lxml中etree的简单应用3_datas =etree.html(data)-程序员宅基地

文章浏览阅读551次。本次主要介绍,无论使用的xpath表达式中是否包含text()方法,最后都可以获取目标标签下的文本。使用的依然是etree.HTML和etree.tostring方法。1.思路首先将字符串源码转换成_Element对象,然后使用_Element对象的xpath()方法解析xpath表达式。如果通过xpath表达式解析得到的是文本对象,那么先将文本对象(也是字符串)转换成_Element对象,最后通过etree.tostring方法获取_Element对象中的文本内容(可以参考这里)。2.代码实现_datas =etree.html(data)