分类:架构与高并发 · 作者:管理员 · 发布于 2026-09-22 · 阅读 0 · 标签:库存扣减 / 防超卖 / Redis Lua / 分布式锁 / 高并发
1. 场景:为什么库存会超卖?
电商秒杀/抢购场景下,多个用户同时下单扣减同一商品库存,如果扣减操作不是原子的,就会出现「超卖」——卖出去的数量 > 实际库存。
典型错误代码:
// 问题代码:先查后扣,非原子操作
public boolean deductStock(Long skuId, Integer quantity) {
// 步骤1:查询当前库存
Integer stock = stockMapper.selectStock(skuId);
// 步骤2:判断是否足够
if (stock == null || stock < quantity) {
return false;
}
// 步骤3:扣减(UPDATE)
int affected = stockMapper.deductStock(skuId, quantity);
return affected > 0;
}
并发下线程 A 查到 stock=1,线程 B 也查到 stock=1,两线程都进入扣减,导致库存变成 -1。这就是经典的「读-改-写」并发冲突。
2. 方案一:数据库乐观锁(低并发兜底)
利用 MySQL 行锁+版本号或条件 UPDATE 保证扣减原子性:
// 乐观锁:扣减的同时校验库存 >= 扣减量
@Update("UPDATE sku_stock SET stock = stock - #{quantity}, version = version + 1 " +
"WHERE sku_id = #{skuId} AND stock >= #{quantity} AND version = #{version}")
int deductByOptimisticLock(@Param("skuId") Long skuId,
@Param("quantity") Integer quantity,
@Param("version") Integer version);
优点:实现简单,依赖数据库本身的行锁机制。
缺点:
- 每次扣减都要重试(affected=0 时用户重试),并发越高重试次数越多
- MySQL 行锁在热点 SKU 上会形成锁竞争,TPS 天花板低(单机 MySQL 约 500~2000 TPS)
- 库存频繁更新导致 InnoDB 二级索引页分裂和 Binlog 量暴增
适用场景:低并发(<100 TPS)、非热点商品、作为兜底校验。
3. 方案二:Redis SETNX 分布式锁(防重复扣减)
对同一个 SKU 加锁,使扣减操作串行化:
public boolean deductWithLock(Long skuId, Integer quantity) {
String lockKey = "lock:stock:" + skuId;
String lockValue = UUID.randomUUID().toString();
// 加锁:SET NX EX 3(3秒自动过期,防死锁)
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, Duration.ofSeconds(3));
if (Boolean.FALSE.equals(locked)) {
return false; // 获取锁失败,提示稍后重试
}
try {
// 查库存 → 扣库存(此时已串行化)
Integer stock = (Integer) redisTemplate.opsForValue().get("stock:" + skuId);
if (stock == null || stock < quantity) return false;
redisTemplate.opsForValue().decrement("stock:" + skuId, quantity);
return true;
} finally {
// Lua 脚本保证原子释放锁(只释放自己的锁)
String script = "if redis.call('get', KEYS[1]) == ARGV[1] " +
"then return redis.call('del', KEYS[1]) " +
"else return 0 end";
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
redisTemplate.execute(redisScript, Collections.singletonList(lockKey), lockValue);
}
}
踩坑记录:
- 锁续命(Watchdog):3 秒锁过期时间在 GC STW 或网络慢时不够,要用 Redisson 的 Watchdog 机制(默认每 10 秒续一次,续到业务完成)
- 锁粒度:锁 SKU 粒度比锁整个方法细很多,但仍属于「串行化」,高并发下大量线程阻塞等待
- Redis 主从延迟:主写锁 key 未同步到从时主宕机,新主无锁→多线程同时进入,库存仍然超卖
4. 方案三:Redis Lua 原子扣减(生产首选)
Redis 单线程执行 Lua 脚本,整个脚本原子完成「查库存 → 判足 → 扣减」三步,不需要外部锁:
-- redis_stock_deduct.lua
-- KEYS[1] = stock:sku:{skuId} 库存 key
-- KEYS[2] = sold:sku:{skuId} 已售 key(可选,用于记录销量)
-- ARGV[1] = 扣减数量
-- ARGV[2] = 当前时间戳(用于记录)
local stock = redis.call('GET', KEYS[1])
if not stock then
return {-1, '库存不存在'}
end
local stockNum = tonumber(stock)
local deductNum = tonumber(ARGV[1])
if stockNum < deductNum then
return {0, '库存不足'}
end
redis.call('DECRBY', KEYS[1], deductNum)
-- 可选:记录已售数量
redis.call('INCRBY', KEYS[2], deductNum)
return {1, '扣减成功'}
// Java 调用 Lua 脚本
private static final String DEDUCT_SCRIPT =
"local stock = redis.call('GET', KEYS[1])\n" +
"if not stock then return {-1, '库存不存在'} end\n" +
"local stockNum = tonumber(stock)\n" +
"local deductNum = tonumber(ARGV[1])\n" +
"if stockNum < deductNum then return {0, '库存不足'} end\n" +
"redis.call('DECRBY', KEYS[1], deductNum)\n" +
"redis.call('INCRBY', KEYS[2], deductNum)\n" +
"return {1, '扣减成功'}";
public DeductResult deductByLua(Long skuId, int quantity) {
DefaultRedisScript<List> script = new DefaultRedisScript<>(DEDUCT_SCRIPT, List.class);
List<Object> result = redisTemplate.execute(script,
Arrays.asList("stock:sku:" + skuId, "sold:sku:" + skuId),
String.valueOf(quantity));
int code = ((Long) result.get(0)).intValue();
String msg = (String) result.get(1);
return new DeductResult(code == 1, msg);
}
性能对比(实测):
- MySQL 乐观锁:~800 TPS(单机)
- Redis 分布式锁(串行等待):~3,000 TPS
- Redis Lua 原子扣减:~15,000 TPS(单 Redis 节点)
关键注意点:
- Lua 脚本中 禁止使用随机值/非确定性函数(如 TIME、SRANDMEMBER),否则 Redis Cluster 模式下无法保证所有 key 在同一 slot,导致 CROSSSLOT 错误
- 所有操作涉及的 key 必须传 KEYS 数组,不能在脚本内拼接,否则 Cluster 下无法路由
- 脚本内容建议注册(SCRIPT LOAD + EVALSHA),减少带宽
5. 方案四:分段库存(热点 SKU 终极解法)
单 key 在极端热点(如 iPhone 首发 100 万人抢 1000 台)下,即使 Lua 原子扣减,Redis 单线程执行也会成为瓶颈。此时将库存拆成多个段(segment):
// 初始化:将库存 1000 拆成 10 段,每段 100
public void initSegmentStock(Long skuId, int totalStock, int segmentCount) {
int base = totalStock / segmentCount;
int remainder = totalStock % segmentCount;
for (int i = 0; i < segmentCount; i++) {
int segStock = base + (i < remainder ? 1 : 0);
redisTemplate.opsForValue().set("stock:seg:" + skuId + ":" + i, segStock);
}
}
// 扣减:随机选段,Lua 原子扣其中一段
public boolean deductSegment(Long skuId, int quantity, int segmentCount) {
int startIdx = ThreadLocalRandom.current().nextInt(segmentCount);
for (int i = 0; i < segmentCount; i++) {
int idx = (startIdx + i) % segmentCount;
String segKey = "stock:seg:" + skuId + ":" + idx;
// 用 Lua 脚本尝试扣该段
Long result = redisTemplate.execute(SEGMENT_DEDUCT_SCRIPT,
Collections.singletonList(segKey),
String.valueOf(quantity));
if (result != null && result >= 0) {
return true;
}
}
return false; // 所有段都不足
}
分段后,压力从 1 个 key 分散到 N 个 key,Redis 单线程可交错处理不同段的请求,整体吞吐提升 3~5 倍。
坑:需要处理「段碎片」——最后一段可能有余量但所有段分布不均,扣减时需遍历所有段才返回失败。
6. 完整方案设计:三层防线
生产环境不要只依赖一层,建议三层防线组合:
| 层级 | 技术 | 作用 |
|---|---|---|
| 第一层(Redis Lua) | Redis 原子扣减库存 | 承载 99% 流量,高吞吐、低延迟 |
| 第二层(数据库乐观锁) | UPDATE ... WHERE stock >= quantity | 最终一致性保障,防 Redis 宕机或主从切换丢数据 |
| 第三层(异步对账) | 定时任务 + Binlog 消费,核对订单数与库存扣减数 | 发现不一致自动补偿(退款或补货) |
7. 最佳实践总结
- 原子性是第一原则:无论用 MySQL 还是 Redis,扣减操作必须在一个原子步骤内完成,绝不能「先查后扣」
- Redis Lua 是性价比最高的方案:性能足够、实现简单、无锁竞争,适合 95% 的库存场景
- 分布式锁要配合 Lua 释放:非必要不引入外部锁,如果没有分段需求,Lua 加 Redis 单 key 就够
- 库存预热:活动开始前将库存从 MySQL 加载到 Redis,用 EXEC 或 Lua 保证批量写入原子性
- 库存归还:下单后 30 分钟未支付自动取消,通过延时消息(RabbitMQ 死信 / Redis Keyspace Notification)归还库存
- 只减不增陷阱:严禁直接从数据库 UPDATE stock = stock + 1 恢复库存——必须经过过期时间校验,防止刷单恶意占库存后退款套利
8. 踩坑实录
- Redis Cluster 模式下 Lua 玩脱了:脚本内用了 KEYS 之外的动态 key,报 CROSSSLOT——修复方式是将所有 key 通过 KEYS 数组传入
- 锁的过期时间太短:一次 Full GC 耗时 2.8 秒,锁过期,另一个线程进入扣减——后面用 Redisson Watchdog 或手动延长超时
- 库存初始化为 0 导致全部失败:活动开始前没有预热,Redis 中 key 不存在,Lua 脚本返回「库存不存在」——加一层兜底从 MySQL 同步加载
- 分段库存碎片导致用户看到「有货但买不到」:每段 100,用户买 5 件,但每段只剩 3 件——解决方案是扣减时遍历所有段聚合判断