订单超时未支付自动取消实现方案 | 三种方案详解

订单超时取消是电商系统中的核心功能,也是面试高频考点。本文详细介绍三种实现方案:定时任务扫描、Redis ZSet 延迟队列、RabbitMQ 延时队列,从原理到代码,手把手教你搞定这个功能。


📑 目录


一、业务场景与需求

1.1 典型业务场景

markdown 复制代码
用户下单
    ↓
订单创建(状态:待支付)
    ↓
30分钟内完成支付 → 支付成功 → 订单完成
    ↓(超时未支付)
订单自动取消 → 库存释放 → 优惠券退回

1.2 核心需求

需求 说明
时间精确 下单后30分钟自动取消
状态一致 取消前必须检查订单状态
幂等处理 防止重复取消
高可用 不能漏掉任何超时订单

二、为什么不能用简单定时任务?

2.1 定时任务扫描的问题

diff 复制代码
问题1:时间精度差
- 定时任务最快也是分钟级
- 用户感知:下单29分钟还在挂着,第31分钟才取消
- 体验不好

问题2:数据库压力大
- 每次扫描全表 or 大量数据
- 订单量大了数据库扛不住

问题3:分布式重复执行
- 多台服务器部署定时任务
- 没有协调机制会重复取消

2.2 延时队列的核心思想

vbnet 复制代码
把"时间"变成"数据结构"

定时任务:轮询时间 → 判断是否过期
延时队列:时间戳作为key → 时间到了自动触发

核心区别:定时任务是主动"拉",延时队列是自动"推"

三、方案一:定时任务扫描

3.1 原理

ini 复制代码
定时任务(每分钟执行)
    ↓
查询:status='待支付' AND create_time < NOW()-30分钟
    ↓
逐个处理:检查状态 → 取消订单 → 释放库存

3.2 代码实现

java 复制代码
@Service
@Slf4j
public class OrderTimeoutJob {

    @Autowired
    private OrderMapper orderMapper;

    @Autowired
    private OrderService orderService;

    /**
     * 每分钟扫描一次超时订单
     */
    @Scheduled(fixedDelay = 60000)  // 60秒 = 1分钟
    public void cancelTimeoutOrders() {
        log.info("开始扫描超时订单...");

        // 计算超时时间点:当前时间 - 30分钟
        LocalDateTime expireTime = LocalDateTime.now().minusMinutes(30);

        // 查询超时未支付的订单
        List<Order> timeoutOrders = orderMapper.selectList(
            new LambdaQueryWrapper<Order>()
                .eq(Order::getStatus, OrderStatus.PENDING_PAYMENT)  // 待支付
                .lt(Order::getCreateTime, expireTime)                // 创建时间早于超时时间
        );

        log.info("发现{}个超时订单", timeoutOrders.size());

        // 逐个取消
        for (Order order : timeoutOrders) {
            try {
                orderService.cancelOrder(order.getId(), "超时自动取消");
                log.info("订单{}已超时取消", order.getId());
            } catch (Exception e) {
                log.error("取消订单{}失败", order.getId(), e);
            }
        }
    }
}

3.3 优缺点

优点 缺点
✅ 实现简单 ❌ 时间精度差(分钟级)
✅ 代码好维护 ❌ 数据库压力大
✅ 适合小系统 ❌ 分布式下可能重复

四、方案二:Redis ZSet 延迟队列

4.1 原理

css 复制代码
┌─────────────────────────────────────────────────────────┐
│                    Redis ZSet 延迟队列                    │
└─────────────────────────────────────────────────────────┘

下单时:
ZADD order:timeout {过期时间戳} {订单ID}

定时轮询(每秒):
ZRANGEBYSCORE order:timeout 0 {当前时间戳}
→ 获取所有已过期的订单ID

处理完成:
ZREM order:timeout {订单ID}

4.2 代码实现

java 复制代码
@Service
@Slf4j
public class OrderTimeoutService {

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Autowired
    private OrderService orderService;

    /** 延时队列的Key */
    private static final String TIMEOUT_QUEUE_KEY = "order:timeout:pending";

    /** 超时时间:30分钟 */
    private static final long TIMEOUT_MS = 30 * 60 * 1000;

    /**
     * 下单时:加入延时队列
     */
    public void addToDelayQueue(Long orderId) {
        // 过期时间 = 当前时间 + 30分钟
        long expireTime = System.currentTimeMillis() + TIMEOUT_MS;

        // ZSet:score = 过期时间戳
        redisTemplate.opsForZSet().add(TIMEOUT_QUEUE_KEY, orderId.toString(), expireTime);

        log.info("订单{}已加入延时队列,过期时间:{}", orderId, new Date(expireTime));
    }

