本文只考虑redis单机部署情况。
redis分布式锁
1、安全性:互斥。保证同一时刻只有一个线程持有锁;
2、避免死锁:最终,即使锁定资源的客户端崩溃或被分区,也始终有可能获取锁。
3、容错性:只要大多数Redis节点都启动了,客户端就可以获取和释放锁。
4、自己的锁自己解,不能解别人的锁。
多个客户端同时发起请求,为了保证数据的一致性,使用redis加锁,将关键数据作为key,锁不存在时才能设置锁(互斥),并设置过期时间(避免死锁)。
setnx命令
redis版本低时,设置锁和过期时间是分开执行的,不是原子操作,会有各种问题。
try{
...
String threadId = Thread.currentThread().getId(); //将线程作为value,保证唯一性
if(jedis.setnx(key, threadId) == 1){
//此时程序崩溃或节点挂掉,过期时间没有设置,就会发生死锁,别的线程再也无法获得锁了
jedis.expire(key, expireTime);
}else{
return;
}
...
}catch(Exception e){
logger.error(e);
}finally{
jedis.del(key); //删除锁 如果客户端A设置了锁,因为某些原因导致执行的很慢,没执行完锁过期了,客户端B获取了锁,此时A执行完,删除锁,这样实际删除的就是B的锁。
}
set命令
redis在版本2.6.12开始,set命令包含设置过期时间的功能,保证操作的原子性。
String result = jedis.set(lockKey, value, "NX", "PX", expireTime);
"NX"表示没有锁时设置锁,“XX”–有锁时设置锁
“PX”–过期时间单位是秒,“PX”–过期时间单位是毫秒。
expireTime–过期时间
其它问题
虽然上面一步已经满足了我们的需求,但是还是要考虑其它问题?
1、 redis发现锁失败了要怎么办?中断请求还是循环请求?
2、 循环请求的话,如果有一个获取了锁,其它的在去获取锁的时候,是不是容易发生抢锁的可能?
3、 锁提前过期后,客户端A还没执行完,然后客户端B获取到了锁,这时候客户端A执行完了,会不会在删锁的时候把B的锁给删掉?
解决方案
1、锁失败就循环请求。
2、可以在抢锁失败后睡眠几毫秒后再请求。
3、加锁时每个请求设置一个value,解锁时判断value是不是自己的,是自己的时候再删除锁。
do{
String value = UUID.randomUUID().toString();
String result = jedis.set(lockKey, value, "NX", "PX", 60);
if ("OK".equals(result)) {
if(value.equals(jedis.get(key)){ //判断锁是不是自己的
... //执行内部代码
jedis.del(key);
//此时也有问题:判断和释放锁是两个独立操作,不是原子性。A客户端加锁,在执行这行代码之前锁过期了,B获取了锁,那么这行代码就把B的锁删除了。
continue;//跳出循环
}
}else {
Thread.sleep(100); //睡眠,降低抢锁频率,缓解redis压力
}
}while(!"OK".equals(result)) //循环获取锁
解锁方案
使用Lua脚本语言解锁
public class RedisTool {
private static final Long RELEASE_SUCCESS = 1L;
/**
* 释放分布式锁
* @param jedis Redis客户端
* @param lockKey 锁
* @param requestId 请求标识
* @return 是否释放成功
*/
public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; //Lua脚本
Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
if (RELEASE_SUCCESS.equals(result)) {
return true;
}
return false;
}
}
首先获取锁对应的value,如果value与自己的值一致,则删除锁。
第二行代码,我们将Lua代码传到jedis.eval()方法里,并使参数KEYS[1]赋值为lockKey,ARGV[1]赋值为requestId。eval()方法是将Lua代码交给Redis服务端执行。
eval命令执行Lua代码的时候,Lua代码将被当成一个命令去执行,并且直到eval命令执行完成,Redis才会执行其他命令。保证操作的原子性。
出现并发的可能性
虽然我们避免了线程A误删掉key的情况,但是同一时间有A,B两个线程在访问代码块,仍然是不完美的。
怎么办呢?我们可以让获得锁的线程开启一个守护线程,用来给快要过期的锁“续航”。
假设过期时间是30秒,当过去了29秒,线程A还没执行完,这时候守护线程会执行expire指令,为这把锁“续命”20秒。守护线程从第29秒开始执行,每20秒执行一次。
当线程A执行完任务,会显式关掉守护线程。
另一种情况,如果节点1 忽然断电,由于线程A和守护线程在同一个进程,守护线程也会停下。这把锁到了超时的时候,没人给它续命,也就自动释放了。
如果你的项目中Redis是多机部署的,那么可以尝试使用Redisson实现分布式锁,这是Redis官方提供的Java组件;
参考阅读
https://redis.io/topics/distlock