库存预扣减之后,用户订单超时之后补偿库存的方案

方案一:Redis的过期监听

在redis里面中,key可以设过期时间,这就给我们提供了一个思路:我们可以给预扣减的库存键设一个过期时间,使用redis的发布订阅机制来监听键过期事件,从而自动触发回增库存的逻辑

功能目标:

  • 监听 Redis 中所有以 product: 开头的商品 key 的过期事件。
  • 一旦某个商品 key 过期(比如用户超时未支付),自动执行库存回补(INCR)操作。
csharp 复制代码
js
 体验AI代码助手
 代码解读
复制代码
public class RedissonStockExpiredListener {

    public static void main(String[] args) {
        
        // 模拟预扣减库存
        String productKey = "product:1001:stock";
        RBatch batch = redisson.getBatch();
        batch.get(productKey).setAsync(100L);           // 初始化库存
        batch.get(productKey).decrementAsync();         // 预扣减
        batch.get(productKey).expireAsync(5, TimeUnit.SECONDS); // 设置5秒过期
        batch.execute();

        System.out.println("已预扣减库存,并设置5秒后过期");

        // 订阅 Redis 的 key 过期事件
        RTopic topic = redisson.getTopic("__keyevent@*:expired");
        topic.addListener(String.class, new MessageListener<String>() {
            @Override
            public void onMessage(CharSequence channel, String key) {
                System.out.println("监听到 key 过期:" + key);

                if (key != null && key.startsWith("product:")) {
                    System.out.println("处理商品 key 过期,准备补偿库存...");

                    // 补偿库存:INCR
                    RAtomicLong stock = redisson.getAtomicLong(key);
                    long newStock = stock.incrementAndGet(); // INCR
                    System.out.println("库存已补偿,当前库存:" + newStock);
                } else {
                    System.out.println("忽略非商品 key:" + key);
                }
            }
        });

        System.out.println("正在监听 Redis key 过期事件...");
    }
}

优点是实时性强,对要求实时性高的业务比较友好,如抢票等。缺点是过于依靠Redis,风险不能分摊。且容易造成大量过期Key问题,影响性能。

方案二:Redis的延时队列

我们可以基于Redis的Sorted Set数据类型来做一个延时队列,通过score来进行排序,我们服务端下单时在当前时间加上超时时间,将订单ID放入进去,通过定时任务来定量拿取超时订单进行取消订单+补偿库存。

添加订单到延时队列

  • 当用户下单但未支付时,计算出订单的超时时间(例如:当前时间 + 30 分钟),然后将订单 ID 以这个时间为 score 添加到 Redis Sorted Set 中。
ini 复制代码
js
 体验AI代码助手
 代码解读
复制代码
RScoredSortedSet<String> delayedQueue = redisson.getScoredSortedSet("delayed:order:queue");
long timeoutTime = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(30); // 30分钟后超时
delayedQueue.add(timeoutTime, orderId);

定时任务检查并处理超时订单

  • 定期运行一个后台任务,使用 ZRANGEBYSCORE 获取所有已经到期的订单(即 score 小于等于当前时间戳的所有订单)。
  • 对于每个找到的订单,执行取消订单和补偿库存的操作,并从 Sorted Set 中移除这些订单。
scss 复制代码
js
 体验AI代码助手
 代码解读
复制代码
long currentTime = System.currentTimeMillis();
Collection<String> expiredOrders = delayedQueue.valueRange(0, true, currentTime, false);
for (String orderId : expiredOrders) {
    try {
        // 执行取消订单逻辑
        cancelOrder(orderId);

        // 补偿库存逻辑
        restoreStock(orderId);
        
        // 从延迟队列中删除该订单
        delayedQueue.remove(orderId);
    } catch (Exception e) {
        // 异常处理
        logger.error("处理超时订单失败: {}", orderId, e);
    }
}

方案优点和方案一差不多,实时性好延迟小,但是就是过于依靠Redis,有数据丢失的风险。而且Redis是基于内存的,如果超时订单过多对Redis压力过大,容易挂机。

