电商订单自动取消的8种实现方式


电商项目中,凡是涉及到交易的,必然会涉及到一个经典的问题,当用户点击下单,但是不支付,一般会设置一定的时间,比如半个小时倒计时,当超过该时间未支付,订单自动取消;该场景下,如何实现,以下是罗列的一些实现逻辑,仅供参考…

1. DelayQueue

DelayQueue是JDK提供的api,是一个延迟队列,而DelayQueue泛型参数得实现Delayed接口,Delayed继承了Comparable接口。

1.1 demo

import lombok.Getter;

import java.util.concurrent.Delayed;
import java.util.concurrent.TimeUnit;

public class OrderPayTask implements Delayed {
    /**
     * 延迟任务的具体的内容
     */
    @Getter
    private final String taskContent;
    /**
     * 延迟时间,秒为单位
     */
    private final Long triggerTime;

    public OrderPayTask(String taskContent, Long delayTime) {
        this.taskContent = taskContent;
        this.triggerTime = System.currentTimeMillis() + delayTime * 1000;
    }

    @Override
    public long getDelay(TimeUnit unit) {
        return unit.convert(triggerTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
    }

    @Override
    public int compareTo(Delayed o) {
        return this.triggerTime.compareTo(((OrderPayTask) o).triggerTime);
    }

}
  • getDelay方法返回这个任务还剩多久时间可以执行,小于0的时候说明可以这个延迟任务到了执行的时间了。
  • compareTo这个是对任务排序的,保证最先到延迟时间的任务排到队列的头。

1.2 测试

import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.DelayQueue;

@Slf4j
public class DelayQueueDemo {

    public static void main(String[] args) {
        DelayQueue<OrderPayTask> orderPayTaskDelayQueue = new DelayQueue<>();
        //TODO 这里正儿八经开发中请使用线程池
        new Thread(() -> {
            while (true) {
                try {
                    OrderPayTask orderPayTask = orderPayTaskDelayQueue.take();
                    log.info("获取到延迟任务:{}", orderPayTask.getTaskContent());
                    log.info("开始处理延迟任务");
                } catch (Exception e) {
                }
            }
        }).start();

        log.info("提交延迟任务");
        orderPayTaskDelayQueue.offer(new OrderPayTask("张三提交了订单未支付", 5L));
        orderPayTaskDelayQueue.offer(new OrderPayTask("李四提交了订单未支付", 10L));
        orderPayTaskDelayQueue.offer(new OrderPayTask("王五提交了订单未支付", 15L));
    }
}
  • offer方法在提交任务的时候,会通过根据compareTo的实现对任务进行排序,将最先需要被执行的任务放到队列头。
  • take方法获取任务的时候,会拿到队列头部的元素,也就是队列中最早需要被执行的任务,通过getDelay返回值判断任务是否需要被立刻执行,如果需要的话,就返回任务,如果不需要就会等待这个任务到延迟时间的剩余时间,当时间到了就会将任务返回。

测试结果

14:33:41.806 [main] INFO com.antispam.test.DelayQueueDemo - 提交延迟任务
14:33:46.826 [Thread-0] INFO com.antispam.test.DelayQueueDemo - 获取到延迟任务:张三提交了订单未支付
14:33:46.829 [Thread-0] INFO com.antispam.test.DelayQueueDemo - 开始处理延迟任务
14:33:51.824 [Thread-0] INFO com.antispam.test.DelayQueueDemo - 获取到延迟任务:李四提交了订单未支付
14:33:51.824 [Thread-0] INFO com.antispam.test.DelayQueueDemo - 开始处理延迟任务
14:33:56.818 [Thread-0] INFO com.antispam.test.DelayQueueDemo - 获取到延迟任务:王五提交了订单未支付
14:33:56.818 [Thread-0] INFO com.antispam.test.DelayQueueDemo - 开始处理延迟任务

2. Timer

Timer也是JDK提供的api

2.1 demo

import lombok.extern.slf4j.Slf4j;

import java.util.Timer;
import java.util.TimerTask;

@Slf4j
public class TimerDemo {

    public static void main(String[] args) {
        Timer timer = new Timer();
        log.info("提交延迟任务");
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                log.info("执行延迟任务");
            }
        }, 5000);
    }
}

2.2 测试

14:41:04.913 [main] INFO com.antispam.test.TimerDemo - 提交延迟任务
14:41:09.936 [Timer-0] INFO com.antispam.test.TimerDemo - 执行延迟任务

