当前位置:   article > 正文

支付宝二面:生成订单 30 分钟未支付,则自动取消,该怎么实现?

支付宝店铺下单自动取消设置

来源:https://blog.csdn.net/hjm4702192

在开发中,往往会遇到一些关于延时任务的需求。例如

  • 生成订单30分钟未支付,则自动取消

  • 生成订单60秒后,给用户发短信

对上述的任务,我们给一个专业的名字来形容,那就是延时任务

那么这里就会产生一个问题,这个延时任务和定时任务的区别究竟在哪里呢?

一共有如下 3 点区别:

  • 定时任务有明确的触发时间,延时任务没有

  • 定时任务有执行周期,而延时任务在某事件触发后一段时间内执行,没有执行周期

  • 定时任务一般执行的是批处理操作是多个任务,而延时任务一般是单个任务

下面,我们以判断订单是否超时为例,进行方案分析。

方案分析

1)数据库轮询

思路

该方案通常是在小型项目中使用,即通过一个线程定时的去扫描数据库,通过订单时间来判断是否有超时的订单,然后进行update或delete等操作

实现

实习那会,我是用quartz来实现的,简单介绍一下。

maven项目引入一个依赖如下所示

  1. <dependency>
  2.     <groupId>org.quartz-scheduler</groupId>
  3.     <artifactId>quartz</artifactId>
  4.     <version>2.2.2</version>
  5. </dependency>

调用Demo类MyJob:

  1. public class MyJob implements Job {
  2.     public void execute(JobExecutionContext context)
  3.             throws JobExecutionException {
  4.         System.out.println("要去数据库扫描啦。。。");
  5.     }
  6.     public static void main(String[] args) throws Exception {
  7.         // 创建任务
  8.         JobDetail jobDetail = JobBuilder.newJob(MyJob.class)
  9.                 .withIdentity("job1""group1").build();
  10.         // 创建触发器 每3秒钟执行一次
  11.         Trigger trigger = TriggerBuilder
  12.                 .newTrigger()
  13.                 .withIdentity("trigger1""group3")
  14.                 .withSchedule(
  15.                         SimpleScheduleBuilder.simpleSchedule()
  16.                                 .withIntervalInSeconds(3).repeatForever())
  17.                 .build();
  18.         Scheduler scheduler = new StdSchedulerFactory().getScheduler();
  19.         // 将任务及其触发器放入调度器
  20.         scheduler.scheduleJob(jobDetail, trigger);
  21.         // 调度器开始调度任务
  22.         scheduler.start();
  23.     }
  24. }

运行代码,可发现每隔3秒,输出如下:

要去数据库扫描啦。。。

优点:简单易行,支持集群操作

缺点:

  • 对服务器内存消耗大

  • 存在延迟,比如你每隔3分钟扫描一次,那最坏的延迟时间就是3分钟

  • 假设你的订单有几千万条,每隔几分钟这样扫描一次,数据库损耗极大

2)JDK的延迟队列

思路

利用JDK自带的DelayQueue来实现,这是一个无界阻塞队列,该队列只有在延迟期满的时候才能从中获取元素,放入DelayQueue中的对象,是必须实现Delayed接口的。

DelayedQueue实现工作流程如下图所示:

0393f5d3bea0cc80dd844bc0f1ecbb43.png

  • Poll():获取并移除队列的超时元素,没有则返回空

  • take():获取并移除队列的超时元素,如果没有则wait当前线程,直到有元素满足超时条件,返回结果。

实现

