库存系统调研

目录

库存系统架构设计

库存系统的痛点

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

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

如何保证库存扣减的幂等?

先新增流水,再扣减库存?

是否需要预占库存?

如何设计库存接口的语义?

库存扣减返回值处理

对时间不敏感的场景

对时间敏感的场景

库存回滚接口返回值

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

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

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

加减法扣减 Redis 库存

更新库存时同步新增幂等记录

设置合理过期时间

长期的库存不一致问题。

定期校正 Redis和 MySQL 的一致性

同步更新法扣减

顺序消费带来的一致性问题

生产端到消费端保证顺序

版本号控制顺序消费


库存系统架构设计

  • 可用库存=现货库存-预占库存-锁定库存

  • 库存什么时候进行预占(或者扣减)呢?  提交订单时进行库存预占。
  • 重复提交订单的问题?(1、用户善意行为)app侧在用户第一次单击“提交订单”按钮后对按钮进行置灰,禁止再次提交订单(2、用户恶意行为)采用令牌机制,用户每次进入结算页,提单系统会颁发一个令牌ID(全局唯一),当用户点击“提交订单”按钮时发起的网络请求中会带上这个令牌ID,这个时候提单系统会优先进行令牌ID验证,令牌ID存在&令牌ID访问次数=1的话才会放行处理后续逻辑,否则直接返回(3、提单系统重试)这种情况则需要后端系统(比如库存系统)来保证接口的幂等性,每次调用库存系统时均带上订单号,库存系统会基于订单号增加一个分布式事务锁
  • 库存数据的回滚机制如何做?库存回滚的场景:(1、用户未支付)用户下单后后悔了(2、用户支付后取消)用户下单&支付后后悔了(3、风控取消)风控识别到异常行为,强制取消订单(4、耦合系统故障)比如提交订单时提单系统T1同时会调用积分扣减系统X1、库存扣减系统X2、优惠券系统X3,假如X1,X2成功后,调用X3失败,需要回滚用户积分与商家库存。
  • 其中场景1,2,3比较类似,都会造成订单取消,订单中心取消后会发送MQ出来,各个系统保证自己能够正确消费订单取消MQ即可。而场景4订单其实尚未生成,相对来说要复杂些,如上面提到的,提单系统T1需要主动发起库存系统X2、优惠券系统X3的回滚请求(入参必须带上订单号),X2、X3回滚接口需要支持幂等性。
  • 其实针对场景4,还存在一种极端情况,如果提单系统T1准备回滚时自身也宕机了,那么库存系统X2、优惠券系统X3就必须依靠自己为完成回滚操作了,也就是说具备自我数据健康检查的能力,具体来说怎么实现呢?可以利用当前订单号所属的订单尚未生成的特点,可以通过定时任务,每次捞取40分钟(这里的40一定要大于容忍用户的支付时间)前的订单,调用订单中心查询订单的状态,确保不是已取消的,否则进行自我数据的回滚。即定时任务库存校对
  • 多人同时购买1件商品,如何安全地库存扣减?  可以在where条件里增加了and stockNum>="+requestBuyNum即可防止超卖的行为,不用使用悲观锁串行执行,先检查再更新,直接可以在SQL语句中判断。50%的流量将直接告诉其抢购失败。同一个用户,不允许多次抢购同一件商品。

参考:京东到家库存系统架构设计

库存系统的痛点

电商库存业务的查询量往往远大于写入数量,例如写入TPS在 500 的业务,QPS 查询数量级往往可以达到 3000,例如访问量极高的商品详情页面会展示商品库存,这将带来极高的查询并发,也给数据库带来极大的压力,将商品的库存数据从 MySQL异构到 Redis 中,是解决库存查询高并发的必然方案。这就带来一个问题,数据一致性问题,以及加减法扣减还是同步更新法

