【订单超时自动关闭】解决方案

遇到一个场景,订单超时未支付自动关闭释放库存的场景,故做此纪录。

生活中,12306购票,京东,淘宝下单的时候,都会遇到30分钟内进行支付的场景,互联网电商的订单系统都需要解决订单超时的问题。
订单超时业务场景,符合"在一段时间之后,完成一个工作任务"的需求,总结了几种订单超时未支付自动关闭的实现方案和各自的优缺点,如下:

使用场景实现方案优点缺点
单机版系统用定时任务成本低,实现简单时间不精确,增加服务器和数据库的压力
单机版系统用被动取消成本低,实现简单依赖客户端,如果客户端不发起请求,订单可能永远没法过期,一直占用库存
一般不用jdk延迟队列不依赖其他组件,不依赖数据库,实现简单数据量大会导致OOM,jvm重启后数据会丢失。
一般不用redis过期通知性能高,速度快redis5.0之前,没有消息确认机制,不适合可靠事件通知
分布式系统用rocketmq延迟队列高可用、高性能,系统解耦,吞吐量高,支持万亿级数据量相对上面来说mq是重量级组件,引入后,带来消息丢失,幂等性等问题加深了系统的复杂性

第一种:定时任务,定时任务实现大概分为两类:本地定时任务和分布式定时任务

本地定时任务:

  1. 永动机线程:开启一个线程,通过sleep去完成定时
  2. JDK Timer:JDK提供的Timer API
  3. 延迟线程池:JDK提供延迟线程池ScheduledExecutorService
  4. Spring Task:Spring框架提供的定时任务
  5. Quartz:Quartz任务调度框架

分布式定时任务:
6. xxl-job:大众点评的居于Mysql轻量级分布式定时任务框架
7. elastic-job:当当网的弹性分布式任务调度系统

1.引入maven依赖:

<dependency>
    <groupId>org.quartz-scheduler</groupId>
    <artifactId>quartz</artifactId>
    <version>2.2.2</version>
</dependency>

2.调用Demo类:

public class Demo implements Job {
 
    public void execute(JobExecutionContext context) throws JobExecutionException {
        System.out.println("扫描数据库---");
    }
 
    public static void main(String[] args) throws Exception {
        // 创建任务
        JobDetail jobDetail = JobBuilder.newJob(MyJob.class)
                .withIdentity("job1", "group1").build();
        // 创建触发器 每3秒钟执行一次
        Trigger trigger = TriggerBuilder
                .newTrigger()
                .withIdentity("trigger1", "group3")
                .withSchedule(
                        SimpleScheduleBuilder
                                .simpleSchedule()
                                .withIntervalInSeconds(3).
                                repeatForever())
                .build();
        Scheduler scheduler = new StdSchedulerFactory().getScheduler();
        // 将任务及其触发器放入调度器
        scheduler.scheduleJob(jobDetail, trigger);
        // 调度器开始调度任务
        scheduler.start();
    }
}

//每隔 3 秒,输出"扫描数据库---"

优点:实现简单
缺点:对数据库的压力很大;计时不准,定时任务做不到非常精确的时间控制

第二种:被动取消

客户端计时+服务端检查。
1 用户留在收银台的时候,客户端倒计时+主动查询订单状态,服务端每次都去检查一下订单是否超时、剩余时间
2 用户每次进入订单相关的页面,查询订单的时候,服务端也检查一下订单是否超时
优点:实现简单
缺点:依赖客户端,如果客户端不发起请求,订单可能永远没法过期,一直占用库存

第三种:jdk自带的延时队列

JDK中提供了一种延迟队列数据结构DelayQueue
1.把订单插入DelayQueue中,以超时时间作为排序条件,将订单按照超时时间从小到大排序。
2.起一个线程不停轮询队列的头部,如果订单的超时时间到了,就出队进行超时处理,并更新订单状态到数据库中。
注意:此处可以扩展为了防止机器重启导致内存中的DelayQueue数据丢失,每次机器启动的时候,需要从数据库中初始化未结束的订单,加入到DelayQueue中。

public class DelayQueueDemo {

    public static void main(String[] args) {
        List<String> list = new ArrayList<String>();
        list.add("1");
        list.add("2");
        list.add("3");
        list.add("4");
        list.add("5");
        list.add("6");

        // 延时队列 ,消费者从其中获取消息进行消费
        DelayQueue<PayOrderDelay> queue = new DelayQueue<PayOrderDelay>();
        for (int i = 0; i < list.size(); i++) {
            // 生产者,添加延时消息,1 延时3s  将延时消息放到延时队列中
            queue.put(new PayOrderDelay(i, "订单" + list.get(i), TimeUnit.NANOSECONDS.convert(i + 1, TimeUnit.SECONDS)));
        }

        // 启动消费线程 消费添加到延时队列中的消息,前提是任务到了延期时间
        ExecutorService exec = Executors.newFixedThreadPool(1);
        exec.execute(new ConsumerThreadDemo(queue));
        exec.shutdown();

    }

    //实现Delayed接口就是实现两个方法即compareTo 和 getDelay最重要的就是getDelay方法,这个方法用来判断是否到期……
    static class PayOrderDelay implements Delayed {
        //消息id
        private int id;
        //消息内容
        private String orderId;
        //延迟时长,
        private long timeout;

        public int getId() {
            return id;
        }

        public void setId(int id) {
            this.id = id;
        }

        public String getOrderId() {
            return orderId;
        }

        public void setOrderId(String orderId) {
            this.orderId = orderId;
        }

        public long getTimeout() {
            return timeout;
        }

        public void setTimeout(long timeout) {
            this.timeout = timeout;
        }

