【黑马点评日记】高并发秒杀:库存超卖与锁机制解析

🔥个人主页:北极的代码(欢迎来访)

🎬作者简介:java后端学习者

❄️个人专栏:苍穹外卖日记SSM框架深入JavaWeb

命运的结局尽可永在,不屈的挑战却不可须臾或缺!
前言:

我们之前学习完了优惠卷秒杀的简单实现,而使用场景仅仅是我们自己测试的时候,在现实生活中,优惠卷秒杀,同一时间可能会有成百上千的人同时点击,这时就会导致一系列的问题,这里我们具体讲解。

摘要:

本文探讨了高并发环境下优惠券秒杀系统面临的库存超卖问题及其解决方案。

通过模拟200线程抢购100张优惠券的场景,发现数据库库存出现负值(-9),订单数量异常(109)。

原因在于线程竞争导致并发查询和扣减不同步。文章对比了两种主流解决方案:

1)悲观锁(for update),通过独占锁确保数据一致性,但性能较低;

2)乐观锁(基于版本号或库存值),利用CAS机制实现无锁并发,性能更优但存在失败率

此外,还列举了6种扩展方案,包括Redis原子操作、消息队列削峰等。最终建议秒杀场景优先采用乐观锁配合Redis预扣减,平衡性能与数据一致性。

库存超卖问题:

我们先模拟一下,假设有200个线程(代表200个用户同时)去抢100张优惠卷,看看会得到什么结果。

按照正常来说,200个人抢100张优惠卷,那么肯定是会成功一般的,也就是还有100人没抢到优惠卷。但是查看结果时发现,我们数据库表中的库存变成-9,订单数量变成了109。这在实际场景中时完全不允许的。

为什么会出现这种问题呢:

如图所示:

高并发的环境下,线程之间是竞争的关系,往往是一个还没完全执行完,就会进入其他的线程了。

如图,我们在执行线程1时,原来库存还剩一个,查询库存得到1,此时线程2进来了,也查询库存,由于线程1还没执行完扣减操作,所以线程2查询操作得到的库存也是1,之后经过两次扣减,此时我们的库存就变成了-1,这仅仅是两个线程,如果更多,那么产生的后果可能更严重。


超卖问题的解决方案:

1. 悲观锁

不相信其他人不会同时改数据,所以每次操作前,先主动把数据锁起来,不让别人动。

悲观锁认为,每次操作数据时,都很可能有其他线程同时修改。因此,它在操作前就先"锁住"数据,让自己独占,等操作完成后再释放。就像去洗手间,先锁上门,里面的人用完出来,下一个人才能进。

标准语法(MySQL)

sql 复制代码
sql

-- 普通查询
select * from seckill_voucher where voucher_id = 1;

-- 加悲观锁的查询(就在后面加 for update)
select * from seckill_voucher where voucher_id = 1 for update;

