今天给大家聊一个有意思的话题:每秒上千订单场景下,如何对分布式锁的并发能力进行优化?
首先来看看这个问题的背景!
前段时间有个朋友在外面面试,然后有一天找我聊说:有一个国内不错的电商公司,面试官给他出了一个场景题:
假如下单时,用分布式锁来防止库存超卖,但是是每秒上千订单的高并发场景,如何对分布式锁进行高并发优化来应对这个场景?
他说他当时没答上来,因为没做过没什么思路。其实我当时听到这个面试题心里也觉得有点意思,因为如果是我来面试候选人的话,应该会给的范围更大一些。
比如,我会让面试的同学聊一聊电商高并发秒杀场景下的库存超卖解决方案,各种方案的优缺点以及实践,进而聊到分布式锁这个话题。
因为库存超卖问题是有很多种技术解决方案的,比如悲观锁,分布式锁,乐观锁,队列串行化,Redis原子操作,等等吧。
但是既然那个面试官兄弟限定死了用分布式锁来解决库存超卖,我估计就是想问一个点:在高并发场景下如何优化分布式锁的并发性能
我觉得,面试官提问的角度还是可以接受的,因为在实际落地生产的时候,分布式锁这个东西保证了数据的准确性,但是他天然并发能力有点弱。
刚好我之前在自己项目的其他场景下,确实是做过高并发场景下的分布式锁优化方案。
因此正好是借着这个朋友的面试题,把分布式锁的高并发优化思路,给大家来聊一聊。
库存超卖现象是怎么产生的?
先来看看如果不用分布式锁,所谓的电商库存超卖是啥意思?大家看看下面的图:
这个图,其实很清晰了,假设订单系统部署两台机器上,不同的用户都要同时买10台iphone,分别发了一个请求给订单系统。
接着每个订单系统实例都去数据库里查了一下,当前iphone库存是12台。
俩大兄弟一看,乐了,12台库存大于了要买的10台数量啊!
于是乎,每个订单系统实例都发送SQL到数据库里下单,然后扣减了10个库存,其中一个将库存从12台扣减为2台,另外一个将库存从2台扣减为-8台。
现在完了,库存出现了负数!泪奔啊,没有20台iphone发给两个用户啊!这可如何是好。
用分布式锁如何解决库存超卖问题?
我们用分布式锁如何解决库存超卖问题呢?其实很简单,我们先看看分布式锁的实现原理:
同一个锁key,同一时间只能有一个客户端拿到锁,其他客户端会陷入无限的等待来尝试获取那个锁,只有获取到锁的客户端才能执行下面的业务逻辑。
代码大概就是上面那个样子,现在我们来分析一下,为啥这样做可以避免库存超卖?
大家可以顺着上面的那个步骤序号看一遍,马上就明白了。
从上图可以看到,只有一个订单系统实例可以成功加分布式锁,然后只有他一个实例可以查库存、判断库存是否充足、下单扣减库存,接着释放锁。
释放锁之后,另外一个订单系统实例才能加锁,接着查库存,一下发现库存只有2台了,库存不足,无法购买,下单失败。不会将库存扣减为-8的。
有没有其他方案可以解决库存超卖问题?
当然有啊!比如悲观锁,分布式锁,乐观锁,队列串行化,异步队列分散,Redis原子操作,等等,很多方案,我们对库存超卖有自己的一整套优化机制。
但是前面说过了,这篇文章就聊一个分布式锁的并发优化,不是聊库存超卖的解决方案,所以库存超卖只是一个业务场景而已
分布式锁的方案在高并发场景下
好,现在我们来看看,分布式锁的方案在高并发场景下有什么问题?
问题很大啊!兄弟,不知道你看出来了没有。分布式锁一旦加了之后,对同一个商品的下单请求,会导致所有客户端都必须对同一个商品的库存锁key进行加锁。
比如,对iphone这个商品的下单,都必对“iphone_stock”这个锁key来加锁。这样会导致对同一个商品的下单请求,就必须串行化,一个接一个的处理
大家再回去对照上面的图反复看一下,应该能想明白这个问题。
假设加锁之后,释放锁之前,查库存 -> 创建订单 -> 扣减库存,这个过程性能很高吧,算他全过程20毫秒,这应该不错了。
那么1秒是1000毫秒,只能容纳50个对这个商品的请求依次串行完成处理。
比如一秒钟来50个请求,都是对iphone下单的,那么每个请求处理20毫秒,一个一个来,最后1000毫秒正好处理完50个请求。
大家看一眼下面的图,加深一下感觉。
所以看到这里,大家起码也明白了,简单的使用分布式锁来处理库存超卖问题,存在什么缺陷。
缺陷就是同一个商品多用户同时下单的时候,会基于分布式锁串行化处理,导致没法同时处理同一个商品的大量下单的请求。
这种方案,要是应对那种低并发、无秒杀场景的普通小电商系统,可能还可以接受。
因为如果并发量很低,每秒就不到10个请求,没有瞬时高并发秒杀单个商品的场景的话,其实也很少会对同一个商品在一秒内瞬间下1000个订单,因为小电商系统没那场景。
如何对分布式锁进行高并发优化?
好了,终于引入正题了,那么现在怎么办呢?
面试官说,我现在就卡死,库存超卖就是用分布式锁来解决,而且一秒对一个iphone下上千订单,怎么优化?
现在按照刚才的计算,你一秒钟只能处理针对iphone的50个订单。
其实说出来也很简单,相信很多人看过java里的ConcurrentHashMap的源码和底层原理,应该知道里面的核心思路,就是分段加锁!
把数据分成很多个段,每个段是一个单独的锁,所以多个线程过来并发修改数据的时候,可以并发的修改不同段的数据。不至于说,同一时间只能有一个线程独占修改ConcurrentHashMap中的数据。
另外,Java 8中新增了一个LongAdder类,也是针对Java 7以前的AtomicLong进行的优化,解决的是CAS类操作在高并发场景下,使用乐观锁思路,会导致大量线程长时间重复循环。
LongAdder中也是采用了类似的分段CAS操作,失败则自动迁移到下一个分段进行CAS的思路。
其实分布式锁的优化思路也是类似的,之前我们是在另外一个业务场景下落地了这个方案到生产中,不是在库存超卖问题里用的。
但是库存超卖这个业务场景不错,很容易理解,所以我们就用这个场景来说一下。
大家看看下面的图:
其实这就是分段加锁。你想,假如你现在iphone有1000个库存,那么你完全可以给拆成20个库存段,要是你愿意,可以在数据库的表里建20个库存字段,比如stock_01,stock_02,类似这样的,也可以在redis之类的地方放20个库存key。
总之,就是把你的1000件库存给他拆开,每个库存段是50件库存,比如stock_01对应50件库存,stock_02对应50件库存。
接着,每秒1000个请求过来了,好!此时其实可以是自己写一个简单的随机算法,每个请求都是随机在20个分段库存里,选择一个进行加锁。
这样就好了,同时可以有最多20个下单请求一起执行,每个下单请求锁了一个库存分段,然后在业务逻辑里面,就对数据库或者是Redis中的那个分段库存进行操作即可,包括查库存 -> 判断库存是否充足 -> 扣减库存。
这相当于什么呢?相当于一个20毫秒,可以并发处理掉20个下单请求,那么1秒,也就可以依次处理掉20 * 50 = 1000个对iphone的下单请求了。
一旦对某个数据做了分段处理之后,有一个坑大家一定要注意:如果某个下单请求,咔嚓加锁,然后发现这个分段库存里的库存不足了,此时咋办?
这时你得自动释放锁,然后立马换下一个分段库存,再次尝试加锁后尝试处理。这个过程一定要实现。
分布式锁并发优化方案有没有什么不足?
不足肯定是有的,最大的不足,大家发现没有,很不方便啊!实现太复杂了。
首先,你得对一个数据分段存储,一个库存字段本来好好的,现在要分为20个分段库存字段;
其次,你在每次处理库存的时候,还得自己写随机算法,随机挑选一个分段来处理;
最后,如果某个分段中的数据不足了,你还得自动切换到下一个分段数据去处理。
这个过程都是要手动写代码实现的,还是有点工作量,挺麻烦的。
不过我们确实在一些业务场景里,因为用到了分布式锁,然后又必须要进行锁并发的优化,又进一步用到了分段加锁的技术方案,效果当然是很好的了,一下子并发性能可以增长几十倍。
该优化方案的后续改进
以我们本文所说的库存超卖场景为例,你要是这么玩,会把自己搞的很痛苦!
因此再次强调,这里的库存超卖场景,仅仅只是作为演示场景而已,以后有机会,再单独聊聊高并发秒杀系统架构下的库存超卖的其他解决方案。
马上2022年金三银四面试就要开始,也是有许多小伙伴找我要面经,其实很多大厂老哥都喜欢整理这些东西,比如阿里就产出很多,但是市面上的阿里内部培训资料都是零零散散的。前段时间阿里内部一个P7员工对2021年下半年阿里的培训资料做了一个整合,总结出了这套《Java辟邪剑谱》。
Java核心知识内容涉及:Java开发介绍、Java数组、Java面向对象、常用基础类、集合、IO流、多线程、异常、反射等。
一:Java开发介绍
DOS常用命令
JVM、JRE、JDK之间的关系
Java开发环境的搭建:安装JDK,配置环境变量
Java入门程序(Java的开发流程)
Java的注释,标识符、标识符的命名规范
Java基本数据类型
变量和常量的定义及初始化
Java的运算符
运算符的优先级
Java分支语句之if...else
循环的嵌套
方法的定义
方法的形参和实参
方法的递归调用
二、Java数组
Java 数组的定义
Java 数组的声明
数组的优势与局限
数组的遍历访问(普通循环,增强for循环)
数组元素的顺序查找
数组元素的冒泡法排序
Arrays工具类的使用
二维数组
三、Java面向对象
面向对象设计思想
面向对象的分析与设计
Java与面向对象
类中成员变量的定义与意义
构造方法的定义和调用
面向对象的封装特性
局部变量和成员变量的作用域问题
静态属性、静态方法、静态代码块
面向对象的继承特性
继承中构造方法的细节
面向对象的多态特性
抽象方法与抽象类
接口
四、异常
异常的概念
异常的分类
异常的理解
常见异常介绍
运行时异常
编译时异常
运行和编译异常区别
异常的处理方式之捕获异常
异常的处理方式之抛出异常
异常的处理方式之断点调试
断点调试之引导运行步骤
自定义异常
异常关键字处理
关键字执行流程
异常总结
五、集合
集合的概念和作用
集合和数组的区别
集合框架体系介绍
集合框架之Collection接口
ArrayList和LinkedList的方法使用
ArrayList和LinkedList各自的工作原理分析原理分析
使用多种方式遍历集合
HashSet和LinkedHashSet各自的工作原理分析
集合框架之Map接口
泛型的使用
Collections工具类的使用
六、IO流
File类的作用
File类中常用方法的使用介绍
使用File类操作文件和文件夹
相对路径和绝对路径的介绍
IO流的概念和工作原理
IO流的分类
文件流的使用
转换流的使用
缓冲流的使用
对象流的使用
内存流的使用
使用不同的流实现文件内容的拷贝
七、多线程
进程和线程的介绍
进程和线程之间的区别与联系
线程实现方式之继承自Thread类
线程实现方式之实现Runnable接口
线程实现方式之线程池
线程的生命周期
线程中常用方法的使用
我把一些常用Java工具包的思维导图做了汇总,持续更新中,方便读者查阅。
从这份文档截图中就可以看到这份资料总结得非常全面,有需要的话,完整版本PDF文档可以点击此处免费领取,如果觉得本文对你有帮助,可以转发关注支持一下。