    /**
     * 定时任务:每秒检查一次延时队列
     */
    @Scheduled(fixedDelay = 1000)  // 1秒
    public void processTimeoutOrders() {
        long now = System.currentTimeMillis();

        // 获取所有已过期的订单(score <= 当前时间)
        Set<String> expiredOrders = redisTemplate.opsForZSet()
            .rangeByScore(TIMEOUT_QUEUE_KEY, 0, now);

        if (expiredOrders == null || expiredOrders.isEmpty()) {
            return;
        }

        log.info("发现{}个过期订单", expiredOrders.size());

        for (String orderId : expiredOrders) {
            // 1. 从队列移除(防止重复处理)
            Long removed = redisTemplate.opsForZSet().remove(TIMEOUT_QUEUE_KEY, orderId);
            if (removed == null || removed == 0) {
                // 已经被其他节点处理过了
                continue;
            }

            // 2. 取消订单
            try {
                orderService.cancelOrder(Long.parseLong(orderId), "超时自动取消");
                log.info("订单{}已超时取消", orderId);
            } catch (Exception e) {
                log.error("取消订单{}失败", orderId, e);
                // 处理失败,重新加入队列(可选)
                addToDelayQueue(Long.parseLong(orderId));
            }
        }
    }
}

4.3 优缺点

优点 缺点
✅ 时间精度高(秒级) ❌ 需要额外部署 Redis
✅ 数据库压力小 ❌ Redis 挂了会丢任务
✅ 单节点处理,无重复 ❌ 需要额外处理 Redis 持久化

五、方案三:RabbitMQ 延时队列

5.1 原理

arduino 复制代码
┌─────────────────────────────────────────────────────────┐
│                    RabbitMQ 延时队列                      │
└─────────────────────────────────────────────────────────┘

下单时:
发送消息到 order.delay.queue(TTL=30分钟)

30分钟后:
消息TTL过期 → 进入死信队列 order.cancel.queue

消费者:
监听死信队列 → 检查订单状态 → 取消订单

5.2 配置类实现

java 复制代码
@Configuration
public class RabbitMQDelayConfig {

    /** 延时交换机 */
    public static final String DELAY_EXCHANGE = "order.delay.exchange";
    /** 死信交换机 */
    public static final String DLX_EXCHANGE = "order.dlx.exchange";

    /** 延时队列(TTL=30分钟) */
    public static final String DELAY_QUEUE = "order.delay.queue";
    /** 死信队列(真正消费) */
    public static final String CANCEL_QUEUE = "order.cancel.queue";

    /** 路由Key */
    public static final String ROUTING_KEY = "order.timeout";

    /**
     * 延时队列配置
     * - 设置 TTL = 30分钟
     * - 过期后进入死信交换机
     */
    @Bean
    public Queue delayQueue() {
        return QueueBuilder.durable(DELAY_QUEUE)
            .ttl(30 * 60 * 1000)  // 30分钟 TTL
            .deadLetterExchange(DLX_EXCHANGE)  // 死信交换机
            .deadLetterRoutingKey(ROUTING_KEY)  // 死信路由Key
            .build();
    }

    /**
     * 死信队列配置
     */
    @Bean
    public Queue cancelQueue() {
        return QueueBuilder.durable(CANCEL_QUEUE).build();
    }

    /**
     * 延时交换机
     */
    @Bean
    public DirectExchange delayExchange() {
        return new DirectExchange(DELAY_EXCHANGE);
    }

    /**
     * 死信交换机
     */
    @Bean
    public DirectExchange dlxExchange() {
        return new DirectExchange(DLX_EXCHANGE);
    }

    /**
     * 绑定延时队列
     */
    @Bean
    public Binding delayBinding() {
        return BindingBuilder
            .bind(delayQueue())
            .to(delayExchange())
            .with(ROUTING_KEY);
    }

    /**
     * 绑定死信队列
     */
    @Bean
    public Binding cancelBinding() {
        return BindingBuilder
            .bind(cancelQueue())
            .to(dlxExchange())
            .with(ROUTING_KEY);
    }
}

5.3 生产者和消费者

java 复制代码
@Service
@Slf4j
public class OrderServiceImpl implements OrderService {

    @Autowired
    private RabbitTemplate rabbitTemplate;

    @Autowired
    private OrderMapper orderMapper;

    /**
     * 创建订单
     */
    @Override
    @Transactional
    public Order createOrder(CreateOrderDTO dto) {
        // 1. 创建订单
        Order order = new Order();
        order.setStatus(OrderStatus.PENDING_PAYMENT);
        order.setCreateTime(new Date());
        orderMapper.insert(order);

        // 2. 发送延时消息(30分钟后投递)
        rabbitTemplate.convertAndSend(
            RabbitMQDelayConfig.DELAY_EXCHANGE,
            RabbitMQDelayConfig.ROUTING_KEY,
            order.getId()
        );

        log.info("订单{}创建成功,30分钟后检查超时", order.getId());

        return order;
    }
}