方案三:MQ的延时消息

用户下单之后,生产者将设置好时间的消息发送给broker,等到规定的消息之后,该消息才对消费者可见,broker发送相应消息给消费者,消费者执行订单取消+库存补偿逻辑即可(消息重试机制保证消费者一定可以消费)

生产者:用户下单后发送延时消息

java 复制代码
js
 体验AI代码助手
 代码解读
复制代码
@Service
public class OrderService {

    private final RocketMQTemplate rocketMQTemplate;

    public OrderService(RocketMQTemplate rocketMQTemplate) {
        this.rocketMQTemplate = rocketMQTemplate;
    }

    public void createOrder(String orderId, String productKey) {
        // 1. 创建订单(伪代码)
        System.out.println("创建订单:" + orderId);

        // 2. 预扣减库存(假设用 Redisson)
        // RAtomicLong stock = redisson.getAtomicLong(productKey);
        // stock.decrementAndGet();

        // 3. 发送延迟消息,level=15 对应 30分钟
        OrderTimeoutMessage message = new OrderTimeoutMessage(orderId, productKey);
        rocketMQTemplate.convertAndSend("ORDER_TIMEOUT_TOPIC", message, null, 15); // level=15
        System.out.println("已发送延迟消息,30分钟后处理订单:" + orderId);
    }
}

消费者:监听并处理超时订单

typescript 复制代码
js
 体验AI代码助手
 代码解读
复制代码
@Service
@RocketMQMessageListener(topic = "ORDER_TIMEOUT_TOPIC", consumerGroup = "order-timeout-group")
public class OrderTimeoutConsumer implements RocketMQListener<OrderTimeoutMessage> {

    @Override
    public void onMessage(OrderTimeoutMessage message) {
        String orderId = message.orderId;
        String productKey = message.productKey;
        long expectedExpireTime = message.timestamp + TimeUnit.MINUTES.toMillis(30);

        // 1. 判断是否已经过期(防止重复消费或提前消费)
        if (System.currentTimeMillis() < expectedExpireTime) {
            System.out.println("消息尚未到期,跳过处理:" + orderId);
            return;
        }

        // 2. 查询订单状态(伪代码)
        boolean paid = checkIfOrderPaid(orderId);
        if (paid) {
            System.out.println("订单已支付,无需处理:" + orderId);
            return;
        }

        // 3. 未支付 → 取消订单 + 补偿库存
        cancelOrder(orderId);
        restoreStock(productKey);
    }

    private boolean checkIfOrderPaid(String orderId) {
        // 查询数据库判断是否已支付(伪逻辑)
        return false; // 假设未支付
    }

    private void cancelOrder(String orderId) {
        System.out.println("取消订单:" + orderId);
        // 实际操作:更新订单状态为已取消
    }

    private void restoreStock(String productKey) {
        System.out.println("补偿库存:" + productKey);
        // 实际操作:INCR productKey
        // RAtomicLong stock = redisson.getAtomicLong(productKey);
        // stock.incrementAndGet();
    }
}

RocketMQ是5.x的版本就可以支持任意时刻的延时消息了,否则就是对应级别的延时消息。

优点是高并发性能好,而且处理消息丢失等方案成熟。缺点就是系统架构更加复杂了,需要处理MQ的其他问题,维护成本变高了。

方案四:分布式定时任务调度框架

使用分布式定时任务调度框架,比如xxl-job 或 Quartz是企业里面非常常见的方案。用户下单之后,我们在数据库里面记录订单状态为"待支付",然后通过xxl-job定期扫描过期的订单,筛选出超时未支付的订单,进行取消订单+补偿库存。

java 复制代码
js
 体验AI代码助手
 代码解读
复制代码
@Component
public class OrderTimeoutJobHandler {

