用户下订单之后15分钟支付实现_对于订单超时场景处理的思考(单机)

本文介绍了订单超时场景的处理,包括Timer、HashedWheelTimer、DelayQueue和ScheduledExecutorService四种方案。重点讨论了Timer的并发问题以及HashedWheelTimer的时间轮算法。建议对时间精确性要求不高的场景使用HashedWheelTimer,对时间精确性要求高的场景采用DelayQueue。
摘要由CSDN通过智能技术生成

业务概述

订单超时是非常常见的开发场景,如果用户下单超过30分钟时间没有支付,那么这个订单就会被自动关闭,关闭过程包括设置订单超时状态,同时释放订单锁住的商品资源。这个业务场景其实和redis的键过期删除机制有点像,redis删除机制是使用后台线程定期的扫描缓存,发现失效的缓存后就删除,但是定期扫描仍然可能导致客户端访问到过期的缓存,所以客户端在访问redis缓存的时候首先会检查key是否到期,如果已经到期,那么就返回nil。在相似的订单超时业务场景中,对于订单数据量不大的场景,笔者总结了如下四种方案,我们可以针对具体的业务场景选择适合的处理方案。

方案一 Timer

Timer是执行周期调度任务的一种解决方案,它支持如下几种调度方式

1.public void schedule(TimerTask task, Date time)

在指定的时间time调度任务

2.public void schedule(TimerTask task, long delay)

在指定的延迟后执行任务

3.public void schedule(TimerTask task, long delay, long period)

在指定的延迟delay之后以固定的频率period调度执行任务

4.public void schedule(TimerTask task, Date firstTime, long period)

以firstTime作为第一次调度的时间,之后以period为频率周期性的调度任务

5.public void scheduleAtFixedRate(TimerTask task, long delay, long period)

在指定的延迟delay之后以固定的频率period执行任务

6.public void scheduleAtFixedRate(TimerTask task, long delay, long period)

在指定的延迟delay之后以固定频率period执行任务

5和6的scheduleAtFixedRate同4和3的schedule类型的方法区别在于5和6存在并发问题,举一个例子,如果调度器从19:00开始以10分钟的频率周期性的调度任务,schedule方法第一次定时任务在19:00触发,而第二个任务理应在19:10触发,但是第一个任务执行了15分钟才执行完成,那么第二个任务在19:15才会触发。scheduleAtFixedRate则不同,即使第一个任务在19:10没有执行完成,第二个任务仍然在19:10被触发调度,scheduleAtFixedRate会出现同一时间存在多个相同任务同时执行的场景,这种方式会存在并发问题。

如果使用Timer实现定时关闭超时订单的需求,可以使用如下代码,服务每收到用户一个请求,就向Timer中添加一个Task任务

@Override
public Order newOrder(OrderRequestDto orderRequestDto) {
    Order order = new Order();
    order.setOrderStatus(OrderStatusEnum.UNPAID.getCode());
    order.setUsername(orderRequestDto.getUsername());
    order.setNum(UUID.randomUUID().toString());
    Date current = new Date();
    order.setCreateTime(current);
    order.setMoney(100 * 100);
    this.orderRepository.saveOrder(order);
    OrderTimerTask orderTimerTask = new OrderTimerTask(order.getId(), orderRepository);
    this.timer.schedule(orderTimerTask, new Date(current.getTime() + OrderConstant.CLOSE_ORDER_TIME_IN_MS));
    return order;
}

关闭订单的任务代码如下

@AllArgsConstructor
public class OrderTimerTask extends TimerTask {
    private int id;
    private OrderRepository orderRepository;
    @Override
    public void run() {
        System.out.println("执行关闭订单的任务:" + id);
        final Order order = this.orderRepository.findOrderById(id);
        if (order != null && order.getOrderStatus() == OrderStatusEnum.UNPAID.getCode()) {
            this.orderRepository.closeOrder(id);
        }
    }
}

