点评--day04--超卖问题

3 阅读8分钟

重点搞懂:

  • 什么是超卖
  • 为什么会超卖
  • 怎么解决
  • 为什么课程里先讲乐观锁
  • 这部分和后面“一人一单、分布式锁、Redis 优化秒杀”是什么关系

优惠券秒杀——超卖问题学习文档

1. 这一节在整个秒杀模块里的位置

你现在学的“超卖问题”,是在“实现秒杀下单”之后马上出现的第一个核心并发问题。

因为基础版秒杀流程只是:

  1. 查询优惠券
  2. 判断秒杀是否开始
  3. 判断秒杀是否结束
  4. 判断库存是否充足
  5. 扣减库存
  6. 创建订单
  7. 返回订单 id

这个流程在单线程下看起来没问题,但一旦多人同时抢购,就会发生超卖。


2. 什么是超卖

超卖,就是实际库存已经不够了,但系统仍然卖出了更多商品。

例如:

  • 库存本来只有 1
  • 两个用户同时下单
  • 最终却生成了 2 个订单

这就叫超卖。

资料里明确把它定义为典型的多线程安全问题


3. 为什么会出现超卖

根本原因不是“判断库存的代码没写”,而是:

查询库存和扣减库存不是一个原子操作,在高并发下会被多个线程同时执行。

资料里对这个过程讲得很清楚:
假设线程 1 查询库存,发现库存大于 0,正准备扣减;这时线程 2 也来了,它查询到的库存同样也大于 0,于是两个线程都会去扣减库存,最后就发生超卖。

你可以把它画成这样:

并发过程示意

初始库存:1

线程 1

  1. 查询库存 = 1
  2. 判断库存 > 0,准备扣减

线程 2

  1. 查询库存 = 1
  2. 判断库存 > 0,准备扣减

然后:

  • 线程 1 扣减,库存变 0
  • 线程 2 也扣减,库存变 -1

课件里就是用这种双线程过程来解释超卖问题的。


4. 你现在代码里哪里会导致超卖

基础版代码的关键逻辑大概是:

// 4.判断库存是否充足
if (voucher.getStock() < 1) {
    return Result.fail("库存不足!");
}

// 5.扣减库存
boolean success = seckillVoucherService.update()
        .setSql("stock = stock - 1")
        .eq("voucher_id", voucherId)
        .update();

资料中明确展示了这段结构:先在 Java 里判断库存,再执行数据库扣减。

问题就在这里:

  • if (voucher.getStock() < 1) 是一次读操作
  • update stock = stock - 1 是一次写操作

这两个动作分开了,中间就会被别的线程插进来。

所以你要记住一句最重要的话:

超卖的本质,是“先查后改”在并发场景下不安全。


5. 超卖问题属于什么类型的问题

资料里明确说:超卖是多线程安全问题

更准确一点说,它属于:

  • 并发读写冲突
  • 共享资源竞争
  • 数据库库存字段更新不安全

这里的共享资源就是 stock

所有线程都在抢同一个库存值,所以一定会有线程安全问题。


6. 解决超卖的两大思路

资料里给了两个大方向:

方案一:悲观锁

先假设并发问题一定会发生,所以先加锁,让线程串行执行。
例如 synchronizedLock 都属于悲观锁。

方案二:乐观锁

假设并发问题不一定发生,不先加锁,而是在更新数据时判断数据是否被别人改过。

这两种方案你先建立概念就行:

  • 悲观锁:先锁再操作
  • 乐观锁:更新时判断条件

7. 为什么这里更适合先学乐观锁

因为秒杀场景的特点是:

  • 并发量大
  • 请求很多
  • 性能要求高

如果所有请求都先加锁串行执行,吞吐量会比较差。
所以课程先引入的是乐观锁,因为它性能通常更好。资料里也明确总结了:

  • 悲观锁:优点是简单粗暴,缺点是性能一般
  • 乐观锁:优点是性能好,缺点是成功率低一些

这里你不用死记“成功率低”这几个字,你只要理解成:

多个线程同时来竞争时,不是每个线程都能更新成功,失败的线程要返回失败或者重试。


8. 乐观锁是怎么解决超卖的

资料里讲了两种常见做法:

8.1 版本号法

更新时带上版本号条件:

update ... 
set stock = stock - 1, version = version + 1
where id = ? and version = ?

只有版本号没变,才允许更新。

8.2 CAS 法

