如何实现订单主动过期释放


订单主动过期释放,如何实现?

解决方案:

  1. 被动设置:在查询订单的时候检查是否过期并设置过期状态;
  2. 定时调度:定时器定时查询并过期需要过期的订单
  3. 延时队列:将未支付的订单放入一个延时队列中,依次取出过期订单
  4. 过期提醒:redis支持将一个过期的key(订单号)通知给客户端,监听过期的key进行相应的处理;
  5. 延时队列:利用RabbitMq的延时队列功能,订单创建后发送检查订单状态的信息,24小时后监听到该消息并执行订单是否支付完成的逻辑

1.被动设置

就是在查询的时候判断是否失效,如果失效了就给他设置失效状态。但是弊端也很明显,每次查询都要对未失效的订单做判断,如果用户不查询,订单就不失效,那么如果有类似统计失效状态个数的功能,将会受到影响,所以只能适用于简单独立的场景。

2. 定时调度

这种是常见的方法,利用一个定时器,在设置的周期内轮询检查并处理需要过期的订单。
具体实现可以基于quartz,还有Springboot自带的Scheduler,实现起来比较简单。

下面实现一下第三个基于Springboot的方法

  1. 启动类加上注解@EnableScheduling
  2. 新建一个定时调度类,方法加上@Scheduled注解

弊端:

  • 不能够精准去处理过期订单,轮询周期设置的越小,精准度越高,导致项目的压力越大,项目运行起来比较笨重

3. 延时队列

基于JDK的实现方法,将未支付的订单放到一个有序的队列中,程序会依次取出过期的订单。
如果当前没有过期的订单,就会阻塞,直至有过期的订单。由于每次只处理过期的订单,并且处理的时间也很精准,不存在定时调度的两个弊端。

