【Java面试场景题】如何解决高并发下的库存抢购超卖少买问题?

一、问题解析

我相信很多人都看到过相关资料,但是在实践过程中,仍然会碰到具体的实现无法满足需求的情况,比如说有的实现无法秒杀多个库存,有的实现新增库存操作缓慢,有的实现库存耗尽时会变慢等等。

这是因为对于不同的需求,库存争抢的具体实现是不一样的,我们需要详细深挖,理解各个锁的特性和适用场景,才能针对不同的业务需要做出灵活调整。

由于秒杀场景是库存争抢非常经典的一个应用场景,接下来我会结合秒杀需求,带你看看如何实现高并发下的库存争抢,相信在这一过程中你会对锁有更深入的认识。

26.1 锁争抢的错误做法

在开始介绍库存争抢的具体方案之前,我们先来了解一个小知识——并发库存锁。还记得在我学计算机的时候,老师曾演示过一段代码:

public classThreadCounter {
    private staticint count = 0;
 
    public staticvoidmain(String[] args) throws Exception {
        Runnable task = new Runnable() {
            public void run() {
                for (int i = 0; i < 1000; ++i) {
                    count += 1;
                }
            }
        };
 
        Thread t1 = new Thread(task);
        t1.start();
        
        Thread t2 = new Thread(task);
        t2.start();
 
        t1.join();
        t2.join();
 
        cout << "count = " << count << endl;
    }
}

从代码来看,我们运行后结果预期是2000,但是实际运行后并不是。为什么会这样呢?

当多线程并行对同一个公共变量读写时,由于没有互斥,多线程的set会相互覆盖或读取时容易读到其他线程刚写一半的数据,这就导致变量数据被损坏。反过来说,我们要想保证一个变量在多线程并发情况下的准确性,就需要这个变量在修改期间不会被其他线程更改或读取。

对于这个情况,我们一般都会用到锁或原子操作来保护库存变量:

  • 如果是简单int类型数据,可以使用原子操作保证数据准确;
  • 如果是复杂的数据结构或多步操作,可以加锁来保证数据完整性。

考虑到我们之前的习惯会有一定惯性,为了让你更好地理解争抢,这里我再举一个我们常会犯错的例子。因为扣库存的操作需要注意原子性,我们实践的时候常常碰到后面这种方式:

redis> get prod_1475_stock_1
15
redis> set prod_1475_stock_1 14
OK

也就是先将变量从缓存中取出,对其做-1操作,再放回到缓存当中,这是个错误做法。

如上图,原因是多个线程一起读取的时候,多个线程同时读到的是5,set回去时都是6,实际每个线程都拿到了库存,但是库存的实际数值并没有累计改变,这会导致库存超卖。如果你需要用这种方式去做,一般建议加一个自旋互斥锁,互斥其他线程做类似的操作。

不过锁操作是很影响性能的,在讲锁方式之前,我先给你介绍几个相对轻量的方式。

26.2 原子操作

在高并发修改的场景下,用互斥锁保证变量不被错误覆盖性能很差。让一万个用户抢锁,排队修改一台服务器的某个进程保存的变量,这是个很糟糕的设计。

因为锁在获取期间需要自旋循环等待,这需要不断地循环尝试多次才能抢到。而且参与争抢的线程越多,这种情况就越糟糕,这期间的通讯过程和循环等待很容易因为资源消耗造成系统不稳定。

对此,我会把库存放在一个独立的且性能很好的内存缓存服务Redis中集中管理,这样可以减少用户争抢库存导致其他服务的抖动,并且拥有更好的响应速度,这也是目前互联网行业保护库存量的普遍做法。

同时,我不建议通过数据库的行锁来保证库存的修改,因为数据库资源很珍贵,使用数据库行锁去管理库存,性能会很差且不稳定。

前面我们提到当有大量用户去并行修改一个变量时,只有用锁才能保证修改的正确性,但锁争抢性能很差,那怎么降低锁的粒度、减少锁的争枪呢?