/**
 * 订单取消消费者
 */
@Component
@Slf4j
public class OrderCancelConsumer {

    @Autowired
    private OrderService orderService;

    @Autowired
    private OrderMapper orderMapper;

    /**
     * 监听死信队列,处理超时订单
     */
    @RabbitListener(queues = RabbitMQDelayConfig.CANCEL_QUEUE)
    public void handleTimeoutOrder(Long orderId, Channel channel,
                                   @Header(AmqpHeaders.DELIVERY_TAG) long deliveryTag) {
        log.info("收到超时订单消息:{}", orderId);

        try {
            // 1. 查询订单当前状态
            Order order = orderMapper.selectById(orderId);

            if (order == null) {
                log.warn("订单{}不存在", orderId);
                channel.basicAck(deliveryTag, false);
                return;
            }

            // 2. 检查订单状态(可能用户已经支付了)
            if (order.getStatus() == OrderStatus.PENDING_PAYMENT) {
                // 3. 取消订单
                orderService.cancelOrder(orderId, "超时自动取消");
                log.info("订单{}已超时取消", orderId);
            } else {
                log.info("订单{}已支付,无需取消,状态:{}", orderId, order.getStatus());
            }

            // 4. 确认消息
            channel.basicAck(deliveryTag, false);

        } catch (Exception e) {
            log.error("处理超时订单{}失败", orderId, e);
            // 5. 拒绝消息,重新入队(会再次尝试)
            try {
                channel.basicNack(deliveryTag, false, true);
            } catch (IOException ex) {
                log.error("消息拒绝失败", ex);
            }
        }
    }
}

5.4 优缺点

优点 缺点
✅ 时间精度高(秒级) ❌ 实现复杂度高
✅ 高可靠性(消息持久化) ❌ 需要额外部署 RabbitMQ
✅ 支持重试和死信队列 ❌ 运维成本高
✅ 天然支持分布式 ❌ 消息顺序性问题

六、三种方案对比

维度 定时任务 Redis ZSet RabbitMQ 延时队列
实现复杂度 低 ⭐⭐ 中 ⭐⭐⭐ 高 ⭐⭐⭐⭐⭐
时间精度 分钟级 秒级 秒级
可靠性 低 中 高
数据库压力 高 低 低
可扩展性 差 中 好
运维成本 低 中 高
适用规模 小于1万订单/天 1-100万订单/天 百万级以上

选型建议

markdown 复制代码
┌─────────────────────────────────────────────────────────┐
│                     选型决策树                            │
└─────────────────────────────────────────────────────────┘

订单量 < 1万/天?
    ↓ 是
定时任务足够(简单直接)

订单量 1万-100万/天?
    ↓ 是
Redis ZSet(性能与复杂度平衡)

订单量 > 100万/天 或 需要高可靠?
    ↓ 是
RabbitMQ 延时队列

七、核心代码实现

7.1 订单服务

java 复制代码
@Service
@Transactional
@Slf4j
public class OrderServiceImpl implements OrderService {

    @Autowired
    private OrderMapper orderMapper;

    @Autowired
    private InventoryService inventoryService;

    @Autowired
    private CouponService couponService;

    /**
     * 取消订单(核心方法)
     */
    @Override
    public void cancelOrder(Long orderId, String reason) {
        // 1. 查询订单
        Order order = orderMapper.selectById(orderId);
        if (order == null) {
            throw new BusinessException("订单不存在");
        }

        // 2. 幂等检查:只有待支付状态才能取消
        if (order.getStatus() != OrderStatus.PENDING_PAYMENT) {
            log.info("订单{}状态不是待支付,无需取消,当前状态:{}",
                orderId, order.getStatus());
            return;
        }

        // 3. 更新订单状态
        order.setStatus(OrderStatus.CANCELLED);
        order.setCancelTime(new Date());
        order.setCancelReason(reason);
        orderMapper.updateById(order);

        // 4. 释放库存
        inventoryService.releaseStock(order.getProductId(), order.getQuantity());

        // 5. 退回优惠券(如有)
        if (order.getCouponId() != null) {
            couponService.returnCoupon(order.getCouponId());
        }

        log.info("订单{}已取消,原因:{}", orderId, reason);
    }
}

7.2 单元测试

java 复制代码
@SpringBootTest
class OrderTimeoutTest {

    @Autowired
    private OrderTimeoutService timeoutService;

    @Autowired
    private OrderService orderService;

