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

方案一:Redis的过期监听🤤

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

功能目标:

  • 监听 Redis 中所有以 product: 开头的商品 key 的过期事件。
  • 一旦某个商品 key 过期(比如用户超时未支付),自动执行库存回补(INCR)操作。
js 复制代码
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 中。
js 复制代码
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 中移除这些订单。
js 复制代码
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发送相应消息给消费者,消费者执行订单取消+库存补偿逻辑即可(消息重试机制保证消费者一定可以消费)

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

js 复制代码
@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);
    }
}

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

js 复制代码
@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定期扫描过期的订单,筛选出超时未支付的订单,进行取消订单+补偿库存。

js 复制代码
@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 的分布式锁。只有成功获取锁的进程才能执行补偿操作。这样可以防止多个实例同时处理同一个过期事件。
  • 示例代码片段:
js 复制代码
RLock lock = redisson.getLock("lock:product:" + productId);
if (lock.tryLock()) {
    try {
        // 执行库存补偿逻辑
    } finally {
        lock.unlock();
    }
}

2.状态标记

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

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

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

4.数据库唯一约束

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

相关推荐
知其然亦知其所以然3 小时前
这波AI太原生了!SpringAI让PostgreSQL秒变智能数据库!
后端·spring·postgresql
观望过往4 小时前
Spring Boot 集成 EMQ X 4.0 完整技术指南
java·spring boot·后端·emqx
心之语歌4 小时前
对于 时间复杂度和空间复杂度分析
后端
青旬4 小时前
AI编程祛魅-最近几个失败的ai编程经历
后端·程序员
莹Innsane4 小时前
记一次 float64 排序失效的灵异事件
后端
Python私教4 小时前
使用 SQLAlchemy 操作单表:以 SQLite 用户表为例的完整实战指南
后端
Python私教4 小时前
使用 SQLAlchemy 连接数据库:从基础到最佳实践
后端
码起来呗5 小时前
基于Spring Boot的乡村拼车小程序的设计与实现-项目分享
spring boot·后端·小程序