前言
多线程下的数据一致性问题一直都是热点问题,既要考虑到数据的一致,又要考虑实现的效率,在分布式情况下,这又要成为一种新的难题。
分布式锁和我们java基础中学习到的synchronized略有不同,在synchronized中我们的锁是个对象,如果一个线程拿到了该锁,别的线程就只
能等待了。比如购物下单这种业务场景,下单的系统部署在不同的服务实例上,单纯使用synchronized或者lock 已经无法满足对库存一致性的
判断,所以分布式锁由此诞生,其本质 加锁/释放锁的操作 不再当前服务实现了。
如今常见的锁方案如下:
基于数据库实现分布式锁
基于缓存,实现分布式锁,如redis
基于Zookeeper实现分布式锁
由于实践中使用的是基于redis 的分布式锁, 所以今天主要说说redis 的分布式说
redis分布式锁的实现
SETNX
使用redis的SETNX实现分布式锁,多个进程执行以下Redis命令:
SETNX
是将 key 的值设为 value,当且仅当 key 不存在。若给定的 key 已经存在,则 SETNX 不做任何动作。
返回1,说明该进程获得锁,
返回0,说明其他进程已经获得了锁,进程不能进入临界区。进程可以在一个循环中不断地尝试 SETNX 操作,以获得锁。
基础版1.0
先来一个最简单版本
public void lock() {
while(true){
String result = jedis.set(lockKey, "value", NOT_EXIST);
if(OK.equals(result)){
System.out.println(Thread.currentThread().getId()+"加锁成功!");
break;
}
}
}
而unlock方法就是调用DEL命令将键删除。
但是这样会存在一个问题
假如服务器A在加锁成功后 服务器宕机或者 网络断了 等等。。。 总之断开与redis 的连接,那么服务器BCDEF永远都无法获取锁了
带着这个问题 升级到1.1版本
基础版1.1设置锁的过期时间
public void lock() {
while(true){
String result = jedis.set(lockKey, "value", NOT_EXIST,SECONDS,30);
if(OK.equals(result)){
System.out.println(Thread.currentThread().getId()+"加锁成功!");
break;
}
}
}
注:要保证设置过期时间和设置锁具有原子性,防止设置过期时间时前服务器断开连接
但是这样还是会存在一个问题
步骤如下
服务器A获取锁成功,过期时间10秒。
服务器A在某个操作上阻塞了30秒。
10秒时间到了,锁自动释放了。
服务器B获取到了对应同一个资源的锁。
服务器A从阻塞中恢复过来,释放掉了服务器B持有的锁。
基础版1.2 设置带Value的锁
在1.1的基础上 解决自己的锁自己释放 , 即自己的坑自己填
直接上代码
public void lock() {
while(true){
String result = jedis.set(lockKey, requestId, NOT_EXIST,SECONDS,30);
if(OK.equals(result)){
System.out.println(Thread.currentThread().getId()+"加锁成功!");
break;
}
}
}
requestId 就是每个线程自己生成一个唯一标识, 再释放锁的时候 判断这个锁是否是自己加的
public void unlock() {
String requestId = jedis.get(lockKey);
if (requestId .equals(requestId )){
jedis.del(lockKey);
}
}
看着好像没什么问题,但是, 这里又一个但是,获取值、判断和删除锁这是三步操作,在多线程的情况下,就会出现问题
所以要确保解锁过程是原子型操作。 噔噔噔 上神器 <redis的lua脚本>
入门版 ->lua 初窥
先说说什么是lua 脚本?
Lua 是一个小巧的脚本语言。是巴西里约热内卢天主教大学(Pontifical Catholic University of Rio de Janeiro)里的一个研究小组,由Roberto Ierusalimschy、Waldemar Celes 和 Luiz Henrique de Figueiredo所组成并于1993年开发。 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。Lua由标准C编写而成,几乎在所有操作系统和平台上都可以编译,运行。Lua并没有提供强大的库,这是由它的定位决定的。所以Lua不适合作为开发独立应用程序的语言。Lua 有一个同时进行的JIT项目,提供在特定平台上的即时编译功能。 Lua脚本可以很容易的被C/C++ 代码调用,也可以反过来调用C/C++的函数,这使得Lua在应用程序中可以被广泛应用。不仅仅作为扩展脚本,也可以作为普通的配置文件,代替XML,ini等文件格式,并且更容易理解和维护。 Lua由标准C编写而成,代码简洁优美,几乎在所有操作系统和平台上都可以编译,运行。一个完整的Lua解释器不过200k,在目前所有脚本引擎中,Lua的速度是最快的。这一切都决定了Lua是作为嵌入式脚本的最佳选择。
上面巴拉巴拉说了一堆总结起来就是 体积很小,运行速度很快,并且每次的执行都是作为一个原子事务来执行的。重点,重点,重点,速度快,每次执行都是一个原子事务。 如果能把上面 的取值 判断 删除三步放在一个执行事务中,不就解决问题了吗???
问题1:解决 自己的锁自己释放问题
lua 的作用就是为了解决 在解锁中获取值、判断、删除这三步操作 合成一步执行,在执行lua脚本的时候把requestId 传进去, 作为ARGV[1] , KEYS[1]为锁的key,不多逼逼,上代码
//一个脚本 既有取值 又有if else 判断 还有具体操作, 神器啊
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
问题2:解决业务操作时间大于自动释放时间
业务操作的时间长短的确具有不确定性,但是按照正常来说,一个业务,开始前先获取锁,成功拿到锁后,执行业务操作,执行完成删除锁。假设想象每个请求都需要执行完成,不在乎它的时间长短,那么在服务步不宕机的情况下,锁必由自己来释放,如果可以启动一个线程,每隔一段时间不断查询这个key是否过期,如果没有,那么就延长一定时间。在验证key 是否过期的时候也必须 考虑多线程访问,所以还是需要使用到lua脚本 获取值 ,判断, 延期,看着是不是和上面的很像,上代码比较比较
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call('expire',KEYS[1],ARGV[2])
else
return 0
end
那么现在只需要启动一个线程 隔断时间查询某个key 都否过期 ,执行上面这段脚本,就可以完美的解决这个问题了。
刷新key的过期时间
private class ExpirationRenewal implements Runnable{
@Override
public void run() {
while (isOpenExpirationRenewal){
System.out.println("执行延迟失效时间中...");
String checkAndExpireScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('expire',KEYS[1],ARGV[2]) " +
"else " +
"return 0 end";
jedis.eval(checkAndExpireScript, 1, lockKey, requestId , "30");
//休眠10秒
sleepBySencond(10);
}
}
}
完美开源解决方案
刚才说了手写一个redis分布式事务锁需要考虑的茫茫多的东西,那么有已经开源已经写好的吗?说实话,如果在公司里落地生产环境用分布式锁的时候,一定是会用开源类库的。
redisson,下面是它的官方wiki ,大家有兴趣可以看看 redisssion 官网wiki
下面给大家看一段简单的使用代码片段,先直观的感受一下:
RLock lock = redisson.getLock("myLock");
// Most familiar locking method
lock.lock();
lock.unlock();
RLock lock = redisson.getLock("myLock");
lock.lock();
lock.unlock();
是不是特别清爽,没有自己写的茫茫多的判断,
此外,人家还支持redis单实例、redis哨兵、redis cluster、redis master-slave等各种部署架构,都可以给你完美实现。
redission实现分布式锁原理
(1)加锁机制
咱们来看上面那张图,现在某个客户端要加锁。如果该客户端面对的是一个redis cluster集群,他首先会根据hash节点选择一台机器。这里注意,仅仅只是选择一台机器!这点很关键!
紧接着,就会发送一段lua脚本到redis上,那段lua脚本如下所示:
是不是很眼熟,这就是上面提到的lua脚本
参数解析
KEYS[1] 代表的是你加锁的那个key,比如说:RLock lock = redisson.getLock(“myLock”);这里你自己设置了加锁的那个锁key就是“myLock”。
ARGV[1] 代表的就是锁key的默认生存时间,默认30秒。
ARGV[2] 代表的是加锁的客户端的ID,类似于下面这样:8743c9c0-0795-4907-87fd-6c719a6b4586:1 (本质其实就是uuid+线程id)
这个lua和咱们自己手写的好像还不太一样,咱们一行行的来看看
第一个if
第一段if判断语句,就是用“exists myLock”命令判断一下,如果你要加锁的那个锁key不存在的话,你就进行加锁。
使用到了一个hash的结构 ,加锁命令如下:
hset myLock 8743c9c0-0795-4907-87fd-6c719a6b4586:1 1
加完后的数据结构
myLock:{
"8743c9c0-0795-4907-87fd-6c719a6b4586:1": 1
}
接着会执行“pexpire myLock 30000”命令,设置myLock这个锁key的生存时间是30秒。
到此就第一个if 执行完成
好了,ok,加锁完成了。
第二个if
在先说第二个if 前,先看一种情况
RLock lock = redisson.getLock("myLock");
lock.lock();
lock.lock();
lock.unlock();
lock.unlock();
在这种情况下 第二个if判断会成立,因为myLock的hash数据结构中包含的那个ID,就是客户端1的那个ID,也就是“8743c9c0-0795-4907-87fd-6c719a6b4586:1”
此时就会执行
incrby myLock 8743c9c0-0795-4907-87fd-6c71a6b4586:1 1
通过这个命令,对客户端1的加锁次数,累加1。
此时myLock数据结构变为下面这样:
myLock:{
"8743c9c0-0795-4907-87fd-6c719a6b4586:1": 2
}
大家看到了吧,那个myLock的hash数据结构中的那个客户端ID,就对应着加锁的次数
这种情况就是所谓的可重入锁,表现在同一个线程可以多次获得锁,而不同线程依然不可多次获得锁
还有一个return哦
也就是说前面两个if 都不满足的情况下,就会走向第三个return, 想象以下:
不满足第一个if,也就说已经有其他客户端加锁成功,
不满足第二个if,也就说不是这个客户端加的锁
也就说已经有其他客户端加锁成功,这个时候就会获取到pttl myLock返回的一个数字,这个数字代表了myLock这个锁key的剩余生存时间。比如还剩15000毫秒的生存时间。
此时当前会进入一个while循环,不停的尝试加锁。
看门狗
刚才咱们自己实现分布式锁的时候好像还启动一个线程监控自己的key是否实现延期,
其实redission 也有,watchdog机制
一旦加锁成功,就会启动一个watch dog看门狗,他是一个后台线程,会每隔10秒检查一下,如果客户端1还持有锁key,那么就会不断的延长锁key的生存时间。
unlock
如果执行lock.unlock(),就可以释放分布式锁,此时的业务逻辑也是非常简单的。
刚才解析第二个if 我们发现hash 对应的value 就是加锁数量
其实每次unlock ,都对myLock数据结构中的那个加锁次数减1。
如果发现加锁次数是0了,说明这个客户端已经不再持有锁了,此时就会用:
“del myLock”命令,从redis里删除这个key。