这是一个最简单的下订单流程,但是先判断库存再下单扣减,容易出现超卖的情况,所以更为合理的方式就是在校验库存满足的同时扣减库存。这里就会遇到两个问题:
- 要保证库存校验和扣减库存的原子性,不然同样会出现超卖的情况
- 如果后面下单失败,需要补偿库存
如何保证库存校验和扣减库存的原子性?
分布式锁
加锁永远是最简单粗暴的方式,但也会导致服务处理请求的能力就从并发执行退化成串行执行,失去了高并发性,同时锁的释放和锁超时都是头疼的问题。
数据库锁
sql
update sku_stock set stock = stock - num where sku_code = '' and stock - num > 0;
同样是简单粗暴的方式,但在大量并发修改的情况,数据库操作会被阻塞,同样不具备高并发性。大量库存校验落到数据库也会使数据库压力较大。
redis + lua
在lua脚本内实现库存的校验和扣减。Redis本身就是单线程操作,可以将并发简化成串行执行,但基于内存的操作由能很好的保证高并发性,同时基于lua脚本实现原子性。
分析一下问题:
- redis扣减库存后,服务挂了,没有下订单成功,缓存数据库库存不一致
- redis扣减库存后,下订单失败,补偿redis库存操作失败,缓存数据库库存不一致
- redis宕机,重启后缓存重构
再深入分析一下:
- 一个服务存在多个实例,当一个实例挂了之后,如何找出缓存中那个挂掉的实例预扣,但没有实际持久化的库存数并进行补偿。
- 服务一直在处理请求,由于MVCC永远无法获取最新的实际库存,即使进行加锁,计算出来的库存也不包括已经预扣但还没有持久化的库存(请求已经被接受,扣减了redis的库存,但是数据扣减库存事务未提交或者下订单流程未处理完毕。并且这样的请求一直在增加,预扣操作一直在持续进行中),如何在不切断流量的情况下,在服务实例重启之后把缓存的库存更新到一个正确的数量。
Redis库存+流水的方式
在网上看到一个比较多的做法就是:当通过lua脚本往Redis中扣减库存的时候,同时记录流水。
即Redis中的库存为实际库存-预扣库存,流水记录=预扣库存。应用程序下单成功之后,删除对应的流水记录,在Redis等于预扣库存转换实际扣除库存;如果下单失败,删除流水并补偿流水中记录的预扣库存数。
当程序由于服务宕机或者下单完成后请求处理Redis流水网络超时等原因失败时,可能存在Redis中存在无效流水未进行补偿或转换为实际扣除库存。有两种兜底方案:
- 通过定时任务,扫描Redis中存在的流水记录,当流水记录时间超过可接受的下单执行时长时,对比数据库中是否存在对应的流水记录,数据库中存在,即流水有效,直接删除。不存在,则可能时Redis预扣库存后,程序宕机等原因,未实际下单并没有补偿Redis库存,则根据流水中记录的库存数据对Redis库存进行补偿。
- 在预扣Redis库存前,先往数据库插入流水记录。有以下状态:初始状态为INIT,下单成功状态为SUCCESS,完成下单后的Redis操作为DONE。同样是通过定时任务,通过检索状态为INIT同时创建时间距离当前时间超过一定范围的记录,对比Redis中的流水记录。Redis流水记录中存在,则删除并补偿库存;不存在则可能是数据库先记录流水,往Redis中扣除库存并插入流水时,由于Redis宕机等原因导致Redis操作失败的,则可以直接丢弃。更新状态为FAIL。再通过另一个定时任务,检索状态为SUCCESS并且创建时间超过一定范围的,检查Redis中是否有对应流水,有的话删除,并更新状态为DONE。
总的来说,我并不是很喜欢往Redis中插入流水的方式。因为在高并发的情况,在短期内流水记录会比较多,在业务流程还没执行完毕期间可能会记录大量流水未删除,导致Redis内存不足触发内存淘汰策略。这也意味着需要为Redis配置较大的内存,尽管它可能在大部分时间内都是闲置。
上面的两种方案,都能很好的解决Redis扣除库存后,由于应用程序宕机等原因导致的库存不一致的问题。第二种虽然先插入数据库,会给数据库带来比较大的压力,但是可以解决一方案存在比较极限的场景,即Redis扣除库存操作执行成功后,Redis宕机,导致数据在Redis中没有持久化,同时程序下单成功了,这时候也会出现库存不一致的情况。
同一sku多实例多份库存记录
在Redis中记录流水一个很重要的原因就是为了找出那些错误的预扣库存数量并进行恢复,而导致Redis库存数量错误的一个重要原因就是服务可能宕机,导致Redis扣多了没有进行补偿。如果能准确找到这个服务实例扣多的部分,进行恢复,那么其他实例也可以正常继续处理请求。
首先库存表可以分为两个库存,一个是产品sku的初始化库存,一个是产品的实际库存。
同样在Redis中可以记录每个sku的初始库存,另外每个sku存在多份扣除库存,每一个服务实例有一份。
那么当前的可用库存=初始库存-每个服务实例的扣除库存。
当一个下单请求进来,会先在该实例对应的那一份sku扣除库存中增加扣除数,再执行下单流程并数据库持久化,再在数据库的库存流水中,也会记录每个流水对应的服务实例id。如果这个过程中,服务宕机了,那么重启之后(其实相当于销毁后重新创建新的实例,我想不到什么办法可以给每个服务实例唯一标识,并且在重启之后仍然能够拥有原来的id,一个最简单的办法就是数据库自增或者雪花等分布式id,每次重启之后有一个新的id),增加一份新的服务实例扣除库存记录(一开始是0),剩下的只用通过定时任务,找到那些已经失效的服务实例id,计算数据库中对应的流水记录扣除的库存数量,把Redis中对应的那一份数据如果不一致就进行覆盖。(Redis库存和实际库存可能会出现短暂的不一致,但Redis实际上是超扣,只会导致少卖而不会超卖,并且满足最终一致性,最后都会恢复到一个正确的库存)。