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

一、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>
相关推荐
没有bug.的程序员1 天前
服务安全:内部服务如何防止“裸奔”?
java·网络安全·云原生安全·服务安全·零信任架构·微服务安全·内部鉴权
一线大码1 天前
SpringBoot 3 和 4 的版本新特性和升级要点
java·spring boot·后端
weixin_440730501 天前
java数组整理笔记
java·开发语言·笔记
weixin_425023001 天前
Spring Boot 实用核心技巧汇总:日期格式化、线程管控、MCP服务、AOP进阶等
java·spring boot·后端
一线大码1 天前
Java 8-25 各个版本新特性总结
java·后端
2501_906150561 天前
私有部署问卷系统操作实战记录-DWSurvey
java·运维·服务器·spring·开源
better_liang1 天前
每日Java面试场景题知识点之-TCP/IP协议栈与Socket编程
java·tcp/ip·计算机网络·网络编程·socket·面试题
niucloud-admin1 天前
java服务端——controller控制器
java·开发语言
To Be Clean Coder1 天前
【Spring源码】通过 Bean 工厂获取 Bean 的过程
java·后端·spring
Fortunate Chen1 天前
类与对象(下)
java·javascript·jvm