1、大纲要点
spring-framework5.2.x (我的fork版)gitee地址:https://gitee.com/appleyk/spring-framework/tree/5.2.x/
分布式锁springboot项目github地址:https://github.com/kobeyk/springboot-distribute-lock-sample
分布式锁springboot项目gitee地址:https://gitee.com/appleyk/springboot-distribute-lock-sample
1.1 场景:以最常见的XXX商品下单(减库存)为例,如何在高并发的互联网环境下,避免出现“超卖”的问题?
1.2 解决方案:加锁,准确点,分布式锁
1.3 锁技术选型:
1.3.1 基于数据库层面的“乐观”和“悲观”锁
1.3.2 基于redisson框架的分布式锁
redis可视化工具
链接:https://pan.baidu.com/s/1KWeBEDQicTNkF9TT6e00Kg
提取码:k4zn
1.3.3 基于zookeeper分布式协调器的分布式锁
ZooInspector可视化工具(jar包)
链接:https://pan.baidu.com/s/1rMQNSJp975xMvS1X0ejKBw
提取码:0988
1.4 数据库:postgresql、mysql8
1.5 ORM框架选型:mybatis-plus、tk.mybatis、jpa
1.6 数据库连接工具:
1.6.1 DataGrip 2020
链接:https://pan.baidu.com/s/1FTz21gPJj73LRV-HPe5sFQ
提取码:troo
1.6.2 Navicat
1.7 并发测试工具:jmeter
链接:https://pan.baidu.com/s/1ZjHNcKIpIY-lbkshlNzfFg
提取码:vel3
1.8 测试出现的问题:锁的释放在事务提交成功之前完成
1.8.1 导致的后果:锁白加了,浪费了性能不说,最后还没能挡住商品的“超卖”
1.8.2 解决方案调整:
1、第一种解决方案:service方法上不加事务注解,在具体的reduce(repo)方法上加事务注解
2、第二种解决方案: 使用事务同步管理器,约束释放锁的时机必须在事务完成后才可以
3、第三种解决方案:service方法上不加@Transaction,在程序中,手动管理事务的提交和回滚
4、第四种解决方案:压根就不加锁,由上层进行锁控制
2、为什么要使用分布式锁
(1)单机应用无法满足互联网三高(高并发、高性能、高可用),所以需要对应用进行拆分,必然牵扯服务集群部署
(2)一旦集群部署,就牵扯到多线程并发下,对同一资源的操作,如果仍然使用单机场景下的JVM锁,会有问题,因此不得不考虑采用分布式锁来实现并发情况下,数据共享状态下线程操作结果的一致性
举例:拿最常见的商品下单来说,如果存在同一时刻,购买同一商品的人数激增,假如有1000人同时购买,但实际上库存中的商品仅剩下500件。这种业务场景就属于多线程操作同一共享变量:多线程就是同一时刻的N多用户点击下单按钮发出的请求,共享变量就是同一件商品的库存量,而操作就是用户下单导致的商品减库存。有点多线程编程经验的都知道,这种情况如果不针对业务场景做出点demo改变,减库存的结果必然是不安全的,比如库存出现负数,导致“超卖”问题的发生。
消除多线程不安全问题的做法大体有两种:
1、干掉共享变量
这个不太现实,但是干掉共享变量有两种思路可以提供
(1)使用final来修饰变量,定义变量一开始就赋值,后续无法再进行赋值操作(这种多线程下必然是安全的),这种适合读做常量,不适应读写(不仅读,还要改)的业务场景。
(2)使用ThreadLocal,为共享变量在每一个线程中保存一个副本,即线程操作的都是自己的变量副本,所以不会再出现多线程 同时操作同一变量的情况了。但是ThreadLocal这种不适用本篇提到的业务场景,一是它是基于单机JVM的,微服务多JVM下是无法搞的,二是我们每个线程对变量副本操作完之后,最后的业务处理是要对变量进行统一更新的,ThreadLocal更多的作用是对全局共享变量起到一个隔离的作用,比如其在Spring事务管理器中的应用,比如其在Mybaits3数据库会话管理器中的应用:
如在Spring事务同步管理器(TransactionSynchronizationManager)中的应用:
这个类是一个抽象类,有一堆的静态常量(NamedThreadLocal类型)和方法,如下
那么这个类中的这些方法是在什么时候调用的呢,请继续往下看....
其实现/继承的类视图(部分)如下(主要看Jpa事务管理器):
由于本篇演示案例是在spring-framework5.2.x源码中进行调试的,而orm用的是spring-data-jpa依赖包,因此d事务管理器接口的最终实现子类是JpaTransactionManager,在d该类中,我们定位到方法doBegin(...)上(该方法在调用相关带有@Transactional注解的方法时会进入)
点进去定位到下面这一行
我们继续跟进bindResource(...)方法
我们接着点开set(map)方法进行跟踪
有绑定必然有解绑,不然占着资源可还行?
关于ThreadLocal,这里我还要在补充一下,看下图
由于本篇不是重点讲Spring事务工作原理的,所以下面就不再继续了,大家只要知道Spring事务管理器中用到了ThreadLocal就算有收获了
如在Mybatis3的SqlSessionManager中的应用:
都讲到这里了,我们来重点看下commit方法的执行过程吧,如下:
其会调用DefaultSqlSessionle类中的commit方法,该方法又调用了commit(boolean force)方法
然后我们进入isCommitOrRollbackRequired方法看一下它的实现,如下:
基于以上分析,如果sql执行的是update操作的话,isCommitOrRollbackRequired(false)的值就是true,继续跟进如下:
事务提交后,再将dirty设置为false
关于ThreadLocal在框架级别项目中的应用,就不多说了(其实是说的有点多了,都跑偏了),奉劝各位,多下源码,多阅读多调试,时间久了,你就会感叹:卧槽,别人写的代码就是牛逼啊!
2、对操作,也就是减库存上锁,而上锁又可以分为两种
(1)JVM锁:如果系统用户量不大,且业务不复杂,不涉及服务拆分,单服务实例即可支撑每日的访问量的话,则在减库存的业务上加JVM锁即可,比如synchronize加在方法或代码块上,比如,具体的方法中,使用JUC并发包下面的lock即可。
(2)分布式锁:如果系统用户量大,且业务需要进行拆分,以微服务的方式多节点部署的话,此时如果仍采用JVM锁的话,就会导致同一共享资源的锁出现“分身”的情况:比如节点A、B、C...上都有一把资源的锁,那还搞个锤子哦。这就好比,明明一个厕所坑位只有一个门,你却设计了三个门,那这个坑位到底是一次只能一个人(线程)进来,还是三个人(线程)都可以进来?因此,分布式服务必须要用分布式锁来解决多线程下共享资源安全的问题!!!
3、分布式锁满足的条件
(1)在分布式环境下,同一时间,一个方法(称作资源比较好,比如sentinel的资源保护机制)只能被一个机器的一个线程执行
(2)高可用的锁获取和锁释放 -- 不能这边刚获取到锁,提供锁的应用就挂掉了
(3)高性能的锁获取和锁释放 -- 不能获取锁和释放锁比应用方法执行还要费时间吧?
(4)具备可重入特性,线程再次进入方法时,如果持有了资源的锁,则直接进入,但是释放的时候,有几把锁就要释放几次
(5)具备锁失效机制,防止死锁 -- 比如redis的key锁,一旦客户端挂掉,如果先前客户端线程持有的锁没有正常释放的话,就会产生死锁,因此需要设置key锁的有效期,再比如zk的临时顺序节点,如果客户端没有正常delete节点,则由zk服务端自动删除
(6)具备非阻塞锁特性,既没有获取到锁后立即返回获取锁失败的状态 -- 可以while自旋,直到获取锁成功,但是这种办法会消耗cpu,还有一种就是像zk那样,注册一个顺序节点的监听器,一旦节点删除后,便通知排在该节点后面的节点,以此成功获取锁
3、分布式锁实现的技术方案有哪些
常用到的有三种,分别是基于db、redis和zk,具体如下:
3.1 基于db的锁实现
db采用的orm框架是在mybatis框架的基础上增强的mybatis-plus,数据库则采用的是PostgreSql
由于篇幅有限,只举其中一个例子作为测试,如拿不加锁的和基于资源插入的测试进行比较
1》不加锁的代码片段如下
2》基于资源插入“锁”的代码片段如下:
3.1.1 不加锁,裸奔 -- 测试
数据准备,Mac电脑初始库存量为500
jmeter进行并发测试
最终聚合报告如下(当然最终的吞吐量是个大概,可能执行的值是不一样的,具体以生产环境为主)
我们看下数据库中最终的库存量是不是等于0?
3.1.2 基于资源插入,多线程争抢 -- 测试
将库存量重置为500,如下:
新增资源表(项目中src目录下,有基于mysql和postgresql的表脚本)如下:
再次jmeter并发测试(还是2s产生2000个线程),执行的聚合结果报告如下:
我们再看下数据库中的商品库存量是否为0?
我们看下控制台打印的结果:
有一个点需要注意,那就是减完库存后,一定要对资源进行delete
如果不删除的话,只会有一个线程成功的获取锁(插入资源),其他线程只能获取失败(再插入通用的资源,会出现唯一性约束的db异常)
比如,我们这样做
重复上述的测试,最终结果你会发现
3.2 基于redisson框架的分布式锁实现
orm采用的是jpa,数据库采用的是双驱动,即postgresql和mysql进行配置切换(目的是对比两者的读写性能)
3.2.1 使用分布式RLock的代码片段
话不多,直接上jmeter测试结果
postgresql:
mysql:
我丢? postgresql和mysql的并发测试结果居然不一样?postgresql经过几轮测试始终能保证库存量最终为0 ,而mysql无论经过多少次测试,库存量始终不等于0!!!
3.2.2 基于分布式锁与事务共用的思考
其实网上随便搜下,就会出来很多几乎一模一样的文章,那就是关于分布式锁与Spring事务一起用时会导致锁失效的问题,为什么会这样呢,我们看下代码中我们是在什么时候释放锁的
总结一点就是:finally块中的代码,是在return执行之后,return返回之前执行,也就是中间的空档期,就看是锁释放的效率快啊,还是事务执行的效率快了。从并发测试的结果(吞吐量和结果的一致性)来看,postgresql的读写性能要高于mysql,而且postgresql事务执行的效率要优于分布式锁释放的效率(猜测),因此postgresql数据库在并发测试时,库存量始终为0(当然,可能是巧合,还是得大量测试才更有说服力),而mysql就不同了,无论怎么测试,结果都是“惊人”的相似,那就是商品“超卖”了!!!!!!
3.2.3 基于分布式锁与事务共用的思考
基于上述的测试结果,我决定在spring-framework中进行一个demo测试,测试模块是基于gradle构建的,其中build.gradle内容如下
apply plugin: 'idea'
apply plugin: 'war' // 引入war插件, 它默认包含java插件
apply plugin: 'com.bmuschko.tomcat' //tomcat: 插件
group 'org.springframework'
version '5.2.7.BUILD-SNAPSHOT'
// tomcat: 以下配置会在第一次启动时下载插件二进制文件
buildscript {
repositories {
jcenter()
}
dependencies {
classpath 'com.bmuschko:gradle-tomcat-plugin:2.5'
}
}
repositories {
mavenCentral()
}
dependencies {
compile(project(":spring-context"))
compile(project(":spring-orm"))
compile(project(":spring-webmvc"))
// https://mvnrepository.com/artifact/org.springframework.data/spring-data-jpa
compile (group: 'org.springframework.data', name: 'spring-data-jpa', version: '2.4.0'){
// 排除对org.springframework的依赖,用本项目里自带的源码级别的模块
exclude module: 'spring-tx'
exclude module: 'spring-context'
exclude module: 'spring-core'
exclude module: 'spring-beans'
exclude module: 'spring-aop'
exclude module: 'spring-orm'
}
// https://mvnrepository.com/artifact/org.redisson/redisson
compile group: 'org.redisson', name: 'redisson', version: '3.13.6'
// https://mvnrepository.com/artifact/javax.persistence/javax.persistence-api
compile group: 'javax.persistence', name: 'javax.persistence-api', version: '2.2'
// https://mvnrepository.com/artifact/com.alibaba/druid
compile group: 'com.alibaba', name: 'druid', version: '1.2.4'
// https://mvnrepository.com/artifact/org.hibernate/hibernate-entitymanager
compile group: 'org.hibernate', name: 'hibernate-entitymanager', version: '5.4.25.Final'
// https://mvnrepository.com/artifact/mysql/mysql-connector-java
compile group: 'mysql', name: 'mysql-connector-java', version: '8.0.21'
// https://mvnrepository.com/artifact/org.postgresql/postgresql
compile group: 'org.postgresql', name: 'postgresql', version: '42.2.5'
// https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-databind
compile group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.11.0'
// https://mvnrepository.com/artifact/org.slf4j/slf4j-simple
compile group: 'org.slf4j', name: 'slf4j-simple', version: '1.7.30'
testCompile group: 'junit', name: 'junit', version: '4.12'
// https://mvnrepository.com/artifact/javax.servlet/javax.servlet-api
providedCompile group: 'javax.servlet', name: 'javax.servlet-api', version: '4.0.1'
def tomcatVersion = '8.5.16'
tomcat "org.apache.tomcat.embed:tomcat-embed-core:${tomcatVersion}",
"org.apache.tomcat.embed:tomcat-embed-logging-juli:8.5.2",
"org.apache.tomcat.embed:tomcat-embed-jasper:${tomcatVersion}"
}
// tomcat参数配置
tomcat{
httpProtocol = 'org.apache.coyote.http11.Http11Nio2Protocol'
ajpProtocol = 'org.apache.coyote.ajp.AjpNio2Protocol'
httpPort = 8088
}
tomcatRun.contextPath = '/'
tomcatRunWar.contextPath = '/'
// UTF-8
tasks.withType(JavaCompile) {
options.encoding = "UTF-8"
}
tasks.withType(JavaCompile) {
// Try to turn them all off automatically
options.compilerArgs << '-Xlint:none'
options.compilerArgs << '-nowarn' // same as '-Xlint:none'
// Turn them off manually
options.compilerArgs << '-Xlint:-auxiliaryclass'
options.compilerArgs << '-Xlint:-cast'
options.compilerArgs << '-Xlint:-classfile'
options.compilerArgs << '-Xlint:-deprecation'
options.compilerArgs << '-Xlint:-dep-ann'
options.compilerArgs << '-Xlint:-divzero'
options.compilerArgs << '-Xlint:-empty'
options.compilerArgs << '-Xlint:-fallthrough'
options.compilerArgs << '-Xlint:-finally'
options.compilerArgs << '-Xlint:-options'
options.compilerArgs << '-Xlint:-overloads'
options.compilerArgs << '-Xlint:-overrides'
options.compilerArgs << '-Xlint:-path'
options.compilerArgs << '-Xlint:-processing'
options.compilerArgs << '-Xlint:-rawtypes'
options.compilerArgs << '-Xlint:-serial'
options.compilerArgs << '-Xlint:-static'
options.compilerArgs << '-Xlint:-try'
options.compilerArgs << '-Xlint:-unchecked'
options.compilerArgs << '-Xlint:-varargs'
}
测试模块如下:
项目依赖如下:
在调试过程中,特意在事务执行的相应代码处增加了日志打印,通过分别测试postgresql和mysql发现,两者执行同样的方法,锁释放的时机均在事务完成提交之前,但唯一区别的是结果,无论怎么测试postgresql都不会出现“超卖”,而mysql则恰恰相反
这种事情显得很“诡异”,有同样遇到这种情况的大兄弟可以告诉我,这种是如何解释的
3.2.4 基于分布式锁与事务共用的解决方案
1、service方法上不加事务注解,在具体的reduce(repo)方法上加事务注解
这种执行结果是ok的,锁的释放在事务条件成功后释放
2、使用事务同步管理器,约束释放锁的时机必须在事务完成后才可以
并发测试结果是ok的,最终库存=0
3、service方法上不加@Transaction,在程序中,手动管理事务的提交和回滚
代码片段如下:
@Autowired
private JpaTransactionManager transactionManager;
/**
* 第三种解决方案:service方法上不加@Transaction,在程序中,手动管理事务的提交和回滚
* @param commodityCode 商品编码
*/
@Override
public Integer reduceLock3(String commodityCode) {
int res = 0;
RLock lock4Reduce = distributeLock.lock4Reduce(commodityCode);
TransactionDefinition transactionDefinition = new DefaultTransactionDefinition();
TransactionStatus txStatus = transactionManager.getTransaction(transactionDefinition);
try{
CommodityStockEntity stockEntity = stockRepo.findByCommodityCode(commodityCode);
if(stockEntity == null){
throw new EntityNotFoundException("指定的商品["+commodityCode+"]不存在!");
}
int inventory = stockEntity.getInventory();
if(inventory == 0) {
logger.warn("商品不够了,需要加库存!");
return 0;
}
stockEntity.setInventory(inventory-1);
stockRepo.save(stockEntity);
transactionManager.commit(txStatus);
return 1;
}catch (Exception e){
transactionManager.rollback(txStatus);
}finally {
if (lock4Reduce.isLocked() && txStatus.isCompleted()) {
if (lock4Reduce.isHeldByCurrentThread()) {
lock4Reduce.unlock();
System.out.println("▲锁释放成功,当时时间:"+ DateUtils.getCurrentTime());
}
}
}
return res;
}
通用这种也是ok的
4、压根就不加锁,由上层进行锁控制
代码片段如下:
// 使用redisson分布式锁(将service的锁粒度提到controller,“远离”@Transactional(aop),使得事务提交一定发生在锁释放之前)
@GetMapping("/reduce/lock4/{commodityCode}")
public Result commodityReduceLock4(@PathVariable("commodityCode") String commodityCode) throws Exception{
RLock lock4Reduce = distributeLock.lock4Reduce(commodityCode);
try{
int reduce = commodityReduce.reduceLock4(commodityCode);
if(reduce>0){
return Result.ok("减库存成功!");
}else{
logger.info("商品{}已售罄",commodityCode);
return Result.ok("商品已售罄!");
}
}finally {
if (lock4Reduce.isLocked()) {
if (lock4Reduce.isHeldByCurrentThread()) {
lock4Reduce.unlock();
}
System.out.println("controller,释放锁成功,当前时间:"+ DateUtils.getCurrentTime());
}
}
}
/**
* 第四种解决方案:压根就不加锁,由上层进行锁控制
*/
@Override
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public Integer reduceLock4(String commodityCode) {
CommodityStockEntity stockEntity = stockRepo.findByCommodityCode(commodityCode);
if(stockEntity == null){
throw new EntityNotFoundException("指定的商品["+commodityCode+"]不存在!");
}
int inventory = stockEntity.getInventory();
if(inventory == 0) {
logger.warn("商品不够了,需要加库存!");
return 0;
}
return stockRepo.reduce(commodityCode);
}
3.3 基于zookeeper分布式锁的实现
orm选用tk-mybatis,数据库选用mysql,zk客户端连接选用curator框架,接口生成工具选用swagger,zk客户端工具选用ZooInspector
启动项目,浏览器输入:http://localhost:8088/swagger-ui.html
可视化工具
分布式锁代码片段
@Autowired
private CuratorFramework curatorFramework;
@Transactional(rollbackFor = {Exception.class})
@Override
public Integer reduceLock(String commodityCode) throws Exception{
// recipes不可重入的互斥锁
InterProcessSemaphoreMutex lock = new InterProcessSemaphoreMutex (curatorFramework, "/lock");
try{
//获取锁资源
boolean flag = lock.acquire(10, TimeUnit.SECONDS);
if(flag){
CommodityEntity entity = commodityMapper.findByCode(commodityCode);
if(entity == null){
throw new MybatisEntityNotFoundException("所购的商品不存在!");
}
if(entity.getInventory() == 0){
logger.warn("商品不够了,需要加库存!");
return 0;
}
return commodityMapper.reduce(commodityCode);
}
}catch (Exception e){
logger.error("错误信息:{}",e.getMessage());
}finally {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCompletion(int status) {
try {
lock.release();
} catch (Exception e) {
logger.info("错误信息:{}",e.getMessage());
}
}
});
}
return 0;
}
jmeter并发测试报告
库存结果
控制台输出
4、完结