高并发场景下,如何设计订单库存架构,一共9个关键性问题

库存系统可以分为实物库存和虚拟库存两种类型。实物库存的管理涉及到采购、存储、销售和库存轮换等复杂的活动,需要进行供应链管理和仓库管理等工作。相比之下,虚拟库存的管理相对简单,主要适用于线上资源的数量管理,包括各类虚拟商品权益,例如线上课程、付费优惠券包和活动库存等。五阳哥长期从事虚拟电商领域,在今天的分享中将主要介绍虚拟库存的管理。

假设产品需求中要求设置商品库存,限制售卖数量。我们应该如何设计技术方案?有哪些设计重点?

一共9个关键问题,全文目录如下

1. 记录库存余额还是记录售卖数量?

方案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记录售卖数量的方案更加简单,调整库存更加简洁优雅,而且不会出现数据一致性问题。通过记录售卖数量,很容易就可以知道当前商品的已售卖数量。

2. 是否需要预占库存?

在我所看到的大部分库存系统设计中,都提到了预占库存。仿佛必须要有预占库存,这里五哥甩一句话:虚拟商品库存无需预占库存。

预占库存是实物商品库存领域的设计,用户下单完成库存预占,仓储系统发货后释放预占库存,预占库存可以监控已下单未发货库存量。由于实物商品下单完成到发货完成有一段较长的时间窗口,并且为了更好的监控未发货库存数量,设计出预占库存这样一个概念。虚拟商品领域库存不存在发货这个动作,库存直接扣减就完事,整预占库存完全是增加系统理解难度,完全脱ku zi放X 的设计。

假如有预占库存

如果引入预占库存,库存充足的计算公式更加复杂,库存交互场景也更加复杂。

库存是否充足的公式则变为:总库存 > 已售卖数量+预占数量+当前购买数量。这个方案跟更加复杂。

下单阶段需要增加预占库存,支付阶段需要扣减预付库存,订单取消还需要回补库存,订单超时未支付也需要扣减预占库存。需要4个场景交互。

且每一个环节工作内容不同:分别包括增加预占库存、扣减预占库存、扣减预占库存+扣减实际库存、回补库存。这4步,每一步的内容都不同,非常复杂。

每一个写操作都需要保证幂等性,这4个接口,就需要设计4种幂等方案。例如如何保证" 扣减预占库存" 的幂等,如何保证 "扣减预占库存+扣减实际库存的幂等"

预占库存的数据准确性、预占库存和总库存的一致性也很难保证。

引入预占库存概念,大大增加了系统的复杂度。

假如没有预占库存

如果没有预占库存,库存充足计算公式更简单,库存交互也更加简单

下单阶段扣减库存,订单取消回补库存,订单超时未支付回补库存。只需要3个场景交互,相比预占库存方案,支付完成后,减少了 "扣减实际库存+扣减预占库存" 这一步。

更重要的是没有了预占库存这个概念,库存操作只有扣减库存和回补库存这两个动作,系统设计会更加简洁。保证这两个写接口的幂等,只需要库存扣减流水和库存回补流水就能做到。

由此可见,虚拟商品库存设计时增加预占库存,属于画蛇添足的设计,得不偿失。

3. 下单扣减库存还是支付扣减库存?

下单扣库存

下单前扣减库存,当订单取消和退款时回补库存。这个方案可以保证用户下单成功就一定能购买成功,下单阶段就占用库存的坏处是,如果大量用户虚假下单,但是不支付订单,就会有大量库存被占用,影响正常用户下单。

支付前扣库存

为了避免下单扣库存带来的问题,可以引入了一种新的方案:支付前扣减库存,当订单退款时,回补库存。这样,用户下单时不再占用库存,避免了大量库存被占用但未支付的情况。

然而,这种方案也存在一些问题。如果用户下单成功后在支付时库存不足,系统会向其提示 "库存已售罄",这对用户体验造成了极大的影响。

我曾经在京东抢茅台的过程中遇到过这个问题,我认为只要下单成功,就应该能购买到商品,但在支付时被告知库存不足,我感觉自己被耍了,我非常气愤,从此以后我就再也不参与这个平台的秒杀抢购了。(后来也不用京东了,基本只用拼多多了)。

最好使用下单扣减库存

