百万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 --

相关推荐
战族狼魂1 小时前
CSGO 皮肤交易平台后端 (Spring Boot) 代码结构与示例
java·spring boot·后端
杉之3 小时前
常见前端GET请求以及对应的Spring后端接收接口写法
java·前端·后端·spring·vue
hycccccch3 小时前
Canal+RabbitMQ实现MySQL数据增量同步
java·数据库·后端·rabbitmq
bobz9654 小时前
k8s 怎么提供虚拟机更好
后端
bobz9654 小时前
nova compute 如何创建 ovs 端口
后端
用键盘当武器的秋刀鱼5 小时前
springBoot统一响应类型3.5.1版本
java·spring boot·后端
Asthenia04125 小时前
从迷宫到公式:为 NFA 构造正规式
后端
Asthenia04126 小时前
像整理玩具一样:DFA 化简和状态等价性
后端
Asthenia04126 小时前
编译原理:打包思维-NFA 怎么变成 DFA
后端
非ban必选6 小时前
spring-ai-alibaba第五章阿里dashscope集成mcp远程天气查询tools
java·后端·spring