对“锁”大家肯定都不陌生,锁是针对多线程情况下对资源访问的控制,初学java时候,就知道synchronize和lock,synchronize是重量级锁,lock是轻量级的锁,巴拉巴拉。。。但是它们两都是针对单个jvm来说的,现在稍微大点的网站都是多台服务器,通过nginx等负载均衡到多个服务器节点,这样的话,就得使用分布式锁了,常见的分布式锁有基于redis或者zookeeper,这里讨论下基于redis实现分布式锁及其会遇到的问题。
1.0 setnx
根据redis提供的setnx命令,可以当做互斥锁来使用,成功了会返回1,失败返回0,然后做一些操作,最后删除key,伪代码如下:
if(1 == setnx(key,value)){
dosomething;
del(key);
}
考虑的更完善点呢,可以加入“锁超时”的情况,假如一个获得锁权限的节点,因为一些其他因素导致迟迟没有执行del(key)的操作,导致锁无法释放,会阻塞依赖该key的业务流程。伪代码如下:
if(1 == setnx(key,value)){
expire(key,timeOutSecond)
dosomething;
del(key);
}
加入了锁超时的代码,看似是“很完善”了,但是,还是有一些问题的,setnx和expire不是原子性、del会误删。
2.0 原子性和误删操作
2.1 操作的原子性
先说下原子性,在1.0中的代码,setnx和expire不是原子性,极端情况,第一行代码,setnx(key,value)执行完之后,再执行第二行expire的时候,节点GG思密达了,第二行代码指令就没有抵达redis,那这样的话,之前设置的key就永恒不朽了。
如何处理这个问题呢?redis 2.6.12及其以上版本,提供了原子性操作的api:
set(key,value,ex second,nx)
set四个参数的这种等于 setnx+expire两行代码,还保证了原子性
这里吐槽一下啊,redis不仅提供了setnx、还提供了setex,还还提供了psetex,一个set(key,value,ex|px second,nx)命令不就搞定了吗,弄这么多干嘛呢?
2.2 误删key
再说下误删操作,还是1.0的伪代码,假如线程A获得了锁,超时时间是30秒,假如线程A在30秒内未执行完成,则锁超时,锁会被其他线程获取;假如获取到锁的是线程B,线程B在执行代码块的过程中,线程A终于执行完了并删除了锁,那这时就是误删了,这个时间线程A删的锁其实是线程B的锁了。
如何处理这种误删操作呢?可以把当前线程的线程ID作为value,在删除锁之前比对下key的value是不是当前线程的线程ID,如果是,才会去删除锁。伪代码如下:
String currendThreadId = ();
if("ok".equals(set(key,currendThreadId,ex second,nx))){
doSomething;
if((get(key))){
del(key)
}
}
上面的代码,其实还是隐含一个问题--判断锁的值和删除锁,不是原子性,要达到效果,redis的现成api是没有的,得需要使用redis脚本了
String script_lua = "if (('get',KEYS[1]) == ('get',ARGV[1])) then return ('del',KEYS[1]) else return 0 end";
(script_lua,(key),(currendThreadId));
这行代码的效果等同于在redis-cli中执行
2.3 存在并发可能性
虽然使用脚本的方式可以有效防止误删的操作,但是还是会有并发问题--线程A和线程B同时都在操作,这显然不合理,为解决这个问题,那只有限制B不能进入了。
这里就需要引入一个“守护线程”的东西了,守护线程会在线程A即将过期的时候,去主动给线程A“续命”,当线程A执行完毕,再关掉守护线程;即使线程A所在的节点GG思密达了,因为线程A和守护线程是在一个节点的,守护线程也会GG的,锁到期后,会自动释放。
到这里才算是把redis的分布式锁比较全面的说完。