库存系统可以分为实物库存和虚拟库存两种类型。实物库存的管理涉及到采购、存储、销售和库存轮换等复杂的活动,需要进行供应链管理和仓库管理等工作。相比之下,虚拟库存的管理相对简单,主要适用于线上资源的数量管理,包括各类虚拟商品权益,例如线上课程、付费优惠券包和活动库存等。五阳哥长期从事虚拟电商领域,在今天的分享中将主要介绍虚拟库存的管理。
假设产品需求中要求设置商品库存,限制售卖数量。我们应该如何设计技术方案?有哪些设计重点?
一共9个关键问题,全文目录如下
方案1:记录库存余额的方案是,每次购买时库存余额都减去购买数量。一旦库存余额小于等于 0,则无法再减少库存数量,商品则不可再售卖,直至库存得到补充。示例SQL如下
update inventory set cnt = cnt - #{buyCnt} WHERE productId = #{productId} AND cnt - #{buyCnt} >= 0
方案2:记录售卖数量的方案是,每次购买时,已售卖数量 + 当前购买数量。加和以后,一旦已购买数量大于库存总数时,则库存不足,商品则不可再对外售卖,直至库存得到补充。
update inventory set cnt = cnt + #{buyCnt} WHERE productId = #{productId} AND cnt + #{buyCnt} <= totalCnt
这两个方案都可以实现库存功能。但是在库存不足后,需要补充库存的场景,两个方案存在差异,存在优劣。
例如当前总库存为100,已售卖了51个,剩余库存为49个。按照方案1(库存余额)的要求,当库存从100增加到200时,除了库存总数需要增加,剩余库存数量也需要增加100个,变为149个。
然而,减少库存总数这一场景,方案1的实现方案更加复杂。当库存从100减少到50时,由于库存余额为49个,库存余额减掉50后,则为-1。因为库存余额为负数属于异常场景,所以需要将剩余库存设置为0。然而,这会引起数据的一致性问题。
因为库存余额不准确,所以 已售卖数量 = 库存总数 - 库存余额,这个等式也不再正确 ,如果商品需要展示 已售数量,使用这个公式就无法保证已售数量的准确性。
在方案1中,每次调整库存总数都需要调整库存余额,增加了操作复杂度。因为C端交易流程需要操作库存余额,B端调整库存总数也会涉及调整库存余额,BC端的库存操作需要互斥,否则会出现数据不一致的问题。
相比之下,方案2(记录售卖数量)则没有方案1的困境。增加或减少库存总数只需要调整相应的库存总数即可。当前售卖数量只有C端交易流程会修改,库存总数只有B端库存管理场景会修改。查询商品的已售卖数量只需要查库存的已售数量即可,而商品的库存余额可以通过库存总数减去已售数量得到。商品库存余额 = 库存总数 - 已售数量 ,BC端的操作不会影响这个等式的正确性。
综上所述,方案1使用库存余额更加复杂,并且没有明显的收益。而方案2记录售卖数量的方案更加简单,调整库存更加简洁优雅,而且不会出现数据一致性问题。通过记录售卖数量,很容易就可以知道当前商品的已售卖数量。
在我所看到的大部分库存系统设计中,都提到了预占库存。仿佛必须要有预占库存,这里五哥甩一句话:虚拟商品库存无需预占库存。
预占库存是实物商品库存领域的设计,用户下单完成库存预占,仓储系统发货后释放预占库存,预占库存可以监控已下单未发货库存量。由于实物商品下单完成到发货完成有一段较长的时间窗口,并且为了更好的监控未发货库存数量,设计出预占库存这样一个概念。虚拟商品领域库存不存在发货这个动作,库存直接扣减就完事,整预占库存完全是增加系统理解难度,完全脱ku zi放X 的设计。
如果引入预占库存,库存充足的计算公式更加复杂,库存交互场景也更加复杂。
库存是否充足的公式则变为:总库存 > 已售卖数量+预占数量+当前购买数量。这个方案跟更加复杂。
下单阶段需要增加预占库存,支付阶段需要扣减预付库存,订单取消还需要回补库存,订单超时未支付也需要扣减预占库存。需要4个场景交互。
且每一个环节工作内容不同:分别包括增加预占库存、扣减预占库存、扣减预占库存+扣减实际库存、回补库存。这4步,每一步的内容都不同,非常复杂。
每一个写操作都需要保证幂等性,这4个接口,就需要设计4种幂等方案。例如如何保证“ 扣减预占库存” 的幂等,如何保证 “扣减预占库存+扣减实际库存的幂等”
预占库存的数据准确性、预占库存和总库存的一致性也很难保证。
如果没有预占库存,库存充足计算公式更简单,库存交互也更加简单
下单阶段扣减库存,订单取消回补库存,订单超时未支付回补库存。只需要3个场景交互,相比预占库存方案,支付完成后,减少了 “扣减实际库存+扣减预占库存” 这一步。
更重要的是没有了预占库存这个概念,库存操作只有扣减库存和回补库存这两个动作,系统设计会更加简洁。保证这两个写接口的幂等,只需要库存扣减流水和库存回补流水就能做到。
由此可见,虚拟商品库存设计时增加预占库存,属于画蛇添足的设计,得不偿失。
下单前扣减库存,当订单取消和退款时回补库存。这个方案可以保证用户下单成功就一定能购买成功,下单阶段就占用库存的坏处是,如果大量用户虚假下单,但是不支付订单,就会有大量库存被占用,影响正常用户下单。
为了避免下单扣库存带来的问题,可以引入了一种新的方案:支付前扣减库存,当订单退款时,回补库存。这样,用户下单时不再占用库存,避免了大量库存被占用但未支付的情况。
然而,这种方案也存在一些问题。如果用户下单成功后在支付时库存不足,系统会向其提示 "库存已售罄",这对用户体验造成了极大的影响。
我曾经在京东抢茅台的过程中遇到过这个问题,我认为只要下单成功,就应该能购买到商品,但在支付时被告知库存不足,我感觉自己被耍了,我非常气愤,从此以后我就再也不参与这个平台的秒杀抢购了。(后来也不用京东了,基本只用拼多多了)。
以上两个方案各有优劣,因为方案2(支付扣库存)的用户体验太差。所以尽量避免使用方案2(支付扣库存),优先使用方案1(下单扣库存)。
对于方案1(下单扣库存)的弊端,也有解决办法。
通常情况下,业界会限制订单支付时长,要求用户在15-30分钟内完成支付,以避免订单长时间处于待支付状态。如果订单未在规定时间内支付,订单会被取消,库存也会被还原,因此不会一直占用库存的情况发生。
大量正常用户下单后不支付而导致订单取消率过高,说明系统存在问题阻碍用户支付。这种情况下,大量占用库存属于异常场景,可以忽略不计。
只有黑产用户才会故意使用大量账号占用库存,以影响售卖。然而仔细思考一下,占用库存、影响售卖对黑产并没有好处。所以实际发生的概率非常低。只需要让订单提单接口接入风控,风控接口识别并封杀黑产用户,让风控团队和黑产斗智斗勇就能解决方案1(下单扣库存)的弊端。
在系统设计时,我们必须进行正确的权衡。方案1的缺陷是:容易受到黑产用户的攻击,但黑产动力有限,概率较低。方案2的缺陷是:用户体验极差,可能导致用户流失。
两害相权取其轻,我认为用户体验更为重要,应该选择方案1(下单扣库存)。这也是和大多数公司的选择相同。
在高并发和低并发场景下,我们需要考虑不同的实现方法。
对于高并发场景,要求一秒钟能够支持一万次库存扣减操作。这种情况下,我们可以选择Redis作为库存系统的存储模型。Redis具有高性能和高并发的特点,能够满足这种高并发的秒杀场景。
相比MySQL 版库存方案,基于Redis版本的库存系统要更加的笨重和复杂,数据可靠性更低、库存功能的丰富度更低,可维护性也要更差。如果在低并发业务场景使用 Redis实现库存能力,想要实现同样的功能除牺牲数据可靠性,也牺牲了数据一致性,不光如此,Redis版库存也难以实现多商品库存扣减、分时库存扣减等场景。
接下来我们优先探讨使用MySQL实现秒杀库存的瓶颈点
在秒杀场景中,当大量用户同时抢购同一件商品时,需要同时更新商品库存。在使用InnoDB数据库时,通过行锁和死锁检测机制来确保数据并发的一致性。然而,由于大量的竞争和并发操作,行锁和死锁检测机制会导致数据库的CPU资源被短时间内占满,使得整个数据库几乎无法响应其他请求。
秒杀的性能瓶颈并非完全因为,"大量请求集中更新一条库存很慢",而是因为MySQL 开启死锁检测。当大量扣减库存请求到MySQL,MySQL会根据深度优先遍历整个图是否有环,有环说明死锁,这个环是事务和锁构建的环。由于请求量巨大,这个图是巨大的,这将导致遍历过程CPU负载极高。那么如果MySQL 关闭死锁检测,这个问题是否会不复存在呢?
MySQL 在其官方文档中,提到了高并发场景,建议可关闭死锁检测
在高并发系统上,当大量线程等待同一锁时,死锁检测可能会导致速度减慢。有时,禁用死锁检测,在发生死锁时依赖事务回滚的设置可能会更有效。可以使用该变量禁用死锁检测 innodb_deadlock_detect 。
默认情况下,innodb_deadlock_detect 死锁检测是开启的。需要注意禁用死锁检测后,当真出现死锁时,只能依赖事务超时机制结束死锁。超时时间通过 innodb_lock_wait_timeout 参数配置,默认50秒,比较长,建议调整到10s以下。
下图是 并发场景修改同一条库存记录时,开启和关闭死锁检测的性能对比。
从图中可得知,当512个客户端线程执行扣减SQL时,响应时间超过了1秒以上,系统接近于不可用。而同样并发度,关闭死锁检测,响应时间在129毫秒,系统还可用。由此可见,关闭死锁检测确实能提高秒杀场景的库存扣减性能。但是这并不是最优的思路,因为关闭死锁检测,也无法解决行锁争抢问题带来的性能下降。
除关闭死锁检测外,AliSQL 也提供了排队的思路解决 MySQL 热点记录更新问题。
AliSQL是基于MySQL官方版本的一个分支,由阿里云数据库团队维护。宣称“在通用基准测试场景下,AliSQL版本比MySQL官方版本有着 70% 的性能提升。在秒杀场景下,性能提升 100倍”。
AliSQL解决热点记录更新问题的方法是通过排队,以解决死锁检测和行锁争抢的问题。需要你在SQL中明确指定更新记录的ID,以让AliSQL知道在哪个记录上排队,同时AliSQL在这个场景禁用了死锁检测。并且由于采用了排队执行的方式,也避免了行锁的争抢问题。
我没有找到秒杀场景,AliSQL和MySQL的性能对比报告。但我了解到,美团MTSQL的性能报告。MTSQL也使用排队思路解决热点记录更新问题。MTSQL的性能评测结果显示,基于MySQL,单条记录每秒2500次库存扣减时,系统接近不可用,而使用MTSQL,单条记录每秒超过10000次库存扣减,响应时间仍然很快。
因此,使用MySQL端的排队解决方案能够显著提升高并发场景下库存扣减的性能,相较于原生MySQL至少提升了5倍以上的性能。
相比基于Redis,使用MySQL 服务端排队技术,对于业务方更加简单。Redis需要复杂的机制保证Redis和数据库的数据一致。Redis难以保证 库存扣减和库存流水的一致性,难以保证多个商品库存扣减的一致性。
使用AliSQL,可以继续使用数据库的事务机制,没有数据一致性的困扰,技术方案更加简洁和可靠。
有强大的底层存储系统,可以极大降低业务系统设计的复杂度!提高系统的健壮性!
底层越强大,上层越轻松!
如果一笔订单购买多个商品,如何保证多商品扣减一致性呢?在低并发场景,完全可使用MySQL事务保证库存操作的一致性。
update inventory set cnt = cnt + #{buyCnt} WHERE productId = #{productId1} AND cnt + #{buyCnt} <= totalCnt
update inventory set cnt = cnt + #{buyCnt} WHERE productId = #{productId2} AND cnt + #{buyCnt} <= totalCnt
例如同时操作productId1,productId2 两个商品的库存,可以使用以上两个SQL,在同一个事务中执行。 任何一个SQL更新失败,则抛出异常,回滚事务。
高并发场景如何实现一致性呢?分别聊聊使用Redis、AliMq如何保证一致性?
Redis中同时修改多个库存,可以使用Lua脚本,一次性检查多个库存是否充足,然后扣减库存。因为Redis是单线程模型,所以Lua脚本执行中,不会被打断,不会存在并发问题,所以每个Lua脚本可近似看成 同时成功、同时失败。
但是当Redis使用 集群模式时,无法使用修改多个Key的Lua脚本。
因为Redis-Cluster结构无法保证Lua脚本中多个Key的操作路由到一个节点,自然无法保证多Key操作的一致性。
所以使用Lua脚本实现多商品库存修改,必须确保Redis不得使用Cluster集群模式。
使用AliSQL 和正常使用MySQL几乎一样,所以推荐高并发场景使用AliSQl实现库存方案,不推荐使用Redis。
以上库存操作的SQL,如果重复执行会导致库存重复扣减。可以考虑在库存操作事务中,新增库存扣减流水,使用订单ID作为流水幂等键,当流水新增冲突时,则说明库存重复扣减,回滚事务即可。
SQL代码示例如下
可以考虑先增加库存流水,后修改库存。
在使用AliSQL 优化秒杀场景的库存修改时,可以设置 库存修改SQL在MySQL服务端执行完成后,立即提交事务,无需等待客户端提交事务,减少网络交互,提高性能。
ClientAliSQL开启事务 (Spring托管事务,Spring帮忙开启事务)发起新增库存流水 SQL发起库存扣减 SQL提交事务ClientAliSQL
AliSQL 支持在第三步,扣减库存时,自动提交事务,省却了一次网络开销。
同时,先扣减库存再增加流水,会导致行锁持有的时间更长,降低了库存扣减并发度。新增库存流水在前,新增流水时,还未锁定 库存行锁,其他事务可扣减库存,这样并发度更高。
(MySQL 新增记录默认是并发的 # 真丢人,工作六七年了,没搞明白MySQL插入是并发还是串行?)
回补库存也需要流水。避免回补库存操作,重复执行,出现库存不准确现象。
库存流水表的唯一键 应该包括 orderId + productId + opType。加上库存操作方向,库存扣减和回补各自对应一条流水记录。
如果多个商品的库存扣减是在同一个事务中进行的,可以考虑只记录一条库存流水。
那么,对于库存回补操作,是否也应该对应一条流水呢?库存回补操作需要对应多条流水。这是因为订单可能会进行部分退款,也就是说,部分商品会退款,而部分商品不会退款。在这种情况下,就会出现部分商品的库存回滚,而另一部分商品则不会回滚。因此,一个订单的库存回补操作可能会执行多次,相应地,库存流水也应该有多条。
刚才提到的库存流水表的唯一键orderId + productId + opType。当进行库存扣减时,由于多个商品共享同一条库存流水,可以指定 productId=0 即可。
以日库存为例,每日库存均生成一条库存记录,扣减库存时,需指定订单的提单时间,扣减库存模块,需根据提单时间,生成对应的 日库存 key。 扣减对应的库存。
存储模型如下
当扣减库存时,从商品的库存配置中获取到,该商品有哪类库存。如果包含日库存、周库存,构建库存扣减上下文,计算要扣减库存的Key。
在未来可以使用inventoryType进行扩展其他库存。例如月库存、年库存等也可以这样扩展。
值得一提的是,如果库存不限制时间,而是总库存,可设定 inventoryType = 1,inventoryKey = "Total" 表示总库存。
在电商环境中,并非只有商品具备库存,很多资源实体都有库存。
例如用户领券时,当库存不足时,则无法领券,需要设置发券的库存。
例如售卖商品时,不同的渠道共用一个库存,此时库存的维度并非商品,而是渠道。
例如某个营销活动需要控制预算,需要配置活动库存,此时的库存维度并非商品,而是活动。
所以在原有的库存模型上,需要增加维度。
targetType 表示 库存资源实体的类型
targetId 表示 库存资源实体的 ID。
在查询和扣减库存时,我们需要额外指定所需库存资源的类型。如果客户端是商品库存场景,就需要指定资源类型为商品;如果客户端是活动库存场景,就需要指定资源类型为活动。通过新增资源类型,我们可以实现多种业务场景共用一个库存系统。
通过问题2的讨论,可以得出一个结论:不需要设置预占库存。没有预占库存后,库存接口只有两个,扣减库存和回滚库存。这两个接口如何设计接口语义呢?换句话说,上游调用这两个接口何时为成功,何时为失败呢?
接下来,讨论这两个接口的返回值场景
库存扣减的返回场景和对应处理如下
扣减成功和库存不足失败的场景,上游分别认定为成功和失败处理即可,无需赘言。
值得说明的是,如果上游调用扣减库存超时,应如何处理?重新扣减库存还是直接认定失败。我认为两者都可以,但是要区分场景。
当调用扣减库存超时,如果是时间不敏感的场景,例如异步发券等,可以考虑通过重试接口,获取准确的结果。一般情况下,库存都是充足的,接口超时的时候,大多数重试扣减都是可以扣减成功的。
当调用扣减库存超时,如果库存接口上游对时间比较敏感,调用库存扣减超时,则认定为失败。
例如提单扣减库存接口对耗时极为敏感。因为提单接口调用链路非常复杂,往往需要很多次下游接口调用和数据库调用,所以提单接口的耗时较长。同时提单时间太长,对用户影响非常大。当提单阶段扣减库存超时了,就应该认定为库存扣减失败,终止提单即可。因为库存接口超时,说明提单接口耗时已经很长了,再次重试,则会雪上加霜,不如选择抛出异常,由用户发起重试提单。
调用库存扣减超时,认定为失败,还需要调用库存回滚接口,尝试回滚库存。因为接口超时时,无法确定库存是否扣减成功。 上游应该发消息,尝试异步回滚库存。
除了扣减库存超时,需要异步回滚库存。其他场景,包括订单退款,也需要扣减库存。
库存接口的语义如下
回滚库存接口应该保证,如果扣减成功,则立即回滚库存;如果扣减失败或未扣减,则回滚失败。
异步回滚库存时,如果调用接口超时,上游应该重试回滚;如果返回库存已回滚,则认定为回滚成功。
总之,异步回滚库存,应该通过重试,保证回滚接口返回 成功或重复回滚 两个返回值中的一个。