Timer的原理是内部使用了一个数组维护的任务优先队列,Timer的处理能力比较有限,Timer内部只有一个线程来调度所有的关闭订单的任务,这也就意味着同一时间只能执行一个任务,如果有一批1000个订单同时到期需要关闭,由于数据库IO操作访问延迟,关闭最后一个订单的时候可能已经延时几秒钟了。并且如果Timer执行关闭订单任务的过程中出现了没有捕获的异常,那么Timer也会崩溃,并且其它未执行的任务也会被中断,不再执行。Timer存在的缺陷还是比较明显,Timer最严重的缺陷是没有对任务异常进行处理,所以应该尽量避免在生产环境中使用Timer,在开发中我们可以使用下面的其它方案

方案二 HashedWheelTimer

前段时间,笔者在学习netty的过程中接触了HashedWheelTimer(时间轮),在netty它可以高效处理类似于定时发心跳或者关闭心跳超时连接的功能。它对任务到期时间的管理非常的有意思,它的原理是使用了一个环形数组,数组每个位置存放一个槽位,数组中的每一个槽位都存放即将到期的任务列表。

dde0709bd35214d6c5ac642aba1efa0d.png

HashedWheelTimer中有一个行为tick,它指的是指针从当前槽位移动到相邻的下一个槽位的动作。指针会扫描所有的槽位,并执行槽位中已经到期的任务,并且将即将到期但是未到期的任务加入到后续的槽位中,以此达到让任务按时执行的目的。HashedWheelTimer有两个关键的参数,其中tickDuration代表指针从某个槽位到达相邻的下个槽位的时间间隔,而ticksPerWheel代表槽位的个数,它必须是2的幂,如果用户传入值不是2的幂次,那么HashedWheelTimer会将其转换为大于用户传入值的最小2的幂次值,目的是为了通过按位计算快速求余,是不是和HashMap的哈希桶有点像?

如果要向HashedWheelTimer中添加一个任务,任务首先会进入到优先级队列中,然后HashedWheelTimer每tick一次都从优先级队列中获取超时任务,并将任务存放到槽位中,为了让任务分布到不同的槽位中,首先会利用如下算法生成一个idx值,这个值直接对应槽位。

int stopIndex = (int) (ticks & mask);

ticks值指的是从HashedWheelTimer创建到现在总共tick的总数,而mask的值代表总槽位数减去1。举一个例子,以如下环形结构为例,这个Timer总共存在8个槽位,所以ticksPerWheel的值为8,如果tickDuration为1s,那么指针扫描所有的槽位总共需要耗时8s。如果向槽位中添加一个10秒后执行的任务,HashedWheelTimer首先需要计算出idx,假设tick的值为13,那么idx的值则为5,所以要把任务放在5所在的槽位上。

2576e9dfbc008b3d3ab7c69ce8b42a57.png

HashedWheelTimer设计中使用了优先级队列,目的是防止客户端线程添加任务的时候直接向HashedWheelTimer槽位中添加任务造成的同步阻塞HashedWheelTimer主线程的问题,而且主线程每tick一次只会从优先级队列中选取10000个任务,目的也是防止队列中任务太多,长时间取任务阻塞主线程。

如果任务量较大的时候,会有大量的任务被分配到相同的槽位,当HashedWheelTimer访问这个槽位的时候需要遍历全部的任务,比较消耗时间,我们可以增加槽位的总数。让任务尽量地负载到不同的槽位中。

使用HashedWheelTimer实现代码如下

@Override
public Order newOrder(OrderRequestDto orderRequestDto) {
    Order order = new Order();
    order.setOrderStatus(OrderStatusEnum.UNPAID.getCode());
    order.setUsername(orderRequestDto.getUsername());
    order.setNum(UUID.randomUUID().toString());
    order.setCreateTime(new Date());
    order.setMoney(100 * 100);
    this.orderRepository.saveOrder(order);
    this.hashedWheelTimer.newTimeout(new CloseOrderTask(order.getId(), orderRepository), CLOSE_TIME_IN_SECOND, TimeUnit.SECONDS);
    return order;
}

