首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >如何解决高并发下的库存抢购超卖少买问题?

如何解决高并发下的库存抢购超卖少买问题?

作者头像
java金融
发布2025-07-16 17:35:55
发布2025-07-16 17:35:55
2010
举报
文章被收录于专栏:java金融java金融

一、前言

库存问题,作为当前电商及零售领域普遍面临的通行难题,其复杂性和多样性不容忽视。库存的管理不仅关乎企业的运营效率,更直接影响到客户的购物体验和满意度。然而,不同量级、不同类型的库存问题,所遇到的挑战和所需的解决方案也各不相同。在众多库存问题中,秒杀场景下的库存扣减问题尤为突出,它考验着系统的并发处理能力和数据一致性保障。

面对这一常规但又棘手的问题,如何提出一个既靠谱又实用的解决方案,是每位面试官和开发者都需要深入思考的问题。这不仅仅是对技术能力的考验,更是对项目实战经验、问题解决能力和创新思维的一次全面检验。

二、背景

在电商平台的秒杀活动中,库存的争抢往往异常激烈,这对系统的性能和稳定性提出了极高的要求。虽然市面上不乏关于库存管理的相关资料和解决方案,但在实际操作过程中,我们仍然会遇到许多具体的问题和挑战。

例如,有的实现方案在秒杀多个库存时会出现性能瓶颈,导致用户抢购失败或体验不佳;有的方案在新增库存时操作缓慢,无法满足快速补货的需求;还有的方案在库存耗尽时会变得异常缓慢,严重影响系统的响应速度和用户体验。

这些问题的出现,往往是因为对于不同的业务需求,库存争抢的具体实现方式存在差异。因此,我们需要深入挖掘和理解各种锁的特性和适用场景,以便针对不同的业务场景做出灵活调整和优化。

秒杀场景作为库存争抢的一个经典应用场景,其复杂性和挑战性不言而喻。接下来,我将结合秒杀需求,详细探讨如何实现高并发下的库存争抢,并带你深入了解各种锁机制在其中的应用。相信通过这一过程的学习和实践,你会对锁有更深入的认识和理解,从而能够更好地应对库存管理的各种挑战。

26.1 锁争抢的错误做法

在开始详细介绍库存争抢的具体解决方案之前,我们先来探讨一个基础知识——并发库存锁。在计算机科学的学习旅程中,我曾深刻记得老师在课堂上演示过的一段经典代码。这段代码生动展示了在高并发环境下,如果处理不当,库存锁可能会引发的各种问题和挑战。

老师通过模拟多个线程同时尝试访问和修改同一库存资源,清晰地揭示了锁争抢现象的本质。那些没有采用正确锁机制或锁策略的代码示例,往往会导致严重的性能下降、数据不一致甚至系统崩溃。这些错误的做法不仅未能有效解决问题,反而加剧了系统的负担和复杂性。

因此,了解并发库存锁的基础知识,以及识别和分析锁争抢的错误做法,对于设计和实现高效的库存管理系统至关重要。接下来,我们将进一步深入探讨这一领域,以期找到更加稳健和可靠的解决方案。

图片
图片

从代码来看,我们运行后的结果预期是2000,但实际运行后却往往与预期相悖。这背后的原因何在呢?关键在于多线程并行处理时对同一个公共变量的读写操作。当多个线程没有互斥机制地同时访问和修改这个变量时,就会出现问题。一个线程的set操作可能会覆盖另一个线程的set结果,或者在读取时容易读取到其他线程刚写一半、尚未完成的数据,这就会导致变量数据被意外损坏,最终使得我们得到的结果与预期大相径庭。

反过来说,如果我们想要在多线程并发的情况下保证一个变量的准确性,就必须确保这个变量在修改期间不会被其他线程更改或读取。为了实现这一目标,我们通常会采用锁或原子操作来保护库存变量。

对于简单的int类型数据,我们可以直接使用原子操作来确保数据的准确性。原子操作是一种不可被中断的操作,它能够在多线程环境中保证对变量的读写是原子的,即要么全部完成,要么全部不完成,从而避免了数据被部分修改或读取的问题。