实现方式(在SQL中使用 for update

sql 复制代码
sql

-- 查询库存时加行锁(for update)
select stock from seckill_voucher where voucher_id = ? for update;
-- 判断库存,扣减库存
update seckill_voucher set stock = stock - 1 where voucher_id = ?;

在Java代码中的典型实现(使用 @Transactional 加同步锁,或直接在SQL层面控制)

java 复制代码
java

@Transactional
public Result seckillVoucher(Long voucherId) {
    // 1. 使用for update加悲观锁查询
    SeckillVoucher voucher = seckillVoucherMapper.selectForUpdate(voucherId);
    
    // 2. 判断库存是否充足
    if (voucher.getStock() <= 0) {
        return Result.fail("库存不足");
    }
    
    // 3. 扣减库存
    boolean success = seckillVoucherMapper.decreaseStock(voucherId);
    if (!success) {
        return Result.fail("扣减库存失败");
    }
    
    // 4. 创建订单...
    return Result.ok();
}

缺点:性能较差,同一时间内只有一个线程能处理,并发能力低。

举例:

1. 假设有一张秒杀券表(简化版)

sql

复制代码
CREATE TABLE seckill_voucher (
    voucher_id INT PRIMARY KEY,
    stock INT
);

数据举例:

voucher_id stock
1001 10

2. 正常情况下(无锁),两个用户同时来买

用户A:查询库存 → 发现是 10

用户B:查询库存 → 发现也是 10

用户A:库存 10 > 0 → 改成 9

用户B:库存 10 > 0 → 改成 9

结果 :卖了 2 件,库存只减少 1 件 → 超卖

3. 使用悲观锁(for update)后

用户A:

sql

复制代码
-- 1. 先锁住这条记录
select stock from seckill_voucher where voucher_id = 1001 for update;

-- 此时,这行数据被“行锁”锁住
-- 其他所有事务必须等待

用户B:

sql

复制代码
-- 也尝试锁同一行
select stock ... for update;
-- 会被阻塞,必须等 A 释放锁

用户A:

sql

复制代码
-- 判断库存 → 扣减
update seckill_voucher set stock = stock - 1 where voucher_id = 1001;

-- 提交事务(释放锁)
commit;

用户B:

sql

复制代码
-- 此时才拿到锁,看到的是最新库存(9)
-- 继续判断、扣减

结果 :一次只允许一个人改 → 不会超卖




2. 乐观锁

乐观锁认为,数据冲突的概率较低,所以不主动加锁,而是在更新时检查数据是否被其他线程修改过。一般通过版本号库存值本身来判断。

就像更新文档:先复制一份出来改,保存时看服务器上的版本有没有变化,如果没变就成功保存;如果变了就重试或失败。

实现方式一:基于版本号

实现方式二:基于库存值(CAS法)

sql 复制代码
sql

-- 扣减时检查库存是否还是之前查到的数值
update seckill_voucher 
set stock = stock - 1 
where voucher_id = ? and stock = old_stock;

其实就是把看版本号的变化的判断条件变成了库存本身。

java 复制代码
java

Boolean success = iSeckillVoucherService
        .update()           // 1. 开始构建更新操作
        .setSql("stock = stock - 1")  // 2. 设置要更新的字段
        .eq("voucher_id", voucherId)  // 3. 条件1:指定哪个商品
        .gt("stock", 0)               // 4. 条件2:库存必须大于0(乐观锁核心)
        .update();                    // 5. 执行更新

一、这段代码对应什么SQL

MyBatis-Plus 会帮我们自动生成以下SQL:

sql 复制代码
sql

update seckill_voucher 
set stock = stock - 1 
where voucher_id = ? 
  and stock > 0     -- 这是乐观锁的关键!

注意: 代码里的 .gt("stock", 0) 就是 where stock > 0


二、乐观锁是怎么防止超卖的

核心原理:CAS(Compare And Swap,比较并交换)

更新时,检查库存是否还是原来的值(或满足条件),如果满足才更新。

正常情况(一人购买)

sql 复制代码
sql

-- 原始库存:stock = 10

-- 用户A执行update
update seckill_voucher 
set stock = 9 
where voucher_id = 1 
  and stock > 0;  -- 10 > 0 ✅ 条件成立

-- 受影响行数:1(更新成功)
-- 库存变成:9

并发情况(两人同时购买)

sql 复制代码
sql

-- 原始库存:stock = 10

-- 用户A和用户B同时执行(时间差微乎其微)
-- 假设A先到达数据库

-- 用户A:
update seckill_voucher 
set stock = 9 
where voucher_id = 1 and stock > 0;  
-- ✅ 成功,受影响行数=1,stock变成9

-- 用户B:
update seckill_voucher 
set stock = 9 
where voucher_id = 1 and stock > 0;  
-- 现在stock=9,9 > 0 ✅ 条件也成立
-- ✅ 也成功,受影响行数=1,stock变成8

-- 结果:卖出2件,库存从10→8,✅ 正确!

极端并发(库存为1时)

sql 复制代码
sql

-- 原始库存:stock = 1

-- 用户A、B、C三个人同时抢(假设同时到达数据库)

-- 数据库内部串行执行:
-- 用户A:where stock > 0(1>0 ✅)→ 成功,stock变成0
-- 用户B:where stock > 0(0>0 ❌)→ 失败,受影响行数=0
-- 用户C:where stock > 0(0>0 ❌)→ 失败,受影响行数=0

-- 结果:只卖出1件,库存从1→0,✅ 没有超卖!

两种写法的对比

对比项 stock = 旧值 stock > 0
检查粒度 精确到具体数值 只检查范围
并发成功率 低(每人旧值不同) 高(只要>0就能抢)
实现复杂度 需要先查询 一条SQL搞定
适用场景 库存可增可减 库存只减不增

三、代码中怎么判断成功还是失败

java 复制代码
java

Boolean success = iSeckillVoucherService
        .update()
        .setSql("stock = stock - 1")
        .eq("voucher_id", voucherId)
        .gt("stock", 0)
        .update();

// update() 方法返回 boolean
// - true:更新成功(至少有一行被修改)
// - false:更新失败(没有行被修改,说明库存不足)

if (!success) {
    return Result.fail("库存不足,抢购失败");
}

// 成功,继续创建订单...

实际上 update() 返回的是:

java 复制代码
java

// MyBatis-Plus 的 update() 底层
// 受影响行数 > 0 返回 true
// 受影响行数 = 0 返回 false

四、这种乐观锁的优缺点

✅ 优点

优点 说明
无需加锁 不会阻塞其他请求,性能高
实现简单 一行 .gt("stock", 0) 就搞定
无死锁风险 因为是单条update语句
适合高并发 数据库能处理大量并发更新

❌ 缺点

缺点 说明
会有失败 库存为1时,100人抢只有1人成功,99人失败
不适合激烈竞争 大量请求会失败,浪费数据库资源
不能保证每次都成功 用户体验可能不好(提示重试)

在Java代码中的典型实现(配合重试机制)

java 复制代码
java

@Transactional
public Result seckillVoucher(Long voucherId) {
    // 1. 查询原始库存
    SeckillVoucher voucher = seckillVoucherMapper.selectById(voucherId);
    Integer oldStock = voucher.getStock();
    
    // 2. 判断库存
    if (oldStock <= 0) {
        return Result.fail("库存不足");
    }
    
    // 3. 乐观锁扣减:只有stock == oldStock时才成功
    int updateCount = seckillVoucherMapper.decreaseStockWithCAS(voucherId, oldStock);
    if (updateCount == 0) {
        // 说明数据被修改过,扣减失败
        return Result.fail("抢购失败,请重试");
    }
    
    // 4. 创建订单...
    return Result.ok();
}

Mappper中的SQL:

sql 复制代码
java

@Update("update seckill_voucher set stock = stock - 1 " +
        "where voucher_id = #{voucherId} and stock = #{oldStock}")
int decreaseStockWithCAS(Long voucherId, Integer oldStock);

对比总结

特性 悲观锁 乐观锁
核心思想 先锁后操作 更新时检查版本
数据库实现 select ... for update update ... where stock = ?
优点 数据强一致,简单可靠 并发性能高,无阻塞
缺点 并发性能低,可能死锁 冲突时需重试,可能失败
适用场景 高冲突、临界区短的场景 低冲突、读多写少的场景
在黑马点评中的应用 后台管理等低并发场景 秒杀、抢购等高并发场景

总结

在实际秒杀场景中,通常优先推荐乐观锁,因为:

  • 秒杀场景下,读库存的次数远多于写库存

  • 乐观锁不会阻塞大量并发请求

  • 配合Redis预扣减库存 可以极大减少数据库压力

如果需要保证100%不超卖且可以接受低吞吐量 ,则使用悲观锁


解决超卖的其他思路:

方案1:唯一索引防重

思路: 不让超卖发生,而是让"重复购买"直接失败

sql 复制代码
sql

-- 订单表加唯一约束
ALTER TABLE voucher_order 
ADD UNIQUE INDEX uk_user_voucher (user_id, voucher_id);

-- 用户重复下单会直接报错,不会超卖

优点: 数据库级别保证,绝不超卖
缺点: 只能防止同一用户重复买,不能防止多个用户抢最后一个


方案2:Redis原子递减 + 异步落库

思路: 在Redis里扣减库存,成功了再去数据库创建订单

java 复制代码
java

// 1. Redis原子操作
Long stock = redisTemplate.opsForValue().decrement("stock:" + voucherId);

if (stock < 0) {
    // 库存不足,加回去
    redisTemplate.opsForValue().increment("stock:" + voucherId);
    return "抢购失败";
}

// 2. 异步发送消息,慢慢落库
rabbitTemplate.convertAndSend("orderQueue", voucherId);

优点: 性能极高(10万级QPS)
缺点: 可能丢失订单,需要补偿机制


方案3:消息队列削峰填谷

思路: 所有请求先进队列,消费者一个一个处理

java 复制代码
java

// 1. 请求进来,先进队列
boolean success = queue.offer(request);

if (!success) {
    return "系统繁忙,请稍后重试";
}

// 2. 消费者单线程处理
@RabbitListener(queues = "seckillQueue")
public void handle(SeckillRequest request) {
    // 这里就不会有并发问题了
    synchronized (this) {
        // 扣减库存、创建订单
    }
}

优点: 彻底解决并发问题,流量可控
缺点: 延迟增加,用户体验下降


方案4:令牌桶限流 + 超卖检测

思路: 限制进入的人数,让超卖"不可能发生"

java 复制代码
java

// 1. 限流:每秒只放100个请求进去
RateLimiter limiter = RateLimiter.create(100);

public Result seckill(Long voucherId) {
    // 拿不到令牌直接拒绝
    if (!limiter.tryAcquire()) {
        return Result.fail("太火爆了,请稍后再试");
    }
    
    // 2. 这里只有100个人能进来
    // 乐观锁扣减库存
    boolean success = update().gt("stock", 0).update();
    
    return success ? Result.ok() : Result.fail("库存不足");
}

优点: 保护系统不被冲垮
缺点: 限流值需要精确估算,否则会误杀或漏杀


方案5:提前预分配库存(分段库存)

思路: 把库存分成N段,每段独立扣减

java 复制代码
java

// 假设1000个库存,分10个段,每段100个
// 用户请求进来,hash到不同的段

int segmentId = userId % 10;
String segmentKey = "stock:segment:" + segmentId;

// 每个段独立扣减
Long stock = redisTemplate.opsForValue().decrement(segmentKey);

优点: 锁粒度更细,并发度提升N倍
缺点: 可能出现"段1卖完,段2还有"的局面


方案6:CAS无锁编程(Java层面)

思路: 用AtomicInteger在JVM里做库存扣减

java 复制代码
java

// 适用场景:单机、库存不大
private AtomicInteger stock = new AtomicInteger(10);

public Result seckill() {
    // CAS操作,原子性
    int oldValue = stock.get();
    while (oldValue > 0) {
        if (stock.compareAndSet(oldValue, oldValue - 1)) {
            return Result.ok("抢购成功");
        }
        oldValue = stock.get();
    }
    return Result.fail("库存不足");
}

优点: 无锁,性能极高
缺点: 只适用于单机,分布式不适用


完整对比表

方案 适用场景 性能 复杂度 是否超卖
数据库悲观锁 低并发、强一致性 ❌ 不会
数据库乐观锁 中并发、冲突少 ❌ 不会
Redis原子操作 高并发、可接受少许失败 极高 ⚠️ 可能丢失
消息队列 超高并发、不要求实时 ❌ 不会
限流 + 乐观锁 保护系统、流量可控 ❌ 不会
分段库存 超高并发、库存分散 极高 ❌ 不会
JVM CAS 单机、库存较小 极高 ❌ 不会

结语:如果对你有帮助,请点赞,关注,收藏,你的支持就是我最大的鼓励!

相关推荐
Java成神之路-1 小时前
面试题:Spring事务失效场景
java·spring
阿亮爱学代码1 小时前
日期与滚动视图
java·前端·scrollview
java1234_小锋1 小时前
说说MyBatis的工作原理吗?
java·mybatis
恶猫1 小时前
自动拨号换ip软件简单实现。aardio版。
java·网络·aardio·adsl·换ip·rasphone.exe·rasdial.exe
lsx2024061 小时前
《jEasyUI 创建树形下拉框》
开发语言
minji...1 小时前
Linux 网络套接字编程(一)端口号port,socket套接字,socket,bind,socket 通用结构体
linux·运维·服务器·开发语言·网络
qq_283720051 小时前
Python3 模块精讲:Redis 第三方库从入门到精通全攻略
redis·缓存
2301_814809861 小时前
踩坑实战pywebview:用 Python + Web 技术打造轻量级桌面应用
开发语言·前端·python