2.3 小总结

  • TimerTask内部有一个nextExecutionTime属性,代表下一次任务执行的时间,在提交任务的时候会计算出nextExecutionTime值。
  • Timer内部有一个TaskQueue对象,用来保存TimerTask任务的,会根据nextExecutionTime来排序,保证能够快速获取到最早需要被执行的延迟任务。
  • 在Timer内部还有一个执行任务的线程TimerThread,这个线程就跟DelayQueue demo中开启的线程作用是一样的,用来执行到了延迟时间的任务。

所以总的来看,Timer有点像整体封装了DelayQueue demo中的所有东西,让用起来简单点。

虽然Timer用起来比较简单,但是在阿里规范中是不推荐使用的,主要是有以下几点原因:

  • Timer使用单线程来处理任务,长时间运行的任务会导致其他任务的延时处理
  • Timer没有对运行时异常进行处理,一旦某个任务触发运行时异常,会导致整个Timer崩溃,不安全

3. ScheduledThreadPoolExecutor

由于Timer在使用上有一定的问题,所以在JDK1.5版本的时候提供了ScheduledThreadPoolExecutor,这个跟Timer的作用差不多,并且他们的方法的命名都是差不多的,但是ScheduledThreadPoolExecutor解决了单线程和异常崩溃等问题

3.1 demo

import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

@Slf4j
public class ScheduledThreadPoolExecutorDemo {

    public static void main(String[] args) {
        ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(2, new ThreadPoolExecutor.CallerRunsPolicy());
        log.info("提交延迟任务");
        executor.schedule(() -> log.info("执行延迟任务"), 5, TimeUnit.SECONDS);
    }

}

3.2 测试

14:41:04.913 [main] INFO com.antispam.test.TimerDemo - 提交延迟任务
14:41:09.936 [Timer-0] INFO com.antispam.test.TimerDemo - 执行延迟任务

3.3 小总结

  • ScheduledThreadPoolExecutor继承了ThreadPoolExecutor,也就是继承了线程池,所以可以有很多个线程来执行任务。
  • ScheduledThreadPoolExecutor在构造的时候会传入一个DelayedWorkQueue阻塞队列,所以线程池内部的阻塞队列是·DelayedWorkQueue
  • 在提交延迟任务的时候,任务会被封装一个任务会被封装成ScheduledFutureTask对象,然后放到DelayedWorkQueue阻塞队列中。
    在这里插入图片描述
  • ScheduledFutureTask实现了前面提到的Delayed接口,所以其实可以猜到DelayedWorkQueue会根据ScheduledFutureTask对于Delayed接口的实现来排序,所以线程能够获取到最早到延迟时间的任务。
  • 当线程从DelayedWorkQueue中获取到需要执行的任务之后就会执行任务。

4. RocketMQ

  • RocketMQ是阿里开源的一款消息中间件,实现了延迟消息的功能。
  • RocketMQ延时消息的延迟时长不支持随意时长的延迟,是通过特定的延迟等级来指定的。默认支持18个等级的延迟消息,延时等级定义在RocketMQ服务端的MessageStoreConfig类中的如下变量中:
  • MessageStoreConfig.java
private String messageDelayLevel = "1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h";

发消息时,设置delayLevel等级即可:msg.setDelayLevel(level)。level有以下三种情况:

  • level == 0,消息为非延迟消息

  • 1<=level<=maxLevel,消息延迟特定时间,例如level==1,延迟1s

  • level > maxLevel,则level== maxLevel,例如level==20,延迟2h

  • 如果这18个等级的延迟时间不符和你的要求,可以修改RocketMQ服务端的配置文件。

4.1 引入依赖

  implementation 'org.apache.rocketmq:rocketmq-spring-boot-starter:2.2.1'

4.2 发送消息

import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.client.producer.DefaultMQProducer;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.remoting.common.RemotingHelper;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;

@RestController
@Slf4j
public class RocketMQDelayTaskController {

    @Resource
    private DefaultMQProducer producer;

    @GetMapping("/rocketmq/add")
    public void addTask(@RequestParam("task") String task) throws Exception {
        Message msg = new Message("sanyouDelayTaskTopic", "TagA", task.getBytes(RemotingHelper.DEFAULT_CHARSET));
        msg.setDelayTimeLevel(2);
        // 发送消息并得到消息的发送结果,然后打印
        log.info("提交延迟任务");
        producer.send(msg);
    }

}

4.3 接收消息

import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.stereotype.Component;

@Component
@RocketMQMessageListener(consumerGroup = "sanyouConsumer", topic = "sanyouDelayTaskTopic")
@Slf4j
public class OrderPayDelayTaskTopicListener implements RocketMQListener<String> {