然而,对于复杂的数据结构或多步操作,原子操作可能就不再适用。这时,我们可以考虑使用锁来保证数据的完整性。锁机制能够在多线程环境中实现对共享资源的独占访问,从而避免多个线程同时修改或读取同一个变量导致的冲突和数据损坏。

为了让你更好地理解锁争抢的问题,这里我再举一个常见的错误示例。在实践中,扣库存的操作需要特别注意原子性。然而,我们常常会遇到这样的情况:由于缺乏对原子性的深刻理解,我们可能会采用一些看似合理但实际上存在问题的扣库存方式。比如,我们可能会将库存的读取和扣减操作分开进行,而不是作为一个不可分割的原子操作来完成。这样做就容易导致在多线程环境下,一个线程的扣减操作被另一个线程的读取操作打断,从而引发数据不一致的问题。

关于锁的具体实现和选择,这里我附上几种常见锁的参考资料。如果你对锁机制感兴趣,可以深入了解一下这些锁的特性和应用场景。通过学习和实践,你将能够更好地掌握多线程编程中的锁争抢问题,并设计出更加健壮和高效的并发程序。

图片
图片

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

图片
图片

如上图所示,当多个线程同时读取库存时,它们可能都会读取到相同的数值5。随后,这些线程在执行set操作时,都会将库存值设置为6。尽管每个线程都成功地获取了库存,但实际上库存的数值并未按预期累加,这就导致了库存超卖的风险。如果你打算采用这种方式进行操作,一般建议在操作中加入一个自旋互斥锁,以阻止其他线程执行类似的操作。然而,锁操作往往会对性能产生较大影响。在深入探讨锁的具体方式之前,我先为你介绍几种相对轻量级的解决方案。

26.2 原子操作

在高并发的修改场景下,使用互斥锁来确保变量不被错误覆盖,其性能表现往往不尽如人意。想象一下,当一万个用户竞相争抢同一把锁,排队去修改一台服务器上某个进程所保存的变量,这无疑是一个糟糕的设计。

因为锁在获取过程中需要经历自旋循环等待,这意味着线程需要不断地尝试,才能有机会抢到锁。而且,参与争抢的线程数量越多,这种情况就越发糟糕。期间的通讯开销和循环等待很容易因为资源过度消耗而导致系统不稳定。

针对这一问题,我会将库存放置在一个独立且性能卓越的内存缓存服务——Redis中进行集中管理。这样做的好处在于,它可以减少用户争抢库存对其他服务造成的干扰,同时提供更快的响应速度。这也是当前互联网行业普遍采用的库存保护策略。

此外,我并不推荐通过数据库的行锁来保证库存的修改。因为数据库资源十分宝贵,使用数据库行锁来管理库存,其性能不仅会大打折扣,而且稳定性也会受到影响。

之前我们提到,当有大量用户并行修改一个变量时,锁是确保修改正确性的必要手段。然而,锁争抢带来的性能损耗却不容忽视。那么,如何降低锁的粒度、减少锁的争抢呢?

图片
图片

如上图所示,我们其实可以采用一种巧妙的策略来大幅减少热门商品库存的锁争抢问题:将库存进行拆分,并分散保存在多个key中。

举例来说,假设某热门商品当前库存为100个,我们可以将其拆分为10份,每份10个库存,并分别存储在不同的Redis实例中的不同key里。当用户发起下单请求时,系统可以随机选择一个key进行扣库存操作。若该key下的库存已不足,系统则记录下该key,并继续随机选择剩余的9个key进行尝试,直至成功扣除一个库存为止。

当然,除了上述方法外,我个人更倾向于推荐使用Redis的原子操作来处理库存问题。这是因为原子操作的粒度更为精细,且Redis作为高性能的单线程实现,能够保证全局操作的唯一性和一致性。此外,许多原子操作的底层实现都依赖于硬件支持,因此性能表现尤为出色。例如,文稿后续将详细阐述的一个实例,就充分展示了原子操作在处理高并发库存扣减时的卓越性能。

图片
图片

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

图片
图片
图片
图片