        PayOrderDelay(int id, String orderId, long timeout){
            this.id = id;
            this.orderId = orderId;
            this.timeout = timeout + System.nanoTime();
        }

        @Override
        public long getDelay(TimeUnit unit) {
            return unit.convert(timeout - System.nanoTime(), TimeUnit.NANOSECONDS);
        }
        // 自定义实现比较方法返回 1 0 -1三个参数
        @Override
        public int compareTo(Delayed other){
            if(other == this){
                return 0;
            }
            PayOrderDelay t = (PayOrderDelay) other;
            long d = (getDelay(TimeUnit.NANOSECONDS) - t.getDelay(TimeUnit.NANOSECONDS));
            return (d == 0) ? 0 : ((d < 0) ? -1 : 1);
        }
    }

    static class ConsumerThreadDemo implements Runnable{

        // 延时队列 ,消费者从其中获取消息进行消费
        private DelayQueue<DelayQueueDemo.PayOrderDelay> queue;

        public ConsumerThreadDemo(DelayQueue<DelayQueueDemo.PayOrderDelay> queue) {
            this.queue = queue;
        }

        @Override
        public void run() {
            while (true) {
                try {
                    DelayQueueDemo.PayOrderDelay take = queue.take();
                    System.out.println("消费消息id:" + take.getId() + " 消息订单" + take.getOrderId());
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

}

优点:
效率高,任务触发时间延迟低
简单,不需要借助其他第三方组件,成本低。
缺点:
没法做到分布式处理,只能在集群中选一台leader专门处理,效率低
订单数太多,容易出现 OOM
服务器重启后,数据消失

第四种:redis过期通知

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

1.redis配置文件开启"notify-keyspace-events Ex"
在这里插入图片描述

2.代码

public class RedisTest {

    private static final String IP = "127.0.0.1";
    private static final int PORT = 6379;
    private static JedisPool jedis = new JedisPool(new GenericObjectPoolConfig(), IP, PORT, 10000, "xxxxxx", 0);

    private static RedisSub sub = new RedisSub();

    // 创建一个单线程的线程池
    private static ExecutorService exec = Executors.newFixedThreadPool(1);

    public static void main(String[] args) {
        exec.submit(()->{
            jedis.getResource().subscribe(sub, "__keyevent@0__:expired");
        });
        //消息发布者,向通道发送消息
        for (int i = 0; i < 10; i++) {
            jedis.getResource().setex(i+"", i+2, "订单"+i);
            System.out.println("订单"+ i + "生成");
        }
    }

    static class RedisSub extends JedisPubSub{
        //消息消费者,消费消息
        @Override
        public void onMessage(String channel, String message){
            System.out.println(message.toString() + "取消");
        }
    }

注意:
1.Redis过期删除不精准
Redis过期时间的原理: 当对一个key设置了过期时间,Redis就会把该key带上过期时间,存到过期字典中,在redisDb中通过expires字段维护;过期字典本质上是一个链表,每个节点的数据结构分为:key是一个指针,指向某个键对象;value是一个long long类型的整数,保存了key的过期时间。
Redis主要使用了定期删除和惰性删除策略来进行过期key的删除
定期删除:每隔一段时间(默认100ms)就随机抽取一些设置了过期时间的key,检查其是否过期,如果有过期就删除。之所以这么做,是为了通过限制删除操作的执行时长和频率来减少对cpu的影响。不然每隔100ms就要遍历所有设置过期时间的key,会导致cpu负载太大。
惰性删除:不主动删除过期的key,每次从数据库访问key时,都检测key是否过期,如果过期则删除该key。惰性删除有一个问题,如果这个key已经过期了,但是一直没有被访问,就会一直保存在数据库中。
从以上的原理可以得知,Redis过期删除是不精准的,在订单超时处理的场景下,惰性删除基本上也用不到,无法保证key在过期的时候可以立即删除,更不能保证能立即通知。如果订单量比较大,那么延迟几分钟也是有可能的。
2.消息的可靠性无法保证
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)模式的,因此无法实现事件的可靠通知。也就是说,如果发布/订阅的客户端断连之后又重连,则在客户端断连期间的所有事件都丢失了

优点:
性能高,速度快
缺点:
redis5.0之前,没有消息确认机制,消息的可靠性无法保证
Redis过期删除不精准的

第五种:rocketmq延迟队列

RocketMQ支持任意秒级的定时消息,使用门槛低,只需要在发送消息的时候设置延时时间即可

在这里插入图片描述

MessageBuilder messageBuilder = null;
Long deliverTimeStamp = System.currentTimeMillis() + 10L * 60 * 1000; //延迟10分钟
Message message = messageBuilder.setTopic("topic")
        //设置消息索引键,可根据关键字精确查找某条消息。
        .setKeys("messageKey")
        //设置消息Tag,用于消费端根据指定Tag过滤消息。
        .setTag("messageTag")
        //设置延时时间
        .setDeliveryTimestamp(deliverTimeStamp) 
        //消息体
        .setBody("messageBody".getBytes())
        .build();
SendReceipt sendReceipt = producer.send(message);
System.out.println(sendReceipt.getMessageId());

优点:
精度高,支持任意时刻
使用门槛低,和使用普通消息一样
缺点:
使用限制:定时时长最大值24小时
成本高:每个订单需要新增一个定时消息,且不会马上消费,给MQ带来很大的存储成本
同一个时刻大量消息会导致消息延迟:定时消息的实现逻辑需要先经过定时存储等待触发,定时时间到达后才会被投递给消费者。因此,如果将大量定时消息的定时时间设置为同一时刻,则到达该时刻后会有大量消息同时需要被处理,会造成系统压力过大,导致消息分发延迟,影响定时精度

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值