优惠卷秒杀库存超卖问题分析

一、前言:你是否也遇到过"发了 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 库存不一致怎么办?"

✅ 解决方案:最终一致性 + 对账补偿

  1. 主流程走 Redis:保证高并发下的正确性和性能
  2. 异步落库:将领取记录写入 DB(用于持久化和查询)
  3. 定时对账 :每天凌晨比对 Redis 库存与 DB 发放总数
    • 若不一致,以 Redis 为准(或人工介入)
  4. DB 库存仅作备份:不参与实时扣减

📌 原则性能优先场景,接受最终一致性


七、其他注意事项

1. 缓存穿透防护

  • 若 couponId 不存在,可能被恶意刷
  • 解决:对无效 ID 缓存空值(SET invalid:12345 "" EX 60

2. 库存预热

  • 活动开始前,将库存从 DB 加载到 Redis
  • 避免冷启动时大量请求打到 DB

3. 限流兜底

  • 即使有 Redis,也要在网关层做限流(如 Sentinel)
  • 防止 Redis 被打挂

八、结语

感谢您的阅读!如果你有任何疑问或想要分享的经验,请在评论区留言交流!

相关推荐
Albert Edison1 小时前
【Redis】Centos7.9 安装 Redis 5 教程
数据库·redis·缓存
Steadfast_GG1 小时前
Redis中的通用命令
redis·缓存
小二·1 小时前
Redis 内存溢出(OOM)排查与恢复实战
数据库·redis·bootstrap
pqk6V6Vep1 小时前
Redis 分布式锁进阶第一篇讲解
数据库·redis·分布式
giaz14n9X2 小时前
Redis 分布式锁进阶第六十一篇
数据库·redis·分布式
JAVA面经实录9175 小时前
Redis 知识体系(完整版)
java·redis·nosql数据库·nosql
颜笑晏晏5 小时前
长输入短输出场景下的 SGLang 推理性能实测前缀缓存、PD 分离配比与参数调优
缓存·推理优化·sglang·ai infra·pd分离
ManageEngine卓豪6 小时前
数据库可观测性:MySQL与Redis监控核心监控指标与全栈运维解决方案
数据库·redis·mysql·数据库性能·数据库监控
真实的菜6 小时前
Redis 从入门到精通(十四):Redis 7.x 新特性全解 —— 系列收官之作
数据库·redis·缓存
小小工匠7 小时前
Redis - 缓冲区管理:避免溢出引发的“惨案“
redis·性能优化·集群·内存管理·持久化