一个简单的下单流程包括,商品校验,订单计价,扣库存,保存订单。这个流程中,扣库存的并发问题是整个流程最麻烦,最复杂,可以说聚集了所有的智慧和头发。
对于扣库存并发问题,很容易想到加锁,加锁的目的是为了限制同步代码块并发,进一步的保证原子性,可见性和重排序,实现数据一致性。
在分布式系统中,而解决一个问题常常会带来另外的问题,是的,你没听错。上述锁流程中,牺牲并发保证数据一致性,锁粒度和范围对并发影响是最直接的,所以设计的时候尽可能的缩小锁粒度和范围。锁粒度一般是 skuId,范围尽量限制在扣库存逻辑。
锁的时长,锁过期是另外不得不考虑的问题。最麻烦的锁过期,常用解决方案是依赖 redission 的看门狗机制,相当于定时任务给锁续命,但粗暴续命会增加 rt,同时增加其他请求的阻塞时长。
尽量避免牺牲并发的方案!
一次偶然的机会,我的同事向我推荐了 Google 的 chubby。为什么我们不能用悲观锁+乐观锁的组合呢?在锁过期的时候,乐观锁兜底,不影响请求 rt,也能保证数据一致性。这是个不错的方案,适合简单的场景!
一次偶然的机会,一条公式浮现在我的脑海里,redis = 高性能 + 原子性。加锁就是为了保证原子性,在 redis 用分布式锁也是因为 redis 的原子性和高性能(想想什么情况用 mysql 和 zk),如果我用 redis 代替锁,是不是既能保证扣库存的原子性,同时因为没有锁,所以不需要考虑加锁带来的问题。
说干就干,马上画个图。
图片把订单流程分为5大块,有点复杂,且听我细细道来。
Order process:
扣库存是限制订单并发的瓶颈,依靠 redis 的原子性保证数据一致性,高性能提升并发
1、基于二阶段提交思想,首先插入 INIT 状态的订单
2、第二步有个路由机制,冷门商品走 mysql 下单,热门商品并发大,借助 redis 撑住并发。如何知道商品是否冷门或是热门,答案是 bitMap,所以我们还需要一个定时任务(job4)维护 bitMap。冷门和热门数据的统计来源一般是购物车,埋点统计。大电商平台来源更丰富,大数据追踪,算法推荐等。
3、在 redis 基于 lua 扣减库存,需要考虑请求超时和 redis 宕机。请求超时比较好解决,可以 catch 超时异常,依据业务选择重试或返回。redis 宕机比较棘手,后面分析。
4、扣完库存需要插入订单库存流水,修改订单状态 UNPAY,发送核销优惠券 mq,日志记录等。这几个步骤中,流水用于记录订单和库存的绑定,重建商品库存缓存会用到,核销优惠券选择异步,发核销优惠券的 mq。该 mq 场景需要考虑消息丢失和重复消费,上游订单服务记录本地消息表,同时有个定时任务(job1)扫描重发,下游做好幂等。我们还需要关注该流程可能会出现 jvm 宕机,这是很严重的事故,按理说没有顺利走完订单流程的订单属于异常订单,所以还需要一个定时任务处理异常订单。
JOB2
redis 没有库存流水,订单所扣库存 x 无法得知
订单流程有几处宕机需要考虑,一处是执行 lua 脚本时 redis 宕机,另一处是扣完库存之后,jvm 宕机。无论是 redis 还是 jvm 宕机,这些订单都会返回异常信息到前端,所以这些订单的是无效的,需要还库存到 redis。
mysql 和 redis 的流水描述同一件事情,即记录该笔订单所扣库存。在异常情况下,可能只有 redis 有流水,依然可以断定库存已经扣减,在极端异常的情况,lua 脚本刚扣完库存,redis 进程死了或者宕机,那是很严重的事故,不仅影响服务可用性,还会造成脏数据。虽然可以通过 ha 方案保证 redis 高可用,但你没法保证库存所在 redis 一定不会出现问题。
如果 redis 恢复了,但数据没了,怎么办?
如果 redis 恢复了,但数据丢失了(库存变化还没持久化就宕机,redis 重启恢复的是旧数据),怎么办?
Rebuild stock cache of sku
剩余库存 = (库存服务的总库存减去预占库存) – (mysql 和 redis 流水去重,计算的库存)
把目光锁定到右下角,重建 sku 缓存的逻辑。一般地,在 redis 扣完库存,会发个 mq 消息到库存服务,持久化该库存变动。库存服务采用 a/b 库存的设计,分别记录商品总库存和预占库存,为的解决高并发业务和订单操作库存时,加锁冲突。库存服务里的库存是延迟的,订单服务没发的消息和库存服务没消费的消息造成延迟。
我们既然把库存缓存到 redis,不妨想一下如何准确计算库存的数量。
- 在刚开始启动服务的时候,redis 没有数据,这时候库存 t = a – b
- 服务运行一段时间,redis 有库存 t, 此时 t = a – b – (库存服务还没消费的扣库存消息),所以拿 mysql 和 redis 的流水去重,计算出已扣未消费库存。redis 宕机后,会有一个未知已扣库存 x, x 几乎没有算出来的可能(鄙人尽力了),也没必要算出来,你想,当你 redis 异常了,库存 x 对应的订单是异常订单,异常订单不会返回给用户,用户只会收到下单异常的返回,所以库存 x 是无效的,丢掉就好。
Payment process
用户支付之后,才发扣库存消息到库存服务落地。落地库存服务的流程很简单,不再阐述。重点说说新增库存和减少库存。新增库存不会造成超卖,简单粗暴的加就好。减少库存相当于下单,需要小心超卖问题,所以现在 redis 扣了库存,再执行本地事务,简简单单,凄凄惨惨戚戚,乍暖还寒时候,最难将息
多说两句
纵观整幅图,对比简单下单流程,可以发现,为了解决高并发下单,可以通过引入一个中间环节解决,而引入中间环节带来的系列问题需要我们处理。虽然订单流程变复杂了,但并发提高了。一般来说,redis qps 10万,如果你的 qps 要突破10万,就需要把库存打散,可以部署多台 redis,把库存均匀部署到各个实例。
打散之后需要解决库存倾斜问题,可能实例 a 已经卖完了,实例 b 还有部分库存,但部分用户请求打到实例 a,就会造成明明有货,但下单失败。这个问题也很棘手,感兴趣的同学可以自行研究,学会教教我。
上述流程经过简化,真实情况会更复杂,不一定能应用。如果有错误的地方,烦请留言讨论,多多交流,互相学习,一起进步。
还有个问题需要提,流程中的事务问题。可以发现,订单流程是没有事务控制的,一方面我们认为,21世纪数据很宝贵,订单流程失败的数据可作为分析系统架构缺陷的依据,另一方面接口尽量避免长事务,特别是高并发下,事务越短越好。