背景

库存是电商中的一个核心概念,主要记录商品的可售等数量信息,其既简单又复杂,简单是因为它大多数时候就是提供一个商品是否可售的信息,下单扣库存,退款或则入库加库存就行了,因此从业务的角度上的确不算复杂。为什么复杂?因为在分布式高并发库存的性能上来说是需要考虑很多的,由此而展开的一系列的优化又会使简单的业务操作变的实现上异常复杂。本文介绍接触到一些库存上的优化,也会参照业界的最佳实践与设计模式探讨更好的实践与方案。

常见问题的解决手段

本文不详细介绍一些常规的优化操作,但是简单介绍一下,网上的文章很多可以自行搜索。

  1. 首先缓存能够优化查询,把热点数据加载到redis,这样能避免大的查询流量给数据库的压力,而且redis的内存操作也更快,一般来说redis一个实例可以抗10w的qps。但是值得注意一点就是尽量避免大数据的缓存,因为使用的单线程多复用非阻塞的IO模型,对于大的数据还是会阻塞读取到用户态,因此要注意不要缓存大的value值。
  2. 扣减库存有几种方式,最简单就是查询库存够不够再进行查询,但是这样存在超卖的风险,因此可以进行加锁,这样能解决但是并发性并不好,可以在SQL层做一个乐观锁,UPDATE stock SET avail_count - #{count} WHERE avail_count > 0 AND sku_id=#{skuId}即可。这样在MySQL的底层的确还是会一个一个持有锁做扣减,但是对于该商品来说,整个系统的持有锁时间会降低很多,这也是后面要提到优化的一个方向,即降低持有锁的时间。
  3. 幂等问题,如果扣减只是一个递减的操作,那如何保证它的幂等性?例如用户在极短的时间点击两次,或则是上游超时做重试,这里我们使用的是先生成一个商品级别的唯一订单号,每次扣减都会写入一张操作流水表,同时它也是一张幂等表,这个表根据订单号做了数据库层面的唯一键控制,扣减库存重复操作会写入幂等表报错,由于写入幂等表在扣减库存是一个事务原子操作,保证了幂等性。

进阶优化手段

接下来介绍一些比较非主流的一些优化方案,在网上并不多见,但是借鉴了一些经典的算法。

合并操作

对于一个商品的扣减库存,在高并发情况下,会去频繁的持久化数据库,这样对于底层的锁竞争会很激烈,可以设置一个队列,当并发达到一定的阀值,就进入队列进行等待一个时间窗口的请求,把这些请求进行合并操作库存,批量扣减库存与批量写入日志,当然这里需要考虑到合并后库存不足的情况,这个时候要进行退化成按扣减数量升序循环扣减,最大限度保证不少卖。这里的困难在于扩容与并发的阀值的控制,综合各方面的考虑,这个队列与并发的阀值是维护在本地的,随着机器的水平扩容,单机的并发在降低,但是数据库的并发可能在增加。因此后面可以进一步优化成集群并发的合并。

java 同时扣多个商品的库存 java高并发扣库存_java 同时扣多个商品的库存

异步化缓存与选择性强一致
  1. 缓存穿透与命中率问题
    前面提到缓存的作用,扣减库存因为库存数量在变更,要让用户读取到最新的缓存那也应该更新缓存,当然并不是直接的更新缓存,而是失效缓存,并发更新存在较大概率缓存数据不一致的风险(失效缓存依然存在不一致。例如该数据不在缓存,请求1读取老数据,请求2更新了数据并失效缓存,请求1塞进缓存,但这概率很低)。在高并发的情况下,缓存其实在频繁的失效,缓存的命中率极低,给数据库造成了较大的压力。我们的解决方案是利用MySQL数据的Binlog日志异步去刷新缓存,我们实现了自己的中间件,能够保证商品纬度的顺序消息,因此能够达到最终一致,在吞吐量与正确性之间权衡。缓存不再失效。
  2. 异步就注定存在一定的延迟,但是从业务上来说,绝大部分情况下对可售数字的敏感度并不高,但是对于库存的有和没有影响很大,这关系到上游还是否可售。因此在选择牺牲一定的一致性的同时,又要保证空库存的实时性,在扣减库存失败的情况下(无库存)即立即刷新缓存,但这依旧存在问题,因为消息的延迟会覆盖掉立即刷新的最新值,因此这里需要在记录行上加一个版本号,根据版本判断是否需要覆盖。其实到这里整个缓存的确不失效了,但是本身命中率依旧不高,只是不会打到DB,仍然在频繁的刷新,因此可以结合合并的思想,控制一个时间窗口进行合并刷新,这也是下一步优化的方向。
持有锁耗时
  1. 实际上电商系统,对于支付下单请求来说,是多个商品的库存扣减,上面说的优化手段都是单个商品,如果多个商品,必然存在代码上的顺序,由于要保证事务性,考虑过多线程并发,但是事务的控制是一个棘手的问题,因此循环的扣减,整个订单在一个事务,那事务会变得很大,如果有10个商品,每个DB扣减操作5ms,那第一个商品持有锁的时间就需要50ms,在高并发情况下该商品的性能降会非常低下。因此需要进行批量操作,可以更新操作数量进行分组,批量扣减,也可以使用CASE WHEN的语句进行批量扣减,将循环在MySQL的执行引擎上比应用服务器上要更快。
  2. 代码的顺序
    事务操作把更新商品库存操作的语句放在事务的最后,因为前面还有一些写入日志等SQL操作,把获取锁的时间后置,可以降低持有锁时间。
讨论
  1. 关于12306架构参考
    12306特点不用多说了,可以说承受着全世界最顶级流量峰值的考验,根据公开的资料,12306在2012年引入了Pivotal GemFire解决了高并发的主要问题,早期也是Unix小型机,后面还是演进成主流的x86分布式架构,先不说其票务额外增加的的业务复杂性本身,其面临的核心问题依旧是计算与存储之间的鸿沟,在摩尔定律接近失效的近几年,存储依然是远远跟不上计算速度,其核心是通过虚拟化把分布式的内存进行整合成巨大的内存资源池,然后数据不用再持久化,纯内存运算,降低IO延迟。这的确是一种“根本上”解决问题的办法,但是在我看来还是过于简单粗暴,毕竟12306也不差钱,所以愿意承担这样的成本去换稳定,但是对于传统的互联网公司,这样的投入成本过于高昂,因为很难像12306那样对天花板可期。
  2. 关于淘宝秒杀架构的参考
    淘宝的库主要还是分库分表和oceanbase和自己对InnoDB的一些优化,这些解决方案并非一个开源通用的方案,可能后期会开放在阿里云上去,以后不用在考虑这种复杂性,而是模拟出一台无线资源的巨型机器,这扯远了。
  3. 其他解决方案
    其实网上主要的是秒杀的各种方案,主要的特点是货少用户多,方法也主要是进行各种前置拦截,把无效流量拦截在应用上层,但是可能对于另一种场景是用户多但库存也很多,要保证这种场景下数据的准确性和高性能的解决方案确不多。