Redis

关注公众号 jb51net

关闭
首页 > 数据库 > Redis > Redis库存超卖问题

关于Redis库存超卖问题的分析

作者:洛上言

在高并发场景下进行优惠券秒杀测试时,发现由于并发操作导致了超卖问题,即理论上只能卖出100个优惠券,实际卖出了102个,分析原因,是因为在高并发环境下,多个线程同时操作库存,导致数据不一致,提出了两种解决方案:悲观锁和乐观锁

一、分析问题

刚刚秒杀优惠券购买测试的时候是我们自己在页面上点击进行测试的,这跟真实的秒杀场景还是有很大差异的,因为真实的秒杀场景下肯定有无数的用户一起来抢购,一起来点购这个按钮,因此一瞬间的并发量可能会达到每秒数百甚至上千、上万的并发,那我们这个结构还能不能工作呢?

要想模拟这种高并发的场景,肯定要用到JeMter

image-20240527165204920

数据库总量是100

image-20240527165224348

将订单也清0

image-20240527165348190

接下来我们有100个券,我们希望的是只卖出100个,理论上来讲只生成100个订单。

启动JeMeter,结果肯定有些成功有些失败

image-20240527165842846

查看报告 49.25% 的异常率,跟我们预期有出入,我们的预期应该是一般失败

image-20240527170042389

回到数据库中查看,可以看见订单生成数量是 102

image-20240527170209669

并且库存变为了 -2

image-20240527170238245

由此可见票出现了超卖,我们只能卖一百件,现在却卖出了 102件,如果这件卖出的商品很贵重,这样可能会给商家带来巨大的损失。

那么我们为什么会出现这个问题呢?

有关超卖问题分析:在我们原有代码中是这么写的

if (voucher.getStock() < 1) {
    // 库存不足
    return Result.fail("库存不足!");
}
//5,扣减库存
boolean success = seckillVoucherService.update()
    .setSql("stock= stock -1")
    .eq("voucher_id", voucherId).update();
if (!success) {
    //扣减库存
    return Result.fail("库存不足!");
}

正常情况下一个如下图,一个执行完再执行另一个

image-20240527171052016

但是高并发的场景下,你就没办法控制线程的顺序了,假设线程1过来查询库存,判断出来库存大于1,正准备去扣减库存,但是还没有来得及去扣减,此时线程2过来,线程2也去查询库存,发现这个数量一定也大于1,那么这两个线程都会去扣减库存,最终多个线程相当于一起去扣减库存,此时就会出现库存的超卖问题。

1653368335155

二、解决办法

超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:而对于加锁,我们通常有两种解决方案:见下图:

悲观锁和乐观锁并不是真正的锁,它只是锁设计的理念

image-20240527174028774

悲观锁:

如果我们多个线程是串行执行的,就不会出现安全问题了。所以这就是悲观锁的实现思想,既然多线程并发有安全问题,那你就不要并发执行了。正因为如此,悲观锁的性能就不是很好,因为你不管有多少线程,都只能一个一个的去执行,因此高并发的场景下悲观锁并不是很适合。

JDK中提供的syn,和lock、数据库中的互斥的锁,都是悲观锁的代表,同时,悲观锁中又可以再细分为公平锁,非公平锁,可重入锁,等等。

乐观锁:

因为乐观锁折后转给你方案它不用加锁,而是在执行时才做一个判断,因此它的性能要比悲观锁好很多。

但是它的关键点在于:我怎么知道在我更新的时候别人有没有来做修改?因此这个判断成为了关键,这也是我们接下来要研究的。

悲观锁比较简单,相信大家都会,这里就不演示了,这里就演示乐观锁

三、乐观锁

判断是否有人进行修改,常见的方式有两种

实现方式一:版本号法

这种方案是应用最广泛的,也是最普遍的。

乐观锁:会有一个版本号,每次操作数据会对版本号+1,再提交回数据时,会去校验是否比之前的版本大1 ,如果大1 ,则进行操作成功,这套机制的核心逻辑在于,如果在操作过程中,版本号只比原来大1 ,那么就意味着操作过程中没有人对他进行过修改,他的操作就是安全的,如果不大1,则数据被修改过。

有了版本号后,线程1在做查询的时候,就不仅仅是查库存了,它还要将版本号也查出来,此时线程1查到的库存和版本号是 1,紧接着,它本来要进行扣减了,但是此时另外一个线程插入进来了,此时就出现并发的问题了,此时线程二也去查询,同样也是查询stock和version,查到的也是1。紧接着又切到了线程1,线程1要去扣减库存,判断库存是否大于0,此时就要去扣减。

以前是直接扣减就完了,但是现在不行,版本号每次修改的时候都要加1,因此它在修改库存的时候不仅仅要修改库存,还需要修改版本号,因此在修改时,乐观锁的方案是:修改前先判断一下,之前查询到的数据是否被修改过,这里就是判断版本是否被修改过, where version = 1,因为之前查询出来的version是1,如果执行这个条件时version依然等于1,说明跟我们之前查询到的一样,说明在我执行修改之前,是没有人修改过这个数据的,既然没有人修改过,我就可以放心大胆的去减了。

那么第一个线程在操作后,数据库中的version变成了2,但是他自己满足version=1 ,所以没有问题,此时线程2执行,线程2 最后也需要加上条件version =1 ,但是现在由于线程1已经操作过了,所以线程2,操作时就不满足version=1 的条件了,所以线程2无法执行成功

image-20240527181157966

实现方式二:CAS

在实现方式一的基础上做了简化,版本号法其实是用版本来表示版本是否变化,其次在更新的时候每次除了数据以外,版本也要跟着更新,既然每次更新都要更新版本,如果我查到的版本跟我更新时的版本一致,证明就没有人更新。但是大家看一下我们当前的业务,我们每一次业务,其实在查询版本的时候库存也都跟着查出来了,更新的时候库存也要更新,可以发现库存跟版本所做的事是一样的,既然如此为什么不使用库存代替版本?我在查询的时候将库存查出来,然后我在更新的时候当前的这个库存跟之前查到的库存是不是一样的,如果一样,不就同样可以证明没有人修改过吗?

用数据本身有没有变化来判断线程是否安全,这种方案就称之为cas(compare and set),先比较然后修改。

核心思路和上面差不多

image-20240527182236849

下面是补充,老师没讲的。

利用cas进行无锁化机制加锁,var5 是操作前读取的内存值,while中的var1+var2 是预估值,如果预估值 == 内存值,则代表中间没有被人修改过,此时就将新值去替换 内存值

其中do while 是为了在操作失败时,再次进行自旋操作,即把之前的逻辑再操作一次。

int var5;
do {
    var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

return var5;

总结

以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。

您可能感兴趣的文章:
阅读全文