06 | 秒杀系统“减库存”设计的核心逻辑

如果要设计一套秒杀系统,那我想你的老板肯定会先对你说:千万不要超卖,这是大前提。

如果你第一次接触秒杀,那你可能还不太理解,库存100件就卖100件,在数据库里减到0就好了啊,这有什么麻烦的?是的,理论上是这样,但是具体到业务场景中,“减库存”就不是这么简单了。

例如,我们平常购物都是这样,看到喜欢的商品然后下单,但并不是每个下单请求你都最后付款了。你说系统是用户下单了就算这个商品卖出去了,还是等到用户真正付款了才算卖出了呢?这的确是个问题!

我们可以先根据减库存是发生在下单阶段还是付款阶段,把减库存做一下划分。

减库存有哪几种方式

在正常的电商平台购物场景中,用户的实际购买过程一般分为两步:下单和付款。你想买一台iPhone手机,在商品页面点了“立即购买”按钮,核对信息之后点击“提交订单”,这一步称为下单操作。下单之后,你只有真正完成付款操作才能算真正购买,也就是俗话说的“落袋为安”。

那如果你是架构师,你会在哪个环节完成减库存的操作呢?总结来说,减库存操作一般有如下几个方式:

  • 下单减库存,即当买家下单后,在商品的总库存中减去买家购买数量。下单减库存是最简单的减库存方式,也是控制最精确的一种,下单时直接通过数据库的事务机制控制商品库存,这样一定不会出现超卖的情况。但是你要知道,有些人下完单可能并不会付款。
  • 付款减库存,即买家下单后,并不立即减库存,而是等到有用户付款后才真正减库存,否则库存一直保留给其他买家。但因为付款时才减库存,如果并发比较高,有可能出现买家下单后付不了款的情况,因为可能商品已经被其他人买走了。
  • 预扣库存,这种方式相对复杂一些,买家下单后,库存为其保留一定的时间(如10分钟),超过这个时间,库存将会自动释放,释放后其他买家就可以继续购买。在买家付款前,系统会校验该订单的库存是否还有保留:如果没有保留,则再次尝试预扣;如果库存不足(也就是预扣失败)则不允许继续付款;如果预扣成功,则完成付款并实际地减去库存。

以上这几种减库存的方式都会存在一些问题,下面我们一起来看下。

减库存可能存在的问题

由于购物过程中存在两步或者多步的操作,因此在不同的操作步骤中减库存,就会存在一些可能被恶意买家利用的漏洞,例如发生恶意下单的情况。

假如我们采用“下单减库存”的方式,即用户下单后就减去库存,正常情况下,买家下单后付款的概率会很高,所以不会有太大问题。但是有一种场景例外,就是当卖家参加某个活动时,此时活动的有效时间是商品的黄金售卖时间,如果有竞争对手通过恶意下单的方式将该卖家的商品全部下单,让这款商品的库存减为零,那么这款商品就不能正常售卖了。要知道,这些恶意下单的人是不会真正付款的,这正是“下单减库存”方式的不足之处。

既然“下单减库存”可能导致恶意下单,从而影响卖家的商品销售,那么有没有办法解决呢?你可能会想,采用“付款减库存”的方式是不是就可以了?的确可以。但是,“付款减库存”又会导致另外一个问题:库存超卖。

假如有100件商品,就可能出现300人下单成功的情况,因为下单时不会减库存,所以也就可能出现下单成功数远远超过真正库存数的情况,这尤其会发生在做活动的热门商品上。这样一来,就会导致很多买家下单成功但是付不了款,买家的购物体验自然比较差。

可以看到,不管是“下单减库存”还是“付款减库存”,都会导致商品库存不能完全和实际售卖情况对应起来的情况,看来要把商品准确地卖出去还真是不容易啊!

那么,既然“下单减库存”和“付款减库存”都有缺点,我们能否把两者相结合,将两次操作进行前后关联起来,下单时先预扣,在规定时间内不付款再释放库存,即采用“预扣库存”这种方式呢?