    @XxlJob("orderTimeoutJob")
    public void orderTimeoutJob() throws Exception {
        XxlJobLogger.log("开始执行超时订单检测任务...");

        // 1. 获取当前时间
        LocalDateTime now = LocalDateTime.now();

        // 2. 查询所有 timeout_time <= now 且 status = 0 的订单
        List<Order> expiredOrders = orderMapper.findExpiredOrders(now);

        for (Order order : expiredOrders) {
            // 3. 加锁防止并发处理同一订单(可选 Redis 分布式锁)
            boolean locked = redisTemplate.opsForValue().setIfAbsent(
                "lock:order:" + order.getId(), "locked", 5, TimeUnit.MINUTES);

            if (!locked) {
                continue; // 已被其他节点处理
            }

            try {
                // 4. 再次确认是否已支付(防止并发问题)
                Order updatedOrder = orderMapper.selectById(order.getId());
                if (updatedOrder.getStatus() != 0) {
                    continue;
                }

                // 5. 更新订单状态为"已取消"
                orderMapper.updateStatus(order.getId(), 2);

                // 6. 补偿库存(如使用 Redisson)
                RAtomicLong stock = redisson.getAtomicLong(order.getProductKey());
                stock.incrementAndGet();

                XxlJobLogger.log("已取消订单:" + order.getId() + ",补偿库存:" + order.getProductKey());

            } finally {
                // 7. 释放锁
                redisTemplate.delete("lock:order:" + order.getId());
            }
        }
    }
}

重复补偿库存问题

由于网络延迟或者网络分区,又或者是Redis服务器问题,还有并发问题等,有可能会导致重复补偿库存的情况,那么该怎么解决呢?

1.使用分布式锁

  • 在执行库存补偿之前,尝试获取一个针对特定商品 ID 的分布式锁。只有成功获取锁的进程才能执行补偿操作。这样可以防止多个实例同时处理同一个过期事件。
  • 示例代码片段:
csharp 复制代码
js
 体验AI代码助手
 代码解读
复制代码
RLock lock = redisson.getLock("lock:product:" + productId);
if (lock.tryLock()) {
    try {
        // 执行库存补偿逻辑
    } finally {
        lock.unlock();
    }
}

2.状态标记

  • 在补偿库存后,给对应的 key 添加一个特殊的标识(如 compensated 标记)。下次再遇到该 key 过期时,先检查是否存在这个标记。如果存在,则跳过补偿步骤。
  • 示例代码片段:
arduino 复制代码
js
 体验AI代码助手
 代码解读
复制代码
if (!redissonMap.containsKey(productId + ":compensated")) {
    // 执行库存补偿逻辑
    redissonMap.put(productId + ":compensated", true);
}

3.延迟队列结合定时任务

刚才方案二就可以解决这个问题,将需要补偿的订单信息放入延迟队列中,并设置适当的延迟时间。通过后台定时任务统一处理这些订单,确保每个订单只被处理一次。

4.数据库唯一约束

通过建立补偿库存表,业务和订单id作为唯一索引,每次补偿之前尝试是否可以插入该表,可以则补偿,反之则跳过。

相关推荐
苏三的开发日记5 小时前
linux搭建hadoop服务
后端
sir7615 小时前
Redisson分布式锁实现原理
后端
大学生资源网6 小时前
基于springboot的万亩助农网站的设计与实现源代码(源码+文档)
java·spring boot·后端·mysql·毕业设计·源码
苏三的开发日记6 小时前
linux端进行kafka集群服务的搭建
后端
苏三的开发日记6 小时前
windows系统搭建kafka环境
后端
爬山算法6 小时前
Netty(19)Netty的性能优化手段有哪些?
java·后端
Tony Bai6 小时前
Cloudflare 2025 年度报告发布——Go 语言再次“屠榜”API 领域,AI 流量激增!
开发语言·人工智能·后端·golang
想用offer打牌7 小时前
虚拟内存与寻址方式解析(面试版)
java·后端·面试·系统架构
無量7 小时前
AQS抽象队列同步器原理与应用
后端
9号达人7 小时前
支付成功订单却没了?MyBatis连接池的坑我踩了
java·后端·面试