如上图,其实我们可以将一个热门商品的库存做拆分,放在多个key中去保存,这样可以大幅度减少锁争抢。

举个例子,当前商品库存有100个,我们可以把它放在10个key中用不同的Redis实例保存,每个key里面保存10个商品库存,当用户下单的时候可以随机找一个key进行扣库存操作。如果没库存,就记录好当前key再随机找剩下的9个key,直到成功扣除1个库存。

除了这种方法以外,我个人更推荐的做法是使用Redis的原子操作,因为原子操作的粒度更小,并且是高性能单线程实现,可以做到全局唯一决策。而且很多原子操作的底层实现都是通过硬件实现的,性能很好,比如文稿后面这个例子:

redis> decr prod_1475_stock_1
14

incr、decr这类操作就是原子的,我们可以根据返回值是否大于0来判断是否扣库存成功。但是这里你要注意,如果当前值已经为负数,我们需要考虑一下是否将之前扣除的补偿回来。并且为了减少修改操作,我们可以在扣减之前做一次值检测,整体操作如下:

//读取当前库存,确认是否大于零
//如大于零则继续操作,小于等于拒绝后续
redis> get prod_1475_stock_1
1
 
//开始扣减库存、如返回值大于或等于0那么代表扣减成功,小于0代表当前已经没有库存
//可以看到返回-2,这可以理解成同时两个线程都在操作扣库存,并且都没拿到库存
redis> decr prod_1475_stock_1
-2
 
//扣减失败、补偿多扣的库存
//这里返回0是因为同时两个线程都在做补偿,最终恢复0库存
redis> incr prod_1475_stock
0

这看起来是个不错的保护库存量方案,不过它也有缺点,相信你已经猜到了,这个库存的数值准确性取决于我们的业务是否能够返还恢复之前扣除的值。如果在服务运行过程中,“返还”这个操作被打断,人工修复会很难,因为你不知道当前有多少库存还在路上狂奔,只能等活动结束后所有过程都落地,再来看剩余库存量。

而要想完全保证库存不会丢失,我们习惯性通过事务和回滚来保障。但是外置的库存服务Redis不属于数据库的缓存范围,这一切需要通过人工代码去保障,这就要求我们在处理业务的每一处故障时都能处理好库存问题。

所以,很多常见秒杀系统的库存在出现故障时是不返还的,并不是不想返还,而是很多意外场景做不到。

提到锁,也许你会想到使用Setnx指令或数据库CAS的方式实现互斥排他锁,以此来解决库存问题。但是这个锁有自旋阻塞等待,并发高的时候用户服务需要循环多次做尝试才能够获取成功,这样很浪费系统资源,对数据服务压力较大,不推荐这样去做。

26.3 令牌库存

除了这种用数值记录库存的方式外,还有一种比较科学的方式就是“发令牌”方式,通过这个方式可以避免出现之前因为抢库存而让库存出现负数的情况。

具体是使用Redis中的list保存多张令牌来代表库存,一张令牌就是一个库存,用户抢库存时拿到令牌的用户可以继续支付:

//放入三个库存
redis> lpush prod_1475_stock_queue_1 stock_1
redis> lpush prod_1475_stock_queue_1 stock_2
redis> lpush prod_1475_stock_queue_1 stock_3

//取出一个,超过0.5秒没有返回,那么抢库存失败
redis> brpop prod_1475_stock_queue_1 0.5

在没有库存后,用户只会拿到nil。当然这个实现方式只是解决抢库存失败后不用再补偿库存的问题,在我们对业务代码异常处理不完善时仍会出现丢库存情况。

同时,我们要注意brpop可以从list队列“右侧”中拿出一个令牌,如果不需要阻塞等待的话,使用rpop压测性能会更好一些。