这种方案确实可以在一定程度上缓解上面的问题。但是否就彻底解决了呢?其实没有!针对恶意下单这种情况,虽然把有效的付款时间设置为10分钟,但是恶意买家完全可以在10分钟后再次下单,或者采用一次下单很多件的方式把库存减完。针对这种情况,解决办法还是要结合安全和反作弊的措施来制止。

例如,给经常下单不付款的买家进行识别打标(可以在被打标的买家下单时不减库存)、给某些类目设置最大购买件数(例如,参加活动的商品一人最多只能买3件),以及对重复下单不付款的操作进行次数限制等。

针对“库存超卖”这种情况,在10分钟时间内下单的数量仍然有可能超过库存数量,遇到这种情况我们只能区别对待:对普通的商品下单数量超过库存数量的情况,可以通过补货来解决;但是有些卖家完全不允许库存为负数的情况,那只能在买家付款时提示库存不足。

大型秒杀中如何减库存?

目前来看,业务系统中最常见的就是预扣库存方案,像你在买机票、买电影票时,下单后一般都有个“有效付款时间”,超过这个时间订单自动释放,这都是典型的预扣库存方案。而具体到秒杀这个场景,应该采用哪种方案比较好呢?

由于参加秒杀的商品,一般都是“抢到就是赚到”,所以成功下单后却不付款的情况比较少,再加上卖家对秒杀商品的库存有严格限制,所以秒杀商品采用“下单减库存”更加合理。另外,理论上由于“下单减库存”比“预扣库存”以及涉及第三方支付的“付款减库存”在逻辑上更为简单,所以性能上更占优势。

“下单减库存”在数据一致性上,主要就是保证大并发请求时库存数据不能为负数,也就是要保证数据库中的库存字段值不能为负数,一般我们有多种解决方案:一种是在应用程序中通过事务来判断,即保证减后库存不能为负数,否则就回滚;另一种办法是直接设置数据库的字段数据为无符号整数,这样减后库存字段值小于零时会直接执行SQL语句来报错;再有一种就是使用CASE WHEN判断语句,例如这样的SQL语句:

UPDATE item SET inventory = CASE WHEN inventory >= xxx THEN inventory-xxx ELSE inventory END

秒杀减库存的极致优化

在交易环节中,“库存”是个关键数据,也是个热点数据,因为交易的各个环节中都可能涉及对库存的查询。但是,我在前面介绍分层过滤时提到过,秒杀中并不需要对库存有精确的一致性读,把库存数据放到缓存(Cache)中,可以大大提升读性能。

解决大并发读问题,可以采用LocalCache(即在秒杀系统的单机上缓存商品相关的数据)和对数据进行分层过滤的方式,但是像减库存这种大并发写无论如何还是避免不了,这也是秒杀场景下最为核心的一个技术难题。

因此,这里我想专门来说一下秒杀场景下减库存的极致优化思路,包括如何在缓存中减库存以及如何在数据库中减库存

秒杀商品和普通商品的减库存还是有些差异的,例如商品数量比较少,交易时间段也比较短,因此这里有一个大胆的假设,即能否把秒杀商品减库存直接放到缓存系统中实现,也就是直接在缓存中减库存或者在一个带有持久化功能的缓存系统(如Redis)中完成呢?

如果你的秒杀商品的减库存逻辑非常单一,比如没有复杂的SKU库存和总库存这种联动关系的话,我觉得完全可以。但是如果有比较复杂的减库存逻辑,或者需要使用事务,你还是必须在数据库中完成减库存。

由于MySQL存储数据的特点,同一数据在数据库里肯定是一行存储(MySQL),因此会有大量线程来竞争InnoDB行锁,而并发度越高时等待线程会越多,TPS(Transaction Per Second,即每秒处理的消息数)会下降,响应时间(RT)会上升,数据库的吞吐量就会严重受影响。