    @Test
    void testAddToDelayQueue() {
        // 创建测试订单
        Order order = orderService.createOrder(new CreateOrderDTO());

        // 加入延时队列
        timeoutService.addToDelayQueue(order.getId());

        // 验证
        Order saved = orderService.getById(order.getId());
        assertEquals(OrderStatus.PENDING_PAYMENT, saved.getStatus());
    }

    @Test
    void testCancelAfterTimeout() {
        // 创建并加入队列
        Order order = orderService.createOrder(new CreateOrderDTO());
        timeoutService.addToDelayQueue(order.getId());

        // 模拟超时(实际需要等待30分钟)
        // 这里直接调用处理方法
        timeoutService.processTimeoutOrders();

        // 验证订单已取消
        Order cancelled = orderService.getById(order.getId());
        assertEquals(OrderStatus.CANCELLED, cancelled.getStatus());
    }
}

八、常见踩坑与解决方案

8.1 踩坑1:取消用户刚支付的订单

问题:用户在超时前1秒支付,但定时任务刚好在支付之前扫描到了订单。

解决方案:

java 复制代码
// 取消前必须检查状态
Order order = orderMapper.selectById(orderId);
if (order.getStatus() != OrderStatus.PENDING_PAYMENT) {
    return; // 已支付,无需取消
}
orderService.cancelOrder(orderId, "超时自动取消");

8.2 踩坑2:分布式下重复取消

问题:多台服务器部署,每个节点都在扫描,重复取消。

解决方案:

java 复制代码
// Redis ZSet 方案:先删除再处理
Long removed = redisTemplate.opsForZSet()
    .remove(TIMEOUT_QUEUE_KEY, orderId);
if (removed == null || removed == 0) {
    return; // 已被其他节点处理
}

8.3 踩坑3:Redis 宕机丢任务

问题:Redis 重启后延时队列数据丢失。

解决方案:

java 复制代码
// 1. 开启 Redis 持久化(AOF + RDB)
// 2. 或者定期将延时队列持久化到 DB
// 3. 服务启动时从 DB 恢复延时任务

8.4 踩坑4:消息重复投递

问题:RabbitMQ 消费者处理失败,消息重新入队,导致重复取消。

解决方案:

java 复制代码
// 消费者必须幂等
@RabbitListener(queues = "order.cancel.queue")
public void cancel(Long orderId) {
    // 取消前检查状态
    if (order.getStatus() != PENDING_PAYMENT) {
        return; // 幂等:不是待支付状态,直接返回
    }
    orderService.cancel(orderId);
}

8.5 踩坑5:超时时间配置硬编码

问题:30分钟硬编码,后续修改麻烦。

解决方案:

java 复制代码
// 配置化
@Value("${order.timeout.minutes:30}")
private int timeoutMinutes;

public void addToDelayQueue(Long orderId) {
    long expireTime = System.currentTimeMillis() + timeoutMinutes * 60 * 1000;
    redisTemplate.opsForZSet().add(key, orderId.toString(), expireTime);
}

九、总结

9.1 核心要点回顾

要点 说明
三种方案 定时任务、Redis ZSet、RabbitMQ 延时队列
核心原则 下单注册 → 过期触发 → 幂等取消
定时任务 简单但精度差,适合小系统
Redis ZSet 性能好,需要额外运维 Redis
RabbitMQ 高可靠,复杂度高,适合大规模系统

9.2 一句话总结

订单超时取消 = 下单时注册延时任务 + 过期时检查状态 + 幂等取消;小系统用定时任务,中等用 Redis ZSet,高可靠用 MQ 延时队列!

9.3 延伸阅读


如果这篇文章对你有帮助,欢迎点赞、评论、转发!

相关推荐
easyboot1 小时前
Memurai Windows下的redis
redis
考研保研资料分享1 小时前
国际商务保研复盘:贸大自贸港题、央财投资研究追问与西财华为案例
面试
鱼宵1 小时前
支付网关收官:Spring Boot 里,策略、模板方法、观察者怎么一起干活
spring boot·后端·面试
救救孩子把1 小时前
AI面试八股文-12_11-多模态大模型面试题
人工智能·面试·职场和发展·多模态·clip·rag·llave
我不会起名字3222 小时前
Redis RDB 落盘时的 fork 停顿:COW 机制与 3 个降低影响的配置
redis·持久化·fork·cow·rdb
蓝胖的四次元口袋2 小时前
RabbitMQ知识梳理(2)
分布式·rabbitmq
蓝胖的四次元口袋2 小时前
Redis 知识梳理(4)
数据库·redis
我不会起名字3223 小时前
Redis 从 0 到 1(十一):缓存穿透/击穿/雪崩三连击,Go 3 套防御代码
redis·缓存·go·布隆过滤器·雪崩
考研保研资料分享3 小时前
2022投资学跨保中国农业大学公共管理:四校面试与海投复盘
面试·职场和发展