不过,当我们的库存成千上万的时候,可能不太适合使用令牌方式去做,因为我们需要往list中推送1万个令牌才能正常工作来表示库存。如果有10万个库存就需要连续插入10万个字符串到list当中,入库期间会让Redis出现大量卡顿。

到这里,关于库存的设计看起来已经很完美了,不过请你想一想,如果产品侧提出“一个商品可以抢多个库存”这样的要求,也就是一次秒杀多个同种商品(比如一次秒杀两袋大米),我们利用多个锁降低锁争抢的方案还能满足吗?

26.4 多库存秒杀

其实这种情况经常出现,这让我们对之前的优化有了更多的想法。对于一次秒杀多个库存,我们的设计需要做一些调整。

之前我们为了减少锁冲突把库存拆成10个key随机获取,我们设想一下,当库存剩余最后几个商品时,极端情况下要想秒杀三件商品(如上图),我们需要尝试所有的库存key,然后在尝试10个key后最终只拿到了两个商品库存,那么这时候我们是拒绝用户下单,还是返还库存呢?

这其实就要看产品的设计了,同时我们也需要加一个检测:如果商品卖完了就不要再尝试拿10个库存key了,毕竟没库存后一次请求刷10次Redis,对Redis的服务压力很大(Redis O(1)指令性能理论可以达到10w OPS,一次请求刷10次,那么理想情况下抢库存接口性能为1W QPS,压测后建议按实测性能70%漏斗式限流)。

这时候你应该发现了,在“一个商品可以抢多个库存”这个场景下,拆分并没有减少锁争抢次数,同时还加大了维护难度。当库存越来越少的时候,抢购越往后性能表现越差,这个设计已经不符合我们设计的初衷(由业务需求造成我们底层设计不合适的情况经常会碰到,这需要我们在设计之初,多挖一挖产品具体的需求)。

那该怎么办呢?我们不妨将10个key合并成1个,改用rpop实现多个库存扣减,但库存不够三个只有两个的情况,仍需要让产品给个建议看看是否继续交易,同时在开始的时候用LLEN(O(1))指令检查一下我们的List里面是否有足够的库存供我们rpop,以下是这次讨论的最终设计:

//取之前看一眼库存是否空了,空了不继续了(llen O(1))
redis> llen prod_1475_stock_queue
3

//取出库存3个,实际抢到俩
redis> rpop prod_1475_stock_queue 3
"stock_1""stock_2"

//产品说数量不够,不允许继续交易,将库存返还
redis> lpush prod_1475_stock_queue stock_1
redis> lpush prod_1475_stock_queue stock_2

通过这个设计,我们已经大大降低了下单系统锁争抢压力。要知道,Redis是一个性能很好的缓存服务,其O(1)类复杂度的指令在使用长链接的情况下多线程压测,5.0 版本的Redis就能够跑到10w OPS,而6.0版本的网络性能会更好。

这种利用Redis原子操作减少锁冲突的方式,对各个语言来说是通用且简单的。不过你要注意,不要把Redis服务和复杂业务逻辑混用,否则会影响我们的库存接口效率。

26.5 自旋互斥超时锁

如果我们在库存争抢时需要操作多个决策key才能够完成争抢,那么原子这种方式是不适合的。因为原子操作的粒度过小,无法做到事务性地维持多个数据的ACID。

这种多步操作,适合用自旋互斥锁的方式去实现,但流量大的时候不推荐这个方式,因为它的核心在于如果我们要保证用户的体验,我们需要逻辑代码多次循环抢锁,直到拿到锁为止,如下:

//业务逻辑需要循环抢锁,如循环10次,每次sleep 10ms,10次失败后返回失败给用户
//获取锁后设置超时时间,防止进程崩溃后没有释放锁导致问题
//如果获取锁失败会返回nil
redis> set prod_1475_stock_lock EX 60 NX
OK

//抢锁成功,扣减库存
redis> rpop prod_1475_stock_queue 1
"stock_1"

//扣减数字库存,用于展示
redis> decr prod_1475_stock_1
3