    @Override
    public void onMessage(String msg) {
        log.info("获取到延迟任务:{}", msg);
    }

}

4.4 小总结

在这里插入图片描述

  • 生产者发送延迟消息之后,RocketMQ服务端在接收到消息之后,会去根据延迟级别是否大于0来判断是否是延迟消息

    • 如果不大于0,说明不是延迟消息,那就会将消息保存到指定的topic中
    • 如果大于0,说明是延迟消息,此时RocketMQ会进行一波偷梁换柱的操作,将消息的topic改成SCHEDULE_TOPIC_XXXX中,XXXX不是占位符,然后存储。
  • 在BocketMQ内部有一个延迟任务,相当于是一个定时任务,这个任务就会获取SCHEDULE_TOPIC_XXXX中的消息,判断消息是否到了延迟时间,如果到了,那么就会将消息的topic存储到原来真正的topic(拿我们的例子来说就是sanyouDelayTaskTopic)中,之后消费者就可以从真正的topic中获取到消息了。

  • RocketMQ这种实现方式相比于前面提到的三种更加可靠,因为前面提到的三种任务内容都是存在内存的,服务器重启任务就丢了,如果要实现任务不丢还得自己实现逻辑,但是RocketMQ消息有持久化机制,能够保证任务不丢失。

5. RabbitMQ

RabbitMQ也是一款消息中间件,通过RabbitMQ的死信队列也可以是先延迟任务的功能。

5.1 配置类

import org.springframework.amqp.core.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.HashMap;
import java.util.Map;

@Configuration
public class RabbitMQConfiguration {

    /**
     * 声明普通交换机
     */
    @Bean
    public DirectExchange orderPayDirectExchange() {
        return new DirectExchange("orderPayDirectExchange");
    }

    /**
     * 声明 订单支付队列
     */
    @Bean
    public Queue orderPayQueue() {
        //创建集合保存队列属性
        Map<String, Object> map = new HashMap<>();
        //设置该队列绑定的死信交换机名称
        map.put("x-dead-letter-exchange", "orderPayDeadExchange");
        //设置routing key
//        map.put("x-dead-letter-routing-key", "");
        //设置队列延迟时间 10秒
        map.put("x-message-ttl", 10000);
        //创建队列
        return QueueBuilder.durable("orderPayQueue").withArguments(map).build();
    }

    /**
     * 将 订单支付队列  与  交换机 绑定
     */
    @Bean
    public Binding orderPayQueueBinding() {
        return BindingBuilder.bind(orderPayQueue()).to(orderPayDirectExchange()).with("");
    }


    /**
     * 声明死信 交换机
     */
    @Bean
    public DirectExchange orderPayDeadExchange() {
        return new DirectExchange("orderPayDeadExchange");
    }

    /**
     * 声明 死信 队列
     */
    @Bean
    public Queue orderPayDeadQueue() {
        return QueueBuilder
                //指定队列名称,并持久化
                .durable("orderPayDeadQueue")
                .build();
    }

    /**
     * 把死信交换机和死信队列进行绑定
     */
    @Bean
    public Binding orderPayDelayTaskQueueBinding() {
        return BindingBuilder.bind(orderPayDeadQueue()).to(orderPayDeadExchange()).with("");
    }

}

5.2 测试

import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;
import java.util.UUID;

@RestController
@Slf4j
public class RabbitMQDelayTaskController {

    @Resource
    private RabbitTemplate rabbitTemplate;

    @GetMapping("/rabbitmq/add")
    public void addTask(@RequestParam("task") String task) throws Exception {
        // 消息ID,需要封装到CorrelationData中
        CorrelationData correlationData = new CorrelationData(UUID.randomUUID().toString());
        log.info("提交延迟任务");
        // 发送消息
        rabbitTemplate.convertAndSend("orderPayDirectExchange", "", task, correlationData);
    }

}

5.3 小总结

整个工作流程如下:

  • 消息发送的时候会将消息发送到orderPayDirectExchange这个交换机上
  • 由于orderPayDirectExchange绑定了orderPayQueue,所以消息会被路由到orderPayQueue这个队列上
  • 由于orderPayQueue没有消费者消费消息,并且又设置了10s的过期时间,所以当消息过期之后,消息就被放到绑定的orderPayDeadExchange死信交换机中
  • 消息到达orderPayDeadExchange交换机后,由于跟orderPayDeadQueue进行了绑定,所以消息就被路由到orderPayDeadQueue中,消费者就能从orderPayDeadQueue中拿到消息了

