JDK延时队列实现订单延时关闭

发布时间 2023-04-13 10:23:23作者: _fun_ny

对于商城系统来说,一般都有订单到期未支付取消订单的操作,我们规模较小目前没有引入消息中间件。这个功能之前是通过监听Redis的key过期事件来实现的,后续了解到Reids这种方案受限于Redis的过期策略,如果这个key过期未被Redis扫描到,那么就不会触发过期事件订单也不会关闭。

如果单纯采用定时任务的方式,定时扫表不仅会增加数据库压力,订单关闭的实时性也无法保证。

想起来JDK本身提供了延时队列的实现,缺陷是这个数据是保存在内存中,服务重启或宕机都会丢失数据,针对这一点,其实可以放入延时队列之前就将数据入库,表的字段可以自己根据需要设计,大概需要三个字段

  • 订单Id
  • 到期的时间戳
  • 以及是否被处理

那么重启服务时,就可以监听容器的启动事件,将数据恢复到内存中,然后启动线程消费延时队列,当消费一个订单后,将是否被处理的字段打上标识,防止下次被服务重启被重复处理。

JDK提供的延时队列是java.util.concurrent.DelayQueue,改队列中的元素必须实现java.util.concurrent.Delayed接口,下面是实现的案例

public class Order implements Delayed {
    private final String orderId;

    private final Long expireTime;


    public Order(Long delay, TimeUnit unit, String orderId) {
        this.orderId = orderId;
        this.expireTime = System.currentTimeMillis() + unit.toMillis(delay);
    }


    @Override
    public String toString() {
        return new StringJoiner(", ", Order.class.getSimpleName() + "[", "]")
                .add("orderId='" + orderId + "'")
                .add("expireTime=" + expireTime)
                .toString();
    }

    @Override
    public long getDelay(TimeUnit unit) {
        System.out.println("到期了么?");
        long l = expireTime - System.currentTimeMillis();
		return l;
    }

    @Override
    public int compareTo(Delayed o) {
        long l = this.expireTime - ((Order) o).expireTime;
        return (int) l;
    }

    /**
     * 测试案例
     * @param args
     * @throws InterruptedException
     */
    public static void main(String[] args) throws InterruptedException {
        DelayQueue<Order> orders = new DelayQueue<>();
        orders.put(new Order(2L, TimeUnit.SECONDS, "2秒过期"));
        orders.put(new Order(5L, TimeUnit.SECONDS, "5秒过期"));
        for (int i = 0; i < 2; i++) {
            Order take = orders.take();
            System.out.println(take);
        }
    }
}

需要注意的是DelayQueue中的元素是通过PriorityQueue来存储队列中的元素的,这个队列是无界队列,如果订单量很大的话,会有OOM的风险,我们系统体量较小,所以采用这种方式问题不会太大

Order类需要实现两个方法:分别是compareTo()getDelay(),上面的代码总体上和网上的实现思路相同,测试发现控制台中会不断打印getDelay()方法中输出的“到期了么?”,这明显不符合逻辑,DelayQueue这个队列是实现了BlockingQueue接口,调用该队列的take()方法是应该会阻塞,而不是反复调用来判断是否到达延迟时间,问题就出在getDelay()方法的参数上,通过debug发现,调用该方法时,TimeUnit传来的是NANOSECONDS这个纳秒单位,源码中是这么写的:

image-20230413095552973

原来这里delay方法的返回值决定了线程的休眠时间,由于我们忽略了单位换算,导致这个休眠时间是以毫秒计算的,而这里休眠的方法则是按照纳秒处理,导致这个方法被反复调用了。

知道了原因,处理起来就比较简单了,将getDelay()方法改成下面的写法,进行一次单位换算即可,这样返回的就是毫秒单位,那么上面休眠就是按照正确的数值进行处理的。

    @Override
    public long getDelay(TimeUnit unit) {
        System.out.println("到期了么?");
        long l = expireTime - System.currentTimeMillis();
        return unit.convert(l, TimeUnit.MILLISECONDS);
    }

对于这一点需要格外注意,否则线程并不是阻塞状态,从而浪费CPU资源。