定义一个类OrderDelay实现Delayed:

  1. public class OrderDelay implements Delayed {
  2.     private String orderId;
  3.     private long timeout;
  4.     OrderDelay(String orderId, long timeout) {
  5.         this.orderId = orderId;
  6.         this.timeout = timeout + System.nanoTime();
  7.     }
  8.     public int compareTo(Delayed other) {
  9.         if (other == this)
  10.             return 0;
  11.         OrderDelay t = (OrderDelay) other;
  12.         long d = (getDelay(TimeUnit.NANOSECONDS) - t
  13.                 .getDelay(TimeUnit.NANOSECONDS));
  14.         return (d == 0) ? 0 : ((d < 0) ? -1 : 1);
  15.     }
  16.     // 返回距离你自定义的超时时间还有多少
  17.     public long getDelay(TimeUnit unit) {
  18.         return unit.convert(timeout - System.nanoTime(),TimeUnit.NANOSECONDS);
  19.     }
  20.     void print() {
  21.         System.out.println(orderId+"编号的订单要删除啦。。。。");
  22.     }
  23. }

测试类Demo,我们设定延迟时间为3秒:

  1. public class DelayQueueDemo {
  2.      public static void main(String[] args) {  
  3.             List<String> list = new ArrayList<String>();  
  4.             list.add("00000001");  
  5.             list.add("00000002");  
  6.             list.add("00000003");  
  7.             list.add("00000004");  
  8.             list.add("00000005");  
  9.             DelayQueue<OrderDelay> queue = newDelayQueue<OrderDelay>();  
  10.             long start = System.currentTimeMillis();  
  11.             for(int i = 0;i<5;i++){  
  12.                 //延迟三秒取出
  13.                 queue.put(new OrderDelay(list.get(i),  
  14.                         TimeUnit.NANOSECONDS.convert(3,TimeUnit.SECONDS)));  
  15.                     try {  
  16.                          queue.take().print();  
  17.                          System.out.println("After " +  
  18.                                  (System.currentTimeMillis()-start) + " MilliSeconds");  
  19.                 } catch (InterruptedException e) {}  
  20.             }  
  21.         }  
  22. }

输出如下:

  1. 00000001编号的订单要删除啦。。。。
  2. After 3003 MilliSeconds
  3. 00000002编号的订单要删除啦。。。。
  4. After 6006 MilliSeconds
  5. 00000003编号的订单要删除啦。。。。
  6. After 9006 MilliSeconds
  7. 00000004编号的订单要删除啦。。。。
  8. After 12008 MilliSeconds
  9. 00000005编号的订单要删除啦。。。。
  10. After 15009 MilliSeconds

可以看到都是延迟3秒,订单被删除。

优点:效率高,任务触发时间延迟低。

缺点:

  • 服务器重启后,数据全部消失,怕宕机

  • 集群扩展相当麻烦

  • 因为内存条件限制的原因,比如下单未付款的订单数太多,那么很容易就出现OOM异常

  • 代码复杂度较高

3)时间轮算法

思路

先上一张时间轮的图:

5ffbbc4f1cb10dd147479fc7574539fc.png

时间轮算法可以类比于时钟,如上图箭头(指针)按某一个方向按固定频率轮动,每一次跳动称为一个 tick。

这样可以看出定时轮由个3个重要的属性参数:

  • ticksPerWheel(一轮的tick数)

  • tickDuration(一个tick的持续时间)

  • timeUnit(时间单位)

例如当ticksPerWheel=60,tickDuration=1,timeUnit=秒,这就和现实中的始终的秒针走动完全类似了。

如果当前指针指在1上面,我有一个任务需要4秒以后执行,那么这个执行的线程回调或者消息将会被放在5上。那如果需要在20秒之后执行怎么办,由于这个环形结构槽数只到8,如果要20秒,指针需要多转2圈。位置是在2圈之后的5上面(20 % 8 + 1)

实现

我们用Netty的HashedWheelTimer来实现。

给pom.xml加上下面的依赖:

  1. <dependency>
  2.     <groupId>io.netty</groupId>
  3.     <artifactId>netty-all</artifactId>
  4.     <version>4.1.24.Final</version>
  5. </dependency>

