一、前言:你是否也遇到过"发了 100 张券,结果被领了 120 张"?
在优惠券秒杀活动中,库存超卖是最常见也最致命的问题之一:
- 用户投诉:"说好限量 1000 张,我怎么没抢到?"
- 运营震惊:"系统显示发放了 1200 张!"
- DBA 报警:"coupon_stock 表出现负数!"
这不仅损害用户体验,更可能导致资损和信任危机。
本文将带你彻底搞懂超卖的本质原因 ,并给出生产级可靠解决方案。
二、什么是"超卖"?为什么它会发生?
✅ 定义:
超卖:实际发放的优惠券数量 > 预设库存数量。
🧨 根本原因:
在高并发下,多个请求同时读取到相同的库存值,并各自完成"判断 + 扣减"操作,导致总扣减量超过库存上限。
这是一个典型的**竞态条件(Race Condition)**问题。
三、常见错误实现方式(附代码)
❌ 错误示例 1:先查后减(最常见!)
java
// 查询剩余库存
int stock = couponMapper.selectStock(couponId);
if (stock > 0) {
// 扣减库存
couponMapper.decreaseStock(couponId);
// 发放优惠券...
} else {
throw new RuntimeException("库存不足");
}
并发场景模拟:
| 时间 | 线程 A | 线程 B |
|---|---|---|
| T1 | 读库存 = 1 | 读库存 = 1 |
| T2 | 判断 >0 → 通过 | 判断 >0 → 通过 |
| T3 | 扣减 → 库存=0 | 扣减 → 库存=-1 |
💥 结果:1 张库存,发了 2 张券!
❌ 错误示例 2:数据库乐观锁(仍有风险)
java
int updated = couponMapper.decreaseStockWithVersion(couponId, version);
if (updated == 0) {
throw new RuntimeException("库存不足");
}
- 虽然避免了负库存,但在极高并发下重试成本高
- 若库存为 1,1000 个请求同时竞争,只有 1 个成功,其余 999 次失败 → 用户体验差,系统负载高
❌ 错误示例 3:synchronized 本地锁
java
synchronized (this) {
// 查库存 + 扣减
}
- 仅对单机有效,集群部署下完全失效
- 秒杀系统通常多实例部署,此方案形同虚设
四、正确思路:必须保证"判断 + 扣减"原子性!
要解决超卖,核心原则是:
"检查库存是否足够" 和 "扣减库存" 必须在一个原子操作中完成!
在分布式环境下,有三种主流方案:
| 方案 | 原理 | 可靠性 | 性能 |
|---|---|---|---|
| 1. Redis + Lua 脚本 | 利用 Redis 单线程执行 Lua 的原子性 | ✅✅✅ 极高 | ✅✅✅ 极高 |
| 2. Redis 分布式锁 | 加锁后执行扣减逻辑 | ✅✅ 高 | ⚠️ 中(加锁开销) |
| 3. 数据库悲观锁(SELECT FOR UPDATE) | 行锁阻塞并发 | ✅ 高 | ❌ 低(DB 成瓶颈) |
🔥 结论 :Redis + Lua 是秒杀场景的最优解!
五、终极方案:Redis + Lua 脚本实现原子扣减
步骤 1:将库存预加载到 Redis
bash
# 初始化库存(活动开始前)
SET coupon:stock:1001 1000
💡 为什么用 Redis?
- 内存操作,QPS 高
- 支持原子命令和 Lua 脚本
步骤 2:编写 Lua 脚本(核心!)
Lua
-- check_and_decr.lua
local stock_key = KEYS[1]
local stock = tonumber(redis.call('GET', stock_key))
if stock <= 0 then
return 0 -- 库存不足
end
redis.call('DECR', stock_key)
return 1 -- 扣减成功
✅ 关键点:
- 整个脚本在 Redis 中原子执行
- 不会出现"读到旧值"的问题
步骤 3:Java 调用 Lua 脚本
java
@Component
public class CouponStockService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final DefaultRedisScript<Long> DECREASE_SCRIPT;
static {
DECREASE_SCRIPT = new DefaultRedisScript<>();
DECREASE_SCRIPT.setScriptText(
"local stock = tonumber(redis.call('GET', KEYS[1])) " +
"if stock <= 0 then return 0 end " +
"redis.call('DECR', KEYS[1]) return 1"
);
DECREASE_SCRIPT.setResultType(Long.class);
}
public boolean tryDecreaseStock(Long couponId) {
String stockKey = "coupon:stock:" + couponId;
Long result = redisTemplate.execute(DECREASE_SCRIPT,
Collections.singletonList(stockKey));
return result != null && result == 1;
}
}
步骤 4:在秒杀逻辑中使用
java
public void receiveCoupon(Long userId, Long couponId) {
// 1. 尝试扣减 Redis 库存
if (!couponStockService.tryDecreaseStock(couponId)) {
throw new RuntimeException("手慢了,优惠券已抢光!");
}
// 2. 生成唯一领取记录 ID(参考上一篇文章)
String recordId = idGenerator.generateCouponRecordId();
// 3. 异步写入数据库(最终一致性)
asyncTask.submit(() -> {
couponRecordMapper.insert(recordId, userId, couponId);
// 可选:同步扣减 DB 库存(用于对账)
});
}
✅ 优势:
- 100% 防超卖
- 高性能(Redis 单 key QPS > 10万)
- 用户体验好(失败立即返回,无重试)
六、数据一致性保障:Redis 与 DB 如何同步?
有人问:"只扣 Redis,DB 库存不一致怎么办?"
✅ 解决方案:最终一致性 + 对账补偿
- 主流程走 Redis:保证高并发下的正确性和性能
- 异步落库:将领取记录写入 DB(用于持久化和查询)
- 定时对账 :每天凌晨比对 Redis 库存与 DB 发放总数
- 若不一致,以 Redis 为准(或人工介入)
- DB 库存仅作备份:不参与实时扣减
📌 原则 :性能优先场景,接受最终一致性
七、其他注意事项
1. 缓存穿透防护
- 若 couponId 不存在,可能被恶意刷
- 解决:对无效 ID 缓存空值(
SET invalid:12345 "" EX 60)
2. 库存预热
- 活动开始前,将库存从 DB 加载到 Redis
- 避免冷启动时大量请求打到 DB
3. 限流兜底
- 即使有 Redis,也要在网关层做限流(如 Sentinel)
- 防止 Redis 被打挂
八、结语
感谢您的阅读!如果你有任何疑问或想要分享的经验,请在评论区留言交流!