点评--day04--乐观锁解决超卖

0 阅读6分钟

乐观锁解决超卖

1. 这节在学什么

这节要解决的问题很简单:

秒杀下单在高并发下会出现库存超卖,怎么在不把并发性能压得太低的前提下,避免库存被扣成负数。

课件里明确指出,超卖是典型的多线程安全问题,而常见解决思路分成两类:悲观锁乐观锁


2. 先回顾:为什么会超卖

基础版代码的逻辑是:

  1. 先查库存
  2. 判断库存是否大于 0
  3. 再去扣减库存

问题就在于“查库存”和“减库存”是分开的。
如果两个线程几乎同时查到库存都大于 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 就能解决超卖

因为它把“判断库存”和“扣减库存”合并进了一条更新语句里。

原来是:

  1. Java 里先判断 stock < 1
  2. 再执行更新

现在变成:

  1. 数据库直接执行更新
  2. 只有满足 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 代码就能看出顺序:

  1. 先按 user_id + voucher_id 查询订单,判断是不是已经下过单
  2. 再执行 stock = stock - 1stock > 0
  3. 最后保存订单

所以两者关系是:

  • 乐观锁:解决共享库存的并发问题
  • 一人一单:解决用户维度的重复下单问题

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 句话,这节就过了:

  1. 超卖是因为先查后改在并发下不安全。
  2. 乐观锁不先加锁,而是在更新时判断数据有没有被改过。
  3. 常见乐观锁方案有版本号法和 CAS 法。
  4. 课程里最终采用的是 stock > 0 的条件更新。
  5. 乐观锁解决的是库存安全,不是一人一单。