这确实是一个颇具创意的库存保护方案,然而,它同样存在着不可忽视的缺陷。想必你已有所察觉,该方案库存数值的准确性高度依赖于业务是否能够准确无误地返还之前扣除的库存值。在服务运行过程中,一旦“返还”操作被意外中断,人工修复将变得异常艰难。因为此时,你无法确切知晓有多少库存正处于流转途中,只能静待活动结束后,所有流程尘埃落定,方能盘点剩余的库存量。

为了确保库存的绝对安全,我们通常会借助事务和回滚机制来保驾护航。但遗憾的是,外置的库存服务Redis并不隶属于数据库的缓存范畴,这意味着我们需要通过精心编写的代码来构筑这道防线。这就要求我们在处理业务的每一个环节时,都能妥善应对可能出现的库存问题。

正因如此,许多常见的秒杀系统中,在出现故障时往往会选择不返还库存。这并非出于本意,而是因为在诸多不可预见的场景下,确实难以做到完美返还。

提及锁机制,你或许会联想到使用Setnx指令或数据库的CAS操作来实现互斥排他锁,以此解决库存扣减的并发问题。然而,这种锁机制伴随着自旋阻塞等待的弊端,在高并发场景下,用户服务可能需要经过多次循环尝试方能成功获取锁,这无疑是对系统资源的极大浪费,同时也给数据服务带来了沉重的压力,因此并不推荐采用这种方式。

26.3 令牌库存方案

除了采用数值记录库存的方式外,还有一种更为科学的库存管理策略——“发令牌”方式。这种方式能够有效避免库存被过度扣减而出现负数的情况,从而确保库存管理的准确性和稳定性。

图片
图片

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

图片
图片

在没有库存之后,用户只会收到一个nil的响应。当然,这种实现方式仅仅解决了抢库存失败后无需再补偿库存的问题。然而,如果我们的业务代码异常处理机制不够完善,仍然可能会出现库存丢失的情况。

同时,值得注意的是,brpop命令可以从list队列的“右侧”弹出一个令牌。如果我们不需要阻塞等待的话,使用rpop命令在性能测试中可能会表现出更好的性能。

但是,当我们的库存数量达到成千上万时,使用令牌方式可能会显得不太合适。因为我们需要向list中推送成千上万个令牌才能正常工作以表示库存量。例如,如果有10万个库存,就需要连续插入10万个字符串到list中,这在入库期间可能会导致Redis出现明显的卡顿现象。

至此,关于库存的设计似乎已经相当完善。不过,请你再深入思考一下,如果产品团队提出“一个商品可以抢购多个库存”这样的需求,即一次秒杀可以购买多个同种商品(比如一次秒杀两袋大米),那么我们之前利用多个锁来降低锁争抢的方案还能满足这一需求吗?

26.4 多库存秒杀

实际上,这种需求并不罕见,它促使我们对之前的优化方案进行更深入的思考和调整。对于一次秒杀多个库存的情况,我们的设计确实需要进行一些必要的改动。

图片
图片

之前,为了缓解锁冲突,我们将库存拆分成了10个key,并随机获取其中一个进行处理。但让我们设想一下,当库存仅剩最后几件商品时,如果用户想要秒杀三件商品(如上图所示),那么我们就需要尝试所有的库存key。在极端情况下,即使尝试了全部10个key,最终可能也只拿到两个商品的库存。这时,我们是应该拒绝用户的下单请求,还是将已扣除的库存进行返还呢?

这个问题的答案,很大程度上取决于产品的具体设计。同时,我们还需要增加一个检测机制:一旦商品售罄,就无需再尝试获取那10个库存key了。毕竟,在没有库存的情况下,一次请求还要刷10次Redis,这无疑会给Redis服务带来巨大的压力(尽管Redis的O(1)指令性能理论上可以达到10万OPS,但一次请求刷10次,那么理想情况下抢库存接口的性能就会降至1万QPS。在实际压测中,建议按照实测性能的70%进行漏斗式限流)。

