面试官问我:库存预扣减之后,用户订单超时之后怎么补偿库存?我的方案让他满意...



引言

在后端开发和面试中,如果是做类似秒杀和商城等业务时,我们难免会遇到关于订单超时了,但是Redis里面的库存已经预扣减了,那么如何把预扣减的库存进行补偿呢?下面和大家讨论几种比较常见常用的方案。

为什么要补偿预扣减的库存?😵

古人常说,要做一件事之前,先要清楚为什么要做这件事,再去想解决这件事的方案。我认为这种说法也是对的,只有先清楚为什么要补偿预扣减的库存,才能获得解决方案的灵感。

在一个秒杀业务中,大家一起来抢购这种商品,但是商品数额有限。一位用户率先抢到这个商品,已经成功下单(即已经有了订单号),但是尚未结款,也就代表数据库还没有真正落库扣减这个商品,只是在Redis进行预扣减了这件库存。该用户在后来的一段时间因为某事忘记了付款,即订单超时,那么我们就要把这个Redis中预扣减的库存补偿回Redis中,否则就会造成后来的用户无法继续抢购,也就是少卖现象,会给商家利益造成损失。

方案一:Redis的过期监听🤤

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

功能目标:

  • 监听 Redis 中所有以 product: 开头的商品 key 的过期事件。
  • 一旦某个商品 key 过期(比如用户超时未支付),自动执行库存回补(INCR)操作。
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 中。
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 中移除这些订单。

    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的延时消息🤩

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

@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);
    }
}

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

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

@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());
            }
        }
    }
}

笔者更加推荐xxl-job来实现分布式定时任务,因为它自带可视化界面,兼顾性能与精准,更适合微服务架构下的分布式任务调度

这种方案对实时性支持不是很好,且框架学习成本高。

重复补偿库存问题😗

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

1.使用分布式锁

  • 在执行库存补偿之前,尝试获取一个针对特定商品 ID 的分布式锁。只有成功获取锁的进程才能执行补偿操作。这样可以防止多个实例同时处理同一个过期事件。

  • 示例代码片段:

    RLock lock = redisson.getLock("lock:product:" + productId);
    if (lock.tryLock()) {
        try {
            // 执行库存补偿逻辑
        } finally {
            lock.unlock();
        }
    }
    

2.状态标记

  • 在补偿库存后,给对应的 key 添加一个特殊的标识(如 compensated 标记)。下次再遇到该 key 过期时,先检查是否存在这个标记。如果存在,则跳过补偿步骤。

  • 示例代码片段:

    if (!redissonMap.containsKey(productId + ":compensated")) {
        // 执行库存补偿逻辑
        redissonMap.put(productId + ":compensated", true);
    }
    

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

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

4.数据库唯一约束

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

总结❤️

实际业务不一定要按照上面的方案执行,可以根据实际来组合使用(●’◡’●)。如果你看了这篇文章有收获可以点赞+关注+收藏🤩,这是对笔者更新的最大鼓励!如果你有更多方案或者文章中有错漏之处,请在评论区提出帮助笔者勘误,祝你拿到更好的offer!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

想用offer打牌

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值