写点什么

高并发下如何防止商品超卖?

  • 2025-06-05
    福建
  • 本文字数:2280 字

    阅读完需:约 7 分钟

前言


"快看我们的秒杀系统!库存显示-500 了!"

3 年前的这个电话让我记忆犹新。

当时某电商大促,我们自认为完美的分布式架构,在 0 点整瞬间被击穿。

数据库连接池耗尽,库存表出现负数,客服电话被打爆...

今天这篇文章跟大家一起聊聊商品超卖的问题,希望对你会有所帮助。


1 为什么会发生超卖?


首先我们一起看看为什么会发送超卖?


1.1 数据库的"最后防线"漏洞


我们用下面的列子,给大家介绍一下商品超卖是如何发生的。


public boolean buy(int goodsId) {    // 1. 查询库存    int stock = getStockFromDatabase(goodsId);    if (stock > 0) {        // 2. 扣减库存        updateStock(goodsId, stock - 1);        return true;    }    return false;}
复制代码


在并发场景下可能变成下图这样的:



请求 1 和请求 2 都将库存更新成 9。


根本原因:数据库的查询和更新操作,不是原子性校验,多个事务可能同时通过 stock>0 的条件检查。


1.2 超卖的本质


商品超卖的本质是:多个请求同时穿透缓存,同一时刻读取到相同库存值,最终在数据库层发生覆盖。


就像 100 个人同时看上一件衣服,都去试衣间前看了眼牌子,出来时都觉得自己应该拿到那件衣服。


2 防止超卖的方案


2.1 数据库乐观锁


数据库乐观锁的核心原理是通过版本号控制并发。


例如下面这样的:


UPDATE product SET stock = stock -1, version=version+1 WHERE id=123 AND version=#{currentVersion};
复制代码


Java 的实现代码如下:


@Transactionalpublic boolean deductStock(Long productId) {    Product product = productDao.selectForUpdate(productId);    if (product.getStock() <= 0) return false;        int affected = productDao.updateWithVersion(        productId,         product.getVersion(),        product.getStock()-1    );    return affected > 0;}
复制代码


基于数据库乐观锁方案的架构图如下:



优缺点分析



适用场景:日订单量 1 万以下的中小系统。


2.2 Redis 原子操作


Redis 原子操作的核心原理是使用:Redis + Lua 脚本。


核心代码如下:


// Lua脚本保证原子性String lua = "if redis.call('get', KEYS >= ARGV[1] then " +             "return redis.call('decrby', KEYS[1], ARGV " +             "else return -1 end";
public boolean preDeduct(String itemId, int count) { RedisScript<Long> script = new DefaultRedisScript<>(lua, Long.class); Long result = redisTemplate.execute(script, Collections.singletonList(itemId), count); return result != null && result >= 0;}
复制代码


该方案的架构图如下:



性能对比

  • 单节点 QPS:数据库方案 500 vs Redis 方案 8 万

  • 响应时间:<1ms vs 50ms+


2.3 分布式锁


目前最常用的分布式锁的方案是 Redisson。


下面是 Redisson 的实现:


RLock lock = redisson.getLock("stock_lock:"+productId);try {    if (lock.tryLock(1, 10, TimeUnit.SECONDS)) {        // 执行库存操作    }} finally {    lock.unlock();}
复制代码


注意事项


  1. 1.锁粒度要细化到商品级别

  2. 2.必须设置等待时间和自动释放

  3. 3.配合异步队列使用效果更佳

该方案的架构图如下:



2.4 消息队列削峰


可以使用 RocketMQ 的事务消息。


核心代码如下:


// RocketMQ事务消息示例TransactionMQProducer producer = new TransactionMQProducer("stock_group");producer.setExecutor(new TransactionListener() {    @Override    public LocalTransactionState executeLocalTransaction(Message msg) {        // 扣减数据库库存        return LocalTransactionState.COMMIT_MESSAGE;    }});
复制代码


该方案的架构图如下:



技术指标

  • 削峰能力:10 万 QPS → 2 万 TPS

  • 订单处理延迟:<1 秒(正常时段)


2.5 预扣库存


预扣库存是防止商品超卖的终极方案。

核心算法如下:


// Guava RateLimiter限流RateLimiter limiter = RateLimiter.create(1000); // 每秒1000个令牌
public boolean preDeduct(Long itemId) { if (!limiter.tryAcquire()) return false; // 写入预扣库存表 preStockDao.insert(itemId, userId); return true;}
复制代码


该方案的架构图如下:



性能数据

  • 百万级并发支撑能力

  • 库存准确率 99.999%

  • 订单处理耗时 200ms 内


3 避坑指南


3.1 缓存与数据库不一致


某次大促因缓存未及时失效,导致超卖 1.2 万单。

错误示例如下:

// 错误示例:先删缓存再写库redisTemplate.delete("stock:"+productId);productDao.updateStock(productId, newStock); // 存在并发写入窗口
复制代码


3.2 未考虑库存回滚


秒杀取消后,忘记恢复库存,引发后续超卖。

正确做法是使用事务补偿。

例如下面这样的:


@Transactionalpublic void cancelOrder(Order order) {    stockDao.restock(order.getItemId(), order.getCount());    orderDao.delete(order.getId());}
复制代码


库存回滚和订单删除,在同一个事务中。


3.3 锁粒度过大


锁粒度过大,全局限流导致 10%的请求被误杀。

错误示例如下:


// 错误示例:全局限锁RLock globalLock = redisson.getLock("global_stock_lock");
复制代码


总结


其实在很多大厂中,一般会将防止商品超卖的多种方案组合使用。

架构图如下:


通过组合使用:

  1. Redis 做第一道防线(承受 80%流量)

  2. 分布式锁控制核心业务逻辑

  3. 预扣库存+消息队列保证最终一致性


实战经验:某电商在 2023 年双 11 中:

  • Redis 集群承载 98%请求

  • 分布式锁拦截异常流量

  • 预扣库存保证最终准确性


系统平稳支撑了每秒 12 万次秒杀请求,0 超卖事故发生!


记住:没有银弹方案,只有适合场景的组合拳!


文章转载自:苏三说技术

原文链接:https://www.cnblogs.com/12lisu/p/18908447

体验地址:http://www.jnpfsoft.com/?from=001YH

用户头像

还未添加个人签名 2025-04-01 加入

还未添加个人简介

评论

发布
暂无评论
高并发下如何防止商品超卖?_高并发_量贩潮汐·WholesaleTide_InfoQ写作社区