此时,你可能已经发现,在“一个商品可以抢多个库存”的场景下,拆分库存key并没有减少锁争抢的次数,反而还增加了维护的难度。随着库存的减少,抢购的成功率也会逐渐下降,性能表现也会越来越差。这显然已经违背了我们的设计初衷(在实际项目中,由于业务需求导致底层设计不合适的情况并不罕见。因此,在设计之初,我们就需要深入挖掘产品的具体需求)。

那么,我们应该如何应对这个问题呢?一个可行的方案是,将原本拆分的10个key合并成一个,并使用rpop命令来实现多个库存的扣减。但是,如果库存不足三个而只有两个时,我们仍然需要征求产品的意见,以确定是否继续交易。同时,在开始时,我们可以使用LLEN(O(1))命令来检查List中是否有足够的库存供我们进行rpop操作。以下是这次讨论后得出的最终设计方案:

图片
图片

通过这个精妙的设计,我们已经显著降低了下单系统所面临的锁争抢压力。Redis作为一个性能卓越的缓存服务,其O(1)复杂度的指令在长连接多线程压测下,5.0版本即可轻松达到10万OPS,而6.0版本的网络性能更是更上一层楼。

利用Redis的原子操作来减少锁冲突,这一策略对于各种编程语言来说都是既通用又简便的。但务必牢记,切勿将Redis服务与复杂的业务逻辑混杂使用,以免拖慢我们的库存接口效率。

26.5 自旋互斥超时锁

当库存争抢涉及多个决策key的同步操作时,原子操作便显得力不从心。因为其粒度过于精细,难以确保多个数据的事务性ACID特性。

对于这类多步骤操作,自旋互斥锁或许是一个合适的选择。但在高流量场景下,却并不推荐采用这种方式。原因在于,为了确保用户体验,我们的逻辑代码需要不断循环尝试获取锁,直至成功为止。这一过程如下所示:

图片
图片
图片
图片

这种方式的明显弊端在于,抢锁阶段若排队竞争的线程数量激增,等待时间亦会随之延长。加之多线程频繁循环检查的机制,使得在高并发时段,Redis承受的压力空前巨大。试想,若有100人同时下单,每个线程每隔10毫秒便检查一次库存,那么Redis在此时的操作频次将高达:

100线程×(1000毫秒÷10毫秒)次=10000次操作

26.6 CAS乐观锁:锁操作后置策略

在此,我另推荐一种高效的实现途径——CAS乐观锁。

相较于自旋互斥锁,CAS乐观锁在并发争抢库存的线程数量较少时,展现出更为卓越的性能。

传统锁机制往往遵循“先抢锁,后操作”的原则。这意味着,线程必须先成功获取锁,方能继续执行后续的数据操作。然而,抢锁过程本身便伴随着性能损耗,即便没有其他线程参与竞争,这份消耗依旧存在。

而CAS乐观锁的核心精髓在于:它预先记录或监控当前库存信息(或版本号),并基于此进行数据的预操作处理。

图片
图片

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

图片
图片

显而易见,这一方法能够让我们迅速且批量地完成库存扣减,同时极大地缩短了锁争抢的时间。其优势在于,当争抢线程较少时,效率尤为出众。尽管在争抢线程众多时可能需要多次重试,但即便如此,CAS乐观锁的性能依然优于自旋锁的实现。

在运用此方法时,我强烈建议将内部操作步骤精简至最少。此外,还需留意的是,

若Redis采用Cluster模式,使用multi命令时,必须确保所有操作都在同一个slot内执行,以保证操作的原子性。

26.7 利用Redis Lua脚本实现锁机制

除了“事务+乐观锁”的方式外,还有一种类似的方法,那就是借助Redis的Lua脚本来执行多步骤的库存操作。由于

Lua脚本内的所有操作都是连续且无缝衔接的

,因此不会被其他操作打断,从而避免了锁争抢的问题。

更值得一提的是,我们可以根据不同的业务需求对Lua脚本进行灵活调整。业务方只需执行指定的Lua脚本并传递相应参数,即可实现高性能的库存扣减。这种方式能够大幅度减少因业务多次请求而带来的往返时延(RTT)。为了更直观地展示如何执行Lua脚本,以下我采用了PHP代码进行示例:

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

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2025-02-11,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 java金融 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档