四、Redis分布式锁
Java中的锁我们通常以synchronized 、Lock来使用它,但是只能保证在同一个JVM进程内中执行。如果在分布式集群环境下呢?分布式锁的实现有很多,比如基于数据库乐观锁、Redis、zookeeper、memcached、系统文件等。
1、命令行加锁:SET lock_key random_value NX PX 5000 执行成功,则证明客户端获取到了锁。
random_value:是客户端生成的唯一的字符串。
NX:代表只在键不存在时,才对键进行设置操作。
PX:5000 设置键的过期时间为5000毫秒。
EX:可以让该 key 在超时之后自动删除。
2、Jedis加锁代码实现
(1)、Long setnx(final String key, final String value):该方法只会对不存在的key设值,返回1代表获取锁成功;对存在的key设值,会返回0代表获取锁失败。value是客户端锁的唯一标识,不能重复,例:UUID或System.currentTimeMillis() (获取锁的时间)+锁持有的时间。
命令格式:SETNX key value
注:执行setnx加了锁,需要再次执行语句设置过期时间,如果在setnx之后宕机,所就不会释放,就会产生死锁。无法保证两步操作的原子性,不可取
(2)、String getSet(final String key, final String value):返回redis中key对应的oldValue,然后再把key原来的值oldValue更新为新传入的值value。
命令格式:GETSET key value
注:当某个客户端锁过期时间,多个客户端开始争抢锁。虽然最后只有一个客户端能成功锁,但是获取锁失败的客户端调用getSet能覆盖获取锁成功客户端的value。当客户端的value被覆盖,会造成锁不具有标识性,会造成客户端没有释放锁。该方式同样不可取。
(3)、String set(final String key, final String value, final String nxxx, final String expx, final long time):该方法合并普通的set()和expire()操作,保证NX和EX的原子性。不能把两个命令(NX EX)分开执行,如果在 NX 之后程序出现问题就有可能产生死锁。
private static final String SET_IF_NOT_EXIST = "NX";
private static final String SET_WITH_EXPIRE_TIME = "PX”;
private final String LOCK_PREFIX = "test:lock:key-";
public boolean lock(String key, String value, long expireSeconds) {
String key = LOCK_PREFIX + MD5.convertToMD5(key);
String result = this.jedis.set(LOCK_PREFIX +key, value, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireSeconds);
if (LOCK_MSG.equals(result)){
return true ;
}else {
return false ;
}
}
Jedis(单机)、JedisCluster(集群)
3、RedisTemplate加锁代码实现
(1)、加锁:
private final String LOCK_PREFIX = "test:lock:key-";
@Resource
private RedisTemplate<String, String> redisTemplate;
public boolean lock(String keyLock, String value, long expireSeconds){
redisTemplate.opsForValue().setIfAbsent(LOCK_PREFIX + keyLock, value, expireSeconds, TimeUnit.SECONDS);
}
(2)、解锁:
//单个删除
redisTemplate.delete(keyLock);
//批量删除
List<String> keys;
redisTemplate.delete(keys);
(3)、指定key失效时间:redisTemplate.getExpire(key,TimeUnit.SECONDS);
(4)、根据key获取过期时间:redisTemplate.expire(key, time, TimeUnit.SECONDS);
(5)、判断key是否存在:redisTemplate.hasKey(key);
(6)、Jedis和RedisTemplate区别
Jedis是Redis官方推荐的面向Java的操作Redis的客户端,而RedisTemplate是spring-data-redis.jar中对JedisApi的高度封装。spring-data-redis.jar相对于Jedis来说可以方便地更换Redis的Java客户端,比Jedis多了自动管理连接池的特性,方便与其他Spring框架进行搭配使用如SpringCache。原生Jedis效率优于RedisTemplate。
4、解锁:将key键删除,加锁时需要传递一个参数,将该参数作为这个 key 的 value,这样每次解锁时判断 value 是否相等从而知道这个锁是不是该进程自己的。
//此方式无法保证get和del的原子性
if(value.equals(redisTemplate.opsForValue().get(keyLock))){
redisTemplate.delete(keyLock);
}
解决方案:
(1)、使用lua脚本
使用lua脚本合并get()和del()操作,先判断value是否相等,相等再执行del命令。lua脚本能保证这里两个操作的原子性。
try {
...
} catch (Exception e) {
...
} finally {
//获取Jedis资源
Jedis jedis = RedisUtils.getJedis();
//定义lua脚本
String script = "if redis.call('get',KEYS[1]) == ARGV[1]" +
"then" +
" return redis.call('del',KEYS[1])" +
"else" +
" return 0" +
"end";
try {
//对指定的key、value执行脚本
Object o = jedis.eval(script, Collections.singletonList(keyLock), Collections.singletonList(value));
if ("1".equals(o.toString())) {
System.out.println("del redis lock ok");
}else {
System.out.println("del redis lock error");
}
}finally {
//关闭Jedis资源
if (null != jedis) {
jedis.close();
}
}
}
(2)、使用Redis事务
while (true) {
//监视key
redisTemplate.watch(keyLock);
if(value.equals(redisTemplate.opsForValue().get(keyLock))){
//开启事务支持
redisTemplate.setEnableTransactionSupport(true);
//开始事务
redisTemplate.multi();
redisTemplate.delete(keyLock);
//执行命令,如果返回null,则事务执行失败,key对应的value被别人修改过,需要重新执行。
List<Object> list = redisTemplate.exec();
if(list == null){
continue;
}
}
//解除监视
redisTemplate.unwatch();
break;
}
5、Redisson分布式锁实现原理:
Redisson这个开源框架对Redis分布式锁的实现原理,Jedis和Redisson都是Java中对Redis操作的封装。Jedis只是简单的封装了Redis的API库,可以看作是Redis客户端,它的方法和Redis的命令很类似。Redisson不仅封装了redis,还封装了对更多数据结构的支持,以及锁等功能,相比于Jedis更强大。但Jedis相比于Redisson更原生一些,更灵活。
//获取Redisson对象
RLock lock = redisson.getLock(keyLock);
//加锁
redissonLock.lock();
try {
...
} catch (Exception e) {
...
} finally {
//判断当前锁仍在锁定状态 && 是当前线程所持有。
//如果当前判断不加可能会出现异常:attempt to unlock lock, not locked by current thread by node id: 0dsad...
if (lock.isLocked() && lock.isHeldByCurrentThread()) {
//解锁
lock.unlock();
}
}
某个客户端要加锁,如果该客户端面对的是一个redis cluster集群,首先会根据hash节点只选择一台机器,然后发送一段LOCK的lua脚本到redis上,lua脚本用来封装复杂的业务逻辑,并保证这段复杂业务逻辑执行的原子性。
String LOCK = "if(redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end;" +
"if(redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end;" +
"return redis.call('pttl', KEYS[1]); ";
锁的数据结构:
lockKey:{
"8743c9c0-0795-4907-87fd-6c719a6b4586:1": 1
}
(1)、加锁机制:客户端1加锁,因为没有,所以执行第一个if
参数释义:
KEYS[1]:要加锁的key
ARGV[1]:锁key的默认有效时间
ARGV[2]:加锁的客户端的ID,例:8743c9c0-0795-4907-87fd-6c719a6b4586:1
语句解析:
exists KEYS[1]:判断要加的锁是否存在
hset KEYS[1] ARGV[2] 1:执行加锁操作
pexpire KEYS[1] ARGV[1]:设置有效时间
(2)、锁互斥机制:客户端2加锁,因为存在客户端1之前加了锁,所以这个key的锁已存在,则执行第二个if,锁key的hash数据结构中,是否包含客户端2的ID,不包含返回锁key的剩余生存时间,此时客户端2会进入一个while循环,不停的尝试加锁。
(3)、可重入加锁机制:重复执行lock.lock(),同一客户端多次加锁
第一个if判断肯定不成立,“exists myLock”显示锁key已经存在。第二个if判断会成立,因为myLock的hash数据结构中包含的那个ID,就是客户端1的那个ID,也就是“8743c9c0-0795-4907-87fd-6c719a6b4586:1”。此时就会执行可重入加锁的逻辑,会用:incrby lockKey
通过这个命令,对客户端1的加锁次数,累加1。此时myLock数据结构变为下面这样:
lockKey:{
"8743c9c0-0795-4907-87fd-6c719a6b4586:1": 2
}
(4)、释放锁机制:如果执行lock.unlock(),就可以释放分布式锁,其实就是每次都对lockKey数据结构中的那个加锁次数减1。如果加锁次数是0了,说明这个客户端已经不再持有锁了,此时就会用:"del lockKey"命令,从redis里删除这个key。然后,另外的客户端2就可以尝试完成加锁了。
6、Redis分布式锁的缺点:
如果你对某个redis master实例,写入了lockKey这种锁key的value,此时会异步复制给对应的master slave实例。但是这个过程中一旦发生redis master宕机,主备切换,redis slave变为了redis master。就会导致,客户端2来尝试加锁的时候,在新的redis master上完成了加锁,而客户端1也以为自己成功加了锁。此时就会导致多个客户端对一个分布式锁完成了加锁。这时系统在业务语义上一定会出现问题, 导致各种脏数据的产生 。所以这个就是redis cluster,或者是redis master-slave架构的主从异步复制导致的redis分布式锁的最大缺陷:在redis master实例宕机的时候,可能导致多个客户端同时完成加锁。
7、死锁的四个必须要条件及解决方案
(1)、必要条件
①、互斥条件:某种资源一次只允许一个进程访问,即该资源一旦分配给某个进程,其他进程就不能再访问,直到该进程访问结束。
②、占有且等待条件:一个进程本身占有资源(一种或多种),同时还有资源未得到满足,正在等待其他进程释放该资源。
③、不可剥夺条件:一个进程已经占有了某项资源,在末使用完之前,其他进程不能强行剥夺该资源。
④、循环等待条件:存在一个进程链,使得每个进程都占有下一个进程所需的至少一种资源。