实现:

  1. 首先创建一个订单类OrderDelayDto需要实现Delayed接口。然后重写getDelay()方法和compareTo()方法,只加了订单编号和过期时间两个属性。
  2. 这2个方法很重要

    getDelay()方法实现过期的策略,比如,订单的过期时间等于当前时间就是过期,返回负数就代表需要处理。否则不处理。
    compareTo()方法实现订单在队列中的排序规则,这样即使后面加入的订单,也能加入到排序中,我这里写的规则是按照过期时间排序,最先过期的排到最前面,这一点很重要,因为排在最前面的如果没有被处理,就会进入阻塞状态,后面的不会被处理。

     import lombok.Data;
     import java.util.Date;
     import java.util.concurrent.Delayed;
     import java.util.concurrent.TimeUnit;
        
     /**
      * @author mashu
      * Date 2020/5/17 16:25
      */
     @Data
     public class OrderDelayDto implements Delayed {
         /**
          * 订单编号
          */
         private String orderCode;
         /**
          * 过期时间
          */
         private Date expirationTime;
        
         /**
          * 判断过期的策略:过期时间大于等于当前时间就算过期
          *
          * @param unit
          * @return
          */
         @Override
         public long getDelay(TimeUnit unit) {
             return unit.convert(this.expirationTime.getTime() - System.currentTimeMillis(), TimeUnit.NANOSECONDS);
         }
        
         /**
          * 订单加入队列的排序规则
          *
          * @param o
          * @return
          */
         @Override
         public int compareTo(Delayed o) {
             OrderDelayDto orderDelayDto = (OrderDelayDto) o;
             long time = orderDelayDto.getExpirationTime().getTime();
             long time1 = this.getExpirationTime().getTime();
             return time == time1 ? 0 : time < time1 ? 1 : -1;
         }
     }
    

    写个main 方法测试一下,创建两个订单o1和o2,放入到延时队列中,然后while()方法不断的去取。
    在此方法内通过队列的take()方法获得已过期的订单,然后做出相应的处理。

      public static void main(String[] args) {
             DelayQueue<OrderDelayDto> queue = new DelayQueue<>();
             OrderDelayDto o1 = new OrderDelayDto();
             //第一个订单,过期时间设置为一分钟后
             o1.setOrderCode("1001");
             Calendar calendar = Calendar.getInstance();
             calendar.add(Calendar.MINUTE, 1);
             o1.setExpirationTime(calendar.getTime());
             OrderDelayDto o2 = new OrderDelayDto();
             //第二个订单,过期时间设置为现在
             o2.setOrderCode("1002");
             o2.setExpirationTime(new Date());
             //往队列中放入数据
             queue.offer(o1);
             queue.offer(o2);
             // 延时队列
             while (true) {
                 try {
                     OrderDelayDto take = queue.take();
                     System.out.println("订单编号:" + take.getOrderCode() + " 过期时间:" + take.getExpirationTime());
                 } catch (InterruptedException e) {
                     e.printStackTrace();
                 }
             }
         }
    

    运行结果:

 我故意把第二个订单的过期时间设置为第一个订单之前,从结果可以看出,他们已经自动排序把最先过期的排到了最前面。

 第一个订单的失效时间是当前时间的后一分钟,结果也显示一分钟后处理了第一条订单。
  1. 然而通常情况下,我们会使用多线程去取延时队列中的数据,这样即使线程启动之后也能动态的向队列中添加订单。

    创建一个线程类OrderCheckScheduler实现Runnable接口,添加一个延时队列属性,重写run()方法,在此方法内通过队列的take()方法获得已过期的订单,然后做出相应的处理。

     import java.util.concurrent.DelayQueue;
     /**
      * @author mashu
      * Date 2020/5/17 14:27
      */
     public class OrderCheckScheduler implements Runnable {
        
       // 延时队列
       private DelayQueue<OrderDelayDto> queue;
        
       public OrderCheckScheduler(DelayQueue<OrderDelayDto> queue) {
           this.queue = queue;
       }
        
       @Override
       public void run() {
           while (true) {
               try {
                   OrderDelayDto take = queue.take();
                   System.out.println("订单编号:" + take.getOrderCode() + " 过期时间:" + take.getExpirationTime());
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
           }
       }
     }
    

    进行测试

         public static void main(String[] args) {
             // 创建延时队列
             DelayQueue<OrderDelayDto> queue = new DelayQueue<>();
             OrderDelayDto o1 = new OrderDelayDto();
             //第一个订单,过期时间设置为一分钟后
             o1.setOrderCode("1001");
             Calendar calendar = Calendar.getInstance();
             calendar.add(Calendar.MINUTE, 1);
             o1.setExpirationTime(calendar.getTime());
             OrderDelayDto o2 = new OrderDelayDto();
             //第二个订单,过期时间设置为现在
             o2.setOrderCode("1002");
             o2.setExpirationTime(new Date());
             //运行线程
             ExecutorService exec = Executors.newFixedThreadPool(1);
             exec.execute(new OrderCheckScheduler(queue));
             //往队列中放入数据
             queue.offer(o1);
             queue.offer(o2);
             exec.shutdown();
         }
    

4.过期提醒

基于redis的过期提醒功能,听名字就知道这个方案最是纯真、最直接的,就是单纯处理过期的订单。

修改个redis的配置吧先,因为redis默认不开启过期提醒。 notify-keyspace-events改为notify-keyspace-events "Ex" 写一个类用来接收来自redis的暖心提醒OrderExpirationListener,继承一下KeyExpirationEventMessageListener抽象类。重写onMessage()方法,在此方法中处理接收到的过期key.

import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.listener.KeyExpirationEventMessageListener;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.stereotype.Component;
import java.util.Date;

/**
 * @author mashu
 * Date 2020/5/17 23:01
 */
@Component
public class OrderExpirationListener extends KeyExpirationEventMessageListener {

    public OrderExpirationListener(RedisMessageListenerContainer listenerContainer) {
        super(listenerContainer);
    }

    @Override
    public void onMessage(Message message, byte[] pattern) {
        final String expiredKey = message.toString();
        System.out.println("我过期了" + expiredKey+"当前时间:"+new Date());
    }
}

ok,向redis中存入一个订单,过期时间为1分钟。

redis.set("orderCode/10010", "1", 1L, TimeUnit.MINUTES);
System.out.println("redis存入订单号 key: orderCode/10010,value:1,过期时间一分钟,当前时间"+new Date());

5.延时队列

RabbitMQ默认是不支持延时消息的,需要安装rabbitmq_delayed_message_exchange延时插件,下载地址 上传插件rabbitmq_delayed_message_exchange-3.8.0.ez到/usr/lib/rabbitmq/lib/rabbitmq_server-3.8.14/plugins目录。

开启插件

rabbitmq-plugins```enable```rabbitmq_delayed_message_exchange

查看已安装插件列表

rabbitmq-plugins list

重启RabbitMQ

/bin/systemctl restart rabbitmq-server.service

生产者延时发送代码: delayTime:延时时间

rabbitTemplate.convertAndSend(DELAYED_EXCHANGE_NAME, DELAYED_ROUTING_KEY, msg, a ->{
     a.getMessageProperties().setDelay(delayTime);
     return a;
});

延迟队列

  1. 什么是延迟队列? 首先要有队列的特性,再加上延迟消息的特性,就是指定队列中的消息在某个时间点,然后再等一段的时间后被消费。 定时任务与延迟队列不一样
    • 定时任务 好比我们平时用的闹钟,指定某个时间,定时触发,或者轮询,比如:每天早上7:30执行或者每个月周末凌晨2:30 执行一次,这样周期执行。
    • 延迟队列 而延迟队列就没有执行周期,它是在某个事件发生后等一段时间再执行,而我们也无法确定这个事件何时发生。
  2. 延迟队列的实现

    单机环境实现延迟队列,相信大家有很多办法,但是在分布式环境下,该怎么做呢? 可能有些人还是会想,定时任务每5分钟,查询一次数据库,不就实现延迟队列了吗(前提是忽略5分钟的误差),但是不停的查,会一直阻塞,很耗费资源,而大家其实都清楚,在高并发场景下CPU资源有多珍贵。

    其实成熟的实现方式还是很多的,比如:

    • 基于Redis的zset数据结构或者基于Redis的事件监听
    • 基于kafka的 delay_topic 或者 单层文件时间轮
    • 阿里开源的RocketMQ延迟消息 而本次我们通过RabbitMQ的方式,通过死信队列实现一个延迟消息队列,通过消息监听,也缓解了定时任务不断轮询的缺点。
  3. RabbitMQ延迟队列

    RabbitMQ可以对指定消息接受存储和转发的消息队列。本身并没有延迟队列的特性,但是我们可以过期时间(TTL)和死信队列(DXL)实现。

    • TTL:指的是消息的存活时间,RabbitMQ可以通过x-message-tt参数来设置指定Queue(队列)和 Message(消息)上消息的存活时间,它的值是一个非负整数,单位为微秒。
    • DXL:DLX即死信交换机,绑定在死信交换机上的即死信队列。RabbitMQ的 Queue(队列)可以配置两个参数x-dead-letter-exchange 和 x-dead-letter-routing-key(可选),一旦队列内出现了Dead Letter(死信),则按照这两个参数可以将消息重新路由到另一个Exchange(交换机),让消息重新被消费。

    那么什么情况下会出现死信(DL)呢?上网查了一下:

    • 消息或者队列过期(延迟队列就靠它)
    • 队列达到最大长度
    • 消息被消费端拒绝消费

模拟场景分析:加入我们下单超时15分钟没有支付订单,则关闭并取消订单。

  1. 在下单的时候,我们开启一个带有过期时间的消息队列
  2. 如果15分钟后,该消息还没有被消费,就会过期进入到死信队列
  3. 此时我们提前已经绑定好了路由关单交换机,所以会将该消息路由到关闭订单的交换机,进入关闭订单的消息队列
  4. 监听并消费关闭订单的消息,检查数据库该订单的支付状态,若未支付,就关闭订单,并且发送短信提醒客户。

大概处理流程,也许是下面这样




Enjoy Reading This Article?

Here are some more articles you might like to read next:

  • 2379. Minimum Recolors to Get K Consecutive Black Blocks
  • 2471. Minimum Number of Operations to Sort a Binary Tree by Level
  • 1387. Sort Integers by The Power Value
  • 2090. K Radius Subarray Averages
  • 2545. Sort the Students by Their Kth Score