百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术教程 > 正文

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

mhr18 2025-08-01 18:53 6 浏览 0 评论

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

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

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

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

01.临时方案:能跑就行

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

什么是超卖?—— 库 a 存明明只有100台,却卖出 #技术分享 #掘金了120台 什么是少卖?—— 系统显示已售罄,实际却还有20台库存

超卖产生流程:

少卖产生流程:

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

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

代码很快写好了:

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 脚本在一个操作中完成用户校验、库存检查和库存扣减,有效防止超卖和重复购买问题。示例代码如下:

public boolean tryPreDeductStockWithLua(Long userId, Long productId, int quantity) {
        String userRecordKey = USER_SECKILL_PREFIX + userId + ":" + productId;
        String stockCountKey = PRODUCT_STOCK_COUNT_PREFIX + productId;


        String luaScript =

                "if redis.call('exists', KEYS[1]) == 1 then
" +
                "    return 0
" +
                "end
" +

                "local stock = redis.call('get', KEYS[2])
" +
                "if not stock then
" +
                "    return -1
" +
                "end
" +
                "local stockNumber = tonumber(stock)
" +
                "local quantity = tonumber(ARGV[1])
" +
                "if stockNumber < quantity then
" +
                "    return -2
" +
                "end
" +

                "redis.call('decrby', KEYS[2], quantity)
" +

                "redis.call('setex', KEYS[1], tonumber(ARGV[2]), ARGV[1])
" +
                "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. 支付超时 :设置订单支付超时自动取消机制,超时后触发库存补偿。

示例代码:

@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 --

相关推荐

Java面试题及答案总结(2025版)

大家好,我是Java面试陪考员最近很多小伙伴在忙着找工作,给大家整理了一份非常全面的Java面试题及答案。涉及的内容非常全面,包含:Redis、Linux、SpringBoot、Spring、MySQ...

Java面试题及答案最全总结(2025春招版)

大家好,我是Java面试分享最近很多小伙伴在忙着找工作,给大家整理了一份非常全面的Java面试题及答案。涉及的内容非常全面,包含:Spring、MySQL、JVM、Redis、Linux、Spring...

Java面试题及答案最全总结(2025版持续更新)

大家好,我是Java面试陪考员最近很多小伙伴在忙着找工作,给大家整理了一份非常全面的Java面试题及答案。涉及的内容非常全面,包含:Spring、MySQL、JVM、Redis、Linux、Sprin...

蚂蚁金服面试题(附答案)建议收藏:经典面试题解析

前言最近编程讨论群有位小伙伴去蚂蚁金服面试了,以下是面试的真题,跟大家一起来讨论怎么回答。点击上方“捡田螺的小男孩”,选择“设为星标”,干货不断满满1.用到分布式事务嘛?为什么用这种方案,有其他方案...

测试工程师面试必问的十道题目!全答上来的直接免试

最近参加运维工程师岗位的面试,笔者把自己遇到的和网友分享的一些常见的面试问答收集整理出来了,希望能对自己和对正在准备面试的同学提供一些参考。一、Mongodb熟悉吗,一般部署几台?部署过,没有深入研究...

10次面试9次被刷?吃透这500道大厂Java高频面试题后,怒斩offer

很多Java工程师的技术不错,但是一面试就头疼,10次面试9次都是被刷,过的那次还是去了家不知名的小公司。问题就在于:面试有技巧,而你不会把自己的能力表达给面试官。应届生:你该如何准备简历,面试项目和...

java高频面试题整理

【高频常见问题】1、事务的特性原子性:即不可分割性,事务要么全部被执行,要么就全部不被执行。一致性或可串性:事务的执行使得数据库从一种正确状态转换成另一种正确状态隔离性:在事务正确提交之前,不允许把该...

2025 年最全 Java 面试题,京东后端面试面经合集,答案整理

最近京东搞了个TGT计划,针对顶尖青年技术天才,直接宣布不设薪资上限。TGT计划面向范围包括2023年10月1日到2026年9月30日毕业的海内外本硕博毕业生。时间范围还...

idGenerator测评

工作中遇到需要生成随机数的需求,看了一个个人开发的基于雪花算法的工具,今天进行了一下测评(测试)。idGenerator项目地址见:https://github.com/yitter/IdGenera...

2024年开发者必备:MacBook Pro M1 Max深度体验与高效工作流

工作机器我使用的是一台16英寸的MacBookProM1Max。这台电脑的表现堪称惊人!它是我用过的最好的MacBook,短期内我不打算更换它。性能依然出色,即使在执行任务时也几乎听不到风扇的...

StackOverflow 2022 年度调查报告

一个月前,StackOverflow开启了2022年度开发者调查,历时一个半月,在6月22日,StackOverflow正式发布了2022年度开发者调查报告。本次报告StackO...

这可能是最全面的SpringDataMongoDB开发笔记

MongoDB数据库,在最近使用越来越广泛,在这里和Java的开发者一起分享一下在Java中使用Mongodb的相关笔记。希望大家喜欢。关于MongoDB查询指令,请看我的上一篇文章。SpringD...

Mac M2 本地部署ragflow

修改配置文件Dockerfile文件ARGNEED_MIRROR=1//开启国内镜像代理docker/.envREDIS_PORT=6380//本地redis端口冲突RAGFLOW_IMA...

别再傻傻分不清!localhost、127.0.0.1、本机IP,原来大有讲究!

调试接口死活连不上?部署服务队友访问不了?八成是localhost、127.0.0.1、本机IP用混了!这三个看似都指向“自己”的东西,差之毫厘谬以千里。搞不清它们,轻则调试抓狂,重则服务裸奔。loc...

我把 Mac mini 托管到机房了:一套打败云服务器的终极方案

我把我积灰的Macmini托管到机房了,有图有真相。没想到吧?一台在家吃灰的苹果电脑,帮我省了大钱!对,就是控制了自己的服务器,省了租用云服务器的钱,重要数据还全捏在自己手里,这感觉真爽。你可...

取消回复欢迎 发表评论: