大数跨境
0
0

如何防止万级QPS下库存超卖?

如何防止万级QPS下库存超卖? 跨境Amy
2025-10-13
4
导读:作为一名后端开发者,尤其是涉及电商、秒杀等场景时,“库存超卖”就像一个幽灵,时刻徘徊在系统周围。

 

作为一名后端开发者,尤其是涉及电商、秒杀等场景时,“库存超卖”就像一个幽灵,时刻徘徊在系统周围。平时风平浪静,一旦大促来临,流量洪峰涌来,若防御不当,它就会跳出来给你一记重拳——卖出了不存在的商品,导致资损、客诉、信誉崩塌。

今天,我们就来深入聊聊,在万级QPS(每秒查询率)的冲击下,如何构建一个固若金汤的库存防线。

一、 问题的根源:为什么库存会超卖?

在单机单线程环境下,这根本不是问题。我们很自然地会写出这样的代码:

public booleandecreaseStock(Long itemId, Integer quantity) {
    // 1. 查询库存
    Itemitem= itemMapper.selectById(itemId);
    intstock= item.getStock();
    
    // 2. 判断库存是否充足
    if (stock < quantity) {
        returnfalse// 库存不足
    }
    
    // 3. 更新库存
    item.setStock(stock - quantity);
    itemMapper.updateById(item);
    returntrue;
}

这段代码在逻辑上完美无缺。但在并发环境下,它不堪一击。

假设当前库存为1,同时有两个请求A和B到来:

  1. 1. 请求A和B同时执行第1步,查询到的库存 stock 都是 1
  2. 2. 请求A和B都通过第2步的库存判断 (1 >= 1 -> true)。
  3. 3. 请求A执行第3步,将库存更新为 0
  4. 4. 请求B执行第3步,也将库存更新为 0(甚至 -1,如果没做负数校验)。

结果: 库存为1的商品,被成功售出了2件。超卖发生了!

问题的核心在于:“查询-判断-更新” 这三个操作组合在一起,不是一个原子性操作。在高并发下,多个线程交叉执行,共享数据(库存)的一致性被破坏。

二、 解决方案的演进:从数据库锁到缓存原子性

面对并发问题,我们的武器库里有好几把利器,让我们由浅入深,看看它们的优劣。

方案一:悲观锁 —— “总有刁民想害朕”

悲观锁的思想是,我认为在我修改数据的过程中,别人一定会来修改它。所以,我要先把它锁起来,让别人无法操作。

在数据库中,我们可以通过 SELECT ... FOR UPDATE 来实现。

public booleandecreaseStockPessimistic(Long itemId, Integer quantity) {
    // 开启事务
    try {
        // 1. 查询并加锁
        Itemitem= itemMapper.selectByIdForUpdate(itemId);
        intstock= item.getStock();
        
        // 2. 判断库存
        if (stock < quantity) {
            returnfalse;
        }
        
        // 3. 更新库存
        item.setStock(stock - quantity);
        itemMapper.updateById(item);
        
        // 提交事务
        returntrue;
    } catch (Exception e) {
        // 回滚事务
        thrownewRuntimeException(e);
    }
}

优点: 简单粗暴,利用数据库自身机制,能绝对保证数据一致性。
缺点:

  1. 1. 性能瓶颈: 所有请求串行化,在大并发下,数据库连接池容易被耗光,导致系统响应缓慢或崩溃。
  2. 2. 死锁风险: 多个事务相互等待对方持有的锁,容易造成死锁。
  3. 3. 依赖数据库: 对数据库压力巨大。

结论: 悲观锁适用于并发量不高的场景,在万级QPS下,它通常是第一个被排除的方案。

方案二:乐观锁 —— “我相信世界是美好的,但会留个心眼”

乐观锁的思想是,我认为冲突不经常发生。所以我不加锁,但在更新时,会检查一下在我修改期间,数据是否被他人改动过。

通常我们使用一个版本号 version 字段来实现。

  1. 1. 数据库表中增加一个 version 字段,默认值为 0。
  2. 2. 查询时,同时查出 version
  3. 3. 更新时,将 version 作为条件,并使其 +1。
public booleandecreaseStockOptimistic(Long itemId, Integer quantity) {
    intretryTimes=3// 乐观锁冲突,重试几次
    for (inti=0; i < retryTimes; i++) {
        // 1. 查询库存和版本号
        Itemitem= itemMapper.selectById(itemId);
        intstock= item.getStock();
        intversion= item.getVersion();
        
        // 2. 判断库存
        if (stock < quantity) {
            returnfalse;
        }
        
        // 3. 更新库存,带上版本号条件
        introws= itemMapper.updateStockAndVersion(itemId, stock - quantity, version, version + 1);
        
        // 4. 判断更新是否成功
        if (rows == 1) {
            // 更新成功,说明没被其他人修改
            returntrue;
        }
        // 更新失败,说明version被其他线程修改了,循环重试
        // 可以稍等片刻再重试
    }
    // 重试多次后依然失败,返回失败或抛出异常
    returnfalse;
}

对应的SQL映射:

<update id="updateStockAndVersion">
    UPDATE item
    SET stock = #{newStock},
        version = #{newVersion}
    WHERE id = #{itemId}
      AND version = #{oldVersion} <!-- 核心:版本号条件 -->
</update>

优点:

  • • 避免了悲观锁的巨大开销,性能更好。
  • • 在并发冲突不激烈的场景下,效果显著。

缺点:

  1. 1. CAS(Compare-And-Swap)典型问题: ABA问题(虽然业务上不太关心),以及自旋重试带来的CPU开销。
  2. 2. 成功率问题: 在极高并发下(比如秒杀),大量请求同时读取到同一个版本号,然后只有一个能更新成功,其他全部失败。用户体验不佳,感觉“明明有货却秒杀不到”。

结论: 乐观锁适用于并发量较高,但写冲突相对不那么极端的场景。对于真正的秒杀,它还不够。

方案三:Redis 原子操作 —— “天下武功,唯快不破”

当数据库成为瓶颈,我们自然想到用更快的缓存来扛。Redis,单机可达10W+ QPS,是应对读多写少场景的利器。但用它来扣减库存,需要利用其原子性操作

Redis提供了 DECR 和 DECRBY 命令,能原子性地减少一个键的值。

核心思路:

  1. 1. 活动开始前,将商品库存提前预热到Redis中。SET stock:item_1001 100
  2. 2. 扣减时,使用 DECRBY 进行原子扣减。
public booleandecreaseStockByRedis(Long itemId, Integer quantity) {
    StringstockKey="stock:" + itemId;
    
    // 使用 Lua 脚本保证原子性(推荐方式)
    StringluaScript="
        local stockKey = KEYS[1]
        local quantity = tonumber(ARGV[1])
        local currentStock = tonumber(redis.call('get', stockKey))
        
        if (currentStock == nil) then
            return -1 -- 键不存在
        end
        
        if (currentStock < quantity) then
            return 0 -- 库存不足
        end
        
        -- 执行扣减
        redis.call('decrby', stockKey, quantity)
        return 1 -- 扣减成功
    "
;
    
    // 执行Lua脚本
    Longresult= (Long) redisTemplate.execute(
        newDefaultRedisScript<>(luaScript, Long.class),
        Collections.singletonList(stockKey),
        quantity
    );
    
    if (result == 1) {
        returntrue;
    } elseif (result == 0) {
        // 库存不足,可以在这里记录日志或进行其他操作
        returnfalse;
    } else {
        thrownewRuntimeException("库存缓存异常");
    }
}

为什么用Lua脚本?
虽然 DECRBY 本身是原子的,但“判断-扣减”这个逻辑组合不是。Lua脚本在Redis中执行时,会被当作一个整体、不可中断的命令,从而实现了复杂逻辑的原子性。

优点:

  • • 性能极高: 完全在内存操作,扛住万级QPS轻而易举。
  • • 真正的原子性: 通过Lua脚本或原子命令,杜绝超卖。

缺点:

  1. 1. 数据一致性: Redis中的数据是缓存,如何与数据库中的最终库存保持一致?
  2. 2. 架构复杂性: 引入了缓存层,需要维护缓存和数据库的双写一致性。

三、 终极架构:分层校验与异步同步

在实际的万级QPS场景中,我们不会只依赖单一方案,而是采用一套组合拳,进行分层、分级的防御

核心思想: 将请求尽量拦截在上游,减少对底层核心资源的访问。