上面说的队列与交换机的绑定关系,就是上面的配置类所干的事。

  • 其实从这个单从消息流转的角度可以看出,RabbitMQ跟RocketMQ实现有相似之处。

  • 消息最开始都并没有放到最终消费者消费的队列中,而都是放到一个中间队列中,等消息到了过期时间或者说是延迟时间,消息就会被放到最终的队列供消费者消息。

  • 只不过RabbitMQ需要你显示的手动指定消息所在的中间队列,而RocketMQ是在内部已经做好了这块逻辑。

  • 除了基于RabbitMQ的死信队列来做,RabbitMQ官方还提供了延时插件,也可以实现延迟消息的功能,这个插件的大致原理也跟上面说的一样,延时消息会被先保存在一个中间的地方,叫做Mnesia,然后有一个定时任务去查询最近需要被投递的消息,将其投递到目标队列中。

6. 监听Redis过期key

在Redis中,有个发布订阅的机制
在这里插入图片描述

  • 生产者在消息发送时需要到指定发送到哪个channel上,消费者订阅这个channel就能获取到消息。
    图中channel理解成MQ中的topic。
  • 并且在Redis中,有很多默认的channel,只不过向这些channel发送消息的生产者不是我们写的代码,而是Redis本身。这里面就有这么一个channel叫做__keyevent@__:expired,db是指Redis数据库的序号。
  • 当某个Redis的key过期之后,Redis内部会发布一个事件到__keyevent@__:expired这个channel上,只要监听这个事件,那么就可以获取到过期的key。
  • 所以基于监听Redis过期key实现延迟任务的原理如下:
    • 将延迟任务作为key,过期时间设置为延迟时间
    • 监听__keyevent@__:expired这个channel,那么一旦延迟任务到了过期时间(延迟时间),那么就可以获取到这个任务

Spring已经实现了监听__keyevent@__:expired这个channel这个功能,__keyevent@__:expired中的*代表通配符的意思,监听所有的数据库。

所以demo写起来就很简单了,只需4步即可

6.1 demo

KeyExpirationEventMessageListener实现了对__keyevent@*__:expiredchannel的监听
在这里插入图片描述

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.listener.KeyExpirationEventMessageListener;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;

@Configuration
public class RedisConfiguration {

    @Bean
    public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory connectionFactory) {
        RedisMessageListenerContainer redisMessageListenerContainer = new RedisMessageListenerContainer();
        redisMessageListenerContainer.setConnectionFactory(connectionFactory);
        return redisMessageListenerContainer;
    }

    @Bean
    public KeyExpirationEventMessageListener redisKeyExpirationListener(RedisMessageListenerContainer redisMessageListenerContainer) {
        return new KeyExpirationEventMessageListener(redisMessageListenerContainer);
    }

}
  implementation 'org.springframework.boot:spring-boot-starter-data-redis:3.0.2'

当KeyExpirationEventMessageListener收到Redis发布的过期Key的消息的时候,会发布RedisKeyExpiredEvent事件

import org.springframework.context.ApplicationListener;
import org.springframework.data.redis.core.RedisKeyExpiredEvent;
import org.springframework.stereotype.Component;

@Component
public class MyRedisKeyExpiredEventListener implements ApplicationListener<RedisKeyExpiredEvent> {

    @Override
    public void onApplicationEvent(RedisKeyExpiredEvent event) {
        byte[] body = event.getSource();
        System.out.println("获取到延迟消息:" + new String(body));
    }

}
  • 所以我们只需要监听RedisKeyExpiredEvent事件就可以拿到过期消息的Key,也就是延迟消息。

  • 对RedisKeyExpiredEvent事件的监听实现MyRedisKeyExpiredEventListener