这就可能引发一个问题,就是单个热点商品会影响整个数据库的性能, 导致0.01%的商品影响99.99%的商品的售卖,这是我们不愿意看到的情况。一个解决思路是遵循前面介绍的原则进行隔离,把热点商品放到单独的热点库中。但是这无疑会带来维护上的麻烦,比如要做热点数据的动态迁移以及单独的数据库等。

而分离热点商品到单独的数据库还是没有解决并发锁的问题,我们应该怎么办呢?要解决并发锁的问题,有两种办法:

  • 应用层做排队。按照商品维度设置队列顺序执行,这样能减少同一台机器对数据库同一行记录进行操作的并发度,同时也能控制单个商品占用数据库连接的数量,防止热点商品占用太多的数据库连接。
  • 数据库层做排队。应用层只能做到单机的排队,但是应用机器数本身很多,这种排队方式控制并发的能力仍然有限,所以如果能在数据库层做全局排队是最理想的。阿里的数据库团队开发了针对这种MySQL的InnoDB层上的补丁程序(patch),可以在数据库层上对单行记录做到并发排队。

你可能有疑问了,排队和锁竞争不都是要等待吗,有啥区别?

如果熟悉MySQL的话,你会知道InnoDB内部的死锁检测,以及MySQL Server和InnoDB的切换会比较消耗性能,淘宝的MySQL核心团队还做了很多其他方面的优化,如COMMIT_ON_SUCCESS和ROLLBACK_ON_FAIL的补丁程序,配合在SQL里面加提示(hint),在事务里不需要等待应用层提交(COMMIT),而在数据执行完最后一条SQL后,直接根据TARGET_AFFECT_ROW的结果进行提交或回滚,可以减少网络等待时间(平均约0.7ms)。据我所知,目前阿里MySQL团队已经将包含这些补丁程序的MySQL开源。

另外,数据更新问题除了前面介绍的热点隔离和排队处理之外,还有些场景(如对商品的lastmodifytime字段的)更新会非常频繁,在某些场景下这些多条SQL是可以合并的,一定时间内只要执行最后一条SQL就行了,以便减少对数据库的更新操作。

总结一下

今天,我围绕商品减库存的场景,介绍了减库存的三种实现方案,以及分别存在的问题和可能的缓解办法。最后,我又聚焦秒杀这个场景说了如何实现减库存,以及在这个场景下做到极致优化的一些思路。

当然减库存还有很多细节问题,例如预扣的库存超时后如何进行库存回补,再比如目前都是第三方支付,如何在付款时保证减库存和成功付款时的状态一致性,这些都是很大的挑战。

如果你也有实现减库存的经验或者问题,欢迎留言与我分享。

