赞
踩
平常我们都用过淘宝,京东这些电商平台,同时肯定也在这些平台上面下过单,这种情况不保证大家都有遇到过,但做开发的,肯定也知道有这个环节的存在:确认货品配置无误之后,我们都会点击购买,随之而来的就是一个结算页,让你确认商品信息、收货地址、价格等信息,但是如果这个时候,我们退出这个结算页,那此时,这个订单就属于生成但未支付的状态,对应的电商平台就会对这样的订单做延时任务取消操作,这样就可以稳定商品库存和提供用户一定的犹豫时间,那从技术的角度来看,这个延时任务是怎么实现的?
看着上面的案例,有的人会问:这个简单,我们直接用定时任务,做个半小时后的查询数据库来判断是该取消了还是怎么样不就行了吗?
其实这个场景,我们就不能说是定时任务了,准确来说是延时任务,那什么是延时呢?延时就是从一个点开始后的多久后再执行,比方说:火车八点到站,但是晚点十分钟,是那就是八点后的十分钟
上面的解答,概念有点模棱两可,和定时任务不是特别能区分开,那接下来我们就介绍一下定时和延时任务的区别
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提供的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五大数据类型中的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可以针对Queue和Message设置 x-message-tt,来控制消息的生存时间,如果超时,则消息变为dead letter
代码Demo
具体的延时队列的使用介绍和代码,请移驾~到下面的链接,专门为这一个需求写的文章
【优点】
1、效率较高
2、有关RabbitMQ的横向拓展也十分友好
3、支持数据持久化
【缺点】
需要对RabbitMQ的安装配置和API要有一定的功底
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。