商城系统下单库存管控系列杂记(二)(并发安全和性能部分延伸)
前言
参与过几个中小型商城系统的开发,随着时间的增长,以及对系统的深入研究和测试,发现确实有很多值得推敲和商榷的地方(总有很多重要细节存在缺陷)。基于商城系统,无论规模大小,或者本身是否分布架构,个人觉得最核心的一环就是下单模块,而这里面更相关和棘手的一些设计和问题,大多时候都涉及库存系统。想想之前跟某人的交流,他精辟点评“库存管控做得好,系统设计就成功了一半”,自己颇有认同。围绕这个点,结合目前经验和朋友间的交流(包括近来参阅其他文章提到的点),闲来做些整理记录,也许不太完整,但总归希望能有更多启发,自己往后也会重新揣摩。当然,文中若有不妥,欢迎指正。
正文
谈及”下单“,就立刻想起前年参与的一个基于微信的小型商城系统,里面下单这块本身谈不上复杂,大概可以这样描述提交过程:用户提交商品订单,系统核对用户提交的订单,校验商品(商品价格、优惠折扣、积分等),检测附属信息(地址运费等),一切Pass,操作库存(记录/预扣),生成订单及相关联的明细数据。此时下单Ok,那么后续则是等待用户的及时付款了。
然而,看似如此简单的一个流程,放在并发环境下,就暴露了足够多的问题。深入进去,首当其冲的就是库存管控。包括但不限于库存的扣减方式,如何安全操作,以及减少性能损耗等等。
【为了方便独立成文,原谅在内容排版上的一点点个人强迫症】
【本文内容由上一篇扩展论述(详见:商城系统下单库存管控系列杂记(一) http://www.cnblogs.com/bsfz/p/7801980.html)】
四、阐述关于并发环境中库存管控的一些案例问题,以及涉及到的相关技术实现细节
库存扣减,简单来说,就是在对应的存储器中(数据库或者持久缓存)将对应商品的数量减少。
数据库设计时,一般包含但不限于 商品主表,商品规格表,商品库存表,商品库存流水日志表等等。但这里为了方便后续阐述,将其简化为一张表——商品表(PT),该表仅包含两个字段——商品主键(id)和商品库存(qty )。
依然以商品P举例,其主键为pid,那么就是在下单时,将历史库存S修改为 S -N。具体到SQL里,原始操作大概是这样(以SQL SERVER 举例):
update PT set qty = (S - N) where id = pid ;
这是以前的最原始的操作方式,单粒度的看,也没什么大碍。然而,放在一个并发环境中,则立马暴露出诸多问题。
假定在同一时刻,有两个用户提交了订单,一样的操作,一样的商品,一样的数量。那么最终商品P的库存数量应该为 S - N - N。而执行上面的SQL,因为并发,导致两次查询到历史库存均是S(应该至少有一次qty为S - N),则更新完毕后,商品数量最终是 S - N。这种致命性的Bug,也属于超卖(虽然不会扣为负数),如果放在线上,简直是一个定时炸弹,不,还不仅仅只是这一个定时炸弹。
围绕解决这样的问题,考虑到并发安全以及并发性能,产生了各种解决方案。大体基于两种机制:悲观锁和乐观锁。在诸多场景里,基于每种锁,都有配套的辅助手段,以及各自不同的侧重取舍和相关实现。
4.1 使用悲观锁的理念,实际就是在并发的关键地方,强制将“类似并行”改为串行,相关的一些处理方式:
4.1.1 数据库锁,利用数据库的自身的事务隔离机制(Isolation),进行排他操作。
4.1.1.1
极端的在查询时,直接开启事务设置行锁(rowlock)。串行目的是达到了,但即时在单机系统中,也无法承受巨大的性能损耗。并且最终的超卖问题也没有解决,非常不推荐。
4.1.1.2
仅利用数据库在update时造成的排他锁,使真实更新时串行,并增加库存判断,若库存发生变动,则更新无效,超卖问题也不会发生。譬如(以SQL SERVER 举例):
update PT set qty = qty - N where id = pid and qty >= N;
严格来讲,这依然是一个较粗的粒度,但不得不说,在单机环境下有一定的可行性。同时,需要考虑高并发情况下(例如商户举办活动,同时参与用户过多)存在一定性能瓶颈,数据库IO负载过大。此时需要结合其他方案,包括增加上层缓存层等。甚至部分场景需要单独设计一套流程(例如秒杀抢购场景,首先就是应用到队列,否则网站可能没崩溃在并发请求数上,而是直接挂在了DB上,后面会有相关阐述)
4.1.2 使用程序锁(单机线程锁和分布式调度锁),使部分关键代码串行。
4.1.2.1
极端的直接使用程序自带的全局线程锁,以.NET Framewok 举例,里面有各级粒度的锁,常用的轻量锁有lock(Mointor语法糖)、SpinLock(自旋锁)。使用它们,最早大概是应用在“单例模式”的构建,原理本身不复杂,使用也方便,并且也达到了串行的目的。
然而,放在下单库存管控这里,串行的却是所有用户进行任意商品下单操作,打击面太大(甚至直接上升到全面打击),对性能造成极大影响,不可行,不过多延伸,也不推荐。(曾经优化一个旧项目里的模块,初步Review代码时就发现了几处不经意的地方竟直接使用了这种写法,而开发人员还是两名老员工)。
4.1.2.2
构建一个本地的线程锁管理器(这里称为LockerManage),统一分配锁对象(等待对象)。其本质是针对上面4.1.2.1方式的包装处理,实现类似“工厂模式”的机制。主要是通过它来生产具有唯一特征的Object对象,这个对象将会作为锁对象资源返回给Monitor等调用,并具有一定的使用时效,每次生成后保存在内部的线程安全的集合里,同时具有自动销毁机制(运行一个独立线程,定时检查清理)。其中有个小细节,为了优化管理器内部的并发问题,开始使用的是.NET Framewok 里自带的线程安全的字典集合(ConcurrentDictionary),后来经测试,发现并发处理并不理想,后面便换了其他方案(读写分离)。回归到下单这里,这里依然以商品P为例,首先调用LockerManage,获取一个以当前商品主键为标识的Object对象,然后在库存的预扣核对时,使用Mointor加锁处理。(当然,这里是本机锁,后续有说明)。这种方式对比数据库锁,则是降低数据库的操作,而将压力大部分转移到了程序上,但相对可以更灵活的去操控。
4.1.2.3
使用分布式锁。上面的普通程序锁作为单机的存在,决定了其在分布式架构上的不可控性,而这时就有了分布式调度锁。它主要是为了方便解决分布式情况下,在多个Web程序内实现并发线程的一个管控。值得一提的是,这个“轮子”并不需要手动重新创造,目前市面上已经有相对成熟的解决方案,如利用Zookeeper和Redis。在AutumnBing项目中,当时选择的是Redis,使用的驱动库是StackExchange.Redis。(后续听到朋友提到Zookeeper更适合充当这样的角色,但由于目前自己还没有太多涉猎研究,暂时持保留态度)。当然,纯粹采用分布式锁,自然调用性能会有更多损耗。而相对更合理的做法,是结合单机锁搭配应用(试讨论,分布式锁放置外层,单机锁放置内部,每个站点各自维护)。
4.2 遵循乐观锁的理念,则是默许不会有太大的并发问题(聚焦在小粒度的商品P上,则是认为大多数情况下P不会被同时消费),“放任”线程的执行,不做管控。但是会在关键地方进行版本核对,假如失败,则内部重试或抛出失败信号。
4.2.1 数据库层面上,增加显式的版本号字段(ver)。
购买商品P,下单这里需要获取到当前时刻对应的库存qty01,当前记录是版本ver01,然后在真实更新时,再次查询商品P的库存,以及对应的当前的版本ver02,如果 ver01 == ver02,那么可以更新。否则,当前数据已因并发被修改,无法更新。这更像是数据库的“不可重复读”,而出现这种情况后(高并发情况下,出现概率直线上升),必须附有关联的内部尝试机制(注意保证幂等性)。 这是一种实现并发管控的方案,但只适合存在并发,但并发量不太大的情况,否则,一是违背乐观锁的理念初衷,二是整体性能以及体验会大打折扣。
4.2.2 程序控制上,采取队列(queue)方式,进行相对集中化预受理,然后分发逐个处理。
需要声明,这里本身执行原理,其实质依然离不开类似悲观锁的管控性质,一是入队时需要有个小粒度的锁机制保证串行(当然也可以是其他方式,这是队列内部的管控机制之一),二是出队,例如分发到不同服务上去处理,最终也是一个一个在操作更新(依然是某种程度上的串行)。但是,作为用户下单的提交,本身是保证了乐观的态度,一股脑“同时”或者“快速”接收,然后再考虑如何告知处理。
由于单机队列的应用,会出现更多类似上面单机锁的一些额外问题,这里不推荐(当然你可以结合),也不做扩展说明。下面仅就分布式队列在大方向上举例阐述。
如何采用分布式队列来实现下单以及库存管控呢?依然以商品P为例,用户同时购买商品P,本身是一个并发操作,但是我们可以将一系列的请求商品扣减数据Push到一个队列中(生产者开始生产),然后由专门的线程进行订阅消费(消费者开始消费)。暂且假定为一个线程在消费,那么该线程具体消费时,逐个将商品数据出队,进行库存扣减,这里必然不会出现并发。消费完毕,无论扣除库存逻辑上是成功还是失败,均给出一个应答(ACK)。注意这里并没有过多的拆分逻辑,而是将下单的一些操作扔进一个队列中,使用专门的程序去逐个或者逐几个(分批)处理。实际使用往往是根据业务,做更小粒度的拆分和调整。另外,关于技术框架选型,目前各类开源成熟的MQ项目比比皆是,个人圈子里了解到最多的还是 RabbitMQ,对于多个生产者以及与之配合的多个消费者,还有应答处理机制,包括本身的性能和高可用性,均极其出色。额外的,关于web前端,很多时候则是需要配合一些轮询机制来检查订单状态(当然,轮询这里也有一些具体细节,比如异步体验、轮询时长和状态重置等考虑)
五、涉及到分布式SOA架构体系(包括如今基于SOA开始流行的微服务架构)情况下的一些额外考虑。
首先声明,个人认为SOA只是一种架构上的抽离设计,本身与论述的库存管控没有直接关系。但这里以库存管控为例,也有需要额外考虑的地方。
我们假定在一个下单API中,包含了3个独立的API接口:A-积分扣减API,B-优惠券扣减API,C-库存扣减API。考虑一种情况:假定库存本身可以被合法扣除,并且执行C成功了,但是发生了其他问题,A或者B执行失败了,那库存该如何回滚。
必须纠正的是,在这样一个耦合性系统场景里(而上例仅是其中一种案例),需要解决的问题本质和库存如何扣减没有丝毫直接关系,其暴露的实质问题是如何实现一个分布式事务机制。这是一个比较大的专题,实现相对复杂,开发成本也足够高。基于单一RPC接口,到如今流行的更小粒度的微服务,都足够写一本书了。截止目前个人的了解,如早期的2PC (两阶段)、3PC(三阶段)、TCC(补偿事务),以及后来的纯消息列表式方案等等,均是一些无法达到完美的理论(性能、时效、复杂度等)。至于实践上,自然就没有绝对OK的方案,只能根据项目规模和实际业务做些取舍,最终得到一个尽量满足的“高可用”方案。以后待到经验足够,有机会尝试一下单独开篇讨论。(对于分布式事务,写过一些demo,却应用不深,以后会考虑抽个专门的时间在续篇中尝试撰写探讨)。
六、结合高并发场景(如:秒杀活动),简单聊聊如何关联各类技术手段,进行下单及库存管控的应用。
在电商系统里,并发简直无处不在,目前较为突出的一个场景,则是秒杀活动。所谓秒杀,最简单直观的场景如下:在某个时刻,商品P开放购买(P的实际库存仅为1个或者几个),大批量的用户同时进行下单抢购。
秒杀时并发量之大远远超过一般情况下的并发(你要考虑到不止一个商品),甚至还会影响到商城里现有其他业务(这里讨论非独立部署)。需要考虑诸多细节,以及大量技术手段来进行有效管控。以下简单聊聊后台下单相关问题,不讨论其他前端处理技术,包括定时查询,页面静态化,网络带宽优化等。
6.1 明确业务本质需求,脱离业务,当然谈不了任何技术架构和实现方案。
秒杀的业务场景,宏观上来说,就是一个典型的排序模型。谁先来,谁先得到。这里我们尽量简化举例:假定商品P库存为10,同时参与下单的用户数为100000。那么,最终只有开始的(理论上的)10个用户购买成功,其余99990个用户购买失败。商品库存被成功消费为0。
6.2 防作弊等安全监测,从RPC的第一个接口开始,就进行过滤。
例如,在杂记上一篇中提到的(见第一篇主题三),做好基础的安全监测机制。如相同IP的僵尸账号,做限制IP的访问,并增加验证码等。同时,包括但不限于一些额外的业务辅助手段,如限制仅满足一定注册时间的用户可下单等。
6.3 限流机制,在外层计数,达到一个下单阈值,直接抛弃。
从6.1中就可以发现,秒杀业务本身就注定了大部分人是抢不到的,那么针对大部分人的下单请求,完全就可以不做处理(直接抛弃)。在进行真正的下单操作之前,可在具体操作接口上,增加一个拦截计数器来统计,比如当计数超过3000时,后续下单直接返回抢购失败的信息。这样就将数据处理由大化小了,实现了限流(仅针对下单)。当然,具体实现时,这个3000名额推荐是筛选后的。比如,先过滤8000,从中随机抽取3000(这里不扩展)。
6.4 从数据库角度,首先就是要增加单独的临时缓存层。
即使是3000的量,在这个环节也肯定是不能直接操作数据库的(你要明白,实际秒杀的商品,不只一个),直接读库写库对数据库压力太大,甚至直接负载过大导致数据库挂掉。那么,针对这种情况,推荐的一种方案就是结合缓存来操作。譬如:把商品P * 10 这条数据提前Push到专门的缓存中,然后每次读取和更新,均是走的该缓存。这里额外提到一点,如果用户下单成功,预扣库存 -1,但又未进行安全时间内的支付,那么系统将自动回滚商品P的库存,进行 +1(当然,回滚同样需要协调处理并发)。
6.5 从程序角度,修改库存依然需要保证一定串行。
首先,如果保证DAL的串行,可以是数据库上锁,也可以是程序上锁(或者队列)。但如果直接数据库上锁,诸多并发请求(依然考虑到,单时间内的多个商品被多用户抢购),即使前面削减了部分下单处理,数据库的I/O负载依然会很严重。那么,首先就是推荐乐观进队列,然后悲观进分布式程序锁,混合处理(即是对主题四的结合应用)。
结语
电商项目里,几乎处处是并发,无论是单机还是分布式架构。结合下单库存管控相关,我们可以深刻理解解决这些并发性能问题和并发安全顾虑,即使是同一类型的业务,也有诸多方案,每种方案都有一些细粒度的问题需要尝试克服,更需结合实际项目(具体业务性质和规模),做一些实现上的各种优化与权衡等。
[不知不觉又是凌晨两点多了,本文作为系列第二篇杂记(部分延伸篇),暂告一段落吧。第三篇,待续。该睡了,晚安。]
End.