TimerTask任务如下

@Override
public void run(Timeout timeout) throws Exception {
    System.out.println("执行关闭订单的任务:" + id);
    final Order order = this.orderRepository.findOrderById(id);
    if (order != null && order.getOrderStatus() == OrderStatusEnum.UNPAID.getCode()) {
        this.orderRepository.closeOrder(id);
    }
}

HashedWheelTimer和Timer的异同

Timer底层使用优先级队列,即将被执行的任务放在索引为1的数组位置。每获取一个task之后,需要从剩余的任务中选出下一个最早需要被触发的任务,选任务的时间复杂度为O(logN)。而HashedWheelTimer使用了槽位的设计思想,每个槽存储一批任务,指针指向一个槽的时候,需要遍历这个槽的全部任务,执行已经到期的任务。如果槽中任务比较多,这个遍历也会非常的耗时,时间复杂度为O(N)

Timer没有对任务进行异常处理,任务执行过程中一旦发生了异常,那么整个Timer的任务执行线程就会崩溃,其它未处理的任务都不再执行。相比之下HashedWheelTimer处理了任务抛出的异常,某个任务崩溃并不会影响其它任务的执行。仅仅从异常处理角度来考虑,如果一定要从HashedWheelTimer和Timer中选其一,也应该选择HashedWheelTimer。

HashedWheelTimer和Timer更加适用于处理时效性不高,可以快速执行的小任务,比如关闭长时间没有心跳的网络连接,超时订单的关闭。HashedWheelTimer和Timer存在同样的问题在于,Timer和HashedWheelTimer都是单线程执行的,如果在同一时间点有非常多的任务同时被触发,那么Timer和HashedWheelTimer可能都会来不及处理,如果对实时性要求很高的话不应该选择Timer和HashedWheelTimer。

方案三 DelayQueue

如果数据量不大,可以将活跃订单完全放在内存中,DelayQueue是一种比较好的处理方案。DelayQueue也被称为延时阻塞队列,向DelayQueue中存放的元素必须实现Delayed接口,Delayed接口有一个重要的方法

long getDelay(TimeUnit unit)

它表示当前这个元素还需要多长时间到期,我们可以将未关闭订单放在DelayQueue中,如果已经有元素到期那么线程能够从队列中获取到元素,如果没有元素到期那么会阻塞在队列中。消费DelayQueue的线程可以有多个,笔者通过一个线程池管理多个消费线程,同时委托spring管理线程池,在创建线程时候,应该注意为线程设置线程名,以方面后期定位问题,创建线程池的代码如下

@Bean("closeOrderPool")
public ExecutorService executor(@Autowired ApplicationContext ctx) {
    final int processor = Runtime.getRuntime().availableProcessors();
    ThreadPoolExecutor threadPool = new ThreadPoolExecutor(processor, processor, 10,
            TimeUnit.MINUTES, new ArrayBlockingQueue<>(1000));
    threadPool.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
    threadPool.setThreadFactory(runnable -> {
        Thread thread = new Thread(runnable);
        final String name = "CloseOrderThread-" + thread.getName();
        thread.setName(name);
        return thread;
    });
    for (int i = 0; i < threadPool.getCorePoolSize(); i++) {
        threadPool.execute(new CloseOrderTask((DelayQueue<Order>) ctx.getBean("expiredOrderQueue"),
                ctx.getBean(OrderRepository.class)));
    }
    return threadPool;
}

笔者创建了6个消费线程,每个消费线程需要确保任何情况下都不会崩溃,所以笔者捕获了Throwable异常

