重点搞懂:
- 什么是超卖
- 为什么会超卖
- 怎么解决
- 为什么课程里先讲乐观锁
- 这部分和后面“一人一单、分布式锁、Redis 优化秒杀”是什么关系
优惠券秒杀——超卖问题学习文档
1. 这一节在整个秒杀模块里的位置
你现在学的“超卖问题”,是在“实现秒杀下单”之后马上出现的第一个核心并发问题。
因为基础版秒杀流程只是:
- 查询优惠券
- 判断秒杀是否开始
- 判断秒杀是否结束
- 判断库存是否充足
- 扣减库存
- 创建订单
- 返回订单 id
这个流程在单线程下看起来没问题,但一旦多人同时抢购,就会发生超卖。
2. 什么是超卖
超卖,就是实际库存已经不够了,但系统仍然卖出了更多商品。
例如:
- 库存本来只有 1
- 两个用户同时下单
- 最终却生成了 2 个订单
这就叫超卖。
资料里明确把它定义为典型的多线程安全问题。
3. 为什么会出现超卖
根本原因不是“判断库存的代码没写”,而是:
查询库存和扣减库存不是一个原子操作,在高并发下会被多个线程同时执行。
资料里对这个过程讲得很清楚:
假设线程 1 查询库存,发现库存大于 0,正准备扣减;这时线程 2 也来了,它查询到的库存同样也大于 0,于是两个线程都会去扣减库存,最后就发生超卖。
你可以把它画成这样:
并发过程示意
初始库存:1
线程 1
- 查询库存 = 1
- 判断库存 > 0,准备扣减
线程 2
- 查询库存 = 1
- 判断库存 > 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. 解决超卖的两大思路
资料里给了两个大方向:
方案一:悲观锁
先假设并发问题一定会发生,所以先加锁,让线程串行执行。
例如 synchronized、Lock 都属于悲观锁。
方案二:乐观锁
假设并发问题不一定发生,不先加锁,而是在更新数据时判断数据是否被别人改过。
这两种方案你先建立概念就行:
- 悲观锁:先锁再操作
- 乐观锁:更新时判断条件
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 就能避免超卖
因为它把“库存判断”合并到了更新语句里。
以前是:
- 先查库存
- 再扣减库存
现在变成:
- 执行更新
- 只有当
stock > 0时,数据库才允许减库存
也就是说,判断和更新在一条 SQL 里完成了。
这样数据库会保证这条更新语句执行时的条件判断是安全的。
当库存已经变成 0 的时候,后来的线程再执行这条 SQL,就更新失败。
于是:
- 成功更新的线程拿到库存
- 更新失败的线程返回“库存不足”
这样就不会减成负数,也就避免了超卖。这个思路在资料里的代码和图示是一致的。
10. 你要注意:超卖问题解决了,不代表秒杀问题解决了
这是这一阶段最容易混的地方。
用 stock > 0 可以解决的是:
- 库存不能卖成负数
- 高并发下库存扣减安全
但它不能解决:
- 同一个用户重复下单
- 集群环境下多实例并发
- 秒杀接口性能瓶颈
所以资料才会继续往后讲:
- 一人一单
- 分布式锁
- Redis 优化秒杀
- Redis 消息队列实现异步秒杀
11. 超卖和一人一单的区别
你现在要明确分清两个问题:
超卖
关注的是:总库存是否被卖多
一人一单
关注的是:同一个用户是否重复下单
资料里后面的 createVoucherOrder() 逻辑就体现了这两个问题的先后关系:
先按 user_id + voucher_id 查询订单判断是否重复下单,再执行 stock = stock - 1 且 stock > 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. 这一节学完,下一节该怎么衔接
你学完“超卖问题”,下一步就应该进入:
- 乐观锁解决超卖
- 一人一单
因为资料目录里本来就是这样衔接的。
正确理解应该是:
- 超卖:先解决库存安全
- 一人一单:再解决同用户重复购买
- 分布式锁:再解决集群下锁失效
- Redis 优化秒杀:最后解决性能问题
16. 本节小结
你只要记住这 4 句话,这节就算学明白了:
- 超卖是多线程并发导致的库存被重复扣减问题。
- 根因是判断库存和扣减库存分离,属于先查后改。
- 解决思路有悲观锁和乐观锁,项目里优先使用乐观锁。
- 最终通过
stock > 0条件更新,把判断和扣减合并进一条 SQL,避免超卖。