一个典型的防超卖架构如下:

  1. 1. 前端层面:
    • • 按钮限流/禁用: 提交后按钮置灰,防止用户疯狂点击。
    • • 验证码/答题: 分散请求时间,拉长整个秒杀流程,削峰填谷。
  2. 2. 网关/负载均衡层:
    • • 限流: 对同一IP、用户ID进行限流,将超过系统承载能力的请求直接拒绝。
  3. 3. 业务应用层(核心逻辑):
    • • Redis 预扣库存: 使用我们上面提到的Redis Lua脚本方案,进行内存级别的原子扣减。这是最核心的防线
    • • 请求队列化: 对于扣减成功的请求,我们并不急于同步操作数据库。而是将其放入一个消息队列(如RocketMQ, Kafka)中,告诉用户“秒杀请求已提交,正在处理中”。
    • • 内存标记: 在JVM中用一个 ConcurrentHashMap 或 AtomicBoolean 做一个“售罄”标记。当Redis库存扣到0后,立马更新这个标记。后续请求一来,先检查这个标记,如果是售罄状态,直接返回失败,连Redis都不用访问了。这是一个非常高效的快速失败策略。
// 伪代码示例
@Component
publicclassItemStockCache {
    
    @Autowired
    private RedisTemplate redisTemplate;
    
    // 本地内存售罄标记
    private Map<Long, Boolean> soldOutMap = newConcurrentHashMap<>();
    
    publicbooleandecreaseStock(Long itemId, Integer quantity) {
        // 1. 检查本地售罄标记
        if (soldOutMap.getOrDefault(itemId, false)) {
            returnfalse// 已售罄,快速返回
        }
        
        // 2. Redis原子扣减
        booleansuccess= decreaseStockByRedis(itemId, quantity);
        
        if (!success) {
            // 如果扣减失败,可能是库存不足,检查一下当前库存,如果为0则设置售罄标记
            IntegercurrentStock= getStockFromRedis(itemId);
            if (currentStock != null && currentStock <= 0) {
                soldOutMap.put(itemId, true);
            }
            returnfalse;
        }
        
        // 3. 扣减成功,发送MQ消息,进行后续的数据库落地、生成订单等操作
        sendMQMessage(buildOrderMessage(itemId, quantity));
        
        returntrue;
    }
}
  1. 4. 数据层:
    • • 异步落库: 消息队列的消费者,从队列中取出扣减成功的消息,然后执行数据库的库存更新 (UPDATE item SET stock = stock - ? WHERE id = ?)。因为队列的消费是顺序的,所以这里数据库的并发压力很小,甚至可以合并多个更新操作进行一次批量更新,进一步提升性能。
    • • 最终一致性: 通过这种异步方式,我们实现了Redis缓存与数据库的最终一致性。可能会有极短的延迟,但在业务上是可接受的。

四、 总结与展望

防止万级QPS下的库存超卖,是一个典型的系统设计问题。它考验的不是某种单一的“银弹”技术,而是对系统架构、数据一致性、性能权衡的全局理解。

我们的策略可以概括为:

  • • 读多写少,缓存先行: 用Redis扛住绝大部分的读压力和原子写压力。
  • • 原子操作,杜绝竞态: 利用Redis的Lua脚本或原子命令,确保核心扣减逻辑的原子性。
  • • 分层过滤,逐级削减: 从前端到网关,再到业务层,层层设防,保护核心资源。
  • • 异步化与最终一致: 通过消息队列,将高并发的同步写操作,转化为低并发的异步消费,保证数据库的稳定,并接受数据的最终一致性。

这套方案经过各大互联网公司“双十一”、“618”等大促的残酷考验,是应对高并发库存超卖问题的有效实践。当然,没有完美的架构,只有适合业务的架构。在实际应用中,还需要考虑缓存穿透、缓存雪崩、MQ消息丢失、兜底降级等诸多细节。但理解了上述核心思想,你就已经掌握了解决这类问题的“道”,剩下的便是根据具体场景进行“术”的调整与优化了。

 


【声明】内容源于网络
0
0
跨境Amy
跨境分享站 | 每日更新跨境知识
内容 44207
粉丝 2
跨境Amy 跨境分享站 | 每日更新跨境知识
总阅读236.5k
粉丝2
内容44.2k