前言
"快看我们的秒杀系统!库存显示-500了!"
3年前的这个电话让我记忆犹新。
当时某电商大促,我们自认为完美的分布式架构,在0点整瞬间被击穿。
数据库连接池耗尽,库存表出现负数,客服电话被打爆...
今天这篇文章跟大家一起聊聊商品超卖的问题,希望对你会有所帮助。
1 为什么会发生超卖?
首先我们一起看看为什么会发送超卖?
1.1 数据库的"最后防线"漏洞
我们用下面的列子,给大家介绍一下商品超卖是如何发生的。
java
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 数据库乐观锁
数据库乐观锁的核心原理是通过版本号控制并发。
例如下面这样的:
sql
UPDATE product
SET stock = stock -1, version=version+1
WHERE id=123 AND version=#{currentVersion};
Java的实现代码如下:
java
@Transactional
public 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;
}
基于数据库乐观锁方案的架构图如下:

优缺点分析:
优点 | 缺点 |
---|---|
无需额外中间件 | 高并发时DB压力大 |
实现简单 | 可能出现大量更新失败 |
适用场景:日订单量1万以下的中小系统。
2.2 Redis原子操作
Redis原子操作的核心原理是使用:Redis + Lua脚本。
核心代码如下:
java
// 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的实现:
java
RLock lock = redisson.getLock("stock_lock:"+productId);
try {
if (lock.tryLock(1, 10, TimeUnit.SECONDS)) {
// 执行库存操作
}
} finally {
lock.unlock();
}
注意事项
- 1.锁粒度要细化到商品级别
- 2.必须设置等待时间和自动释放
- 3.配合异步队列使用效果更佳
该方案的架构图如下:

2.4 消息队列削峰
可以使用 RocketMQ的事务消息。
核心代码如下:
java
// 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 预扣库存
预扣库存是防止商品超卖的终极方案。
核心算法如下:
java
// 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内
Java集合常见面试题及答案
Java基础常见面试题及答案
并发编程常见面试题及答案
JVM常见面试题及答案
Kafka常见面试题及答案
Redis常见面试题及答案
MySQL常见面试题及答案
Spring常见面试题及答案
分布式常见面试题及答案
我的技术网站,海量八股文、项目实战这里都有:www.susan.net.cn,欢迎大家访问。
3 避坑指南
3.1 缓存与数据库不一致
某次大促因缓存未及时失效,导致超卖1.2万单。
错误示例如下:
java
// 错误示例:先删缓存再写库
redisTemplate.delete("stock:"+productId);
productDao.updateStock(productId, newStock); // 存在并发写入窗口
3.2 未考虑库存回滚
秒杀取消后,忘记恢复库存,引发后续超卖。
正确做法是使用事务补偿。
例如下面这样的:
java
@Transactional
public void cancelOrder(Order order) {
stockDao.restock(order.getItemId(), order.getCount());
orderDao.delete(order.getId());
}
库存回滚和订单删除,在同一个事务中。
3.3 锁粒度过大
锁粒度过大,全局限流导致10%的请求被误杀。
错误示例如下:
java
// 错误示例:全局限锁
RLock globalLock = redisson.getLock("global_stock_lock");
总结
其实在很多大厂中,一般会将防止商品超卖的多种方案组合使用。
架构图如下:
通过组合使用:
- Redis做第一道防线(承受80%流量)
- 分布式锁控制核心业务逻辑
- 预扣库存+消息队列保证最终一致性
实战经验:某电商在2023年双11中:
- Redis集群承载98%请求
- 分布式锁拦截异常流量
- 预扣库存保证最终准确性
系统平稳支撑了每秒12万次秒杀请求,0超卖事故发生!
记住:没有银弹方案,只有适合场景的组合拳!
最后说一句(求关注,别白嫖我)
如果这篇文章对您有所帮助,或者有所启发的话,帮忙关注一下我的同名公众号:苏三说技术,您的支持是我坚持写作最大的动力。
求一键三连:点赞、转发、在看。
关注公众号:【苏三说技术】,在公众号中回复:进大厂,可以免费获取我最近整理的10万字的面试宝典,好多小伙伴靠这个宝典拿到了多家大厂的offer。