赞
踩
在单体架构上,乐观锁和悲观锁可以锁住并发情况下的同步代码块,我们多使用synchronized来对方法加锁。但是在配上负载均衡的集群模式下,普通的synchronized是无法锁住从两台服务器同时进入的请求。
这是在了解秒杀项目的难点之一:一人一单的并发安全问题在使用集群架构出现的难点。我们先从单体项目出发,单体项目很好理解,假如有俩线程:线程1查询订单,判断是否存在。然后第二个线程之后在去查询订单,判断是否存在,在synchronized的作用下两个线程是不会发生问题的。
那现在,我们不再是一台服务器,而是多台。在当前这一个JVM内部,锁的原理是在JVM内部维护了一个锁监视器对象,监视器的对象用的是userId,它是在我们的常量池里面。那么在这个JVM内部是维护了这一个常量池子,当ID相同的情况下,他们永远都是同一个锁,也就是说锁的监视器是同一个。所以无论是线程1也好,线程2也好,他们俩要获取锁的时候,锁监视器就会记录线程ID,当另一个线程再来获取锁的时候肯定是不行的,因为锁监视器已经记录一个了。
但是,当我们部署一个新的服务器的时候,也就是部署了一个新的JVM。两个JVM也拥有各自的常量池,JVM2用userId作为锁的时候,它的监视器对象就会拥有一个新的锁监视器,跟JVM1的监视器不是同一个。现在当我们线程3来获取锁的时候走的是自己的监视器,那这个监视器显示的是空的呀,所以也能获取锁成功,当然了线程4失败是没问题的。也就是说在JVM内部锁监视器能保证这些线程互斥,但是多个JVM就会有多个JVM监视器, 有多少个锁监视器就会有多少个线程成功进入同步代码块。
所以我们要解决的问题就是在多个JVM的情况下让这些锁监视器使用同一把锁。
synchronized就是利用JVM内部的锁监视器来控制线程的,在JVM内部,因为只有一个锁监视器,所以只会有一个线程获取到锁,可以实现线程阶段互斥。但是当有多个JVM的时候,就会有多个锁监视器,这时候synchronized就会显得苍白无力,JVM内部的锁监视器直接作废。所以锁的监视器一定要在JVM的外部,让所有JVM都去找独一无二的锁监视器来获取锁,这样也就只有一个线程获取锁,也就实现了多JVM的线程互斥。
所以满足在分布式系统或集群模式下多线程可见并且互斥的锁就是分布式锁。
分布式锁核心是实现多进程之间的互斥,而满足这一点的方式有很多,常见的有三种:Mysql、Redis。Redis里有setnx
互斥命令,王redis面set数据的时候,只有没数据的时候才会set成功,有数据就会set失败。
实现分布式锁肯定要实现两个基本方法,获取锁和释放锁。
获取锁
setnx
,这个可以确保只有一个可以返回1。释放锁
锁的名称不能写死,不同的业务有不同的锁。
现在的Redis锁处于正常的工作状态,假如现在来了一个线程1,它想要获取到setnx,那么作为第一个的线程是肯定可以获取成功的。然后拿到锁后线程1就开始执行他的业务,其他线程想要获取锁就去阻塞状态等待。
但是由于某种原因,线程1的业务产生了阻塞,那么这样的话它锁的持有周期就会变长,如果这个阻塞时间过长甚至超过了我们设置的最大超时时间,那么这个时候也会触发锁的超时释放。总的来说就是业务没完,超时时间将锁释放。
那么释放后其他线程就会趁机而入,现在线程2来获取,获取成功,然后开始执行它的业务。万万不巧的是,线程1的业务也阻塞完成了,开始了它的释放,这个删锁的过程也很巧的把线程2刚拿到的锁给释放了。
那么姗姗来迟的线程3页理所应当的拿到了锁开始了它的业务。此时此刻,有两个线程同时进入了锁,线程安全问题有可能再次发生。
这个问题的本质就是由于业务阻塞导致锁提前释放, 等待线程1醒来后删掉的不是自己的锁而是线程2的锁。
其实自信分析误删问题,就会发现他们只需要在释放锁的时候验证当前的锁id是不是自己线程的id,所以我们在加锁的时候加上Thread的id即可。
为了确保不会出现两个相同版本号的线程,UUID还是很不错的一个方法,这里用了之后可以转化为String类型然后再去掉横线。最后在UUID后面拼接线程ID即可,完美的保证了线程ID的唯一性。
在释放锁的时候就要去先做判断,步骤为获取线程标示→获取锁标示→判断是否一致(释放/跳过)
就在我们刚更新完Redis2.0后,万万没想到,新的问题又出来了了。是这样的,Redis也是正常的工作状态,现在来了一个线程1,他来获取锁然后执行业务,这段业务非常流程,没有阻塞的过程,所以在执行完代码后就开始了它的释放锁操作。
而要释放锁就要先判断锁标示,这个判断也是没有问题的,因为锁就是他自己的。紧接着他就要执行释放锁的动作,但是万万没想到,就在要释放时,产生了阻塞,就在我纳闷我也没写与操作系统内核态交互的代码啊,为什么就阻塞了呢?我的日志告诉了我,我们JVM刚很不巧的执行了FULL GC,无论是CMS垃圾回收器还是G1垃圾回收器都有独自标记和独自清除的两端STW,所以很不巧,就在我代码执行到释放锁的时候,JVM为了回收垃圾自己进入了阻塞…
所以接下来发生的事不用想就知道了,线程1又触发了超时时间,锁自己释放。珊珊来迟的线程2又不巧的获取到锁,就在准备执行业务代码的时候,线程1又醒了,他很坚定的删了以为是自己的锁,但那个锁确实线程2的… 然后线程2与线程3又开始互相撕逼起来,线程安全问题又来了…qwq
说道原子性,想到的相比都是事务,那redis有没有事务呢?肯定是有的,不够这个事务跟我们所了解的mysql事务ACID是有很大差别的。redis的事务首先是能够保证原子性的,但是无法保证事务的一致性,而且redis事务的多个操作其实是一个批处理,实在最终一次性执行。那也就是说我们没办法先查询判断、最后在释放锁,因为做这些动作是拿不到结果的,他是最终一次性执行,所以是没办法把他们俩放到一个事务中,只能利用redis的乐观锁做一些判断,来确保在释放的时候没有人来进行修改,但是这样做会复杂很多,所以这里可以利用乐观锁来维护原子性,但是会拉低性能,很麻烦。
这里推荐使用Lua脚本来实现。Redis是提供Lua脚本功能的,Lua脚本其实就是在一个脚本中编写多条Redis命令,确保命令执行时的原子性。可以参考这个网页来简单学习下如何维护原子性的操作https://www.runoob.com/lua/lua-tutorial.html
其他语法不需要学习,只需要会用redis提供的调用函数:redis.call('命令名称','key','其他参数',...)
,比如我们要执行set name Jack,则脚本是这样:
先执行set、在执行get、最后用变量来接收:
写好脚本后,需要用Redis命令来调用脚本。
比如执行redis.call(‘set’,‘name’,‘jack’),语法为:
参数的设置很重要,如果不想写死,基本上set后面的两个值都需要传参,而且可能还不止set一个命令。所以我们可以设置数组,key类型的参数会放入KEYS数组,其他参数放入ARGV数组。Lua语言的数组从1开始而不是从0开始。
所以在原子性与一致性的保障下,我们释放锁的流程就变成了:
先使用Lua脚本编写下逻辑代码
-- 锁的key 先写死
local key = "lock:order:5"
-- 当前线程的标示 格式是UUID-线程id
local threadId = "asdwiwahsjdwa-33";
-- 获取锁的线程标示
local id = redis.call('get',key)
--比较线程标示与锁的标示是否一致
if(id == threadId) then
--释放锁
return redis.call('del',key)
end
return 0
因为我们的key和线程的id是存放到KEYS和ARGV数组里面的,所以定义的步骤可以取消,直接在获取标示的时候传参来表示。简化后就是:
-- 先取,然后进行比较
-- 这里的KEYS[1]就是锁的key,ARGV[1]就是当前的线程标示。
-- 获取锁的线程标示
local id = redis.call('get',KEYS[1])
--比较线程标示与锁的标示是否一致
if(id == ARGV[1]) then
--释放锁
return redis.call('del',KEYS[1])
end
return 0
@Override
public <T> T excute(RedisScript<T> script,List<T> keys,Object... args){
return scriptExcutor.excute(script,keys,args);
}
在编写代码的时候,一定要先在resources里存放lua,然后再java代码里调用,不要在java代码里编写lua。
scriptExcutor.excute(script,keys,args)
三个参数,就是脚本,key,arg… 。RedisScript是个类,我们写的Lua是个文件,显然这个类就要加载文件。所以我们就得提前吧文件读取好,所以我们要在全局变量中定义。使用RedisScript的实现类DefaultRedisScript
类定义,调用他的setLocation()
来设置脚本的位置。最后可以给它定义返回值的类型,使用setReturnType()
。
所以在使用起来就很方便了,第一个参数就传UNLOCK_SCRIPT 脚本
第二个参数就是key的集合(也就是key数组吧,key[1]),就是我们定义的“lock:+业务名称
”。很简单,使用Collections工具类提供的singletionList()
;字符串转集合的方法就行。
第三参数就是args就更简单了,就是我们定义的格式UUID+ThreadId,之前已经定义过全局变量了。直接调用就行了。
然后也就不需要返回值了吧,因为你线程id不等于锁的id本来就是当前线程出现问题了,当前线程作废就行。
现在的话,只有当前线程能删自己获得的锁,原子性也得到了解决。锁已经达到了生产可用的标准了。
总结思路:
特性:
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。