测试代码HashedWheelTimerTest:

  1. public class HashedWheelTimerTest {
  2.     static class MyTimerTask implements TimerTask{
  3.         boolean flag;
  4.         public MyTimerTask(boolean flag){
  5.             this.flag = flag;
  6.         }
  7.         public void run(Timeout timeout) throws Exception {
  8.              System.out.println("要去数据库删除订单了。。。。");
  9.              this.flag =false;
  10.         }
  11.     }
  12.     public static void main(String[] argv) {
  13.         MyTimerTask timerTask = new MyTimerTask(true);
  14.         Timer timer = new HashedWheelTimer();
  15.         timer.newTimeout(timerTask, 5, TimeUnit.SECONDS);
  16.         int i = 1;
  17.         while(timerTask.flag){
  18.             try {
  19.                 Thread.sleep(1000);
  20.             } catch (InterruptedException e) {
  21.                 e.printStackTrace();
  22.             }
  23.             System.out.println(i+"秒过去了");
  24.             i++;
  25.         }
  26.     }
  27. }

输出如下:

  1. 1秒过去了
  2. 2秒过去了
  3. 3秒过去了
  4. 4秒过去了
  5. 5秒过去了
  6. 要去数据库删除订单了。。。。
  7. 6秒过去了

优点:效率高,任务触发时间延迟时间比delayQueue低,代码复杂度比delayQueue低。

缺点:

  • 服务器重启后,数据全部消失,怕宕机

  • 集群扩展相当麻烦

  • 因为内存条件限制的原因,比如下单未付款的订单数太多,那么很容易就出现OOM异常

4)redis缓存

思路一

利用redis的zset。zset是一个有序集合,每一个元素(member)都关联了一个score,通过score排序来取集合中的值。

  • 添加元素:ZADD key score member [[score member] [score member] …]

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

  • 查询元素score:ZSCORE key member

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

测试如下:

  1. 添加单个元素
  2. redis> ZADD page_rank 10 google.com
  3. (integer) 1
  4. 添加多个元素
  5. redis> ZADD page_rank 9 baidu.com 8 bing.com
  6. (integer) 2
  7. redis> ZRANGE page_rank 0 -1 WITHSCORES
  8. 1"bing.com"
  9. 2"8"
  10. 3"baidu.com"
  11. 4"9"
  12. 5"google.com"
  13. 6"10"
  14. 查询元素的score值
  15. redis> ZSCORE page_rank bing.com
  16. "8"
  17. 移除单个元素
  18. redis> ZREM page_rank google.com
  19. (integer) 1
  20. redis> ZRANGE page_rank 0 -1 WITHSCORES
  21. 1"bing.com"
  22. 2"8"
  23. 3"baidu.com"
  24. 4"9"

那么如何实现呢?我们将订单超时时间戳与订单号分别设置为score和member,系统扫描第一个元素判断是否超时,具体如下图所示:

49d76272dcc27902112c7f36de504586.png

