Appearance
电商库存如何保证不超卖
超卖是电商系统的核心难题——库存只有 100 件,却卖出 101 件。本质上是高并发下库存扣减不是原子操作导致的。这个问题从单体架构到微服务架构,方案逐步升级。
一、问题本质
库存 100 件,两个用户同时下单:
线程 A:SELECT stock FROM product WHERE id = 1 → 读到 100
线程 B:SELECT stock FROM product WHERE id = 1 → 读到 100
线程 A:判断 100 >= 1 → UPDATE stock = 99
线程 B:判断 100 >= 1 → UPDATE stock = 99
结果:两个人都下单成功,库存变成 99(实际只扣了 1 件)根本原因: 读库存和扣库存不是原子操作,中间有并发窗口。
二、方案对比
| 方案 | 原理 | QPS | 一致性 | 复杂度 | 适用规模 |
|---|---|---|---|---|---|
| 数据库行锁 | SELECT ... FOR UPDATE | ~500 | 强 | 低 | 小规模 |
| 乐观锁(版本号) | UPDATE WHERE version = ? | ~2000 | 强 | 低 | 中小规模 |
| 数据库原子更新 | UPDATE SET stock = stock - 1 WHERE stock >= 1 | ~5000 | 强 | 低 | 中等规模 |
| Redis 原子扣减 | DECR stock:1 | ~50000 | 最终 | 中 | 大规模 |
| Redis + Lua | Lua 脚本判断+扣减 | ~50000 | 最终 | 中 | 大规模 |
| 分库分表 + Redis | 多级缓存 + 分段扣减 | ~100000+ | 最终 | 高 | 超大规模 |
三、方案详解
3.1 数据库行锁(悲观锁)
java
@Transactional
public boolean deductStock(Long productId, int quantity) {
// SELECT ... FOR UPDATE:行级排他锁,其他事务必须等待
ProductStock stock = jdbcTemplate.queryForObject(
"SELECT stock FROM product_stock WHERE product_id = ? FOR UPDATE",
ProductStock.class, productId);
if (stock.getStock() < quantity) {
return false;
}
jdbcTemplate.update(
"UPDATE product_stock SET stock = stock - ? WHERE product_id = ?",
quantity, productId);
return true;
}执行流程:
线程 A:SELECT FOR UPDATE → 获取锁,读到 100
线程 B:SELECT FOR UPDATE → 阻塞等待 A 释放锁
线程 A:UPDATE stock = 99 → COMMIT(释放锁)
线程 B:获取锁,读到 99 → 判断 99 >= 1 → UPDATE stock = 98
优点:逻辑简单,强一致性
缺点:锁竞争严重时性能急剧下降,QPS ~5003.2 乐观锁(版本号)
java
public boolean deductStock(Long productId, int quantity) {
// 先查版本号
ProductStock stock = jdbcTemplate.queryForObject(
"SELECT stock, version FROM product_stock WHERE product_id = ?",
ProductStock.class, productId);
if (stock.getStock() < quantity) {
return false;
}
// UPDATE 带版本号条件
int rows = jdbcTemplate.update(
"UPDATE product_stock SET stock = stock - ?, version = version + 1 " +
"WHERE product_id = ? AND version = ? AND stock >= ?",
quantity, productId, stock.getVersion(), quantity);
return rows > 0;
}执行流程:
线程 A:SELECT stock=100, version=1
线程 B:SELECT stock=100, version=1
线程 A:UPDATE WHERE version=1 AND stock>=1 → 成功(version 变成 2)
线程 B:UPDATE WHERE version=1 AND stock>=1 → 失败(version 已经变成 2)
线程 B 需要重试:重新 SELECT → stock=99, version=2 → UPDATE
优点:无锁等待,QPS 提升到 ~2000
缺点:高并发下重试次数多,需要配合重试机制3.3 数据库原子更新(推荐中小规模)
java
public boolean deductStock(Long productId, int quantity) {
// 一句 SQL 完成判断+扣减,MySQL 行锁保证原子性
int rows = jdbcTemplate.update(
"UPDATE product_stock SET stock = stock - ? " +
"WHERE product_id = ? AND stock >= ?",
quantity, productId, quantity);
return rows > 0; // 返回 0 表示库存不足
}为什么这个方案好?
1. 一句 SQL 完成判断+扣减,MySQL InnoDB 的行锁保证原子性
2. 不需要 SELECT 再 UPDATE,减少一次数据库交互
3. 不需要乐观锁的版本号字段和重试逻辑
4. QPS 可达 ~5000,满足大部分中小电商
这是最简单、最可靠的方案,优先考虑3.4 Redis 原子扣减(高并发首选)
java
@Service
public class StockService {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 初始化库存(活动开始前)
*/
public void initStock(Long productId, int stock) {
redisTemplate.opsForValue().set("stock:" + productId, String.valueOf(stock));
}
/**
* 扣减库存
*/
public boolean deductStock(Long productId, int quantity) {
// DECRBY 是原子操作,Redis 单线程保证
Long afterStock = redisTemplate.opsForValue()
.decrement("stock:" + productId, quantity);
if (afterStock == null || afterStock < 0) {
// 库存不足,回滚(加回去)
redisTemplate.opsForValue().increment("stock:" + productId, quantity);
return false;
}
return true;
}
}DECRBY 原子扣减原理:
请求 1:DECRBY stock:1 1 → Redis 单线程,这条命令执行完才轮到下一条
请求 2:DECRBY stock:1 1 → 排队等待,Redis 单线程保证顺序执行
注意:DECRBY 本身不判断库存是否足够,会扣成负数
需要额外判断 afterStock < 0 并回滚3.5 Redis + Lua 脚本(生产推荐)
lua
-- deduct_stock.lua
-- KEYS[1] = 库存 key, stock:product:100
-- KEYS[2] = 扣减记录 key, stock_log:product:100:user:1
-- ARGV[1] = 扣减数量
-- ARGV[2] = 记录过期时间
local stock = redis.call('GET', KEYS[1])
if not stock then
return -1 -- 库存 key 不存在
end
stock = tonumber(stock)
local quantity = tonumber(ARGV[1])
if stock < quantity then
return 0 -- 库存不足
end
-- 防止重复扣减(同一用户对同一商品)
local exists = redis.call('EXISTS', KEYS[2])
if exists == 1 then
return -2 -- 重复请求
end
-- 扣减库存
redis.call('DECRBY', KEYS[1], quantity)
-- 记录扣减(幂等标记)
redis.call('SETEX', KEYS[2], ARGV[2], 1)
return 1 -- 扣减成功java
public StockResult deductStock(Long userId, Long productId, int quantity) {
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptSource(new ResourceScriptSource(
new ClassPathResource("lua/deduct_stock.lua")));
script.setResultType(Long.class);
Long result = redisTemplate.execute(script,
Arrays.asList(
"stock:product:" + productId,
"stock_log:product:" + productId + ":user:" + userId
),
String.valueOf(quantity),
String.valueOf(Duration.ofHours(1).getSeconds())
);
switch (result.intValue()) {
case 1: return StockResult.success("扣减成功");
case 0: return StockResult.fail("库存不足");
case -1: return StockResult.fail("库存未初始化");
case -2: return StockResult.success("重复请求"); // 幂等
default: return StockResult.fail("未知错误");
}
}3.6 热点商品分流方案
秒杀场景:100 万用户抢 1000 件商品
问题:所有请求都打到一个 Redis key 上,单 key 瓶颈
解决方案:库存分片
不拆分:stock:100 = 1000
拆分 10 个分片:
stock:100:0 = 100
stock:100:1 = 100
stock:100:2 = 100
...
stock:100:9 = 100
用户 user_id % 10 → 路由到对应分片 → 各自扣减java
public boolean deductStockWithShard(Long userId, Long productId, int shardCount) {
int shard = (int) (userId % shardCount);
String key = "stock:" + productId + ":" + shard;
Long afterStock = redisTemplate.opsForValue().decrement(key, 1);
if (afterStock != null && afterStock < 0) {
// 当前分片没库存了,可以尝试其他分片
for (int i = 0; i < shardCount; i++) {
if (i == shard) continue;
key = "stock:" + productId + ":" + i;
afterStock = redisTemplate.opsForValue().decrement(key, 1);
if (afterStock != null && afterStock >= 0) {
return true;
}
// 这个分片也没库存了,回滚
redisTemplate.opsForValue().increment(key, 1);
}
// 所有分片都没库存
redisTemplate.opsForValue().increment(
"stock:" + productId + ":" + shard, 1);
return false;
}
return true;
}四、生产级方案:三层架构
┌─────────────────────────────────────────────────────────┐
│ 第一层:Nginx / API Gateway 限流 │
│ - 令牌桶限流,只放行可控的请求量 │
│ - 拒绝多余请求,直接返回"已售罄" │
├─────────────────────────────────────────────────────────┤
│ 第二层:Redis 原子扣减(Lua 脚本) │
│ - 极速判断库存 + 扣减,单机 QPS 10 万+ │
│ - 库存分片解决热点问题 │
│ - 扣减成功 → 发 MQ 消息异步创建订单 │
├─────────────────────────────────────────────────────────┤
│ 第三层:数据库兜底 │
│ - MQ 消费者异步扣减数据库库存 │
│ - 数据库唯一索引保证不重复 │
│ - 定时对账:Redis 库存 vs DB 库存,修复不一致 │
└─────────────────────────────────────────────────────────┘请求流程:
用户下单
│
▼
Nginx 限流(令牌桶,QPS 限 5000)
│ 放行
▼
Redis Lua 扣减库存(原子操作)
│ 成功
▼
发送 MQ 消息(异步创建订单)
│
▼
MQ 消费者 → 扣减数据库库存
│
▼
DB 唯一索引(user_id + product_id + order_id)兜底五、方案选型建议
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 普通电商(日活 < 10 万) | 数据库原子更新 | 简单可靠,够用 |
| 中型电商(日活 10-100 万) | Redis + Lua + 异步落库 | 扛住高并发,允许最终一致 |
| 秒杀/大促(瞬时流量) | Redis 分片 + Lua + 限流 | 解决热点 key,多级防护 |
| 超大规模(日活 > 100 万) | 分库分表 + Redis 集群 + 多级缓存 | 水平扩展,全面分布 |
六、面试要点
Q1:为什么 Redis 扣库存不会超卖?
Redis 是单线程执行命令,一个命令执行完才执行下一个。DECR 或 Lua 脚本在执行期间不会被其他命令打断,所以判断+扣减是原子操作。
Q2:Redis 扣减成功后,数据库怎么保证一致?
异步落库 + 定时对账。Redis 扣减成功后发 MQ 消息,消费者异步扣减数据库。定时任务对比 Redis 和 DB 的库存,不一致时以 DB 为准修复 Redis。
Q3:乐观锁重试次数太多怎么办?
使用随机退避重试(1ms → 2ms → 4ms → 8ms),避免多个线程同时重试造成冲突。如果重试超过 3 次仍失败,降级为悲观锁(SELECT FOR UPDATE)。
Q4:超卖和少卖,哪个更严重?
超卖更严重——超卖意味着要赔偿用户、平台处罚、口碑损失。少卖只是少赚一点,可以接受。所以库存扣减策略宁可多扣一点(少卖),也不能超卖。
