订单超时实践记录

订单到期自动关闭的需求可以通过多种方案实现,主要分为以下两类:1. 轮询扫库 2. 延迟队列。

一、轮询扫库

定时轮询存在以下问题:

  1. 时间不准,存在几秒钟误差,尤其是在订单量大、处理时间长的情况下,误差更容易累积。
  2. 对数据库有压力,特别是在订单量很大的情况下。

在单机环境下,可以使用 @Scheduled 作为定时任务方案;在分布式环境中,建议使用分布式任务调度平台,例如 XXL-JOB 来实现。

二、延迟队列

延迟队列方案有很多,基本可以分为基于内存基于分布式的实现。

  1. 基于内存的方案,例如自定义实现——可以用一个线程扫描 Java 的无界阻塞队列 DelayQueue,也可以用 Netty 的时间轮来实现。Netty 的时间轮实现简单,但只适用于单机场景,不适用于分布式场景。
  2. 基于分布式的方案,例如 Redis 过期 key 监听消息或 MQ 延迟消息。使用 Redis 过期 key 非常方便,因为大多数分布式项目中都会用到 Redis;不过需要注意,Redis 官方不保证 key 过期时一定会立即删除或发出消息。因此,许多人认为使用 RabbitMQ 或 RocketMQ 这些延迟消息方案较为理想。

然而在实际项目中,我没有选择使用 MQ 处理订单超时关闭。原因在于,从中小型公司的角度来看,这种方式不一定切合实际。

如果项目并发量不大且服务器资源充足,使用 MQ 来实现服务解耦、分布式事务和订单超时关闭问题是可行的。但在低并发量的场景下,采用简单的轮询任务扫描数据库也能承担,并且实现相对简单。

如果项目并发量大,订单数量多,那么更适合让 MQ 处理更重要的业务,而不是订单超时关闭。即便资源充足,接受这种“资源浪费”,很多订单积累在 MQ 中(如大部分订单已支付),都会成为无效信息。随着消息积累,投递延迟的问题也会影响精确性。

在实际工作中,我最终选用了定时轮询的方案。为减轻数据库压力,我选择轮询缓存数据库 Redis,将数据和超时时间存入 Redis 队列。通过 XXL-JOB 轮询,获取超时数据后关闭订单并删除消息。

为了更精确,可以缩短定时任务的轮询间隔,或直接用一个线程 while(true) 不断处理。

Redisson 实现示例:

将消息存入 Redis 队列,利用有序集合(ZSet)存储数据,并使用 score 存储延迟时间:

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.16.0</version> <!-- 使用最新版本 -->
</dependency>
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RBlockingQueue;
import org.redisson.api.RDelayedQueue;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;

@Component
@Slf4j
public class DelayedQueueExample {

    private final RedissonClient redissonClient;
    private final RBlockingQueue<String> blockingQueue;
    private final RDelayedQueue<String> delayedQueue;

    public DelayedQueueExample(RedissonClient redissonClient) {
        this.redissonClient = redissonClient;
        // 创建一个阻塞队列
        this.blockingQueue = redissonClient.getBlockingQueue("delayQueue");
        // 基于阻塞队列创建延迟队列
        this.delayedQueue = redissonClient.getDelayedQueue(blockingQueue);
        this.startConsumer();
    }

    // 添加消息到延迟队列
    public void addMessageToDelayQueue(String message, long delay, TimeUnit timeUnit) {
        delayedQueue.offer(message, delay, timeUnit);
        log.info("消息已添加到延迟队列,消息内容: {}", message);
    }

    // 启动消费线程,处理延迟到期的消息
    public void startConsumer() {
        new Thread(() -> {
            while (true) {
                try {
                    String message = blockingQueue.take();
                    log.info("处理到期消息: {}", message);
                    // 执行业务逻辑
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    log.error("消费线程被中断");
                }
            }
        }, "DelayedQueueConsumer").start();
    }
}

调用 delayedQueue.offer(message, delay, timeUnit) 将消息添加到延迟队列,指定延迟时间。到期后,消息自动进入 blockingQueue,由消费线程处理。

三、并发问题处理方案

订单到期关闭表面看似简单,但容易忽略一个隐藏风险,特别是缺乏完善数据模型时,风险更难被监控系统捕获。

此风险在于支付成功订单超时关闭可能同时发生,有两种情况:

  1. 订单支付成功后执行关闭。此时数据库更新语句带有状态判断,因此关闭订单会失败,无需额外处理。
  2. 拉起支付后订单超时关闭,随后支付成功。此时可选择“原路退款”或“按成功处理”。

如果是支付中台,不需要关注此情况,支付成功时通知业务端,由业务方决定那种处理方案处理即可。业务方若选择“原路退款”,尽管要处理库存解锁、优惠券恢复等,且需通知用户,但可以简化业务逻辑。若选择“按成功处理”,则需重新扣除库存和优惠券,虽避免退款引起的用户困惑,但流程会更复杂。

推荐“原路退款”方案,因为它避免了业务侵入性,与用户申请退款类似,仅需字段区分退款类型。而按成功处理的方式会增加订单支付成功后的流程复杂度。

posted @   cqs1234  阅读(48)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!
点击右上角即可分享
微信分享提示