实现一

  1. public class AppTest {
  2.     private static final String ADDR = "127.0.0.1";
  3.     private static final int PORT = 6379;
  4.     private static JedisPool jedisPool = new JedisPool(ADDR, PORT);
  5.     public static Jedis getJedis() {
  6.        return jedisPool.getResource();
  7.     }
  8.     //生产者,生成5个订单放进去
  9.     public void productionDelayMessage(){
  10.         for(int i=0;i<5;i++){
  11.             //延迟3秒
  12.             Calendar cal1 = Calendar.getInstance();
  13.             cal1.add(Calendar.SECOND, 3);
  14.             int second3later = (int) (cal1.getTimeInMillis() / 1000);
  15.             AppTest.getJedis().zadd("OrderId",second3later,"OID0000001"+i);
  16.             System.out.println(System.currentTimeMillis()+"ms:redis生成了一个订单任务:订单ID为"+"OID0000001"+i);
  17.         }
  18.     }
  19.     //消费者,取订单
  20.     public void consumerDelayMessage(){
  21.         Jedis jedis = AppTest.getJedis();
  22.         while(true){
  23.             Set<Tuple> items = jedis.zrangeWithScores("OrderId"01);
  24.             if(items == null || items.isEmpty()){
  25.                 System.out.println("当前没有等待的任务");
  26.                 try {
  27.                     Thread.sleep(500);
  28.                 } catch (InterruptedException e) {
  29.                     e.printStackTrace();
  30.                 }
  31.                 continue;
  32.             }
  33.             int  score = (int) ((Tuple)items.toArray()[0]).getScore();
  34.             Calendar cal = Calendar.getInstance();
  35.             int nowSecond = (int) (cal.getTimeInMillis() / 1000);
  36.             if(nowSecond >= score){
  37.                 String orderId = ((Tuple)items.toArray()[0]).getElement();
  38.                 jedis.zrem("OrderId", orderId);
  39.                 System.out.println(System.currentTimeMillis() +"ms:redis消费了一个任务:消费的订单OrderId为"+orderId);
  40.             }
  41.         }
  42.     }
  43.     public static void main(String[] args) {
  44.         AppTest appTest =new AppTest();
  45.         appTest.productionDelayMessage();
  46.         appTest.consumerDelayMessage();
  47.     }
  48. }

此时对应输出:

61db7211456a30b2c7c8d164e913db7f.png

可以看到,几乎都是3秒之后,消费订单。

然而,这一版存在一个致命的硬伤,在高并发条件下,多消费者会取到同一个订单号,我们上测试代码ThreadTest:

  1. public class ThreadTest {
  2.     private static final int threadNum = 10;
  3.     private static CountDownLatch cdl = newCountDownLatch(threadNum);
  4.     static class DelayMessage implements Runnable{
  5.         public void run() {
  6.             try {
  7.                 cdl.await();
  8.             } catch (InterruptedException e) {
  9.                 e.printStackTrace();
  10.             }
  11.             AppTest appTest =new AppTest();
  12.             appTest.consumerDelayMessage();
  13.         }
  14.     }
  15.     public static void main(String[] args) {
  16.         AppTest appTest =new AppTest();
  17.         appTest.productionDelayMessage();
  18.         for(int i=0;i<threadNum;i++){
  19.             new Thread(new DelayMessage()).start();
  20.             cdl.countDown();
  21.         }
  22.     }
  23. }`
  24. 输出如下所示:
  25. ![](https://upload-images.jianshu.io/upload_images/1179389-ca3e56dd26dfaf92.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
  26. 显然,出现了多个线程消费同一个资源的情况。
  27. **解决方案**
  28. - 用分布式锁,但是用分布式锁,性能下降了,该方案不细说。
  29. - 对ZREM的返回值进行判断,只有大于0的时候,才消费数据,于是将consumerDelayMessage()方法里的
  30. ```java
  31. if(nowSecond >= score){
  32.     String orderId = ((Tuple)items.toArray()[0]).getElement();
  33.     jedis.zrem("OrderId", orderId);
  34.     System.out.println(System.currentTimeMillis()+"ms:redis消费了一个任务:消费的订单OrderId为"+orderId);
  35. }

修改为:

  1. if(nowSecond >= score){
  2.     String orderId = ((Tuple)items.toArray()[0]).getElement();
  3.     Long num = jedis.zrem("OrderId", orderId);
  4.     if( num != null && num>0){
  5.         System.out.println(System.currentTimeMillis()+"ms:redis消费了一个任务:消费的订单OrderId为"+orderId);
  6.     }
  7. }

在这种修改后,重新运行ThreadTest类,发现输出正常了。

思路二

该方案使用redis的Keyspace Notifications,中文翻译就是键空间机制,就是利用该机制可以在key失效之后,提供一个回调,实际上是redis会给客户端发送一个消息。是需要redis版本2.8以上。

实现二

在redis.conf中,加入一条配置:

notify-keyspace-events Ex

