文章内容已经收录在《高级专家成长笔记》,欢迎订阅专栏! 从原理、系统设计出发,直击面试难点,实现更高维度的降维打击!
高级专家性能调优手段,库存扣减场景,20 倍性能提升!
文章内容参考 B 站 up 主《极海Channel》,视频链接:www.bilibili.com/video/BV1Hv…
看到了海哥讲解项目亮点的视频,讲的内容非常好,这里也整理一篇文章出来,记录一下整个库存扣减优化的流程
背景
库存扣减作为电商场景下的核心业务,对性能要求较高,如何针对库存扣减场景进行性能优化,提升接口 TPS,整个性能调优的演进过程可以作为自己的项目亮点,来展示给面试官,体现出自己的思考深度
库存扣减的本质就是 update 操作 ,即在 MySQL 中找到对应的库存记录,将库存数量减 1 即可
但是在电商场景中,一般不会仅仅执行 update 操作,还会插入库存的流水,将 库存扣减 和 插入库存流水 放入到同一个事务中
因此,接下来的库存扣减调优,就会基于库存扣减 + 插入库存流水这样一个事务,一步一步进行优化, 最终达到 20 倍的性能优化!
基于 MySQL 库存扣减瓶颈
要对库存扣减进行调优,先拿到最简单的实现方案,一步一步分析性能瓶颈,进行优化
初始实现方案: 直接基于 MySQL 进行库存扣减,在事务中进行 库存扣减 + 插入库存流水
SQL 如下:
UPDATE 库存表 SET stock_count = stock_count - 1 WHERE id = #{id}; -- 扣减库存
INSERT 库存流水表() VALUES ();
性能瓶颈
基于 MySQL 的方案,性能瓶颈肯定是 MySQL,主要有以下两个方面:
- 磁盘 IO: MySQL 的数据都在磁盘上存储,因此会产生磁盘 IO,速度会较慢
- 行锁:UPDATE 操作会在库存表上添加行锁,对于热点商品来说,多个扣减库存的请求会产生互斥,导致速度较慢
优化手段一:合并库存扣减请求
那么对应的优化方案也有两个方案:
- 方案一:减少磁盘 IO 开销: 将扣减库存不在 MySQL 进行,而是将库存扣减操作前置到 Redis 来完成,Redis 是基于内存的,因此磁盘 IO 开销就被减少了
该方案存在一个问题,如果基于 Redis 扣减库存,就没办法和插入库存流水放在一个事务中了,因为数据源不同,一个在 Redis,一个在 MySQL,无法保证数据的一致性
因此将库存扣减提前到 Redis 层在这个场景下不可行,该方案不作考虑
为什么需要库存流水呢?
主要用于核对库存数量的准确性,因为后边是使用异步线程去扣减库存,如果异步线程执行失败,需要通过库存流水来判断库存是否已经扣减,来进行库存数量的校准
- 方案二:减少锁的持有时间(采用的方案): 将热门商品的多个库存扣减请求合并,批量扣减库存,减少磁盘 IO 次数,缓解锁竞争
为什么这个方案可以优化性能呢?
库存扣减的性能瓶颈处于热门商品的扣减上,行锁竞争激烈,那么我们想办法减少热门商品的行锁竞争就可以,也就是通过将热门商品的多个库存扣减请求合并,来减少磁盘 IO 次数,并且减少了行锁的竞争
优化方案大致逻辑:
1、先判断商品的并发数,如果超过阈值(默认为 3),则创建队列
2、将用户请求提交到队列中,之后用户线程阻塞,等待 200ms(这里是等待异步线程去拉取队列的任务进行处理)
3、启动异步线程,从队列中拉取请求进行库存扣减,处理完之后,唤醒对应用户线程
性能提升
方案应用后,TPS 从 500 提升至 2000
方案存在问题:库存数量不一致
在使用合并商品库存请求的方案优化性能后,该方案存在一个问题:使用异步线程去处理,如果异步线程处理失败,可能在库存扣减之前失败,也可能在库存扣减之后失败,上游订单服务只得到响应失败,并不清楚是否成功扣减了库存, 因此需要对库存数量进行校准
如何校准库存数量?
在进行库存扣减的时候,需要记录 库存流水 来校准库存数量
如果订单服务发现,下游库存服务扣减库存失败,此时去发送一个校准库存数量消息,也就是判断这个订单是否存在对应的库存流水,如果存在,则证明已经成功扣减了库存
因此,去进行库存数量的回滚,以此来达到库存数量的一致性
仍存在数据不一致的可能: 在进行库存数量校验时,如果回滚失败了,仍然会存在数据的不一致。但是这种情况概率比较低,可以保证大部分情况下,库存数量的一致性就可以了。如果要进一步保证,我个人的思路是对库存数量校验进行落库,记录他的状态,分为 3 个状态:初始化、成功、失败,这样就可以知道库存数量回滚是否成功执行。
这里听海哥的意思是没有进一步去保证数据一致性了,如果要保证很强的一致性,需要付出较多的实现成本,设计的过程就是成本与需求之间的衡量。
极端情况下的问题: 极端情况下,扣减库存的异步线程因为某种原因阻塞,从而导致上游订单服务认为库存扣减失败,之后去校准库存数量,发现不存在库存流水,不进行库存的回滚。但这时,异步线程扣减了库存,并生成库存流水,就会导致库存数量不准确。
针对这个极端情况,可以采用 RocketMQ 的阶梯式重试,在几分钟之内多重试几次,如果发现没有库存流水,就认为库存没有成功扣减,不进行回滚
方案存在问题:库存数量不足
还存在一个问题:对商品的多个库存请求合并之后,如果库存数量不足,如何扣减?
这种情况下可以进行退化,循环扣减,按照用户购买商品的数量,从大到小排序,先扣减数量多的库存,再扣减数量少的库存,优先满足购买数量多的用户
优化手段二:事务级别优化
扣减库存有两个数据库操作:扣减库存 + 插入库存流水
这两个数据库操作,需要放在一个事务 内部 ,保证同时成功或同时失败,因此基于这个事务,还可以进一步优化, 减少锁的持有时间
扣减库存是 UPDATE 操作,会持有行锁
插入库存流水是 INSERT 操作,不会持有锁
因此,事务内部有两种执行选择:
- 先执行 UPDATE,再执行 INSERT
- 先执行 INSERT,再执行 UPDATE(采用方案)
性能分析:
在事务内部:
-
如果先执行 UPDATE 操作,那么会先持有这条库存的行锁,直到事务结束,行锁才释放,从而导致 INSERT 操作也会持有这个行锁。随着表中数据越来越多,INSERT 操作需要建立索引,耗时逐渐变长, 导致该事务的锁持有时间越来越长 ,并发度下降
-
如果先执行 INSERT 操作,此时是不会去持有行锁,之后再去执行 UPDATE 操作,才持有行锁,以此来减少行锁的持有时间
性能提升
方案应用后,TPS 从 3000 提升至 5000
优化手段三:缓存数据更新优化
性能瓶颈分析:
库存数据的访问量是比较大的,因此会放在 Redis 中,采用缓存旁路策略去保证缓存数据的一致性
也就是 UPDATE 库存之后,去 Redis 中删除对应的缓存
这里存在两点性能开销:
- 扣减库存接口:扣减库存接口对性能要求是比较高的,因此尽可能希望去减少一些同步操作,而采用缓存旁路,则会在扣减库存之后,去删除 Redis 中的数据,存在网络 IO,此时存在同步等待的时间
- 缓存频繁失效:库存数量的变化频率还是很高的,因此频繁的删除缓存,会导致缓存命中率较低,数据库的访问压力就会变大
因此,针对这两点问题,对于缓存数据的更新,由 缓存旁路 切换为 基于 binlog 异步更新
这样,扣减库存接口就不需要去删除 Redis 中的数据,而是由另外一个服务去监听 binlog,进行缓存数据的更新即可
对于 缓存频繁失效 的问题,库存数据并不需要那么精准,没有必要在每次扣减库存之后,都去更新缓存中的数据,因此设计了 时间窗口 ,比如一个商品在 5s 之内存在更新,那么就在 5s 结束的时候,再去刷新 Redis 的数据,来减少缓存更新的频率
但是使用时间窗口会导致前端界面的库存数量存在延迟,如果库存已经没有了,前端展示还存在库存的话,就会导致大量无效的流量打入后端应用,因此在库存消耗完之后,要实时更新 Redis 的数据,即:当发现商品的库存数变为 0,这种情况就不需要在时间窗口内等待,而是直接去刷新 Redis 中的缓存数量
其他优化
基于binlog 更新缓存数据的顺序问题: 为了保证不会使用旧数据去更新缓存中的新数据,在表中增加了版本号 version,来对比 Redis 数据的 version 和 binlog 数据的 version,保证使用最新数据去更新旧数据
在 binlog 的官方仓库 wiki 中,也有关于 binlog 一致性的说明,如下:
binlog本身是有序的,写入到mq之后如何保障顺序是很多人会比较关注,在issue里也有非常多人咨询了类似的问题,这里做一个统一的解答
1、canal目前选择支持的kafka/rocketmq,本质上都是基于本地文件的方式来支持了分区级的顺序消息的能力,也就是binlog写入mq是可以有一些顺序性保障,这个取决于用户的一些参数选择
2、canal支持MQ数据的几种路由方式:单topic单分区,单topic多分区、多topic单分区、多topic多分区
- canal.mq.dynamicTopic,主要控制是否是单topic还是多topic,针对命中条件的表可以发到表名对应的topic、库名对应的topic、默认topic name
- canal.mq.partitionsNum、canal.mq.partitionHash,主要控制是否多分区以及分区的partition的路由计算,针对命中条件的可以做到按表级做分区、pk级做分区等
3、canal的消费顺序性,主要取决于描述2中的路由选择,举例说明:
- 单topic单分区,可以严格保证和binlog一样的顺序性,缺点就是性能比较慢,单分区的性能写入大概在2~3k的TPS
- 多topic单分区,可以保证表级别的顺序性,一张表或者一个库的所有数据都写入到一个topic的单分区中,可以保证有序性,针对热点表也存在写入分区的性能问题(该方式保证同一张表的所有 binlog 都投递到同一个分区中,保证同一张表的 binlog 日志有序)
- 单topic、多topic的多分区,如果用户选择的是指定table的方式,那和第二部分一样,保障的是表级别的顺序性(存在热点表写入分区的性能问题),如果用户选择的是指定pk hash(使用表中的主键进行 hash,选择投递到哪一个分区,该方式可以保证主键相同的数据的 binlog 可以有序)的方式,那只能保障的是一个pk的多次binlog顺序性 ** pk hash的方式需要业务权衡,这里性能会最好,但如果业务上有pk变更或者对多pk数据有顺序性依赖,就会产生业务处理错乱的情况. 如果有pk变更,pk变更前和变更后的值会落在不同的分区里,业务消费就会有先后顺序的问题,需要注意
目前的性能瓶颈在于 单机 CPU ,库存扣减请求的合并是在单机里的,单机的 CPU 性能有限,但是如果水平扩容,就会导致库存请求合并的效果没有那么好,并且达到数据库的请求也会变多
因此后期规划单独抽出一个集群,用几台机器专门做库存扣减请求的合并,避免其他的服务(比如报表)占用机器的 CPU,以此压榨单机 CPU 的性能
再通过一些参数调优,比如线程池数量,用户等待 200ms,多久创建请求合并队列等等,最后 TPS 由最初的 500 提升到 10000,接口性能提升了 20 倍!
总结
整体介绍了库存扣减优化思路,其中最核心的优化就是对库存请求进行合并,将性能瓶颈由数据库转移到合并算法的设计中,性能具体可以提升多少,取决于合并算法可以减少多少的数据库 IO 开销
除了核心的请求合并优化,也进行了事务内部的 SQL 顺序调整,减少行锁持有时间
并且将缓存更新由同步更新,改为异步更新,减少扣减库存接口的同步等待时间, 并且降低了 Redis 中库存数据的刷新频率
并且每种优化方案都会存在一些问题,比如缓存更新改为异步刷新,那么当库存消耗完毕,就会存在更新延迟,因此对于库存消耗完毕的更新,实时进行更新,避免无效流量打入后端应用
最后,海哥还给出了合并库存请求的 Demo,这个应该会在下一篇文章中进行整理,写的相当地好!
参考资料:
- B 站 up 主极海 Channel 视频链接:www.bilibili.com/video/BV1g3…