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

4 篇文章 0 订阅

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类型)和方法,如下

 

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

 


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

 

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

 

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

 

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

 

 

 

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

 

 

点进去定位到下面这一行

 

 

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

 

 

 

 

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

 

 

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

 

 

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

 

 

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

 

如在Mybatis3的SqlSessionManager中的应用:

 

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

 

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

 


都讲到这里了,我们来重点看下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、完结

 

  • 6
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值