作为一名后端开发者,尤其是涉及电商、秒杀等场景时,“库存超卖”就像一个幽灵,时刻徘徊在系统周围。平时风平浪静,一旦大促来临,流量洪峰涌来,若防御不当,它就会跳出来给你一记重拳——卖出了不存在的商品,导致资损、客诉、信誉崩塌。
今天,我们就来深入聊聊,在万级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. 请求A和B同时执行第1步,查询到的库存 stock都是1。 -
2. 请求A和B都通过第2步的库存判断 ( 1 >= 1->true)。 -
3. 请求A执行第3步,将库存更新为 0。 -
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. 性能瓶颈: 所有请求串行化,在大并发下,数据库连接池容易被耗光,导致系统响应缓慢或崩溃。 -
2. 死锁风险: 多个事务相互等待对方持有的锁,容易造成死锁。 -
3. 依赖数据库: 对数据库压力巨大。
结论: 悲观锁适用于并发量不高的场景,在万级QPS下,它通常是第一个被排除的方案。
方案二:乐观锁 —— “我相信世界是美好的,但会留个心眼”
乐观锁的思想是,我认为冲突不经常发生。所以我不加锁,但在更新时,会检查一下在我修改期间,数据是否被他人改动过。
通常我们使用一个版本号 version 字段来实现。
-
1. 数据库表中增加一个 version字段,默认值为 0。 -
2. 查询时,同时查出 version。 -
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. CAS(Compare-And-Swap)典型问题: ABA问题(虽然业务上不太关心),以及自旋重试带来的CPU开销。 -
2. 成功率问题: 在极高并发下(比如秒杀),大量请求同时读取到同一个版本号,然后只有一个能更新成功,其他全部失败。用户体验不佳,感觉“明明有货却秒杀不到”。
结论: 乐观锁适用于并发量较高,但写冲突相对不那么极端的场景。对于真正的秒杀,它还不够。
方案三:Redis 原子操作 —— “天下武功,唯快不破”
当数据库成为瓶颈,我们自然想到用更快的缓存来扛。Redis,单机可达10W+ QPS,是应对读多写少场景的利器。但用它来扣减库存,需要利用其原子性操作。
Redis提供了 DECR 和 DECRBY 命令,能原子性地减少一个键的值。
核心思路:
-
1. 活动开始前,将商品库存提前预热到Redis中。 SET stock:item_1001 100 -
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. 数据一致性: Redis中的数据是缓存,如何与数据库中的最终库存保持一致? -
2. 架构复杂性: 引入了缓存层,需要维护缓存和数据库的双写一致性。
三、 终极架构:分层校验与异步同步
在实际的万级QPS场景中,我们不会只依赖单一方案,而是采用一套组合拳,进行分层、分级的防御。
核心思想: 将请求尽量拦截在上游,减少对底层核心资源的访问。
一个典型的防超卖架构如下:
-
1. 前端层面: -
• 按钮限流/禁用: 提交后按钮置灰,防止用户疯狂点击。 -
• 验证码/答题: 分散请求时间,拉长整个秒杀流程,削峰填谷。 -
2. 网关/负载均衡层: -
• 限流: 对同一IP、用户ID进行限流,将超过系统承载能力的请求直接拒绝。 -
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;
}
}
-
4. 数据层: -
• 异步落库: 消息队列的消费者,从队列中取出扣减成功的消息,然后执行数据库的库存更新 ( UPDATE item SET stock = stock - ? WHERE id = ?)。因为队列的消费是顺序的,所以这里数据库的并发压力很小,甚至可以合并多个更新操作进行一次批量更新,进一步提升性能。 -
• 最终一致性: 通过这种异步方式,我们实现了Redis缓存与数据库的最终一致性。可能会有极短的延迟,但在业务上是可接受的。
四、 总结与展望
防止万级QPS下的库存超卖,是一个典型的系统设计问题。它考验的不是某种单一的“银弹”技术,而是对系统架构、数据一致性、性能权衡的全局理解。
我们的策略可以概括为:
-
• 读多写少,缓存先行: 用Redis扛住绝大部分的读压力和原子写压力。 -
• 原子操作,杜绝竞态: 利用Redis的Lua脚本或原子命令,确保核心扣减逻辑的原子性。 -
• 分层过滤,逐级削减: 从前端到网关,再到业务层,层层设防,保护核心资源。 -
• 异步化与最终一致: 通过消息队列,将高并发的同步写操作,转化为低并发的异步消费,保证数据库的稳定,并接受数据的最终一致性。
这套方案经过各大互联网公司“双十一”、“618”等大促的残酷考验,是应对高并发库存超卖问题的有效实践。当然,没有完美的架构,只有适合业务的架构。在实际应用中,还需要考虑缓存穿透、缓存雪崩、MQ消息丢失、兜底降级等诸多细节。但理解了上述核心思想,你就已经掌握了解决这类问题的“道”,剩下的便是根据具体场景进行“术”的调整与优化了。