以上两个方案各有优劣,因为方案2(支付扣库存)的用户体验太差。所以尽量避免使用方案2(支付扣库存),优先使用方案1(下单扣库存)。

对于方案1(下单扣库存)的弊端,也有解决办法。

通常情况下,业界会限制订单支付时长,要求用户在15-30分钟内完成支付,以避免订单长时间处于待支付状态。如果订单未在规定时间内支付,订单会被取消,库存也会被还原,因此不会一直占用库存的情况发生。

大量正常用户下单后不支付而导致订单取消率过高,说明系统存在问题阻碍用户支付。这种情况下,大量占用库存属于异常场景,可以忽略不计。

只有黑产用户才会故意使用大量账号占用库存,以影响售卖。然而仔细思考一下,占用库存、影响售卖对黑产并没有好处。所以实际发生的概率非常低。只需要让订单提单接口接入风控,风控接口识别并封杀黑产用户,让风控团队和黑产斗智斗勇就能解决方案1(下单扣库存)的弊端。

在系统设计时,我们必须进行正确的权衡。方案1的缺陷是:容易受到黑产用户的攻击,但黑产动力有限,概率较低。方案2的缺陷是:用户体验极差,可能导致用户流失。

两害相权取其轻,我认为用户体验更为重要,应该选择方案1(下单扣库存)。这也是和大多数公司的选择相同。

4. 秒杀等高并发场景的库存系统难点

在高并发和低并发场景下,我们需要考虑不同的实现方法。

对于高并发场景,要求一秒钟能够支持一万次库存扣减操作。这种情况下,我们可以选择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官方版本的一个分支 ,由阿里云数据库团队维护。宣称"在通用基准测试场景下,AliSQL版本比MySQL官方版本有着 70% 的性能提升。在秒杀场景下,性能提升 100倍"。

AliSQL解决热点记录更新问题的方法是通过排队,以解决死锁检测和行锁争抢的问题。需要你在SQL中明确指定更新记录的ID,以让AliSQL知道在哪个记录上排队,同时AliSQL在这个场景禁用了死锁检测。并且由于采用了排队执行的方式,也避免了行锁的争抢问题。

我没有找到秒杀场景,AliSQL和MySQL的性能对比报告。但我了解到,美团MTSQL的性能报告。MTSQL也使用排队思路解决热点记录更新问题。MTSQL的性能评测结果显示,基于MySQL,单条记录每秒2500次库存扣减时,系统接近不可用,而使用MTSQL,单条记录每秒超过10000次库存扣减,响应时间仍然很快。

因此,使用MySQL端的排队解决方案能够显著提升高并发场景下库存扣减的性能,相较于原生MySQL至少提升了5倍以上的性能。

使用AliSQL、MTSQL的好处

相比基于Redis,使用MySQL 服务端排队技术,对于业务方更加简单。Redis需要复杂的机制保证Redis和数据库的数据一致。Redis难以保证 库存扣减和库存流水的一致性,难以保证多个商品库存扣减的一致性。

使用AliSQL,可以继续使用数据库的事务机制,没有数据一致性的困扰,技术方案更加简洁和可靠。

有强大的底层存储系统,可以极大降低业务系统设计的复杂度!提高系统的健壮性!

底层越强大,上层越轻松!

5. 多商品库存扣减如何保证一致性?

如果一笔订单购买多个商品,如何保证多商品扣减一致性呢?在低并发场景,完全可使用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 使用Redis Cluster?

Redis中同时修改多个库存,可以使用Lua脚本,一次性检查多个库存是否充足,然后扣减库存。因为Redis是单线程模型,所以Lua脚本执行中,不会被打断,不会存在并发问题,所以每个Lua脚本可近似看成 同时成功、同时失败。

但是当Redis使用 集群模式时,无法使用修改多个Key的Lua脚本。

因为Redis-Cluster结构无法保证Lua脚本中多个Key的操作路由到一个节点,自然无法保证多Key操作的一致性。

所以使用Lua脚本实现多商品库存修改,必须确保Redis不得使用Cluster集群模式。

如果使用 AliSQL

使用AliSQL 和正常使用MySQL几乎一样,所以推荐高并发场景使用AliSQl实现库存方案,不推荐使用Redis。

6. 如何保证库存扣减的幂等