@Override
public void run() {
    LOGGER.info("CloseOrderTask start");
    Order order = null;
    while (true) {
        try {
            order = delayQueue.take();
            this.orderRepository.closeOrder(order.getId());
        } catch (Throwable e) {
            LOGGER.info(e.getMessage(), e);
        }
    }
}

这种方案将订单数据完全放置于内存中,如果进程崩溃,那么原先未来得及处理的订单可能就会无法处理,如果实现地再好一点,可以在进程启动的时候将数据库中未关闭的订单数据全部都载入到延迟队列当中

方案四 ScheduledExecutorService方案

如果数据量比较大,不能完全放在内存当中,ScheduledExecutorService是进程内比较好的处理方案,它以固定的周期执行任务,它有两个重要的方法

public ScheduledFuture<?> schedule(Runnable command, long delay, TimeUnit unit);
public ScheduledFuture<?> scheduleAtFixedRate(Runnable command,long initialDelay,long period,TimeUnit unit);

和Timer一样,ScheduledExecutorService也有schedule和scheduleAtFixedRate方法,scheduleAtFixedRate存在并发问题。

在项目中笔者没有直接使用ScheduledThreadPoolExecutor,而是将定时任务调用委托给Spring管理,在Spring中使用定时任务需要使用@EnableScheduling开启定时任务,在需要被定时调度的方法上使用Schedule注解,在Spring Schedule注解中fixedDelay参数对应于ScheduledExecutorService中的schedule方法,而fixedRate参数对应于scheduleAtFixedRate方法,笔者没有直接使用上述两个参数,笔者使用的是Cron表达式,在使用Cron表达式的时候需要注意,如果上一轮任务没有结束,那么下一轮任务就不会启动,这样的触发方式避免了并发情况,这也符合笔者当前的业务需求。

如果我们需要配置Spring Schedule的线程池,比如为了方便线上debug,我们可以为线程设置线程名。如果有这样的特殊需求,我们也可以实现SchedulingConfigurer接口,同时不要忘了在实现类上添加@Configuration和@EnableScheduling注解。

这种方式还存在一个问题,调度轮训的频率是10分钟,用户仍然可能会查询到理应处于关闭状态但是开放的订单。我们可以借鉴redis删除过期键的设计思想,采用一个弥补策略,在用户查询的时候再次检测订单的状态,如果订单应该处于关闭状态但是没有关闭,那么就在用户查询的时候将订单设置为关闭状态,这样用户就不会查询到状态错误的订单了,但是实现起来会比较复杂,必须在每一个用户查看的订单的Service中添加关闭状态不正确的订单的逻辑,代码如下

@Override
public List<Order> listOrders(OrderRequestDto orderRequestDto) {
    PageHelper.startPage(orderRequestDto.page(), orderRequestDto.size());
    List<Order> orders = this.orderRepository.listUserOrders(orderRequestDto.getUsername());
    if (orders == null) {
        orders = Collections.EMPTY_LIST;
        return orders;
    }
    List<Order> unclosed = new ArrayList<>();
    for (Order order : orders) {
        //查询已到期但是没有关闭的订单
        if (order.getDelay(TimeUnit.SECONDS) <= 0 &&
                order.getOrderStatus() == 100) {
            unclosed.add(order);
        }
    }
    if (!unclosed.isEmpty()) {
        this.orderRepository.closeOrders(unclosed);
    }
    return orders;
}

总结

如果业务对订单关闭的时间精确性要求不是很高,笔者认为第二种方案最好,因为netty利用HashedWheelTimer可以管理十几万甚至几十万网络连接的心跳发送和超时连接管理,这足以证明了HashedWheelTimer的可靠性。

如果业务对订单关闭的时间精确性要求非常高,笔者认为DelayQueue的方案最好,这种方案将所有未关闭的订单都存放在内存中,同时使用多线程消费DelayQueue,即使在同一时间,有大批的订单都到期需要关闭,多线程处理速度也足够快。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值