如果你想开发一个电商库存系统,你最担心的是什么?闭上眼睛想想,当然是高并发和防超卖了!这篇文章给出了一个方案,考虑如何实现高并发,防止数据精度超卖。读者可以直接借鉴这个设计,或者在此基础上做更适合使用场景的设计。背景在今年的敏捷团队建设中,我通过Suiteexecutor实现了一键式自动化单元测试。Juint除了Suite执行器还有哪些执行器?就这样开始了我的Runner探索之旅!下面以电商库存为例,说明高并发情况下如何扣减库存。该原理同样适用于其他需要并发写入和数据一致性的场景。1.1库存数量模型示例为了描述方便,下面采用简化的库存数量模型。在实际场景中,库存数据项会比下面的例子多很多,但足以说明原理。如下表所示,库存数量表(stockNum)包含商品标识和库存数量两个字段,库存数量代表有多少商品可供销售。传统的库存管理为了保证通过数据库不超卖,传统的解决方案使用数据库事务来保证不超卖:通过Sql判断剩余库存是否充足,并发执行多个只做一个更新语句可以成功执行;为了保证不重复推导,会使用一个反重复表来防止重复提交,达到幂等性。反重复表实例(antiRe)的设计如下:例如一个订单流程的扣减流程实例如下:TransactionstartInsertintoantiRe(code)value('ordernumber+Sku')updatestockNumsetnum=num-orderquantitywhereskuId=commodityIDandnum-orderquantity>0交易结束时,系统流量越来越大,数据库的性能瓶颈就会暴露出来:即使是分库和子表是没用的。促销时,高并发针对的是少量商品。最终并发流量会发往少量的表,只能提高单个分片的容量抗性。因此,接下来设计一个利用Redis缓存进行库存扣减的方案。Redis缓存库存扣减方案2.1综合利用数据库和Redis,满足高并发扣减原则。库存扣款其实涉及两个过程:第一步是超卖验证,第二步是扣款数据的持久化;在传统的数据库推导中,这两个步骤是一起完成的。反写的实现原理其实是巧妙的利用了分离的思想,将反超卖和数据持久化分开;首先,防超卖由Redis完成;仓库使用任务引擎,业务数据库使用商品分仓和表,任务引擎任务按单号分库和分表,热点商品库存会通过状态机打散,消除热点.整体结构如下:第一步,解决超卖检查:可以把数据放到Redis中,每次扣库存的时候,都会把Redis里面的数据incryby扣掉。如果返回数量大于0,说明库存充足,因为Redis是单线程的,可以信任返回结果。第一层是Redis,可以抗高并发,性能还可以。通过超卖验证后,进入第二关。第二层解决库存抵扣:第一层之后,第二层不需要判断数量是否充足。它只需要被忽悠去扣除库存。在数据库上执行以下语句。当然,还是要处理防重复幂等的,需要判断数量是否大于0,扣款SQL只需要写成如下。Transactionstart插入antiRe(code)value('ordernumber+Sku')updatestockNumsetnum=num-orderquantitywhereskuId=commodityIDTransactionend关键点:最后还是要用到数据库,如何解决热点?任务库使用订单号进行分库分表,这样同一商品的不同订单会散列到任务库的不同库存中。虽然它仍然是抗数据库的,但它已经消除了数据库热点。整体交互时序图如下:2.2Hotspot反爬虫但是Redis也有一个瓶颈。如果出现过热的SKU,会冲击Redis单片机,导致单片机性能波动。库存防刷是有前提的,就是不能卡单。JVM中可以自定义毫秒级时间窗口的限流。限流的目的是保护Redis,尽量不限流。限流的极端情况是,本该在一秒内售罄的商品,实际却用了两秒。通常,不会有延迟销售。之所以选择JVM,是因为如果使用远程集中缓存进行限流,以后就没有时间收集了。数据杀死了Redis。实现方案可以使用guava等框架,每隔10ms设置一个时间窗口,对每个时间窗口进行计数,超过计数时对单台服务器进行流量限制。比如10ms超过2,就会限流,那么一台服务器每秒就是200台,50台服务器每秒可以卖出10000件商品,可以根据实际情况调整阈值。2.3Redis扣减原则Redis的incrby命令可以用于库存扣减,可以有多个扣减项目。使用Hash结构的hincrby命令,首先使用Reids原生命令模拟整个过程。为了简化模型,下面将演示对一个数据项的操作,多个数据项原则上是完全等价的。127.0.0.1:6379>hsetiphoneinStock1#设置苹果手机有可售库存(整数)1127.0.0.1:6379>hgetiphoneinStock#查看苹果手机可售库存为1"1"127.0。0.1:6379>hincrbyiphoneinStock-1#卖出一扣,返还0,下单成功(整数)0127.0.0.1:6379>hgetiphoneinStock#验证剩余0"0"127.0.0.1:6379>hincrbyiphoneinStock-1#apply并发超卖但Redis单线程返回剩余-1,订单失败(整数)-1127.0.0.1:6379>hincrbyiphoneinStock1#identify-1,回滚库存加一,剩余0(整数)0127.0.0.1:6379>hgetiphoneinStock#Inventoryreturnedtonormal"0"2.3.1扣费幂等性保证如果应用调用Redis扣费,不知道是否成功,可以给batch加个防重码推演命令,对防重码命令执行setnx,当出现异常时,可以根据防重码是否存在来判断推演是否成功,可以使用流水线提高成功率批量命名。//初始化库存127.0.0.1:6379>hsetiphoneinStock1#设置苹果手机有可售库存(整数)1127.0.0.1:6379>hgetiphoneinStock#查看苹果手机可售库存为1"1"//应用线程1扣除库存,订单号a100,jedis开启pipeline127.0.0.1:6379>seta100_iphone"1"NXEX10#通过订单号和产品防重码OK127.0.0.1:6379>hincrbyiphoneinStock-1#卖出扣除1个,剩余0个返还。下单成功(integer)0//管道结束,执行结果OK和0会一起返回,防止并发推算。检查:为了防止并发扣费,需要Redis的hincrby命令返回值是否为负,判断是否出现高并发超卖。如果扣除结果为负数,则需要反向执行hincrby,将数据加回来。如果调用过程中出现网络抖动,调用Redis超时,应用程序不知道运行结果,可以使用get命令查看是否存在防重码,判断是否扣费成功。127.0.0.1:6379>geta100_iphone#扣款成功"1"127.0.0.1:6379>geta100_iphone#扣款失败(nil)2.3.2单向保证在很多场景下,因为你不使用事务,所以很难你要做到不超卖,而且要卖很多,所以在极端情况下,你可以选择不超卖,但是少卖也是有可能的。当然,我们要尽量保证数据准确,不超卖,不卖多;在不能完全保底的前提下,如果我们选择不超卖单向保底,就要用手段尽可能降低超卖的概率。比如在Redis扣费过程中,命令安排是先设置防重码,然后执行扣费命令失败;销售,所以上面命令的顺序是错误的,正确的写法应该是:如果是扣除库存,则顺序是:1.扣除库存2.写重量代码。如果是回滚库存,订单是:1.写重量代码2.扣除库存。2.4为什么要使用Pipeline上面命令中使用了Redis的Pipeline,看看Pipeline的原理。非管道方式请求-->执行-->响应请求-->执行-->响应管道方式请求-->执行服务器对响应结果进行排队请求-->执行服务器对响应结果进行排队-->响应-->响应使用Pipeline,可以尽可能保证多个命令返回结果的完整性。读者可以考虑使用Redis事务代替Pipeline。在实际项目中,我亲身体验过Pipeline的成功阻力,并没有使用过Redis事务。一般情况下,transactions比pipeline慢,所以不使用。Redis事务1)mutil:启动事务,后续所有操作都会加入当前链接事务的“操作队列”2)exec:提交事务3)discard:取消队列执行4)watch:if键手表的修改,触发dicard。2.5通过任务引擎实现数据库的最终一致性前面通过任务引擎来保证数据必须持久化到数据库中。“任务引擎”的设计如下,将任务调度抽象成一个业务无关的框架。“任务引擎”可以支持简单的流程编排,至少保证一次成功。“任务引擎”也可以作为状态机的引擎出现,支持状态机的调度,所以“任务引擎”也可以称为“状态机引擎”,与本文中的概念相同。任务引擎设计的核心原则:首先将任务放入数据库,通过数据库事务保证子任务的拆分与父任务的完成之间的事务一致性。任务库分库分表:任务库采用分库分表,可以支持横向扩展。通过分库字段和商库字段的不同设计,不存在数据热点。2.5.1任务引擎核心处理流程第一步:同步调用提交任务,先将任务持久化到数据库,状态为“锁定处理”,保证这件事一定要处理。注意:在最初的初始版本中,任务是待处理的,然后由扫描Worker扫描。为了防止并发和重复处理,单个任务在扫描后被加锁,加锁成功后再处理。后来优化为直接将投递任务的状态标记为“锁定处理”。这是出于性能的考虑,省去了重新扫描然后抢占任务,直接通过进程内的线程异步处理。LockingSqlreference:UPDATEtasktable_sub-tablenumberSETstatus=100,modifyTime=now()WHEREid=#{id}ANDstatus=0第二步:异步线程调用外部处理,调用外部处理后,接收返回列表子任务。通过数据库事务设置父任务状态为完成,子任务被丢弃。并将子任务添加到线程池中。要点:保证子任务生成和父任务完成的事务性步骤3:子任务调度执行,新的子任务重新入库。如果没有子任务返回,则整个过程结束。异常处理Worker异常解锁Worker,对长时间未处理的任务进行解锁,防止因服务器重启或线程池爆满导致任务被锁定进行serverless执行。leak-trappingWorker防止服务器重启导致线程池任务没有执行,leak-trapping程序重新上锁触发执行。任务状态转换过程2.5.2任务引擎数据库设计任务表数据库结构设计实例(仅作为示例,实际使用有待完善)任务引擎数据库容灾任务库采用分库分表,当一个库宕机时,路由到停机数据库的流量被重新散列到其他幸存的数据库,可以手动配置或通过系统监控自动配置以进行灾难恢复。如下图,当任务库2宕机时,可以修改配置,将任务库2的流量路由到任务库1和任务库3。陷阱引擎继续扫描任务库2,因为任务库2后通过主从容灾恢复,任务库2宕机时可以补充未来和处理过的任务。任务引擎调度示例比如用户买了两部手机和一台电脑,手机和电脑分散在两个数据库中,任务引擎先持久化任务,然后驱动拆分成两个子任务,finally保证两个子任务一定成功。实现数据的最终一致性。整个执行流程的任务布局如下:图7任务引擎调度示例任务引擎交互流程图8任务引擎交互流程总结只要有异质性,就一定有差异性。为了保证差异的影响可控,最终的解决方案还是要靠差异比较来解决。本文篇幅有限,就不展开了,后面会单独写。比较DB和Redis差异的大致流程是:收到库存变化消息,持续跟进比较Redis和DB的数据是否一致,如果持续稳定不一致,则进行数据修复,使用DBdata修改Redis数据。常见问题及解答:第一步验证Redis内存扣超卖,第二步推导数据的持久化。中途中断怎么办?(例:服务重启)答:如果服务重启,则在服务器重启前,本服务器的服务会停止;但是这种方案不能保证数据的绝对一致性。比如redis扣除后,应用服务器出现故障,直接崩溃。在这种情况下,需要一个更复杂的方案来保证实时一致性(更复杂的方案目前暂未采用),可以采用另一个方案,利用库存数据和用户订单数据进行数据比对和修复,以达到最终的一致性。