虚拟电商领域中库存系统在高并发场景下的架构挑战及相关问题,包括秒杀场景下库存系统的难点与优化,如MySQL的死锁检测、AliSQL的排队思路;多商品库存扣减的一致性保证;库存扣减的幂等性;是否需要预占库存;库存接口的语义设计;记录库存余额还是售卖数量;下单扣减库存还是支付扣减库存;分时库存的实现;以及除商品库存外的其他库存类型等9个关键问题。

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

MySQL实现秒杀库存的瓶颈点:

在秒杀场景中,当大量用户同时抢购同一件商品时,需要同时更新商品库存。在使用InnoDB数据库时,通过行锁和死锁检测机制来确保数据并发的一致性。然而,由于大量的竞争和并发操作,行锁和死锁检测机制会导致数据库的CPU资源被短时间内占满,使得整个数据库几乎无法响应其他请求。可以考虑关闭死锁检测;阿里的AliSQL 也提供了排队的思路解决 MySQL 热点记录更新问题。(需要你在SQL中明确指定更新记录的ID,以让AliSQL知道在哪个记录上排队)

AliSQL是基于MySQL官方版本的一个分支,由阿里云数据库团队维护。宣称“在通用基准测试场景下,AliSQL版本比MySQL官方版本有着 70% 的性能提升。在秒杀场景下,性能提升 100倍”。

美团的MTSQL也使用排队思路解决热点记录更新问题。

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

低并发场景:完全可使用MySQL事务保证库存操作的一致性。

高并发场景:

Redis单机模式,可以利用Lua脚本实现多个商品库存扣减的事务性;Redis Cluster集群模式,无法保证Lua脚本中多个Key的操作路由到一个节点,自然无法保证多Key操作的一致性。所以使用Lua脚本实现多商品库存修改,必须确保Redis不得使用Cluster集群模式。

使用 MTSQL、AliSQL 时,将多个商品的库存操作放到一个事务中,可保证同时成功和失败。方案更加简单,所以推荐高并发场景使用MTSQL、AliSQl实现库存方案,不推荐使用Redis。

如何保证库存扣减的幂等?

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

先新增流水,再扣减库存?

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

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

是否需要预占库存?

虚拟商品库存无需预占库存。实物商品库存领域需要预占库存。

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

如何设计库存接口的语义?

不需要设置预占库存。没有预占库存后,库存接口只有两个,扣减库存和回滚库存。这两个接口如何设计接口语义呢?换句话说,上游调用这两个接口何时为成功,何时为失败呢?

库存扣减返回值处理

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

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

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

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

对时间不敏感的场景

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

对时间敏感的场景

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

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

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

库存回滚接口返回值

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

库存接口的语义如下

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

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

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

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

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

推荐记录售卖数量。

使用库存余额更加复杂,并且没有明显的收益。

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

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

最好使用下单扣减库存,确保用户体验,30分钟后未支付的订单自动取消,释放库存。

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

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

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

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

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

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

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

参考:https://juejin.cn/post/7313776114912182313

加减法扣减 Redis 库存

使用加减法扣减 Redis 库存的时机在 MySQL 库存被更新后。一般情况下各个公司都有 MySQL binlog 的订阅消费能力,所以通过监听库存表的binlog 变更即可。当出现一次更新时,获取当前扣减的数量,则扣减相应的 Redis 库存。

当 Redis 扣减出现超时,超时重试的时候如何保证幂等性呢?应该在 incrBy 时,同时新增一个幂等记录,例如订单 id。

更新库存时同步新增幂等记录

这个过程共有两次 Redis 操作。也可以使用更复杂的 Lua脚本方案,将以上操作统一放在 lua 脚本中执行。

但是要注意一个问题: pipeline 和 lua 脚本都只能使用单机版的 redis,不能使用 Redis 集群模式。因为 Redis 集群模式将全量数据 hash 到多个子节点, pipeline 和 lua 中操作的两个 key 无法保证在同一个节点上,也就无法保证操作的原子性。

设置合理过期时间

幂等记录设置合理的超时时间,比如1个小时。

长期的库存不一致问题。

