redis作为分布式锁的运用,网上有无数的案例,这里提供一个我自己设计的unlock解锁方案。
相对于加锁,解锁的过程相对简单,之前我项目里解锁就是直接delete lock_key,由于加锁的过程设置了超时时间,简单的delete lock_key显然有造成误删锁的风险(下面会具体介绍误删的原因)。本人梳理了下解锁的流程,设计了一个大部分情况下可以避免误删的解锁方案。
首先引用一个典型的加锁流程。利用的jedis客户端,其中setnx方法判断锁是否被其他线程占用,如果被占用的话也不是马上退出,而是比较占用的锁是否超时。这儿getSet方法用来避免多个线程同时删除过期锁,从而同时获取到锁的情况,保证只有一个线程能修改过期锁。这个过程网上有无数的案例,在此不作过多描述。下面分析该方法的unlock方法如何解锁。
/*
* 典型加锁流程
* */
public boolean acquireLock(String lock) {
boolean success = false;
Jedis jedis = pool.getResource();
//过期时间3分钟
long value = System.currentTimeMillis() + 3 * 60 * 1000 + 1;
long acquired = jedis.setnx(lock, String.valueOf(value));
//SETNX成功,则成功获取一个锁
if (acquired == 1)
success = true;
//SETNX失败,说明锁仍然被其他对象保持,检查其是否已经超时
else {
long oldValue = Long.valueOf(jedis.get(lock));
//超时
if (oldValue < System.currentTimeMillis()) {
String getValue = jedis.getSet(lock, String.valueOf(value));
// 获取锁成功
if (Long.valueOf(getValue) == oldValue)
success = true;
// 已被其他进程捷足先登了
else
success = false;
}
//未超时,则直接返回失败
else
success = false;
}
pool.returnResource(jedis);
return success;
}
关于unlock方法,如果直接删除lock_key,显然可能存在这样的错误:
当T1加锁成功,但执行过程被挂起,导致执行时间超过3分钟,另一个线程T2修改了T1的锁过期时间,此时T2加锁成功。当T1执行到解锁的流程,如果T1直接delete lock_key,删除的是T2修改过的lock_key,这时候T2未必执行完毕。如果再有T3过来,将直接加锁成功,导致T2和T3并发。
问题的关键是,T1解锁的时候,没有验证删除的是自己锁。为了让T1线程能“认识”自己加的锁,我们修改了lock方法,返回加锁的时间戳,用于unlock判断。加锁代码:
/*
* 加锁成功返回时间戳,加锁失败返回空对象null
* */
public String lock(String lock) {
Jedis jedis = pool.getResource();
long value = System.currentTimeMillis() + 3 * 60 * 1000 + 1;
String timeStamp = String.valueOf(value);
long acquired = jedis.setnx(lock, timeStamp);
//SETNX成功,则成功获取一个锁
if (acquired == 1) {
return timeStamp;
} else {
long oldValue = Long.valueOf(jedis.get(lock));
//超时
if (oldValue < System.currentTimeMillis()) {
String getValue = jedis.getSet(lock, timeStamp);
// 这种情况不能加锁
if (Long.valueOf(getValue) != oldValue) {
timeStamp = null;
}
} else {
//锁未超时,也不能加锁
timeStamp = null;
}
}
pool.returnResource(jedis);
//只有加锁成功情况,才返回了加锁的时间戳
return timeStamp;
}
lock方法可以返回时间戳,当调用unlock方法时,用这个时间戳作为验证的参数
/*
* 解锁要验证加锁返回的时间戳
* */
public void unlock(String lock, String timeStamp) {
Jedis jedis = pool.getResource();
String value = jedis.get(lock); .......................................(1)
long expireTime = Long.parseLong(value);
if (System.currentTimeMillis() > expireTime
&& value.equals(timeStamp)) {
jedis.del(lock); ...............................................(2)
}
pool.returnResource(jedis);
}
这儿解释一下delete lock_key的条件:
1、当锁未超时(小于currentTimeMillis)
2、当锁的value和timeStamp相等
为什么要验证锁未超时?因为如果锁已经超时,就算value和timeStamp相等,即线程自己加的锁,如果直接删除这个超时的锁,可能删除的是另一个线程的锁,具体过程像这样:
(1)T1线程查看锁是否是自己的timeStamp:
String value = jedis.get(lock);
if(value.equals(timeStamp)) ==> true
//判断timeStamp是不是和value相同,结果相同,因此T1线程正准备删除key
(2)这时候T2线程过来获取锁,由于T1的锁已经超时,T2直接加锁成功了,这时候lock的value其实不在是timeStamp
(3)T1执行到删除的命令,然后悲剧发生,T2的lock_key被删掉了。。。
整个错误的核心在于,unlock方法里面(1)、(2)两个步骤之间,不能有别的线程加锁。
按照这个思路,redis的watch似乎可以更简洁的处理lock和unlock的过程。但据说watch涉及到redis事务开销,很少有用watch实现分布锁。如果读者有更好的方式,欢迎向博主推荐
(个人原创,转载请注明出处)