运行代码如下:

  1. public class RedisTest {
  2.     private static final String ADDR = "127.0.0.1";
  3.     private static final int PORT = 6379;
  4.     private static JedisPool jedis = new JedisPool(ADDR, PORT);
  5.     private static RedisSub sub = new RedisSub();
  6.     public static void init() {
  7.         new Thread(new Runnable() {
  8.             public void run() {
  9.                 jedis.getResource().subscribe(sub, "__keyevent@0__:expired");
  10.             }
  11.         }).start();
  12.     }
  13.     public static void main(String[] args) throws InterruptedException {
  14.         init();
  15.         for(int i =0;i<10;i++){
  16.             String orderId = "OID000000"+i;
  17.             jedis.getResource().setex(orderId, 3, orderId);
  18.             System.out.println(System.currentTimeMillis()+"ms:"+orderId+"订单生成");
  19.         }
  20.     }
  21.     static class RedisSub extends JedisPubSub {
  22.         public void onMessage(String channel, String message) {
  23.             System.out.println(System.currentTimeMillis()+"ms:"+message+"订单取消");
  24.         }
  25.     }
  26. }

输出如下:

1564c79aef729e47c8bc675a7b476c21.png

可以明显看到3秒过后,订单取消了。

不过,redis的pub/sub机制存在一个硬伤,官网内容如下

Because Redis Pub/Sub is fire and forget currently there is no way to use this feature if your application demands reliable notification of events, that is, if your Pub/Sub client disconnects, and reconnects later, all the events delivered during the time the client was disconnected are lost.

简单翻译下: Redis的发布/订阅目前是即发即弃(fire and forget)模式的,因此无法实现事件的可靠通知。也就是说,如果发布/订阅的客户端断链之后又重连,则在客户端断链期间的所有事件都丢失了。

因此,方案二不是太推荐。当然,如果你对可靠性要求不高,可以使用。

优点:

  • 由于使用Redis作为消息通道,消息都存储在Redis中。如果发送程序或者任务处理程序挂了,重启之后,还有重新处理数据的可能性。

  • 做集群扩展相当方便

  • 时间准确度高

缺点:需要额外进行redis维护

5)使用消息队列

可以采用rabbitMQ的延时队列。RabbitMQ具有以下两个特性,可以实现延迟队列:

  • RabbitMQ可以针对Queue和Message设置 x-message-tt,来控制消息的生存时间,如果超时,则消息变为dead letter

  • lRabbitMQ的Queue可以配置x-dead-letter-exchange 和x-dead-letter-routing-key(可选)两个参数,用来控制队列内出现了deadletter,则按照这两个参数重新路由。

结合以上两个特性,就可以模拟出延迟消息的功能。具体的,我改天再写一篇文章,这里再讲下去,篇幅太长。

优点:高效,可以利用rabbitmq的分布式特性轻易的进行横向扩展,消息支持持久化增加了可靠性。

缺点:本身的易用度要依赖于rabbitMq的运维,因为要引用rabbitMq,所以复杂度和成本变高。

逆锋起笔是一个专注于程序员圈子的技术平台,你可以收获最新技术动态最新内测资格BAT等大厂的经验精品学习资料职业路线副业思维,微信搜索逆锋起笔关注!

往期精选:

假如有人把支付宝存储服务器炸了,我们的钱还在吗?

Spring Boot 接入支付宝完整流程实战,看完后秒懂!

30 行代码实现蚂蚁森林自动收能量(附源码)

蚂蚁金服开源 增强版 Spring Boot 研发框架

一次蚂蚁金服的辛酸面试历程

更多精彩,点击关注公众号

6fd4c4f28aa72c1bcba1da0a60f1e40f.png080edc13127e72fde67cfd5ecb1fb7ad.pngca79faf098eaf3b738d4a6c667002dab.png

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/小小林熬夜学编程/article/detail/125629
推荐阅读
相关标签
  

闽ICP备14008679号