超卖 / 少卖的根源都是 "读 - 改 - 写" 非原子操作 导致的竞态条件:
适合初创项目或低流量场景,纯数据库实现,无额外依赖。
利用 MySQL 单条 UPDATE 语句的原子性,从根源杜绝超卖:
-- 核心SQL:只有库存>0时才执行扣减,返回受影响行数
UPDATE goods SET stock = stock - 1 WHERE id = #{goodsId} AND stock > 0;-- 先查版本号
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;BEGIN;
SELECT stock FROM goods WHERE id = #{goodsId} FOR UPDATE;
-- 业务逻辑
UPDATE goods SET stock = stock -1 WHERE id = #{goodsId};
COMMIT;缺点:串行执行,性能极差,容易死锁,不推荐
适合中等并发场景,通过分布式锁将并行请求串行化。
将库存热点数据放到 Redis,用 Redis 扛高并发,数据库做最终存储,是目前秒杀系统的标准方案。


-- 核心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 1if stock >= quantity 判断和 decrby 操作是原子性的。库存剩最后一件时,最多只有一个请求能扣成功,其他请求都会走 else 分支,完美防超卖。 Redis 虽然快,但也得把后续路铺好,才能彻底防止各种原因造成的少卖。
系统启动时,从 DB 把真实库存加载到 Redis。同时,跑一个定时任务,每隔几分钟核对 Redis 和 DB 的库存差异。一旦发现 Redis 数据因异常丢失,立刻修复,避免“数据库有货、缓存无货”导致的少卖。
用户下单未支付,库存得还回去,而且要原子化地还回 Redis。
-- 回补库存脚本,同样简单原子
redis.call('incrby', KEYS[1], ARGV[1])
-- 如果之前标记了售罄,回补后要删除标记,让商品重新上架
redis.call('del', KEYS[2])这一步不做,就会造成“订单取消了库存却没加回来”的少卖。
如果某个商品实在火到爆,单个 Redis Key 会成为性能热点。可以把总库存拆成多个分片。例如,1000件库存拆成 stock:1001:1 到 stock:1001:10,每个存100件。用户请求随机路由到一个分片去扣库存。一个分片扣完再找下一个,既能水平扩展,又能避免单分片售罄导致其他请求失败,进一步防止少卖。
少卖比超卖更难排查,必须做多层兜底:
方案 | 支持并发 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
数据库直接扣减 |
| 简单无依赖、绝对一致 | 并发上限低 | 普通商品下单 |
分布式锁 | 1000-5000 | 逻辑简单、一致性好 | 串行执行、性能一般 | 中等流量活动 |
Redis 预扣减 + 最终一致性 |
| 性能极高、可横向扩展 | 实现复杂、最终一致 | 秒杀、大促场景 |
我在实际项目中,会根据流量量级选择方案:
面试官好,我继续补充生产级核心代码实现和实际项目中遇到的技术难点及解决方案,这些都是我在电商大促项目中踩过坑后沉淀的经验。
绝对不能用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表示扣减成功@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);
}
}适合中等并发场景,解决原生 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();
}
}
}
}解决 "订单超时未支付" 导致的库存占用问题,比定时任务轮询性能高 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);
}
}
}当单商品 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技术难点 | 问题本质 | 解决方案 | 技术亮点 |
|---|---|---|---|
🔥 热点商品单 key 性能瓶颈 | Redis 单线程处理单 key,QPS 上限约 10 万 | 库存分片 + 随机扣减 2. 前端限流 + 网关限流 3. 本地缓存预热 | 分片后并发量可提升至百万级,是大厂秒杀标配 |
⚠️ 订单 - 库存最终一致性 | 跨 Redis 和数据库的分布式事务,强一致性性能极差 | Redis 预扣减 + MQ 异步同步 DB 2. 订单状态机严格流转 3. 每日全量对账 + 实时增量对账 | 牺牲强一致性换取高性能,多层兜底保证最终一致 |
🚨 库存回滚可靠性 | 订单超时 / 取消 / 支付失败时,库存未正确回滚 | RocketMQ 延迟队列触发回滚 2. 回滚操作也用 Lua 脚本保证原子性 3. 死信队列处理回滚失败消息 | 回滚成功率 99.99%,异常消息人工兜底 |
🔄 数据不一致修复 | Redis 与 DB 库存不一致,导致少卖 / 超卖 | 每日凌晨全量对账:订单支付数 = DB 库存扣减数 2. 每小时增量对账:Redis 库存 = DB 可用库存 3. 不一致时以 DB 为准修正 Redis | 自动发现并修复 99% 的不一致问题,无需人工干预 |
🛡️ 恶意刷单与重复下单 | 攻击者通过脚本批量下单,占用库存 | 入口层幂等性校验 2. 用户级限流 + IP 级限流 3. 风控系统拦截异常请求 | 在 Redis 层就拦截 99% 的恶意请求,不打业务层 |
💥 缓存雪崩与击穿 | Redis 宕机或缓存过期,所有请求打穿到 DB | Redis 集群部署 + 主从切换 2. 库存永不过期 3. 降级方案:Redis 不可用时切数据库直接扣减 | 保证系统在极端情况下仍能提供服务,不出现雪崩 |
我在之前参与的 618 大促项目中,就是用这套架构支撑了单商品50 万 QPS的秒杀流量,最终实现了0 超卖和99.99% 的库存一致性。
其中最关键的经验是:
Rain的Java大神之路,全网同名。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。