// 释放锁
redis> del prod_1475_stock_lock

这种方式的缺点在于,在抢锁阶段如果排队抢的线程越多,等待时间就越长,并且由于多线程一起循环check的缘故,在高并发期间Redis的压力会非常大,如果有100人下单,那么有100个线程每隔10ms就会check一次,此时Redis的操作次数就是:

\[100线程\\times(1000ms\\div10ms)次 = 10000 ops\]

26.6 CAS乐观锁:锁操作后置

除此之外我再推荐一个实现方式:CAS乐观锁。相对于自旋互斥锁来说,它在并发争抢库存线程少的时候效率会更好。通常,我们用锁的实现方式是先抢锁,然后,再对数据进行操作。这个方式需要先抢到锁才能继续,而抢锁是有性能损耗的,即使没有其他线程抢锁,这个消耗仍旧存在。

CAS乐观锁的核心实现为:记录或监控当前库存信息或版本号,对数据进行预操作。

如上图,在操作期间如果发现监控的数值有变化,那么就回滚之前操作;如果期间没有变化,就提交事务的完成操作,操作期间的所有动作都是事务的。

//开启事务
redis> multi
OK

// watch 修改值
// 在exec期间如果出现其他线程修改,那么会自动失败回滚执行discard
redis> watch prod_1475_stock_queue prod_1475_stock_1

//事务内对数据进行操作
redis> rpop prod_1475_stock_queue 1
QUEUED

//操作步骤2
redis> decr prod_1475_stock_1
QUEUED

//执行之前所有操作步骤
//multi 期间 watch有数值有变化则会回滚
redis> exec
3

可以看到,通过这个方式我们可以批量地快速实现库存扣减,并且能大幅减少锁争抢时间。它的好处我们刚才说过,就是争抢线程少时效率特别好,但争抢线程多时会需要大量重试,不过即便如此,CAS乐观锁也会比用自旋锁实现的性能要好。

当采用这个方式的时候,我建议内部的操作步骤尽量少一些。同时要注意,如果Redis是Cluster模式,使用multi时必须在一个slot内才能保证原子性。

26.7 Redis Lua方式实现Redis锁

与“事务+乐观锁”类似的实现方式还有一种,就是使用Redis的Lua脚本实现多步骤库存操作。因为Lua脚本内所有操作都是连续的,这个操作不会被其他操作打断,所以不存在锁争抢问题。

而且、可以根据不同的情况对Lua脚本做不同的操作,业务只需要执行指定的Lua脚本传递参数即可实现高性能扣减库存,这样可以大幅度减少业务多次请求等待的RTT。

为了方便演示怎么执行Lua脚本,我使用了PHP实现:

<?php$script = <<<EOF
// 获取当前库存个数
local stock=tonumber(redis.call('GET',KEYS[1])); 
//没找到返回-1
if stock==nil 
then 
    return -1; 
end 
//找到了扣减库存个数
local result=stock-ARGV[1]; 
//如扣减后少于指定个数,那么返回0
if result<0 
then 
    return 0; 
else 
    //如果扣减后仍旧大于0,那么将结果放回Redis内,并返回1
    redis.call('SET',KEYS[1],result); 
    return 1; 
end
EOF;

$redis = new\Redis();
$redis->connect('127.0.0.1', 6379);
$result = $redis->eval($script, array("prod_stock", 3), 1);
echo$result;

通过这个方式,我们可以远程注入各种连贯带逻辑的操作,并且可以实现一些补库存的操作。

二、粉丝福利

我根据我从小白到架构师多年的学习经验整理出来了一份50W字面试解析文档、简历模板、学习路线图、java必看学习书籍 、 需要的小伙伴斯我“159”,或者评论区扣“求分享

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/746054.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

国内AI行业对GPU算力的需求有多大?