更新时带上旧库存值条件:

update ...
set stock = stock - 1
where id = ? and stock = ?

只有库存还是之前查询到的那个值,才允许更新。

不过资料后面又进一步优化了这个方案,指出:

不一定非要要求 stock = oldStock,可以直接改成 stock > 0

最后项目里更常见的是这种写法:

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

这段逻辑在资料中出现了多次。


9. 为什么 stock > 0 就能避免超卖

因为它把“库存判断”合并到了更新语句里。

以前是:

  1. 先查库存
  2. 再扣减库存

现在变成:

  1. 执行更新
  2. 只有当 stock > 0 时,数据库才允许减库存

也就是说,判断和更新在一条 SQL 里完成了

这样数据库会保证这条更新语句执行时的条件判断是安全的。
当库存已经变成 0 的时候,后来的线程再执行这条 SQL,就更新失败。

于是:

  • 成功更新的线程拿到库存
  • 更新失败的线程返回“库存不足”

这样就不会减成负数,也就避免了超卖。这个思路在资料里的代码和图示是一致的。


10. 你要注意:超卖问题解决了,不代表秒杀问题解决了

这是这一阶段最容易混的地方。

stock > 0 可以解决的是:

  • 库存不能卖成负数
  • 高并发下库存扣减安全

但它不能解决

  • 同一个用户重复下单
  • 集群环境下多实例并发
  • 秒杀接口性能瓶颈

所以资料才会继续往后讲:

  • 一人一单
  • 分布式锁
  • Redis 优化秒杀
  • Redis 消息队列实现异步秒杀

11. 超卖和一人一单的区别

你现在要明确分清两个问题:

超卖

关注的是:总库存是否被卖多

一人一单

关注的是:同一个用户是否重复下单

资料里后面的 createVoucherOrder() 逻辑就体现了这两个问题的先后关系:
先按 user_id + voucher_id 查询订单判断是否重复下单,再执行 stock = stock - 1stock > 0 的扣减。

所以:

  • 库存问题 是共享资源问题
  • 一人一单问题 是用户维度的并发问题

这俩千万别混。


12. 这节你应该掌握的核心代码思路

你现在不用背整段源码,只要记住这个升级过程:

原始写法

if (voucher.getStock() < 1) {
    return Result.fail("库存不足!");
}
update stock = stock - 1

问题:先查后改,并发下会超卖。

优化写法

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

意义:只有库存大于 0 才能扣减,避免超卖。


13. 这一节的面试表达

你可以直接背这段思路:

秒杀里的超卖问题,本质是高并发下多个线程同时读取库存并扣减库存,导致库存被重复消费。基础实现里“判断库存”和“扣减库存”是两个分离步骤,所以线程之间会发生竞争。解决方案可以分为悲观锁和乐观锁,项目里优先用了乐观锁,把库存判断条件直接放到更新语句中,例如 where voucher_id = ? and stock > 0,这样只有库存大于 0 时才允许扣减,从而避免库存变成负数。这个方案解决了库存安全问题,但还没有解决一人一单和集群环境下的并发问题。

这段说法和资料里的主线是一致。


14. 你这节课后应该自己做的练习

建议你现在做 3 个练习:

练习 1:自己画并发图

画两个线程同时执行:

  • 查库存
  • 判断库存
  • 扣减库存

把“为什么会超卖”画出来。

练习 2:自己写一句总结

写出这句话:

超卖的根因是先查后改在并发下不安全。

练习 3:把代码改造思路默写出来

从:

  • if (stock < 1)

改到:

  • update ... gt("stock", 0)

15. 这一节学完,下一节该怎么衔接

你学完“超卖问题”,下一步就应该进入:

  1. 乐观锁解决超卖
  2. 一人一单

因为资料目录里本来就是这样衔接的。

正确理解应该是:

  • 超卖:先解决库存安全
  • 一人一单:再解决同用户重复购买
  • 分布式锁:再解决集群下锁失效
  • Redis 优化秒杀:最后解决性能问题

16. 本节小结

你只要记住这 4 句话,这节就算学明白了:

  1. 超卖是多线程并发导致的库存被重复扣减问题。
  2. 根因是判断库存和扣减库存分离,属于先查后改。
  3. 解决思路有悲观锁和乐观锁,项目里优先使用乐观锁。
  4. 最终通过 stock > 0 条件更新,把判断和扣减合并进一条 SQL,避免超卖。