百万QPS秒杀如何解决超卖少卖问题?(图解+秒懂)

大家好,我是极特,大厂技术团队负责人

之前公司电商平台计划上线秒杀功能。周五下午五点,产品经理找到核心开发强哥,"强哥,老板说了,下周要上线秒杀活动,预计峰值会有百万QPS,靠你了。"

"百万QPS?下周上线?" 强哥揉了揉太阳穴,"这不是开玩笑吗..."

百万QPS秒杀如何解决超卖少卖问题?(图解+秒懂)

01.临时方案:能跑就行

秒杀系统的核心特征是高并发、瞬时高峰、库存有限,主要挑战在于如何保证系统性能和数据一致性,常见问题包括超卖、少卖、响应延迟和库存回滚不及时。

什么是超卖?------ 库a存明明只有100台,却卖出了120台 什么是少卖?------ 系统显示已售罄,实际却还有20台库存

超卖产生流程:
少卖产生流程:

梳理好思路,整个周末,小强都泡在公司,作为团队的核心开发他设计了一个看似合理的方案:

  1. Redisson分布式锁:控制并发,防止超卖
  2. RabbitMQ消息队列:异步处理订单,提高吞吐量
  3. 定时任务:处理未支付订单,归还库存

代码很快写好了:

csharp 复制代码
public OrderResult createOrder(Long userId, Long productId, Integer quantity) {
    // 构建分布式锁
    RLock lock = redissonClient.getLock("product_" + productId);
    
    try {
        // 尝试获取锁
        if (!lock.tryLock(3, 10, TimeUnit.SECONDS)) {
            return new OrderResult(false, "系统繁忙,请稍后再试");
        }
        
        // 查询数据库库存
        Product product = productMapper.selectById(productId);
        if (product.getStock() < quantity) {
            return new OrderResult(false, "库存不足");
        }
        
        // 扣减库存
        productMapper.decreaseStock(productId, quantity);
        
        // 创建订单(异步)
        OrderMessage message = new OrderMessage(userId, productId, quantity);
        rabbitTemplate.convertAndSend("order.exchange", "order.create", message);
        
        return new OrderResult(true, "下单成功");
    } finally {
        // 释放锁
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

用分布式锁代替数据库,消息队列异步处理,解决超卖跟性能,定时任务释放超时订单,库存归还,解决少卖,强哥感觉稳了!

02.现实的残酷

周一上午,在做方案评审的时候,强哥满怀信心地向我展示了他的方案。

"思路不错,"我点点头,"你压测过吗?而且你的方案还有几个严重问题"

  1. 锁粒度太大:Redisson锁虽然能保证一致性,但会严重影响并发性能
  2. 读写分离不彻底:每次都查询数据库库存,成为性能瓶颈
  3. 异步处理不可靠:缺乏事务保证,导致数据不一致"
  4. 库存少卖:定时任务在大数据量场景下,性能下降,库存归还延迟大

强哥陷入了深深的焦虑。距离上线只剩三天了!

03.优化:全流程解决方案

"老大,我能和你聊聊吗,马上就要上线了,我应该怎么改进呢?" 会后强哥找到了我。

"三个关键词:Redis Lua脚本、RocketMQ事务消息、延时消息。"

通过Redis Lua脚本实现原子性库存预扣,借助RocketMQ事务消息完成库存扣减,支付回调完善订单状态,并利用RocketMQ 5.0时间轮算法的延时消息实现库存补偿,整体流程如下:

1. 库存预扣

采用Lua脚本在Redis服务器端原子执行,保障性能,同时,需要保证用户幂等性。通过Redis Lua脚本在一个操作中完成用户校验、库存检查和库存扣减,有效防止超卖和重复购买问题。示例代码如下:

swift 复制代码
    /**
     * 使用Lua脚本进行库存预扣
     * @param userId 用户ID
     * @param productId 商品ID
     * @param quantity 购买数量
     * @return 预扣结果
     */
    public boolean tryPreDeductStockWithLua(Long userId, Long productId, int quantity) {
        String userRecordKey = USER_SECKILL_PREFIX + userId + ":" + productId;
        String stockCountKey = PRODUCT_STOCK_COUNT_PREFIX + productId;
        
        // Lua脚本:检查用户是否已购买 -> 检查库存 -> 扣减库存 -> 记录用户购买
        String luaScript = 
                //检查用户是否已购买
                "if redis.call('exists', KEYS[1]) == 1 then\n" +
                "    return 0\n" +
                "end\n" +
                //检查库存
                "local stock = redis.call('get', KEYS[2])\n" +
                "if not stock then\n" +
                "    return -1\n" +
                "end\n" +
                "local stockNumber = tonumber(stock)\n" +
                "local quantity = tonumber(ARGV[1])\n" +
                "if stockNumber < quantity then\n" +
                "    return -2\n" +
                "end\n" +
                //扣减库存
                "redis.call('decrby', KEYS[2], quantity)\n" +
                //记录用户购买
                "redis.call('setex', KEYS[1], tonumber(ARGV[2]), ARGV[1])\n" +
                "return 1";
        
        try {
            List<String> keys = Arrays.asList(userRecordKey, stockCountKey);
            List<String> args = Arrays.asList(String.valueOf(quantity), String.valueOf(USER_RECORD_EXPIRE));
            
            Long result = (Long) redisTemplate.execute(new DefaultRedisScript<>(luaScript, Long.class), keys, args.toArray());
            
            if (result == null) {
                logger.error("执行Lua脚本返回空结果");
                return false;
            }
            LuaResult luaResult = LuaResult.fromCode(result.intValue());
            luaResult.log(logger, productId, userId);
            return luaResult.success;
        } catch (Exception e) {
            logger.error("执行库存预扣Lua脚本异常", e);
            return false;
        }
    } 
2. 库存扣减

库存预扣成功后,采用RocketMq的分布式事务实现库存扣减:

第一阶段:发送半消息
  • 生产者发送半事务消息到RocketMQ
  • RocketMQ存储半事务消息,此时消息不可被消费者消费
第二阶段:执行本地事务
  • 生产者执行Redis Lua脚本进行预扣库存操作
  • 如果Lua脚本执行成功,向RocketMQ返回COMMIT
  • 如果Lua脚本执行失败,向RocketMQ返回ROLLBACK
第三阶段:消息回查机制
  • 如果RocketMQ长时间未收到事务执行结果,会触发消息回查
  • 生产者检查是否存在对应的流水记录
  • 如果存在流水记录,说明本地事务执行成功,返回COMMIT
  • 如果不存在流水记录,说明本地事务执行失败,返回ROLLBACK
第四阶段:消费消息
  • RocketMQ将确认提交的消息投递给消费者
  • 消费者执行数据库库存扣减操作
  • 消费者记录操作流水,确保幂等性
  • 只有在确保数据成功落库后,才向RocketMQ返回消费ACK
  • 如果消费失败,RocketMQ会通过重试机制保证消息最终被消费
3. 支付回调

用户下单后,可能支付成功,也可能支付失败或超时。因此,需要通过支付回调正确处理库存状态。

支付状态处理逻辑
  1. 支付成功:更新订单状态为已支付,交易完成。
  2. 支付失败:更新订单状态为支付失败,并触发库存补偿流程。
  3. 支付超时:设置订单支付超时自动取消机制,超时后触发库存补偿。

示例代码:

scss 复制代码
@Service
public class PaymentCallbackService {
    
    @Autowired
    private OrderRepository orderRepository;
    
    @Autowired
    private RocketMQTemplate rocketMQTemplate;
    
    public void handlePaymentCallback(PaymentCallbackRequest callback) {
        Order order = orderRepository.findByOrderId(callback.getOrderId());
        if (order == null) {
            log.error("Order not found: {}", callback.getOrderId());
            return;
        }
        
        if (callback.isSuccess()) {
            // 支付成功,更新订单状态
            updateOrderStatus(order.getId(), OrderStatus.PAID);
        } else {
            // 支付失败,更新订单状态并触发库存补偿
            updateOrderStatus(order.getId(), OrderStatus.PAYMENT_FAILED);
            
            // 发送库存补偿消息
            StockCompensationMessage message = new StockCompensationMessage();
            message.setOrderId(order.getOrderId());
            message.setProductId(order.getProductId());
            message.setQuantity(order.getQuantity());
            
            rocketMQTemplate.syncSend(
                "TOPIC_STOCK_COMPENSATION", 
                MessageBuilder.withPayload(message).build()
            );
        }
    }
    
    @Transactional
    public void updateOrderStatus(Long id, OrderStatus status) {
        orderRepository.updateStatus(id, status, LocalDateTime.now());
    }
}
4. 库存补偿

当用户下单后未在规定时间内完成支付,我们需要及时将预扣的库存释放回去,避免库存少卖。 在面对高并发大量订单的场景时,订单超时关闭机制的设计涉及几个问题:

  • 订单关闭时效性 :超时订单需及时关闭,关系用户体验和库存释放;
  • 性能平衡 :避免直接查询千万级订单表,降低数据库压力;
  • 分布式一致性 :确保订单状态一致,防止重复或遗漏处理;
  • 资源消耗控制 :优化定时扫描策略,合理分配系统资源;
  • 异常处理机制 :在系统宕机等情况下防止订单"卡单";

针对这些挑战,我们可以采用RocketMQ 5.0提供的时间轮算法,它能高效处理大规模定时任务,保证库存及时释放,同时避免系统资源过度消耗。 关于时间轮相关的,可以参考 :

以下为库存补偿的流程:

RocketMQ 5.0引入的时间轮算法相比传统的延时消息实现有以下优势:

  1. 高效处理大量定时任务 :时间轮算法可以高效地管理大量的定时任务,适合电商场景下的大规模订单超时处理;
  2. 精度更高 :支持更精细的时间粒度,可以更准确地控制库存补偿的触发时间;
  3. 资源占用更少 :相比传统的延时队列实现,时间轮算法占用的系统资源更少;
  4. 扩展性更好 :可以轻松应对流量峰值,如秒杀、大促等场景下的订单量激增;

04.写在最后

经过这一系列分析与优化,我们的秒杀系统成功支撑了百万级并发,没有出现一例超卖问题。正如《失控》中所言:"复杂系统往往诞生于简单规则的迭代",我们通过Redis Lua脚本、RocketMQ事务消息、延时消息三招解决了秒杀超卖少卖问题。

每当新人问起秒杀系统超卖少卖如何设计时,我总会笑着说:"让强哥给你讲讲他的'百万QPS'故事吧..."

如果你正在经历类似的技术长征,希望我们的经验能给你带来一些启发。当然,技术无止境,方案肯定存在不足之处。如果你有更好的实践经验或改进建议,非常欢迎在留言区交流分享------毕竟,正如爱因斯坦所说:"提出新问题比解决问题更需要创造性"。

-- END --

相关推荐
落尘298几秒前
Spring MVC——传递参数的方式
后端
ITCharge15 分钟前
Docker 万字教程:从入门到掌握
后端·docker·容器
落尘29842 分钟前
Bean 的作用域和生命周期
后端
是店小二呀43 分钟前
处理Linux下磁盘空间不足问题的实用指南
后端
落尘29844 分钟前
如何通过 JWT 来解决登录认证问题
后端
是店小二呀44 分钟前
处理Linux下内存泄漏问题的诊断与解决方法
后端
倚栏听风雨1 小时前
IDEA 插件开发 对文件夹下的类进行 语法检查
后端
郝同学的测开笔记1 小时前
云原生探索系列(十七):Go 语言sync.Cond
后端·云原生·go
uhakadotcom1 小时前
持续写作的“农耕思维”:如何像农民一样播种,收获稳定成长与收入
后端·面试·github
Java中文社群1 小时前
国内首个「混合推理模型」Qwen3深夜开源,盘点它的N种对接方式!
java·人工智能·后端