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

一、前言:你是否也遇到过"发了 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 被打挂

八、结语

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

相关推荐
pp起床2 小时前
【苍穹外卖】Day05 Redis快速入门
数据库·redis·缓存
三水不滴12 小时前
Redis 过期删除与内存淘汰机制
数据库·经验分享·redis·笔记·后端·缓存
indexsunny15 小时前
互联网大厂Java面试实战:从Spring Boot到微服务架构的技术问答解析
java·spring boot·redis·微服务·kafka·jwt·flyway
ytgytg2816 小时前
HC小区管理系统安装,提示redis连接错误
数据库·redis·缓存
1104.北光c°16 小时前
【从零开始学Redis | 第一篇】Redis常用数据结构与基础
java·开发语言·spring boot·redis·笔记·spring·nosql
Grassto18 小时前
11 Go Module 缓存机制详解
开发语言·缓存·golang·go·go module
czlczl2002092519 小时前
Spring Data Redis
java·redis·spring
he___H20 小时前
Redis高级特性
数据库·redis·缓存
crossaspeed20 小时前
Redis的持久化(八股)
数据库·redis·缓存