6.2 缺点

  • 任务存在延迟
    • Redis过期事件的发布不是指key到了过期时间就发布,而是key到了过期时间被清除之后才会发布事件。

    • 而Redis过期key的两种清除策略,就是面试八股文常背的两种:

      • 惰性清除:当这个key过期之后,访问时,这个Key才会被清除
      • 定时清除:后台会定期检查一部分key,如果有key过期了,就会被清除
        所以即使key到了过期时间,Redis也不一定会发送key过期事件,这就到导致虽然延迟任务到了延迟时间也可能获取不到延迟任务。
  • 丢消息太频繁
    • Redis实现的发布订阅模式,消息是没有持久化机制,当消息发布到某个channel之后,如果没有客户端订阅这个channel,那么这个消息就丢了,并不会像MQ一样进行持久化,等有消费者订阅的时候再给消费者消费。

    • 所以说,假设服务重启期间,某个生产者或者是Redis本身发布了一条消息到某个channel,由于服务重启,没有监听这个channel,那么这个消息自然就丢了。

  • 消息消费只有广播模式
    • Redis的发布订阅模式消息消费只有广播模式一种。
    • 所谓的广播模式就是多个消费者订阅同一个channel,那么每个消费者都能消费到发布到这个channel的所有消息。
    • 所以,如果通过监听channel来获取延迟任务,那么一旦服务实例有多个的话,还得保证消息不能重复处理,额外地增加了代码开发量。
  • 接收到所有key的某个事件
    • 这个不属于Redis发布订阅模式的问题,而是Redis本身事件通知的问题。
    • 当监听了__keyevent@__:expired的channel,那么所有的Redis的key只要发生了过期事件都会被通知给消费者,不管这个key是不是消费者想接收到的。
    • 所以如果你只想消费某一类消息的key,那么还得自行加一些标记,比如消息的key加个前缀,消费的时候判断一下带前缀的key就是需要消费的任务。

7. Hutool的SystemTimer

Hutool工具类也提供了延迟任务的实现SystemTimer

import cn.hutool.cron.timingwheel.SystemTimer;
import cn.hutool.cron.timingwheel.TimerTask;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class SystemTimerDemo {

    public static void main(String[] args) {
        SystemTimer systemTimer = new SystemTimer();
        systemTimer.start();

        log.info("提交延迟任务");
        systemTimer.addTask(new TimerTask(() -> log.info("执行延迟任务"), 5000));
    }

}

Hutool底层其实也用到了时间轮。

7.1 时间轮

在这里插入图片描述

  • 如图,时间轮会被分成很多格子(上述demo中的8就代表了8个格子),一个格子代表一段时间(上述demo中的100就代表一个格子是100ms),所以上述demo中,每800ms会走一圈。

  • 当任务提交的之后,会根据任务的到期时间进行hash取模,计算出这个任务的执行时间所在具体的格子,然后添加到这个格子中,通过如果这个格子有多个任务,会用链表来保存。所以这个任务的添加有点像HashMap储存元素的原理。

8. Qurtaz

Qurtaz是一款开源作业调度框架,基于Qurtaz提供的api也可以实现延迟任务的功能。

import lombok.extern.slf4j.Slf4j;
import org.quartz.*;

@Slf4j
public class OrderPayJob implements Job {
    @Override
    public void execute(JobExecutionContext context) throws JobExecutionException {
        JobDetail jobDetail = context.getJobDetail();
        JobDataMap jobDataMap = jobDetail.getJobDataMap();
        log.info("获取到延迟任务:{}", jobDataMap.get("delayTask"));
    }
}
import cn.hutool.core.date.DateUtil;
import lombok.extern.slf4j.Slf4j;
import org.quartz.*;
import org.quartz.impl.StdSchedulerFactory;

import java.util.Date;

@Slf4j
public class QuartzDemo {

    public static void main(String[] args) throws SchedulerException, InterruptedException {
        // 1.创建Scheduler的工厂
        SchedulerFactory sf = new StdSchedulerFactory();
        // 2.从工厂中获取调度器实例
        Scheduler scheduler = sf.getScheduler();

        // 6.启动 调度器
        scheduler.start();

        // 3.创建JobDetail,Job类型就是上面说的OrderPayJob
        JobDetail jb = JobBuilder.newJob(OrderPayJob.class)
                .usingJobData("delayTask", "这是一个延迟任务")
                .build();

        // 4.创建Trigger
        Trigger t = TriggerBuilder.newTrigger()
                //任务的触发时间就是延迟任务到的延迟时间
                .startAt(DateUtil.offsetSecond(new Date(), 5))
                .build();

        // 5.注册任务和定时器
        log.info("提交延迟任务");
        scheduler.scheduleJob(jb, t);
    }
}

8.1 实现原理

核心组件

  • Job:表示一个任务,execute方法的实现是对任务的执行逻辑
  • JobDetail:任务的详情,可以设置任务需要的参数等信息
  • Trigger:触发器,是用来触发业务的执行,比如说指定5s后触发任务,那么任务就会在5s后触发
  • Scheduler:调度器,内部可以注册多个任务和对应任务的触发器,之后会调度任务的执行

启动的时候会开启一个QuartzSchedulerThread调度线程,这个线程会去判断任务是否到了执行时间,到的话就将任务交给任务线程池去执行。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Alan0517

感谢您的鼓励与支持!

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值