随着人工智能&#xff08;AI&#xff09;技术的飞速发展&#xff0c;算力作为支撑其持续进步的核心动力&#xff0c;在国内的重要性日益凸显&#xff0c;无论是海外还是国内&#xff0c;AI算力行业都呈现出蓬勃发展的态势&#xff0c;而国内对于AI算力的需求更是呈现出爆发式的…

1Panel开源面板项目GitHub Star数量突破20,000!

截至2024年6月25日9:00&#xff0c;FIT2CLOUD飞致云旗下开源项目——1Panel开源Linux服务器运维管理面板GitHub Star数超过20,000个&#xff01; 继Halo和JumpServer之后&#xff0c;1Panel成为飞致云旗下第三个GitHub Star数量超过20,000个的开源项目&#xff0c;也是飞致云旗…

Android studio Logcat 功能介绍

介绍 Android Studio Jellyfish版本下logcat功能&#xff0c;不同的tag会有不同的颜色&#xff0c;不同level等级的log默认也有不同的颜色。log过滤修改的更简洁了&#xff0c;原先的log视图只需要勾选就可以选择不同level的log了&#xff0c;当前需要在输入框中进行过滤器匹配…

OAuth 2.0资源授权机制与安全风险分析

文章目录 前言OAuth2.01.1 OAuth应用1.2 OAuth基础1.3 授权码模式1.4 其它类模式1.5 openid连接 安全风险2.1 隐式授权劫持2.2 CSRF攻击风险2.3 Url重定向漏洞2.4 scope校验缺陷 总结 前言 OAuth 全称为Open Authorization&#xff08;开放授权&#xff09;&#xff0c;OAuth …

odoo 去掉在线聊天的删除和编辑内容

描述 odoo在线聊天功能中,在原有的聊天记录中是可以进行编辑和删除的 这使得产生很多不可控原因,乱改,乱删等 所以要进行屏蔽此功能 优化前 优化后 升级 mail 模块刷新即可。 <Dropdown t-if="messageActions.actions.length gt quickActionCount"onStateCha…

[JS]DOM元素

介绍 DOM(Document Object Model---文档对象模型) 是浏览器提供的一套专门用来操作网页内容的API DOM树 把HTML文档以树状结构直观的表现出来, 称为文档数或者DOM树, DOM树直观的展示了标签与标签的关系 DOM对象 浏览器根据html标签生成的JS对象称为DOM对象 document对象 …

专业,城市,院校,高考填报志愿的三要素怎么排序?

我认为排序方式可以参考&#xff1a; 城市>学校 同样是计算机专业&#xff0c;不论学校的高低&#xff0c;一线城市更容易接触到时代的前端&#xff0c;有更多学习机会&#xff0c;有更好的文化氛围&#xff0c;同样在就业的时候也更容易接触到企业.... 如果要把专业考虑进…

