分布式锁与Spring事务共用产生“冲突”的解决方案 ☞ (篇幅较长,看完绝对有收获)

1、大纲要点

spring-framework5.2.x (我的fork版)gitee地址:https://gitee.com/appleyk/spring-framework/tree/5.2.x/

img


分布式锁springboot项目github地址:https://github.com/kobeyk/springboot-distribute-lock-sample

img

img

分布式锁springboot项目gitee地址:https://gitee.com/appleyk/springboot-distribute-lock-sample

img

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*

img

1.6.2 Navicat

img

1.7 并发测试工具:jmeter

链接:https://pan.baidu.com/s/1ZjHNcKIpIY-lbkshlNzfFg 提取码:vel3

img

1.8 测试出现的问题:锁的释放在事务提交成功之前完成

1.8.1 导致的后果:锁白加了,浪费了性能不说,最后还没能挡住商品的“超卖”

1.8.2 解决方案调整:

1、第一种解决方案:service方法上不加事务注解,在具体的reduce(repo)方法上加事务注解

2、第二种解决方案: 使用事务同步管理器,约束释放锁的时机必须在事务完成后才可以

3、第三种解决方案:service方法上不加@Transaction,在程序中,手动管理事务的提交和回滚

4、第四种解决方案:压根就不加锁,由上层进行锁控制

img


img


2、为什么要使用分布式锁

(1)单机应用无法满足互联网三高(高并发、高性能、高可用),所以需要对应用进行拆分,必然牵扯服务集群部署

(2)一旦集群部署,就牵扯到多线程并发下,对同一资源的操作,如果仍然使用单机场景下的JVM锁,会有问题,因此不得不考虑采用分布式锁来实现并发情况下,数据共享状态下线程操作结果的一致性

举例:拿最常见的商品下单来说,如果存在同一时刻,购买同一商品的人数激增,假如有1000人同时购买,但实际上库存中的商品仅剩下500件。这种业务场景就属于多线程操作同一共享变量:多线程就是同一时刻的N多用户点击下单按钮发出的请求,共享变量就是同一件商品的库存量,而操作就是用户下单导致的商品减库存。有点多线程编程经验的都知道,这种情况如果不针对业务场景做出点demo改变,减库存的结果必然是不安全的,比如库存出现负数,导致“超卖”问题的发生。

消除多线程不安全问题的做法大体有两种:

1、干掉共享变量

这个不太现实,但是干掉共享变量有两种思路可以提供

(1)使用final来修饰变量,定义变量一开始就赋值,后续无法再进行赋值操作(这种多线程下必然是安全的),这种适合读做常量,不适应读写(不仅读,还要改)的业务场景。

(2)使用ThreadLocal,为共享变量在每一个线程中保存一个副本,即线程操作的都是自己的变量副本,所以不会再出现多线程 同时操作同一变量的情况了。但是ThreadLocal这种不适用本篇提到的业务场景,一是它是基于单机JVM的,微服务多JVM下是无法搞的,二是我们每个线程对变量副本操作完之后,最后的业务处理是要对变量进行统一更新的,ThreadLocal更多的作用是对全局共享变量起到一个隔离的作用,比如其在Spring事务管理器中的应用,比如其在Mybaits3数据库会话管理器中的应用:

如在Spring事务同步管理器(TransactionSynchronizationManager)中的应用:

这个类是一个抽象类,有一堆的静态常量(NamedThreadLocal类型)和方法,如下

imgNamedThreadLocal继承ThreadLocal,可以给变量起个名字


img什么获取资源、绑定资源、解绑资源等

那么这个类中的这些方法是在什么时候调用的呢,请继续往下看…

imgSprinig事务管理器实现的标记接口,也是顶层接口,通常标记接口时没有任何方法定义的,比如克隆和序列化接口等

其实现/继承的类视图(部分)如下(主要看Jpa事务管理器):

img

由于本篇演示案例是在spring-framework5.2.x源码中进行调试的,而orm用的是spring-data-jpa依赖包,因此d事务管理器接口的最终实现子类是JpaTransactionManager,在d该类中,我们定位到方法doBegin(…)上(该方法在调用相关带有@Transactional注解的方法时会进入)

img

点进去定位到下面这一行

img

我们继续跟进bindResource(…)方法

img

img

img

我们接着点开set(map)方法进行跟踪

img

有绑定必然有解绑,不然占着资源可还行?

img

关于ThreadLocal,这里我还要在补充一下,看下图

img

