乐观锁解决超卖
1. 这节在学什么
这节要解决的问题很简单:
秒杀下单在高并发下会出现库存超卖,怎么在不把并发性能压得太低的前提下,避免库存被扣成负数。
课件里明确指出,超卖是典型的多线程安全问题,而常见解决思路分成两类:悲观锁和乐观锁。
2. 先回顾:为什么会超卖
基础版代码的逻辑是:
- 先查库存
- 判断库存是否大于 0
- 再去扣减库存
问题就在于“查库存”和“减库存”是分开的。
如果两个线程几乎同时查到库存都大于 0,它们都会继续执行扣减,于是就会把库存卖穿。这个过程在课件里是直接画出来的。
所以你先记住一句话:
超卖的本质,是先查后改在并发场景下不安全。
3. 什么是乐观锁
课件里的定义很清楚:
乐观锁认为线程安全问题不一定发生,因此不先加锁,而是在更新数据时判断有没有别的线程修改过数据;如果没被修改,自己才能更新,否则就失败、重试或报错。
你可以把它理解成:
- 悲观锁:先拦住别人,再改
- 乐观锁:不拦别人,改的时候检查数据有没有变
所以乐观锁的核心不是“锁住”,而是“更新时校验条件”。
4. 乐观锁的两种典型做法
4.1 版本号法
课件里给出的思路是:
set stock = stock - 1, version = version + 1
where id = 10 and version = ?
意思是:
只有当数据库里的 version 还等于我刚查出来的旧版本号时,才允许更新。
如果别的线程已经改过了,版本号就不匹配,这次更新就失败。
4.2 CAS 法
另一种是按旧值比较,也就是:
set stock = stock - 1
where id = 10 and stock = ?
意思是:
只有当前库存还等于我之前读到的那个库存值,才允许减库存。
这本质上也是在做“比较并交换”。课件和 PDF 都明确提到了 CAS 这一思路。
5. 课程里为什么要把方案再改一下
资料里明确说了,直接用“stock = 旧库存值”这种方式,成功率太低。因为假设 100 个线程同时查到库存是 100,那么真正更新时,可能只有极少数线程成功,其他线程因为库存已变化而全部失败。
所以课程后面把乐观锁做了一个更适合秒杀场景的改造:
不再要求
stock必须等于之前查到的值,而是只要求stock > 0。
也就是把更新语句改成:
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
这个写法在资料里反复出现,是这套项目真正落地的方案。
6. 为什么 stock > 0 就能解决超卖
因为它把“判断库存”和“扣减库存”合并进了一条更新语句里。
原来是:
- Java 里先判断
stock < 1 - 再执行更新
现在变成:
- 数据库直接执行更新
- 只有满足
stock > 0时才允许扣减
也就是说,库存判断不再依赖你之前查出来的旧值,而是依赖数据库执行这一刻的真实库存。
于是结果就变成:
- 库存还有时,SQL 才更新成功
- 库存没了时,SQL 更新失败
- 不会出现扣完还继续减的情况
所以它能避免库存减成负数,也就避免了超卖。
7. 你要怎么理解这个 SQL
你现在学的时候,不要把它只当成语法,要把它翻译成中文:
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
等价于:
找到这张优惠券,并且要求当前库存必须大于 0,然后把库存减 1。
这个“当前库存必须大于 0”,就是乐观锁在这里的校验条件。
8. 乐观锁相比悲观锁的优缺点
课件里给了总结:
- 悲观锁:简单粗暴,但性能一般
- 乐观锁:性能更好,但会有失败率问题
放到秒杀场景里理解:
优点
- 不需要把所有线程串行化
- 不像
synchronized那样把并发压成单线程 - 更适合高并发扣库存
缺点
- 有些线程会更新失败
- 只解决了“库存安全”
- 还没解决“一人一单”
9. 乐观锁解决了什么,没解决什么
已解决
- 库存不会被卖成负数
- 高并发下库存扣减更安全
没解决
- 同一个用户重复下单
- 集群环境下的锁问题
- 秒杀接口整体性能瓶颈
所以课件才会继续往后讲:
- 一人一单
- 分布式锁
- Redis 优化秒杀
这点你一定要分清,不要以为“乐观锁 = 秒杀全解决”。
10. 这节和后面“一人一单”的关系
你现在学的乐观锁,是在解决:
大家一起抢时,总库存不能卖多
后面的一人一单,是在解决:
同一个用户不能重复买
资料里的 createVoucherOrder 代码就能看出顺序:
- 先按
user_id + voucher_id查询订单,判断是不是已经下过单 - 再执行
stock = stock - 1且stock > 0 - 最后保存订单
所以两者关系是:
- 乐观锁:解决共享库存的并发问题
- 一人一单:解决用户维度的重复下单问题
11. 这一节你应该会写的代码思路
原始写法
if (voucher.getStock() < 1) {
return Result.fail("库存不足!");
}
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.update();
问题:先查后改,会超卖。
乐观锁改造后
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
if (!success) {
return Result.fail("库存不足!");
}
意义:
更新时直接要求库存大于 0,数据库只允许合法扣减。
12. 面试时怎么讲
你可以直接按这个思路说:
秒杀里的超卖问题,本质上是高并发下多个线程同时读取库存并扣减库存,导致库存被重复消费。基础实现通常是先查库存,再减库存,这种“先查后改”在并发场景下不安全。项目里使用乐观锁解决这个问题,不是先加锁,而是在更新库存时附带条件,例如
where voucher_id = ? and stock > 0,只有库存仍然大于 0 时才允许执行stock = stock - 1。这样库存判断和扣减就合并在一条 SQL 中,避免了超卖。这个方案解决了库存安全问题,但还需要后续再处理一人一单和集群环境下的并发控制。
13. 你这节课后要做的 3 件事
第一,把“版本号法”和“stock > 0”的区别写出来。
第二,把“为什么 stock = oldStock 成功率低”说清楚。
第三,把这句背熟:
乐观锁不是先锁住资源,而是在更新时校验数据是否被别人改过。
14. 本节小结
你只要记住这 5 句话,这节就过了:
- 超卖是因为先查后改在并发下不安全。
- 乐观锁不先加锁,而是在更新时判断数据有没有被改过。
- 常见乐观锁方案有版本号法和 CAS 法。
- 课程里最终采用的是
stock > 0的条件更新。 - 乐观锁解决的是库存安全,不是一人一单。