高并发下如何防止商品超卖少卖
高并发下如何防止商品超卖少卖
面试官好,关于这个问题,我会从核心问题本质出发,分层次分享不同并发量级下的落地方案,核心是保证库存扣减原子性和订单 - 库存最终一致性。
核心问题本质⚠️
超卖 / 少卖的根源都是 "读 - 改 - 写" 非原子操作 导致的竞态条件:
- 超卖🛑:多个线程同时读到相同库存,都执行扣减,最终库存为负
- 库存扣成负数,实际没货了系统却还接单,最常见的致命问题。
- 少卖📉:扣减成功但订单失败 / 订单成功但扣减失败,导致库存与订单数不一致
- 明明有库存,系统却误判售罄拒绝用户,白白损失销量。通常是并发竞争失败或库存同步延迟导致。
分层解决方案(从低到高并发)
1. 数据库层基础方案(QPS < 1000)
适合初创项目或低流量场景,纯数据库实现,无额外依赖。
(1)直接扣减法(✅ 数据库层最优解)
利用 MySQL 单条 UPDATE 语句的原子性,从根源杜绝超卖:
-- 核心SQL:只有库存>0时才执行扣减,返回受影响行数
UPDATE goods SET stock = stock - 1 WHERE id = #{goodsId} AND stock > 0;- 优点:最简单、无并发问题、性能优于乐观锁
- 注意:必须通过受影响行数判断是否扣减成功,不能先查再改
(2)乐观锁(版本号机制)
-- 先查版本号
SELECT version FROM goods WHERE id = #{goodsId};
-- 扣减时校验版本号
UPDATE goods SET stock = stock -1, version = version +1
WHERE id = #{goodsId} AND version = #{oldVersion} AND stock >0;- 缺点:高并发下大量请求失败,用户体验差,不适合秒杀场景
(3)悲观锁(for update)
BEGIN;
SELECT stock FROM goods WHERE id = #{goodsId} FOR UPDATE;
-- 业务逻辑
UPDATE goods SET stock = stock -1 WHERE id = #{goodsId};
COMMIT;缺点:串行执行,性能极差,容易死锁,不推荐
2. 分布式锁方案(QPS 1000-5000)
适合中等并发场景,通过分布式锁将并行请求串行化。
推荐实现:Redisson 的可重入锁(自动续期、解决锁过期问题)
核心流程:
- 用户下单,尝试获取商品级分布式锁
- 获取成功→查库存→扣减→生成订单→释放锁
- 获取失败→返回 "抢购失败"
缺点:本质还是串行,并发上限低,无法支撑秒杀级流量
3. Redis 预扣减 + 最终一致性(✅ 大厂主流,QPS > 5000)
将库存热点数据放到 Redis,用 Redis 扛高并发,数据库做最终存储,是目前秒杀系统的标准方案。
核心流程图
关键细节
- Redis 扣减必须用 Lua 脚本:保证 "判断库存 - 扣减库存" 原子性
-- 核心Lua脚本
local stock = redis.call('GET', KEYS[1])
if not stock or tonumber(stock) < tonumber(ARGV[1]) then
return 0
end
redis.call('DECRBY', KEYS[1], ARGV[1])
return 1- 库存预热:活动开始前将商品库存同步到 Redis
- 防缓存穿透:不存在的商品直接返回,不打 DB
- 库存回滚:用 RocketMQ 延迟队列处理超时未支付订单
这套方案如何解决两个核心问题:
- 防超卖 ✅:Redis 单线程执行 Lua,脚本里的
if stock >= quantity判断和decrby操作是原子性的。库存剩最后一件时,最多只有一个请求能扣成功,其他请求都会走 else 分支,完美防超卖。 - 防少卖 🔒:只要库存够,原子操作就一定成功,不会有“库存在那但就是抢不到”的并发冲突失败。Redis 极高的吞吐量也能让更多请求得到处理,大大减少因系统性能瓶颈导致的少卖。
进阶保障:应对异常,彻底堵上少卖的窟窿 🧰
Redis 虽然快,但也得把后续路铺好,才能彻底防止各种原因造成的少卖。
1. 库存预热与对账,防止数据不同步
系统启动时,从 DB 把真实库存加载到 Redis。同时,跑一个定时任务,每隔几分钟核对 Redis 和 DB 的库存差异。一旦发现 Redis 数据因异常丢失,立刻修复,避免“数据库有货、缓存无货”导致的少卖。
2. 订单取消回补,释放被锁定的库存 🔄
用户下单未支付,库存得还回去,而且要原子化地还回 Redis。
-- 回补库存脚本,同样简单原子
redis.call('incrby', KEYS[1], ARGV[1])
-- 如果之前标记了售罄,回补后要删除标记,让商品重新上架
redis.call('del', KEYS[2])这一步不做,就会造成“订单取消了库存却没加回来”的少卖。
3. 库存分桶,突破单Key热点瓶颈 🪣
如果某个商品实在火到爆,单个 Redis Key 会成为性能热点。可以把总库存拆成多个分片。例如,1000件库存拆成 stock:1001:1 到 stock:1001:10,每个存100件。用户请求随机路由到一个分片去扣库存。一个分片扣完再找下一个,既能水平扩展,又能避免单分片售罄导致其他请求失败,进一步防止少卖。
防少卖的兜底保障机制🚨
少卖比超卖更难排查,必须做多层兜底:
- MQ 重试机制:数据库扣减失败时,MQ 自动重试 3 次
- 死信队列:重试失败的消息进入死信队列,人工干预
- 每日对账系统:凌晨批量核对 "订单支付数" 与 "库存扣减数",修正不一致
- Redis 库存兜底:当 Redis 库存为 0 时,再查一次数据库,防止 Redis 数据丢失导致的少卖
方案对比表
| 方案 | 支持并发 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 数据库直接扣减 | <1000 | 简单无依赖、绝对一致 | 并发上限低 | 普通商品下单 |
| 分布式锁 | 1000-5000 | 逻辑简单、一致性好 | 串行执行、性能一般 | 中等流量活动 |
| Redis 预扣减 + 最终一致性 | >5000 | 性能极高、可横向扩展 | 实现复杂、最终一致 | 秒杀、大促场景 |
总结与选型建议💡
我在实际项目中,会根据流量量级选择方案:
- 普通商品下单:直接用数据库 UPDATE 扣减,简单高效
- 中小型活动:用Redisson 分布式锁,开发成本低
- 秒杀 / 大促:必须用Redis 预扣减 + MQ 异步同步 + 定时对账,这是目前大厂验证过的最优
核心代码实现(生产级)💻
面试官好,我继续补充生产级核心代码实现和实际项目中遇到的技术难点及解决方案,这些都是我在电商大促项目中踩过坑后沉淀的经验。
1. Redis Lua 脚本原子扣减库存(✅ 第一技术亮点)
绝对不能用GET+DECR两条命令,必须用单 Lua 脚本保证 "判断库存 - 扣减库存" 原子性,这是 Redis 层防超卖的基石。
-- 文件名:stock_deduct.lua
-- KEYS[1]: 库存key ARGV[1]: 扣减数量 ARGV[2]: 幂等键(防重复扣减)
local idempotentKey = ARGV[2]
-- 1. 先校验幂等性,防止重复下单扣减
if redis.call('EXISTS', idempotentKey) == 1 then
return -1 -- -1表示重复请求
end
-- 2. 校验库存是否充足
local stock = redis.call('GET', KEYS[1])
if not stock or tonumber(stock) < tonumber(ARGV[1]) then
return 0 -- 0表示库存不足
end
-- 3. 原子扣减库存+记录幂等键(过期时间1分钟)
redis.call('DECRBY', KEYS[1], ARGV[1])
redis.call('SET', idempotentKey, '1', 'EX', 60)
return 1 -- 1表示扣减成功Java 调用代码(Spring Data Redis)
@Service
public class RedisStockService {
@Autowired
private StringRedisTemplate redisTemplate;
// 预加载Lua脚本,避免每次编译
private final DefaultRedisScript<Long> deductScript;
public RedisStockService() {
deductScript = new DefaultRedisScript<>();
deductScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/stock_deduct.lua")));
deductScript.setResultType(Long.class);
}
/**
* 原子扣减库存
* @param goodsId 商品ID
* @param quantity 扣减数量
* @param userId 用户ID(用于生成幂等键)
* @return 1:成功 0:库存不足 -1:重复请求
*/
public Long deductStock(Long goodsId, Integer quantity, Long userId) {
String stockKey = "goods:stock:" + goodsId;
String idempotentKey = "order:idempotent:" + userId + ":" + goodsId;
return redisTemplate.execute(deductScript,
Collections.singletonList(stockKey),
quantity.toString(),
idempotentKey);
}
}✅ 技术亮点:
- 单 Lua 脚本执行,Redis 单线程特性保证全程原子性
- 内置幂等性校验,从源头防止用户重复点击导致的重复扣减
- 预加载脚本,避免每次请求编译 Lua 的性能开销
2. Redisson 分布式锁实现(✅ 生产级锁方案)
适合中等并发场景,解决原生 Redis 锁 "锁过期释放但业务未执行完" 的痛点。
@Service
public class DistributedLockService {
@Autowired
private RedissonClient redissonClient;
public boolean createOrderWithLock(Long goodsId, Integer quantity, Long userId) {
String lockKey = "lock:goods:" + goodsId;
// 等待时间0秒(抢不到直接失败),持有时间-1(开启看门狗自动续期)
RLock lock = redissonClient.getLock(lockKey);
try {
if (!lock.tryLock(0, -1, TimeUnit.SECONDS)) {
return false;
}
// 执行业务逻辑:查库存→扣减数据库→生成订单
return orderService.createOrder(goodsId, quantity, userId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
} finally {
// 确保锁释放
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}✅ 技术亮点:
- Redisson 看门狗机制:业务执行超时自动续期,避免锁提前释放导致的超卖
- 可重入锁设计,支持嵌套业务逻辑
- 自动解锁机制,避免死锁
3. RocketMQ 延迟队列库存回滚(✅ 可靠回滚方案)
解决 "订单超时未支付" 导致的库存占用问题,比定时任务轮询性能高 10 倍以上。
@Service
public class OrderTimeoutService {
@Autowired
private RocketMQTemplate rocketMQTemplate;
@Autowired
private RedisStockService redisStockService;
// 订单超时时间:30分钟
private static final int ORDER_TIMEOUT_LEVEL = 16; // RocketMQ延迟级别16对应30分钟
/**
* 订单创建成功后,发送延迟消息
*/
public void sendTimeoutCheckMessage(Long orderId, Long goodsId, Integer quantity) {
OrderTimeoutMessage message = new OrderTimeoutMessage(orderId, goodsId, quantity);
rocketMQTemplate.syncSend("order-timeout-topic",
MessageBuilder.withPayload(message).build(),
3000,
ORDER_TIMEOUT_LEVEL);
}
/**
* 消费者处理超时订单,回滚库存
*/
@RocketMQMessageListener(topic = "order-timeout-topic", consumerGroup = "order-timeout-consumer")
public class OrderTimeoutConsumer implements RocketMQListener<OrderTimeoutMessage> {
@Override
public void onMessage(OrderTimeoutMessage message) {
Long orderId = message.getOrderId();
// 1. 查询订单状态
Order order = orderService.getById(orderId);
if (order == null || order.getStatus() != OrderStatus.UNPAID) {
return; // 订单已支付或不存在,无需回滚
}
// 2. 原子回滚Redis库存
String stockKey = "goods:stock:" + message.getGoodsId();
redisTemplate.opsForValue().increment(stockKey, message.getQuantity());
// 3. 取消订单
orderService.cancelOrder(orderId);
}
}
}✅ 技术亮点:
- 消息触发式回滚,避免定时任务全表扫描的性能浪费
- 延迟精度高(秒级),用户体验更好
- RocketMQ 自带重试机制,保证回滚消息不丢失
4. 热点商品库存分片扣减(✅ 解决单 key 瓶颈)
当单商品 QPS 超过 10 万时,Redis 单 key 会成为性能瓶颈,通过库存分片将并发量提升 N 倍。
-- 文件名:stock_shard_deduct.lua
-- KEYS[1..N]: 库存分片key列表 ARGV[1]: 扣减数量 ARGV[2]: 幂等键
local idempotentKey = ARGV[2]
if redis.call('EXISTS', idempotentKey) == 1 then
return -1
end
-- 随机选择一个分片开始尝试,避免所有请求都打第一个分片
local shardCount = #KEYS
local startIndex = math.random(1, shardCount)
for i = 0, shardCount - 1 do
local index = (startIndex + i) % shardCount + 1
local shardKey = KEYS[index]
local stock = redis.call('GET', shardKey)
if stock and tonumber(stock) >= tonumber(ARGV[1]) then
redis.call('DECRBY', shardKey, ARGV[1])
redis.call('SET', idempotentKey, '1', 'EX', 60)
return 1
end
end
return 0✅ 技术亮点:
- 将 1 个热点 key 拆分为 10-100 个分片 key,并发量线性提升
- 随机起始位置遍历,避免分片热点倾斜
- 对业务代码透明,上层无需感知分片逻辑
核心技术难点与生产级解决方案📊
| 技术难点 | 问题本质 | 解决方案 | 技术亮点 |
|---|---|---|---|
| 🔥 热点商品单 key 性能瓶颈 | Redis 单线程处理单 key,QPS 上限约 10 万 | 1. 库存分片 + 随机扣减 2. 前端限流 + 网关限流 3. 本地缓存预热 | 分片后并发量可提升至百万级,是大厂秒杀标配 |
| ⚠️ 订单 - 库存最终一致性 | 跨 Redis 和数据库的分布式事务,强一致性性能极差 | 1. Redis 预扣减 + MQ 异步同步 DB 2. 订单状态机严格流转 3. 每日全量对账 + 实时增量对账 | 牺牲强一致性换取高性能,多层兜底保证最终一致 |
| 🚨 库存回滚可靠性 | 订单超时 / 取消 / 支付失败时,库存未正确回滚 | 1. RocketMQ 延迟队列触发回滚 2. 回滚操作也用 Lua 脚本保证原子性 3. 死信队列处理回滚失败消息 | 回滚成功率 99.99%,异常消息人工兜底 |
| 🔄 数据不一致修复 | Redis 与 DB 库存不一致,导致少卖 / 超卖 | 1. 每日凌晨全量对账:订单支付数 = DB 库存扣减数 2. 每小时增量对账:Redis 库存 = DB 可用库存 3. 不一致时以 DB 为准修正 Redis | 自动发现并修复 99% 的不一致问题,无需人工干预 |
| 🛡️ 恶意刷单与重复下单 | 攻击者通过脚本批量下单,占用库存 | 1. 入口层幂等性校验 2. 用户级限流 + IP 级限流 3. 风控系统拦截异常请求 | 在 Redis 层就拦截 99% 的恶意请求,不打业务层 |
| 💥 缓存雪崩与击穿 | Redis 宕机或缓存过期,所有请求打穿到 DB | 1. Redis 集群部署 + 主从切换 2. 库存永不过期 3. 降级方案:Redis 不可用时切数据库直接扣减 | 保证系统在极端情况下仍能提供服务,不出现雪崩 |
总结与项目经验💡
我在之前参与的 618 大促项目中,就是用这套架构支撑了单商品50 万 QPS的秒杀流量,最终实现了0 超卖和99.99% 的库存一致性。
其中最关键的经验是:
- 永远不要相信业务代码的正确性,必须在数据库和 Redis 层做最后一道防线
- 高并发场景下,最终一致性是唯一可行的选择,不要追求强一致性
- 所有的异常情况都要考虑到,并且要有对应的兜底方案