由于本篇不是重点讲Spring事务工作原理的,所以下面就不再继续了,大家只要知道Spring事务管理器中用到了ThreadLocal就算有收获了

如在Mybatis3的SqlSessionManager中的应用:

img除了ThreadLocal的使用,你还会意外发现,这个类的构造器是private的,也就是外界不能直接new,而只能通过该类的静态方法newInstance去实例化

imgThreadLocal的get方法这里就不多说了


都讲到这里了,我们来重点看下commit方法的执行过程吧,如下:

其会调用DefaultSqlSessionle类中的commit方法,该方法又调用了commit(boolean force)方法

img

然后我们进入isCommitOrRollbackRequired方法看一下它的实现,如下:

img

基于以上分析,如果sql执行的是update操作的话,isCommitOrRollbackRequired(false)的值就是true,继续跟进如下:

img

事务提交后,再将dirty设置为false

img


关于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,具体如下:

img

3.1 基于db的锁实现

db采用的orm框架是在mybatis框架的基础上增强的mybatis-plus,数据库则采用的是PostgreSql

img


由于篇幅有限,只举其中一个例子作为测试,如拿不加锁的和基于资源插入的测试进行比较

1》不加锁的代码片段如下

img

img

img


2》基于资源插入“锁”的代码片段如下:

img

img

3.1.1 不加锁,裸奔 – 测试

数据准备,Mac电脑初始库存量为500

img


jmeter进行并发测试

img

最终聚合报告如下(当然最终的吞吐量是个大概,可能执行的值是不一样的,具体以生产环境为主)

img

我们看下数据库中最终的库存量是不是等于0?

img


3.1.2 基于资源插入,多线程争抢 – 测试

将库存量重置为500,如下:

img

新增资源表(项目中src目录下,有基于mysql和postgresql的表脚本)如下:

img

img

再次jmeter并发测试(还是2s产生2000个线程),执行的聚合结果报告如下:

img

我们再看下数据库中的商品库存量是否为0?

img

我们看下控制台打印的结果:

img

有一个点需要注意,那就是减完库存后,一定要对资源进行delete

img

如果不删除的话,只会有一个线程成功的获取锁(插入资源),其他线程只能获取失败(再插入通用的资源,会出现唯一性约束的db异常)

比如,我们这样做

img

重复上述的测试,最终结果你会发现

img

img

img


3.2 基于redisson框架的分布式锁实现

orm采用的是jpa,数据库采用的是双驱动,即postgresql和mysql进行配置切换(目的是对比两者的读写性能)

img

img


3.2.1 使用分布式RLock的代码片段

img

话不多,直接上jmeter测试结果

postgresql:

img

img

img

mysql:

img

img

img

我丢? postgresql和mysql的并发测试结果居然不一样?postgresql经过几轮测试始终能保证库存量最终为0 ,而mysql无论经过多少次测试,库存量始终不等于0!!!


3.2.2 基于分布式锁与事务共用的思考

其实网上随便搜下,就会出来很多几乎一模一样的文章,那就是关于分布式锁与Spring事务一起用时会导致锁失效的问题,为什么会这样呢,我们看下代码中我们是在什么时候释放锁的

img

总结一点就是: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'



}

测试模块如下:


img


项目依赖如下:

img


在调试过程中,特意在事务执行的相应代码处增加了日志打印,通过分别测试postgresql和mysql发现,两者执行同样的方法,锁释放的时机均在事务完成提交之前,但唯一区别的是结果,无论怎么测试postgresql都不会出现“超卖”,而mysql则恰恰相反

img

这种事情显得很“诡异”,有同样遇到这种情况的大兄弟可以告诉我,这种是如何解释的


3.2.4 基于分布式锁与事务共用的解决方案

1、service方法上不加事务注解,在具体的reduce(repo)方法上加事务注解

img

img

这种执行结果是ok的,锁的释放在事务条件成功后释放

img


2、使用事务同步管理器,约束释放锁的时机必须在事务完成后才可以

img

img

并发测试结果是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的

img


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);



	}

img


3.3 基于zookeeper分布式锁的实现

orm选用tk-mybatis,数据库选用mysql,zk客户端连接选用curator框架,接口生成工具选用swagger,zk客户端工具选用ZooInspector

img

启动项目,浏览器输入:http://localhost:8088/swagger-ui.html

img

可视化工具

img


分布式锁代码片段

    @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并发测试报告

img

库存结果

img

控制台输出

img

4、完结

  • 5
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值