订单超时取消机制中的重复取消问题

一、Bug 场景

在一个电商系统中,订单创建后若 30 分钟未支付则自动取消。系统采用两种方式处理超时订单:① 定时任务(每 5 分钟扫描一次未支付订单,执行取消逻辑);② 基于 Redis 过期键通知(订单创建时在 Redis 中设置 30 分钟过期键,过期时触发取消回调)。但实际运行中发现,部分订单被重复取消,导致库存回滚多次,出现库存异常。

二、代码示例

定时任务取消订单

java 复制代码
@Component
@EnableScheduling
public class OrderTimeoutSchedule {

    @Autowired
    private OrderService orderService;

    // 每5分钟执行一次
    @Scheduled(cron = "0 0/5 * * * ?")
    public void cancelTimeoutOrders() {
        // 查询30分钟前未支付的订单
        List<Order> timeoutOrders = orderService.queryUnpaidOrdersBefore(LocalDateTime.now().minusMinutes(30));
        for (Order order : timeoutOrders) {
            // 执行取消订单逻辑(扣减的库存回滚)
            orderService.cancelOrder(order.getId());
        }
    }
}

Redis 过期通知取消订单

java 复制代码
@Component
public class RedisKeyExpireListener {

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Autowired
    private OrderService orderService;

    @PostConstruct
    public void init() {
        // 订阅Redis过期键通知
        redisTemplate.getConnectionFactory().getConnection().subscribe(
            message -> {
                // 消息内容为过期的键名,格式:order:timeout:{orderId}
                String expiredKey = new String(message.getBody());
                if (expiredKey.startsWith("order:timeout:")) {
                    String orderId = expiredKey.split(":")[2];
                    // 执行取消订单逻辑
                    orderService.cancelOrder(Long.parseLong(orderId));
                }
            },
            "__keyevent@0__:expired".getBytes()
        );
    }
}

订单服务(取消订单逻辑)

java 复制代码
@Service
public class OrderService {

    @Autowired
    private OrderMapper orderMapper;

    @Autowired
    private ProductMapper productMapper;

    /**
     * 取消订单(回滚库存)
     */
    @Transactional
    public void cancelOrder(Long orderId) {
        Order order = orderMapper.selectById(orderId);
        if (order == null || order.getStatus() != 0) { // 0-未支付
            return;
        }
        // 1. 更新订单状态为取消
        order.setStatus(2); // 2-已取消
        orderMapper.updateById(order);
        // 2. 回滚商品库存
        productMapper.increaseStock(order.getProductId(), order.getQuantity());
    }

    // 其他方法:queryUnpaidOrdersBefore等
}

三、问题描述

  1. 预期行为:每个超时未支付的订单仅被取消一次,库存正确回滚一次。

  2. 实际行为:部分订单被重复取消,导致库存多次回滚(例如,实际库存 10 件,订单购买 2 件,重复取消 3 次后库存变为 16 件)。原因如下:

    • 定时任务与 Redis 通知冲突:同一订单既被定时任务扫描到,又触发了 Redis 过期通知,两种机制同时执行取消逻辑。
    • 定时任务内部并发:定时任务执行时,若处理时间过长,下一次任务提前启动(如前一次未执行完,新任务开始),导致同一批订单被重复处理。
    • 订单状态判断非原子cancelOrder 方法中,"查询订单状态" 与 "更新状态" 非原子操作,高并发下可能多个线程同时通过状态检查,执行后续逻辑。

四、解决方案

  1. 统一超时取消入口:保留一种主要机制(如 Redis 过期通知),定时任务作为兜底,且兜底任务增加幂等性校验。

    java 复制代码
    // 定时任务优化:仅处理未被Redis通知处理的订单
    @Scheduled(cron = "0 0/5 * * * ?")
    public void cancelTimeoutOrders() {
        List<Order> timeoutOrders = orderService.queryUnpaidOrdersBefore(LocalDateTime.now().minusMinutes(35)); // 比Redis过期时间晚5分钟,确保Redis已处理
        for (Order order : timeoutOrders) {
            orderService.cancelOrder(order.getId());
        }
    }
  2. 定时任务加分布式锁:防止任务并发执行,确保同一时间只有一个实例的定时任务在运行。

    java 复制代码
    @Scheduled(cron = "0 0/5 * * * ?")
    public void cancelTimeoutOrders() {
        String lockKey = "lock:order:cancel:schedule";
        Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 1, TimeUnit.MINUTES);
        if (Boolean.TRUE.equals(locked)) {
            try {
                // 执行取消逻辑
                List<Order> timeoutOrders = orderService.queryUnpaidOrdersBefore(LocalDateTime.now().minusMinutes(35));
                for (Order order : timeoutOrders) {
                    orderService.cancelOrder(order.getId());
                }
            } finally {
                redisTemplate.delete(lockKey);
            }
        }
    }
  3. 订单状态更新原子化:通过 SQL 条件更新确保状态判断与更新的原子性,替代先查后更。

    java 复制代码
    @Transactional
    public void cancelOrder(Long orderId) {
        // 1. 原子更新订单状态(仅当状态为0时更新为2)
        int updateRows = orderMapper.updateStatusToCanceled(orderId);
        if (updateRows == 0) {
            return; // 状态已变更,无需处理
        }
        // 2. 回滚库存(仅当状态更新成功时执行)
        Order order = orderMapper.selectById(orderId);
        productMapper.increaseStock(order.getProductId(), order.getQuantity());
    }

    对应的 SQL(OrderMapper.xml):

    xml 复制代码
    <update id="updateStatusToCanceled">
        UPDATE `order` 
        SET status = 2, cancel_time = NOW() 
        WHERE id = #{orderId} AND status = 0
    </update>
相关推荐
BlockChain8884 分钟前
SpringBoot实战一:10分钟搭建企业级用户管理系统(20000字完整项目)
java·spring boot·后端
消失的旧时光-19437 分钟前
第六课 · 6.1 从 JDBC 到 MyBatis:SQL 工程化是如何发生的?
java·sql·mybatis
Jaxson Lin15 分钟前
Java编程进阶:线程基础与实现方式全解析
java·开发语言
夜喵YM15 分钟前
基于 Spire.XLS.Free for Java 实现无水印 Excel 转 PDF
java·pdf·excel
茶本无香19 分钟前
设计模式之五—门面模式:简化复杂系统的统一接口
java·设计模式
她说可以呀21 分钟前
网络基础初识
java·网络·java-ee
没有bug.的程序员23 分钟前
Java锁优化:从synchronized到CAS的演进与实战选择
java·开发语言·多线程·并发·cas·synchronized·
麦兜*35 分钟前
SpringBoot Profile多环境配置详解,一套配置应对所有场景
java·数据库·spring boot
MetaverseMan35 分钟前
rpc节点: synchronized (this) + 双检锁,在 race condition 的情况下分析
java·区块链
笃行客从不躺平35 分钟前
Seata + AT 模式 复习记录
java·分布式