前言
锁独占一个资源,只能有一个在对这个资源的操作,为了避免多个端对同一资源操作导致的与预期不一样的问题,最近写了下redis锁,也看了比较多的博客,写的例子也不断变化,最终确定Redlock锁,这是Redis的作者antirez设计的锁,我以为是完全安全的,但看了诸多文章与讨论,得出结论是Redlock锁是相对安全的,而安全系数够用。
加锁
正确的代码
/**
* 枷锁
* @param lockName 锁key
* @param randomVal uuid 确定解锁和枷锁是一个锁
* @param lockSeconds 锁的超时时间
* @return
*/
public boolean lock(String lockName,String randomVal,int lockSeconds){
Jedis jedis = null;
//加锁
try {
jedis = pool.getResource();
boolean locked = "OK".equals(jedis.set(lockName, randomVal, "NX", "EX", lockSeconds));
return locked;
} catch (JedisException e) {
throw e;
} finally {
jedis.close();
}
}
命令介绍:SET key value [NX|XX] [EX|PX] seconds
key 平常用rediskey,这里是锁定资源key,或者说锁的名字
value 这里是一个唯一id,比如uuid,为了保证枷锁和解锁都是一个端,后面会说
NX – 只有键key不存在的时候才会设置key的值
XX – 只有键key存在的时候才会设置key的值
EX seconds – 设置键key的过期时间,单位时秒
PX milliseconds – 设置键key的过期时间,单位时毫秒
原子性操作
这个枷锁的操作是原子性的,那如果不是原子性的会有什么问题呢,请看错误例子
错误例子1
public static void wrongGetLock1(Jedis jedis, String lockKey, String requestId, int expireTime) {
Long result = jedis.setnx(lockKey, requestId);
if (result == 1) {
// 若在这里程序突然崩溃,则无法设置过期时间,将发生死锁
jedis.expire(lockKey, expireTime);
}
}
错误例子2
public static boolean wrongGetLock2(Jedis jedis, String lockKey, int expireTime) {
long expires = System.currentTimeMillis() + expireTime;
String expiresStr = String.valueOf(expires);
// 如果当前锁不存在,返回加锁成功
if (jedis.setnx(lockKey, expiresStr) == 1) {
return true;
}
// 如果锁存在,获取锁的过期时间
String currentValueStr = jedis.get(lockKey);
if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
// 锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间
String oldValueStr = jedis.getSet(lockKey, expiresStr);
if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
// 考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才有权利加锁
return true;
}
}
// 其他情况,一律返回加锁失败
return false;
}
错误:1. 由于是客户端自己生成过期时间,所以需要强制要求分布式下每个客户端的时间必须同步。 2. 当锁过期的时候,如果多个客户端同时执行jedis.getSet()
方法,那么虽然最终只有一个客户端可以加锁,但是这个客户端的锁的过期时间可能被其他客户端覆盖。3. 锁不具备拥有者标识,即任何客户端都可以解锁。
解锁
正确的代码
/**
* 解锁
* @param lockName 锁key
* @param randomVal uuid 确定解锁和枷锁是一个锁
* @return
*/
public boolean release(String lockName, String randomVal) {
Jedis jedis = null;
try {
jedis = pool.getResource();
String program = "if redis.call(\"get\",KEYS[1] == ARGV[1] then)"
+ "return resis.call(\"del\",KEYS[1])"
+ "else return 0"
+ "end";
Object rlt = jedis.eval(program, 1, lockName, randomVal);
//解锁:判断当前的value是否还是原值,如果是原值则删除之
return Long.valueOf(1) == rlt;
} finally {
jedis.close();
}
}
Redis+Lua实现:
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
参数:
program lua语句
keycount 这里填1
lockName 唯一的key
randomVal 唯一id只能释放自己加的锁uuid
如果获取key的值和我们穿进去的uuid是一样,则删除,如果不一样返回0代表解锁失败,这样能确保枷锁和解锁的是同一个客户端,举个例子看图
所以为了避免redis超时导致用户1释放用户2的锁,需要在加锁和释放的时候加一个唯一标识,确定用户1只能释放用户1的锁
原子性操作
当然释放锁使用 Redis+Lua 也是原子性操作的,释放的错误例子如下
错误例子1
/最常见的解锁代码就是直接使用jedis.del()方法删除锁,这种不先判断锁的拥有者而直接解锁的方式,会导致任何客户端都可以随时进行解锁,即使这把锁不是它的。
public static void wrongReleaseLock1(Jedis jedis, String lockKey) {
jedis.del(lockKey);
}
错误例子2
如代码注释,问题在于如果调用jedis.del()
方法的时候,这把锁已经不属于当前客户端的时候会解除他人加的锁。那么是否真的有这种场景?答案是肯定的,比如客户端A加锁,一段时间之后客户端A解锁,在执行jedis.del()
之前,锁突然过期了,此时客户端B尝试加锁成功,然后客户端A再执行del()方法,则将客户端B的锁给解除了。
public static void wrongReleaseLock2(Jedis jedis, String lockKey, String requestId) {
// 判断加锁与解锁是不是同一个客户端
if (requestId.equals(jedis.get(lockKey))) {
// 若在此时,这把锁突然不是这个客户端的,则会误解锁
jedis.del(lockKey);
}
}
使用
这里简单封装写个使用的例子代码如下
/**
* 分布式锁,警告:如果执行时间过长,大于超时时间后,会存在并行执行的情况
*
* @param redisKey 要lock的key
* @param lockSeconds 超时时间
* @param func 回调函数
* @param t 回调参数
* @param <T> 回调参数类型
* @return 是否拿到锁并执行了回调函数
*/
public <T> boolean distributedLock(String redisKey, int lockSeconds, Consumer<T> func, T t) {
if (lockSeconds == 0)
throw new RuntimeException("分布式锁过期时间必须大于0");
if (StringUtils.isEmpty(redisKey))
throw new RuntimeException("分布式锁必须设置key");
String uuid = UUID.randomUUID().toString();
//获取锁
if (lock(redisKey,uuid,lockSeconds) == false) {
return false;
}
//加锁成功继续执行回调函数
try {
func.accept(t);
} catch (Exception ex) {
System.out.println("RedisCacheFacade->Lock,执行方法体报错:" + ex);
} finally {
//解锁
release(redisKey, uuid);
}
return true;
}
后记
其实安全都是相对的,虽然用户1释放不了用户2的锁,但是因为redis设置超时的关系,导致用户1和用户2同时执行被锁住的资源,也会导致一些问题,比如用户1查出来资源 然后锁的时间到了,用户2查出来资源,用户1更新资源,用户2更新资源就会导致用户1更新的资源失效,这里解决这个问题可以用数据库的乐观锁来解决,也就是为数据加版本,这样用户2执行保存的时候就会发现与自己查出来的版本不符,就可以事物回滚了。
神仙打架有兴趣一定要看看
文章内容参考
https://www.cnblogs.com/linjiqin/p/8003838.html
https://www.jianshu.com/p/bc8dd3c0db22