mybatis中动态sql语句like concat(“%“,#{xm},“%“)

1、动态SQL是一种可以根据不同条件生成不同SQL语句的技术&#xff0c;随着用户输入或外部条件变化而变化的SQL语句 2、SQL语句中的like模糊查询 xm like %小米%&#xff0c;但开发中经常用到 xm like concat("%",#{xm},"%")&#xff0c;可以防止sql注入…

半藏酒商业模式解读,半藏酱酒营销案例,半藏总院分院招商模式

半藏酱酒通过新零售模式&#xff0c;实现销售额快速增长。其模式包括私域营销、共享门店和DTC模式 入局酱酒市场短短4个月&#xff0c;销售额便破亿&#xff0c;15个月销售额突破6亿&#xff0c;还成立了700多家分院… 主要步骤是三个身份&#xff1a;分院、联创股东、个人股东…

Java毕设服务工作室

Java毕设服务工作室&#xff1a;专注提供高质量Java代码解决方案 在Java编程领域&#xff0c;毕业设计&#xff08;毕设&#xff09;项目往往需要大量的代码编写和调试。为了让同学们能够更专注于项目的核心逻辑和技术实现&#xff0c;Java毕设服务工作室应运而生&#xff0c;…

AME5268-AZAADJ 3A,28V,340KHz同步整流下行变换器芯片IC

一般说明 AME5268 是一款固定频率单片同步稳压器&#xff0c;可接受4.75V至28V的输入电压。两个低接通电阻的NMOS开关集成在模具上。采用电流模式拓扑结构&#xff0c;具有快速的暂态响应和良好的环路稳定性。 关断模式将输入电源电流降低到小于1μa。可调软启动…

2024系统分析师考试总结

考试缘由 我自己在毕业不久就考过了中级的软件设计师&#xff0c;这几年换到外企后事情不多&#xff0c;今年初定计划的时候就想着不如考个系统分析师吧。为什么选这个类别呢&#xff1f;按道理我主做程序开发&#xff0c;如果去考系统架构师通过率可能会大一些&#xff0c;但…

C++应用例程(判断质数、猜数字、爱心曲线)

一、判断质数 质数也叫素数&#xff0c;是指一个大于1的自然数&#xff0c;因数只有1和它自身。质数是数论中一个经典的概念&#xff0c;很多著名定理和猜想都跟它有关;质数也是现代密码学的基础。 判断一个数是否为质数没有什么规律可言&#xff0c;我们可以通过验证小于它的…

SpringBoot集成Druid数据库连接池并配置可视化界面和监控慢SQL

pom.xml <!-- Druid 数据库连接池 --><dependency><groupId>com.alibaba</groupId><artifactId>druid-spring-boot-starter</artifactId><version>1.2.23</version></dependency>application.yml spring:jackson:date-…

ONLYOFFICE 文档 8.1 发布:重塑文档处理

官网链接&#xff1a;ONLYOFFICE官网 一、PDF编辑器功能强大&#xff1a;创造跟随想象 在追求无界办公与高效创作的今天&#xff0c;ONLYOFFICE再次引领风潮&#xff0c;正式发布了其桌面编辑器的最新版本——ONLYOFFICE桌面编辑器8.1。这一版本不仅巩固了其作为顶级办公套件…

工业数据采集网关特别的功能之处

工业数据采集网关作为工业物联网&#xff08;IIoT&#xff09;系统的核心设备&#xff0c;承担着数据采集、传输和处理的关键任务。其特别功能不仅在于数据采集的广泛性和实时性&#xff0c;更在于其智能化、可扩展性和安全性。以下是工业数据采集网关的一些特别功能及其在工业…

VMware的具体使用

&#x1f4d1;打牌 &#xff1a; da pai ge的个人主页 &#x1f324;️个人专栏 &#xff1a; da pai ge的博客专栏 ☁️宝剑锋从磨砺出&#xff0c;梅花香自苦寒来 目录 一 &#x1f324;️VMware的安…

Nodejs使用mqtt库连接阿里云服务器

建项目 命令行输入&#xff1a; npm init 输入项目名&#xff0c;自动化生成项目列表。 6.3 编写代码 新建mqtt_demo_aliyun.js&#xff0c;代码如下&#xff1a; // mqtt_demo_aliyun.jsconst mqtt require("mqtt"); const connectUrl "ws://post-cn-nw**…

PyTorch实战:借助torchviz可视化计算图与梯度传递

文章目录 Tensor计算的可视化&#xff08;线性回归为例&#xff09; 如何使用可视化库torchviz 安装graphviz软件 安装torchviz库使用 torchviz.make_dot() 在学习Tensor时&#xff0c;将张量y用张量x表示&#xff0c;它们背后会有一个函数表达关系&#xff0c;y的 grad_f…

VMamba: Visual State Space Model论文笔记

文章目录 VMamba: Visual State Space Model摘要引言相关工作Preliminaries方法网络结构2D-Selective-Scan for Vision Data(SS2D) VMamba: Visual State Space Model 论文地址: https://arxiv.org/abs/2401.10166 代码地址: https://github.com/MzeroMiko/VMamba 摘要 卷积神…