使用加减法扣减库存,无法保证长期上完全一致。虽然引入幂等记录解决了超时重试带来的一致性问题,但是系统运行的长期时间里,难以保证两者是完全一致的。在某些异常场景,例如binlog 消息存在丢失,重复消费(间隔很短时)导致的重复扣减,系统宕机等等预料之外的问题,都有可能导致两者的数据不一致。 使用加减法扣减的方式,无法保证系统长期能一致,如果要做到这一点,就需要额外的校正极值,定期校正两者是否一致。

定期校正 Redis和 MySQL 的一致性

简单的方案是在系统的业务低峰期执行一个定时任务,对全量库存进行扫描和处理。例如,通过查询数据库和Redis中的库存A是否一致来判断Redis库存是否准确,若不一致,则强制更新Redis库存。

同步更新法扣减

同步扣减法即每次库存更新都 使用 set 命令覆盖 Redis 库存,不管是扣减库存还是回滚库存,统统使用 set 命令强制更新。

这种方式有一个好处,不用担心幂等问题。因为 Redis set 超时后,可以直接重试。不同于 incrBy 操作,执行多次会有不同的结果,set 命令不需要担心重试和幂等问题。

但是 同步更新法也有一致性问题,因为在库存扣减并发度非常高的时候,很难保证扣减的顺序性。

顺序消费带来的一致性问题

举个例子库存 A 的当前库存是 5,经过五次扣减后,分别变为 4,3,2,1,0。 很难保证这五条 binlog是顺序投递到 Kafka,也无法保证 Kafka 能顺序消费 5 个消息。如果没有顺序消费,5 次扣减完成后,Redis 最终的库存数据可能不是 0,而是其他 4 个值。

库存实际上已经没有了,Redis 库存还是非零值,用户能看到有库存,但是无法购买成功,这种用户体验很差。

所以要解决顺序扣减库存的难题。

生产端到消费端保证顺序

如果可以保证库存更新的 binlog 从发送到 Kafka 到消费能完全顺序,就可以保证Redis 库存更新也是有序的。可以通过创建一个分片,保证 Kafka 消费时顺序消费。然而如何保证 binlog 到 Kafka 是有序的呢? 这比较难,我们无法苛求 binlog 消费中间件(例如canal)顺序投递到 Kafka。

要想完全的顺序生产和消费是非常困难的,所以一般情况下我们采用方案 2,即版本号机制

版本号控制顺序消费

一般情况下 mysql 库存更新的 SQL 会同步新增版本号,例如下面这个 SQL。

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

这样每条 binlog 都带有对应的版本号,当更新库存时,先尝试检查当前版本号是否落后于 Redis 版本号,如果落后,那么说明无需更新,如果超前,则尝试更新 Redis 库存值和版本号。

这个过程是先检查再更新,需要保证一致性,可以使用 Lua 脚本。版本号和库存值需要存在 Redis 中,可以使用 redis hash 结构存储两个子 key。

如果存在,则校验 redis 版本号和当前传参的版本号,如果超过 redis 版本号,则立即更新库存。如果落后于 redis 库存,则不更新,返回-1 非法值。

方案加减法扣减同步更新法
缺点需要保证重试和幂等、无法保证长期一致难以保证同时修改版本号和库存值的原子性
补充方案新增幂等记录保证幂等 2、定时任务保证长期一致使用 lua 脚本保证同时成功和失败
  • 在库存场景,查询的数量级要远高于写入的数量级。
  • 库存查询能力基于 Redis 实现可以提供更强的查询性能,速度更快,扛并发能力更强
  • 将MySQL数据库 库存同步到 Redis 中,可以使用同步更新法,即每当库存更新后,即将新库存值和版本号更新到 Redis 中,使用版本号和lua脚本控制顺序,保证Redis库存值是最新的,而不是历史值。

参考:https://juejin.cn/post/7336779196600107062?share_token=600992af-e524-405b-a8e7-9ba70645ff13

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值