以上库存操作的SQL,如果重复执行会导致库存重复扣减。可以考虑在库存操作事务中,新增库存扣减流水,使用订单ID作为流水幂等键,当流水新增冲突时,则说明库存重复扣减,回滚事务即可。

SQL代码示例如下

update inventory set cnt = cnt + #{buyCnt} WHERE productId = #{productId1} AND cnt + #{buyCnt} <= totalCnt

insert into inventory_op_records(......) values(......)

先扣减库存,还是先新增流水

可以考虑先增加库存流水,后修改库存。

在使用AliSQL 优化秒杀场景的库存修改时,可以设置 库存修改SQL在MySQL服务端执行完成后,立即提交事务,无需等待客户端提交事务,减少网络交互,提高性能。

sequenceDiagram Client -> AliSQL: 开启事务 (Spring托管事务,Spring帮忙开启事务) Client -> AliSQL: 发起新增库存流水 SQL Client-> AliSQL: 发起库存扣减 SQL Client -> AliSQL: 提交事务

AliSQL 支持在第三步,扣减库存时,自动提交事务,省却了一次网络开销。

同时,先扣减库存再增加流水,会导致行锁持有的时间更长,降低了库存扣减并发度。新增库存流水在前,新增流水时,还未锁定 库存行锁,其他事务可扣减库存,这样并发度更高。

