一个简单的下单流程包括,商品校验,订单计价,扣库存,保存订单。其中扣库存的并发问题是整个流程中最麻烦,最复杂的环节,可以说聚集了所有的智慧和头发。
解决扣库存并发问题,很容易让人想到加锁,加锁的目的是为了限制同步代码块并发,进一步的保证原子性,可见性和重排序,实现数据一致性。
单机加 jvm 锁,分布式加分布式锁。这让我不禁想起分布式系统一句黑话,分布式系统中,没有什么问题是不能通过增加中间环节解决的,但解决一个问题常常会带来另外的问题,是的,你没听错,以空间换时间,以并发换数据一致性,在这里,锁粒度和范围对并发影响是最直接的,设计的时候尽可能的缩小锁粒度和范围,一般粒度是 skuId,范围尽量减小。
锁时长,锁过期是另外两个不得不考虑的问题。最麻烦的锁过期,常用解决方案是依赖 redission 的看门狗机制,相当于定时任务给锁续命,但粗暴续命会增加 rt,同时增加其他请求的阻塞时长。
尽量避免牺牲并发的方案!
尽量避免牺牲并发的方案!
尽量避免牺牲并发的方案!
一次偶然的机会,我的同事向我推荐了 Google 的 chubby。为什么我们不能用悲观锁+乐观锁的组合呢?在锁过期的时候,乐观锁兜底,不影响请求 rt,也能保证数据一致性。这是个不错的方案,适合简单的场景!
一次偶然的机会,一条公式冲击我的大脑,redis = 高性能 + 原子性。机智的你肯定知道加锁就是为了保证原子性,基于 redis 实现分布式锁也是因为 redis 的原子性和高性能(想想什么情况用 mysql 和 zk),如果我用 redis 代替锁,是不是既能保证扣库存的原子性,同时因为没有锁,又不需要考虑加锁带来的问题。
说干就干,马上画个图。(图片被掘金压缩,有点糊,我上传到图床,点击能跳转到图床看清晰的,如果看不清楚图片,联系我,给我留言)
我把订单流程分为5大块,有点复杂,且听我细细道来。
Order process:
扣库存是限制订单并发的瓶颈,依靠 redis 的原子性保证数据一致性,高性能提升并发
2pc
基于二阶段提交思想,第一步首先插入 INIT 状态的订单
冷热路由
第二步有个路由,冷门商品走 mysql 下单,热门商品并发大,依靠 redis 撑。
如何知道商品冷热,答案是 bitMap,所以我们还需要一个定时任务(job4)维护 bitMap。冷热数据的统计来源一般是购物车,埋点统计。大电商平台来源更丰富,大数据追踪,算法推荐等。
故障处理
lua 扣减库存时,需要考虑请求超时和 redis 宕机。请求超时比较好解决,可以 catch 超时异常,依据业务选择重试或返回。redis 宕机比较棘手,后面分析。
降级
这里说一下降级。redis 宕机之后,走冷门订单流程。但是这里的设计会变的很复杂,因为需要解决两个问题,如何断定 redis 宕机,走冷门路由会不会把 mysql 压垮?这两个问题继续谈论下去会衍生出更多,比如走冷门路由的时机,冷门路由会不会把 mysql 压垮等,所以这里触发熔断还需要马上开启限流,展开真的很复杂,下次有机会说。
扣库存后续动作突然变得顺畅,插入订单库存流水,修改订单状态 UNPAY,发送核销优惠券 mq,日志记录等。这几个步骤中,
- 流水用于记录订单和库存的绑定,重建商品库存缓存会用到
- 核销优惠券选择异步,发核销优惠券的 mq,需要考虑消息丢失和重复消费,上游订单服务记录本地消息表,同时有个定时任务(job1)扫描重发,下游做好幂等
- 我们还需要关注该流程可能会出现 jvm 宕机,这是很严重的事故,按理说没有顺利走完订单流程的订单属于异常订单,异常订单的库存需要返还 redis,所以还需要一个定时任务处理异常订单。
JOB2
redis 没有库存流水,被扣库存 x 无法得知
订单流程有几处宕机需要考虑,一处是执行 lua 脚本时 redis 宕机,另一处是扣完库存之后,jvm 宕机。无论是 redis 还是 jvm 宕机,这些订单都会返回异常信息到前端,所以这些订单的是无效的,需要还库存到 redis。
mysql 和 redis 的流水描述同一件事情,即记录该笔订单所扣库存。在异常情况下,可能只有 redis 有流水,依然可以作为断定库存已经扣减的依据,在极端异常的情况,lua 脚本刚扣完库存,redis 进程死了或者宕机,虽然 lua 是原子性的,但宕机可不是原子性,库存 x 已经扣了,没有流水记录,无法知道 x (redis 的单点问题可以通过 redis ha 保证)。
如果 redis 恢复了,但数据没了,怎么办?
如果 redis 恢复了,但数据丢失了(库存变化还没持久化就宕机,redis 重启恢复的是旧数据),怎么办?
Rebuild stock cache of sku
剩余库存 = (库存服务的总库存减去预占库存) - (mysql 和 redis 流水去重,计算的库存)
把目光锁定到右下角,重建 sku 库存缓存的逻辑。一般地,在 redis 扣完库存,会发个 mq 消息到库存服务,持久化该库存变动。库存服务采用 a/b 库存的设计,分别记录商品总库存和预占库存,为的是解决高并发场景业务操作库存和用户下单操作库存时的锁冲突问题。库存服务里的库存是延迟的,订单服务没发的消息和库存服务没消费的消息造成延迟。
我们既然把库存缓存到 redis,不妨想一下如何准确计算库存的数量。
- 在刚开始启动服务的时候,redis 没有数据,这时候库存 t = a - b(a/b库存)
- 服务运行一段时间,redis 有库存 t, 此时 t = a - b - (库存服务还没消费的扣库存消息),所以拿 mysql 和 redis 的流水去重,计算出已扣未消费库存。redis 宕机后,会有一个未知已扣库存 x, x 几乎没有算出来的可能(鄙人尽力了),也没必要算出来,你想,当你 redis 异常了,库存 x 对应的订单是异常订单,异常订单不会返回给用户,用户只会收到下单异常的返回,所以库存 x 是无效的,丢掉就好。
Payment process
用户支付之后,才发扣库存消息到库存服务落地。落地库存服务的流程很简单,不再阐述。重点说说新增库存和减少库存。新增库存不会造成超卖,简单粗暴的加就好。减少库存相当于下单,需要小心超卖问题,所以现在 redis 扣了库存,再执行本地事务,简简单单,凄凄惨惨戚戚,乍暖还寒时候,最难将息,三杯两盏淡酒,咋敌...
多说两句
纵观整幅图,对比简单下单流程,可以发现,为了解决高并发下单,引入一个中间环节,而引入中间环节的副作用需要我们处理。虽然订单流程变复杂了,但并发提高了。一般来说,redis qps 10万,实际上没有10万,如果你的业务 qps 超过单机 redis 限制,记住,分布式的核心思想就是拆,把库存均匀打散到多台 redis。
打散之后需要解决库存倾斜问题,可能实例 a 已经卖完了,实例 b 还有部分库存,但部分用户请求打到实例 a,就会造成明明有货,但下单失败。这个问题也很棘手,感兴趣的同学可以自行研究,学会教教我。
上述流程经过简化,真实情况会更复杂,不一定适合实际场景。如果有错误的地方,烦请留言讨论,多多交流,互相学习,一起进步。
还有个问题需要提,流程中的事务问题。可以发现,订单流程是没有事务控制的。一方面我们认为,数据很宝贵,不分正常异常。异常的订单数据可作为分析系统架构缺陷的依据,另一方面接口尽量避免长事务,特别是高并发下,事务越短越好。
回答几个问题
为什么感觉拿掉分布式锁之后,流程变得很复杂?
其实我大可给订单流程前后包裹一个分布式锁,新的设计就变成下图,可以看到,核心库存扣减逻辑并没有变化,所以分布式锁的存在并不是让流程变复杂的原因。
为什么流程突然变的很复杂?
- 为保证数据一致性,加了几个定时任务和一个重建缓存接口;为提高性能,加了冷热路由;为减少复杂度,把库存扣减消息延迟到支付,总体流程比简单下单流程多了几道工序
- 因为引入异构数据库,数据源由一变多,就需要维护数据源数据一致性。可以说,这些流程纯纯是为了保证多个数据源的数据一致性。如果以前我们在 mysql 做库存扣减,基于 mysql 事务就能保证数据一致性。但是 mysql 的 qps 并不高,他适合并发不高的情况,所以我才会让冷门商品走 mysql 下单流程,因为冷门商品几乎没有并发
- 所以流程变得复杂的原因是维护数据一致性
总结
场景一:并发较低,MySQL可承受
如果业务量不大,且并发只有几十或百来个,那么 MySQL 可以胜任。为了保证数据一致性,需要在外层套上分布式锁。同时,在使用 MySQL 时需要注意锁粒度和锁区间。此外,避免订单请求把 MySQL 连接数打满,影响其他业务,可以考虑使用 Sentinel 进行限流。
场景二:并发量大,MySQL存在瓶颈
当营销变得复杂时,不仅仅是普通的订单流程,还有秒杀、限时特价和热销推广等复杂场景,此类业务的并发集中在特定的 SKU 上。在这种情况下,接口并发可能没有太大问题,因为分布式锁有限流的作用。但对于用户而言,大量购买失败就会带来严重后果。此类场景的瓶颈在于 MySQL,在理论上,将库存打散到其他 MySQL 实例可以解决问题,但我们不会这样做,因为 MySQL 是有状态的,所以更推荐的做法是基于 Redis 扣库存。
场景三:商品数量过亿
如果有幸业务发展到亿级商品数量,此时如果将所有商品的库存都存储在 Redis,可能会带来非常大的内存开销。一般来说,库存的结构为 {SKU ID: 数量},每个 SKU 只需要占用两个 int(8个字节)的空间,因此在性能方面没有大问题。根据二八原则,非热销商品大约占80%,这些商品可能很久都没人买,把库存存到 redis 实属浪费 700多 m。基于分布式的拆分思想,以热度维度分流商品库存,热门商品库存存储到 Redis,冷门的商品库存存储到 MySQL
此外,可以参照 redis cluster,修改路由算法将商品库存分配到不同的 Redis 实例。不过从实际来说,当你商品过亿,也不差钱搭个 redis cluster。如果你细想,冷热路由相当于把库存分散到多个实例,这会带来一些问题,比如用户购买多件商品的库存跨了多个实例,如果确定扣库存顺序,如何解决库存不足的资损,还有库存的逆向流程等,这些问题展开很复杂,有机会讨论
最后,对于库存的消息落库问题,如果上游订单很多,而下游的库存服务处理速度较慢,可能会出现消息堆积现象。针对这种情况,可以采用生产者-消费者模型,通过合并数据并批量提交的方式来加快落库速度。这种优化方式可以有效地避免消息堆积现象,提高系统的性能和稳定性