【关单机制系列 2】JDK原生DelayQueue:一人一块倒计时牌

134 阅读3分钟

最近一段时间参与的一个电商平台项目中,关于支付模块设计到一个功能:对于用户下单但长时间未支付的订单,系统应自动将其关闭。这个看似简单的需求,背后其实有不少实现方案可选,比如利用数据库的定时任务、消息队列的延迟消息、Redis 的过期键等。每种方案都有各自的优缺点,比如延迟精度、系统开销、可扩展性等。面对这些选择,我们该如何权衡利弊,选择最适合当前业务场景的方案呢?

微信支付系列相关的文章:

在上篇中,我们介绍了定时任务扫描的“巡逻小保安”模型,虽简单易用,但并不精确。这一篇,我们要升级工具箱,引入 JDK 原生延迟队列 DelayQueue,让每个订单拥有一块专属倒计时牌,时间一到,就自动触发关闭。

这是实现订单定时关闭的一个相对轻量、无外部依赖、更精确的方案。用 Java 内建的 DelayQueue 实现一个内存级定时器,每个订单排进队列,到时间就执行关单。

🔧 什么是 DelayQueue?

DelayQueue 是 JDK 提供的一个无界阻塞队列,里面的元素必须实现 Delayed 接口。放入队列的元素只有在延迟时间到了之后才能被消费。

它的底层是最小堆 + ReentrantLock,实现线程安全的延迟出队行为,适合做本地延迟任务调度。

🎯 实现思路

  1. 用户下单后创建一个包含订单 ID 与过期时间的延迟任务对象
  2. 将该对象放入 DelayQueue
  3. 后台线程轮询从队列中获取过期任务
  4. 获取到的订单进行关闭操作(修改状态、回滚库存、发送通知等)
sequenceDiagram
    participant 用户
    participant 订单服务
    participant DelayQueue
    participant 后台线程
    participant 数据库
    participant 库存服务
    participant 通知服务

    Note over 用户,通知服务: 1. 下单流程
    用户->>订单服务: 提交订单
    订单服务->>数据库: 创建订单(状态=待支付)
    订单服务->>库存服务: 预占库存
    订单服务->>DelayQueue: 创建延迟任务(订单ID+过期时间)
    DelayQueue-->>订单服务: 确认加入队列
    
    Note over 用户,通知服务: 2. 支付期处理(两种情况)
    alt 用户支付成功
        用户->>订单服务: 完成支付
        订单服务->>数据库: 更新订单状态=已支付
        订单服务->>DelayQueue: 尝试移除对应订单任务(可选)
    else 超时未支付(进入关单流程)
        loop 后台线程轮询
            后台线程->>DelayQueue: take()阻塞获取到期订单
            DelayQueue-->>后台线程: 返回过期订单对象
            后台线程->>数据库: 检查订单状态(二次确认)
            alt 状态仍为待支付
                后台线程->>数据库: 更新状态=已取消
                后台线程->>库存服务: 释放预占库存
                后台线程->>通知服务: 发送关单通知
            else 状态已更新(如已支付)
                后台线程->>DelayQueue: 丢弃该任务
            end
        end
    end

代码实现

1. 延迟任务定义

/**
 * 订单实体类,实现Delayed接口
 */
 @Data
class OrderDelayTask implements Delayed {
    private final String orderId;         // 订单ID
    private final long expireTime;        // 过期时间戳(毫秒)
    private final LocalDateTime createTime; // 订单创建时间

    public OrderDelayTask(String orderId, long delay, TimeUnit unit) {
        this.orderId = orderId;
        this.expireTime = System.currentTimeMillis() + unit.toMillis(delay);
        this.createTime = LocalDateTime.now();
    }

    @Override
    public long getDelay(TimeUnit unit) {
        // 计算剩余延迟时间
        long diff = expireTime - System.currentTimeMillis();
        return unit.convert(diff, TimeUnit.MILLISECONDS);
    }

    @Override
    public int compareTo(Delayed o) {
        // 比较两个订单的过期时间,用于队列排序
        return Long.compare(this.expireTime, ((Order) o).expireTime);
    }
}

2. 延迟队列消费者线程

@Slf4j
@Component
public class DelayQueueWorker implements InitializingBean {
  private static final DelayQueue<OrderDelayTask> queue = new DelayQueue<>();

  @Autowired
  private OrderService orderService;

  public static void addTask(OrderDelayTask task) {
    queue.offer(task);
  }

  @Override
  public void afterPropertiesSet() {
    Thread worker = new Thread(() -> {
      while (true) {
        try {
          OrderDelayTask task = queue.take(); // 阻塞直到有任务到期
          log.info("订单 {} 到期,准备关闭", task.getOrderId());
          orderService.closeOrder(task.getOrderId());
        } catch (Exception e) {
          log.error("处理延迟订单任务出错", e);
        }
      }
    });
    worker.setDaemon(true);
    worker.start();
  }
}

3. 下单时添加任务

// 订单创建成功后,设置过期时间
Order newOrder = orderService.createOrder(...);
long expireTime = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(15);
DelayQueueWorker.addTask(new OrderDelayTask(newOrder.getId(), expireTime));

这种方案,JDK原生支持,无需引入额外依赖,基于JVM内部时间,精度可达毫秒级,对于小规模数据,处理效率高。

但是因为 layQueue是一个无界队列,如果放入的订单过多,会造成 VM OOMDelayQueue基于 JVM 内存,如果 JVM 重启了,那所有数据就丢失了。而且队列内部状态不可见,不支持取消或变更任务执行时间

如果是关于服务崩溃导致丢失数据的问题,需要在JVM重启时从持久化存储恢复未处理的订单,保证没有漏单。

DelayQueue 是一个本地化、轻量级的延迟任务方案,在中小型项目、功能初期、单机部署下非常实用。它解决了定时任务扫描带来的资源浪费与时间误差,是一人一张倒计时牌的真实演绎。