(MySQL 新增记录默认是并发的 # 真丢人,工作六七年了,没搞明白MySQL插入是并发还是串行?

回补库存也需要流水吗?

回补库存也需要流水。避免回补库存操作,重复执行,出现库存不准确现象。

库存流水表的唯一键 应该包括 orderId + productId + opType。加上库存操作方向,库存扣减和回补各自对应一条流水记录。

多个商品扣减库存,需要多条流水吗?

如果多个商品的库存扣减是在同一个事务中进行的,可以考虑只记录一条库存流水。

那么,对于库存回补操作,是否也应该对应一条流水呢?库存回补操作需要对应多条流水。这是因为订单可能会进行部分退款,也就是说,部分商品会退款,而部分商品不会退款。在这种情况下,就会出现部分商品的库存回滚,而另一部分商品则不会回滚。因此,一个订单的库存回补操作可能会执行多次,相应地,库存流水也应该有多条。

刚才提到的库存流水表的唯一键orderId + productId + opType。当进行库存扣减时,由于多个商品共享同一条库存流水,可以指定 productId=0 即可。

7. 日库存、周库存等分时库存如何实现?

以日库存为例,每日库存均生成一条库存记录,扣减库存时,需指定订单的提单时间,扣减库存模块,需根据提单时间,生成对应的 日库存 key。 扣减对应的库存。

存储模型如下

classDiagram class Inventory { +long productId, +String inventoryKey, +int inventoryType }

例如日库存会生成如下

ini 复制代码
productId = 12345;
inventoryKey = "20231106"
inventoryType  = 2

其中inventoryType = 2 代表是日库存,使用日期作为InventoryKey名称

周库存的key,使用今年的第N周来表示。

ini 复制代码
productId = 12345;
inventoryKey = "202331"
inventoryType  = 3

当扣减库存时,从商品的库存配置中获取到,该商品有哪类库存。如果包含日库存、周库存,构建库存扣减上下文,计算要扣减库存的Key。

在未来可以使用inventoryType进行扩展其他库存。例如月库存、年库存等也可以这样扩展。

值得一提的是,如果库存不限制时间,而是总库存,可设定 inventoryType = 1,inventoryKey = "Total" 表示总库存。

8. 除商品库存外,还有其他库存吗?

在电商环境中,并非只有商品具备库存,很多资源实体都有库存。

例如用户领券时,当库存不足时,则无法领券,需要设置发券的库存。

例如售卖商品时,不同的渠道共用一个库存,此时库存的维度并非商品,而是渠道。

例如某个营销活动需要控制预算,需要配置活动库存,此时的库存维度并非商品,而是活动。

所以在原有的库存模型上,需要增加维度。

classDiagram class Inventory { +int targetType, +long targetId, +String inventoryKey, +int inventoryType }
  1. targetType 表示 库存资源实体的类型

  2. targetId 表示 库存资源实体的 ID。

在查询和扣减库存时,我们需要额外指定所需库存资源的类型。如果客户端是商品库存场景,就需要指定资源类型为商品;如果客户端是活动库存场景,就需要指定资源类型为活动。通过新增资源类型,我们可以实现多种业务场景共用一个库存系统。

9. 如何设计库存接口的语义

通过问题2的讨论,可以得出一个结论:不需要设置预占库存。没有预占库存后,库存接口只有两个,扣减库存和回滚库存。这两个接口如何设计接口语义呢?换句话说,上游调用这两个接口何时为成功,何时为失败呢?

库存接口的各种场景

接下来,讨论这两个接口的返回值场景

库存扣减返回值处理

库存扣减的返回场景和对应处理如下

库存扣减的返回场景 上游处理办法
库存扣减成功 上游认定:扣减成功
库存不足,扣减失败 上游认定:扣减失败
库存已扣减,无需重复扣减 上游认定:扣减成功
上游调用超时 上游认定:扣减失败
其他异常 上游认定扣减失败

扣减成功和库存不足失败的场景,上游分别认定为成功和失败处理即可,无需赘言。

值得说明的是,如果上游调用扣减库存超时,应如何处理?重新扣减库存还是直接认定失败。我认为两者都可以,但是要区分场景。

对时间不敏感的场景

当调用扣减库存超时,如果是时间不敏感的场景,例如异步发券等,可以考虑通过重试接口,获取准确的结果。一般情况下,库存都是充足的,接口超时的时候,大多数重试扣减都是可以扣减成功的。

对时间敏感的场景

当调用扣减库存超时,如果库存接口上游对时间比较敏感,调用库存扣减超时,则认定为失败。

例如提单扣减库存接口对耗时极为敏感。因为提单接口调用链路非常复杂,往往需要很多次下游接口调用和数据库调用,所以提单接口的耗时较长。同时提单时间太长,对用户影响非常大。当提单阶段扣减库存超时了,就应该认定为库存扣减失败,终止提单即可。因为库存接口超时,说明提单接口耗时已经很长了,再次重试,则会雪上加霜,不如选择抛出异常,由用户发起重试提单。

调用库存扣减超时,认定为失败,还需要调用库存回滚接口,尝试回滚库存。因为接口超时时,无法确定库存是否扣减成功。 上游应该发消息,尝试异步回滚库存。

库存回滚接口返回值

除了扣减库存超时,需要异步回滚库存。其他场景,包括订单退款,也需要扣减库存。

库存接口的语义如下

库存回滚的返回场景 上游处理
回滚成功 认定为成功
已回滚,无需重试回滚 重试请求,认定成功
已回滚,无需重试回滚 重试请求,认定成功
回滚失败 重试回滚接口
上游收到超时 重试回滚接口

回滚库存接口应该保证,如果扣减成功,则立即回滚库存;如果扣减失败或未扣减,则回滚失败。

异步回滚库存时,如果调用接口超时,上游应该重试回滚;如果返回库存已回滚,则认定为回滚成功。

总之,异步回滚库存,应该通过重试,保证回滚接口返回 成功或重复回滚 两个返回值中的一个。

相关推荐
用户6120414922135 分钟前
Springboot+Vue3做的图书借阅管理系统(原创)
java·vue.js·后端
訾博ZiBo36 分钟前
VibeCoding 时代来临:如何打造让 AI 秒懂、秒改、秒验证的“AI 友好型”技术栈?
前端·后端
Victor3562 小时前
Redis(25)Redis的RDB持久化的优点和缺点是什么?
后端
Victor3562 小时前
Redis(24)如何配置Redis的持久化?
后端
ningqw9 小时前
SpringBoot 常用跨域处理方案
java·后端·springboot
你的人类朋友9 小时前
vi编辑器命令常用操作整理(持续更新)
后端
胡gh10 小时前
简单又复杂,难道只能说一个有箭头一个没箭头?这种问题该怎么回答?
javascript·后端·面试
一只叫煤球的猫10 小时前
看到同事设计的表结构我人麻了!聊聊怎么更好去设计数据库表
后端·mysql·面试
uzong10 小时前
技术人如何对客做好沟通(上篇)
后端
颜如玉11 小时前
Redis scan高位进位加法机制浅析
redis·后端·开源