精选留言

  • Null

    2019-06-28 18:27:58

    兄台,我看过你的书,感觉你的这个主题和你的书一样,都写的相当的泛,尤其是减库存这个地方,更泛,看了之后依然没办法对一些稍微细节的实现处理作出判断,能麻烦写的稍微细点吗?
  • 周龙亭

    2018-10-07 11:21:14

    下单和扣库存两个操作的事务性是怎么做的?
    作者回复

    可以分两步来做,先创建订单但是先不生效,然后减库存,如果减库存成功后再生效订单,否则订单不生效

    2018-10-09 20:16:15

  • 刘小刘

    2018-12-21 15:33:04

    老师,我觉得你讲的不太明白,你并没有说实际情况下同步是怎样解决并发的,没看到您给的方案,只看到您在评论回复里否定了队列异步处理的方式
    作者回复

    解决的办法就是尽可能避免产生锁,比如根据商品ID进行分库分表设计;再有就是减少锁的粒度例如阿里对MySQL做了定制优化,可以提升MySQL的并发度

    2018-12-26 20:15:24

  • 公号-技术夜未眠

    2018-10-06 19:39:31

    许老师好,我有一个想法,只是没有在实践中这样做过,请指教:
    能否借用"数据库水平拆分"的思想?
    具体思路如下:
    库存在数据库的表中就只有一行数据,上面的方案都是对这一条记录进行频繁更新,是非常"热"的热点数据。我们能否将该行数据拆分到不同的数据库中,这些数据库的库存记录之和就是原始库存数量。这样能否会降低数据库的写压力,提高吞吐量?
    作者回复

    实际上,商品都是进行分库分表的,例如根据商品id进行水平拆分

    分库分表就是提高吞吐量

    2018-10-07 17:17:41

  • Geek_c19c96

    2018-10-16 22:25:47

    我们的库存都放在redis里面,读和减库存都在redis里面操作,redis会定时将库存放到mysql中做备份,
    作者回复

    😉

    2018-10-21 12:40:49

  • 我是李香兰小朋友

    2018-10-13 09:43:41

    “按照商品维度设置队列顺序执行”这句话是什么意思?可以举例说明一下吗?谢谢老师
    作者回复

    “按照商品维度设置队列顺序执行”的意思就是,为了防止同一个商品对数据库的操作占用太多的数据库资源,所以采用队列的方式,让其他商品也有公平的机会得到数据的响应,例如如果秒杀的时候,秒杀商品肯定占用大量的请求,数据库的连接池有可能都被秒杀商品占用了,如果不做队列的话,那么其他商品就得不到数据库执行机会了。加入我们分10个队列,那么秒杀商品就会落在这10个队列中的一个,那么最多也就占用机器10分之一的资源。

    2018-10-14 12:11:10

  • Coder4

    2018-10-06 14:09:34

    这种无只能在串行隔离级别才能用吧,不然肯定超售。。。UPDATE item SET inventory = CASE WHEN inventory >= xxx THEN inventory-xxx ELSE inventory END
    作者回复

    数据库层不都是串行操作吗😊

    2018-10-14 13:51:19

  • 永光

    2018-10-06 06:20:24

    老师,你好,
    你提到秒杀商品减库存直接放到缓存系统中实现,也就是直接在缓存中减库存或者在一个带有持久化功能的缓存系统(如 Redis)中完成。这种实现并发读写怎样保持数据一致?以及是不是要用分布式缓存?
    作者回复

    前面有个同学的类似的问题回答过,可以看一下

    2018-10-09 20:28:13

  • 宇宙浪子

    2020-03-04 15:40:34

    减库存是秒杀系统的难点,尤其是涉及到跨集群时如何既要满足性能又要做到数据一致性,整篇文章只是泛泛而谈,具体细节和核心难点都没讲解决方法。希望能讲的细致些,泛泛而谈没啥意义
  • shawn

    2018-10-14 11:23:36

    个人做法,
    针对确定库存,提前下好单,下单人留空,订单短时间内失效
    订单id压入Redis队列,
    请求来到,订单队列lpop,队空则返回失败,
    pop出来的订单补充下单人为当前用户,
    如果订单过期失效则再次下同一商品的空单存入队列

    这个设计可以考虑单个Redis不够用的时候将队列分组,利用轮转或时间戳hash将请求分配到不同队列,

    想问下老师,这个和扣数字库存相比,会不会有更好的并发性能呢?
    作者回复

    说实话,没看出来哪里性能会更好😄
    不过提前下单的思路还比较新颖,你的思路我理解,但是这样就把一个事情分两次来做,会增加了复杂度,有可能导致得不偿失

    2018-10-14 11:58:10

  • 一笑奈何

    2018-10-06 17:17:12

    老师,问下单机mysql 1s内能抗大约多大的QPS? 大约。
    作者回复

    我印象中单实例一般能抗7-8k左右

    2018-10-07 17:19:02

  • 宁宁

    2018-12-06 10:14:40

    下单减库存的方式存在问题是有些用户下单缺不付款,有一个补偿方案就是付款设置超时时间,一旦超时取消订单,同时发送消息到消息对列,库存服务订阅消息,把库存加回去!
  • 大麦

    2018-10-12 20:17:29

    秒杀是短时间大量请求,使用下单即锁库存方式,可以通过一个 redis 队列记录下单,一个redis key 记录数量 num,超出的库存下单失败,这样大量请求在 redis 层即可被处理。
    通过 num 与库存的判断来解决无效订单。
    下单端通过队列异步消费下单。
    对于前端,用户下单成功,即进入redis 队列的,响应给前端可以轮询。
    没有的,直接提示抢购失败。
    作者回复

    异步下单的方式,也是一个思路,例如在一些场景下其实已经在使用,例如一些支付场景中,付了款以后,前端页面中会有一个转圈,等个几秒钟再告诉你结果。

    这种方式我个人觉得对用户不太友好,就是要让用户等个几秒钟,而不是像同步的方式能及时得到反馈结果

    2018-10-14 12:18:56

  • moliniao

    2018-10-08 09:14:24

    老师,使用应用排队方式,入队后返回,然后app端轮询请求下单结果吗?
    作者回复

    秒杀web请求一般不用排队,谁先到谁先执行。
    排队一般更多是在服务端的内部请求时发生,而且是在异步请求时通过消息队列来处理

    2018-10-09 20:22:30

  • 诗泽

    2018-10-18 12:00:53

    看到有同学说下单排队可以用请求队列来做,想问一下请求队列里存放的是请求数据吧,即用户请求数据入队列之后请求立即返回,后台异步处理请求数据,那处理的结果如何告知用户?是前端发起轮训请求吗?如果是轮训的话又会占用服务器不少连接资源吧?
    如果请求队列里直接存的是http 请求的话服务器端也是会持有大量未释放的http 长连接。
    所以请教一下实际当中一般请求队列这部分是怎么做的呢?
    作者回复

    大家对请求队列这块问的比较多,后面相关的问题我统一回答一下吧

    2018-10-21 12:05:51

  • Joshua

    2018-10-15 08:35:15

    好多同学提到基于redis减库存,我看阿里云的文档时,里面也提到了阿里双11秒杀就用的这种方式,不知道是不是真的?
    作者回复

    早期用过😉

    2018-10-21 13:00:15

  • 坏坏的举哥

    2019-09-05 17:06:31

    库存分为三种,可售库存、未付款库存、未发货库存,三者想加是总库存;
    下单时,可售库存减,未付款库存加;付款后,未付款库存减,未发货库存加;
    库存操作使用数据库乐观锁机制;
    针对秒杀场景,可以针对sku进行合并扣减库存,先放在多队列进行合并数目,然后再一次写到库中;
    这才是实际可行方案。
  • null

    2018-11-01 09:42:14

    方法一和方法三是不是没啥区别?

    方法一:下单减库存,但是用户不支付,订单超时释放库存
    方法三:预扣库存,用户下单时扣库存,超时释放库存

    区别是不是在“超时”时长?但这个也是人为决定的,所以方法一和方法三是同一种方案?
    作者回复

    如果方法一有后续的超时回补库存,那么就差不多了

    2018-11-03 18:10:03

  • 对方正在输入......

    2018-10-06 22:21:31

    预扣库存和扣库存有什么区别?怎么预扣库存?
    作者回复

    预扣是有预定的意思,如果未付款还会补回来
    而扣库存就实际的减去库存了

    每次预扣都可以创建一条记录,如果这条记录超时了就删掉,剩下的库存就是总库存减去预扣的库存

    2018-10-09 20:06:49

  • 如果可以改变

    2019-05-06 13:35:04

    老师,你好,现在项目中使用了redis预扣库存,同时使用了rabbitmq来异步下单,问题在于,先预扣库存,同时为了保证消息一定发送成功,将消息写入mqsql,以用于定时任务扫描,确保消息一定发送出去。遇到一个很棘手的问题,要保证减redis库存,发消息时写入mysql都是原子性的,要么都成功,要么都失败,可以讲一下阿里早期是怎么实现的吗?
    作者回复

    保证减redis库存,发消息时写入mysql都是原子性,还不如直接在MySQL里减库存

    2019-05-19 14:15:44