蹲厕所的熊 转载请注明原创出处,谢谢!

分布式锁可以解决在分布式环境下的多资源竞争问题,常见的分布式锁实现有以下3种:
基于数据库的唯一索引方式或乐观锁方式。
基于Redis单线程特性的原子操作。
基于Zookeeper的临时有序节点。
本文主要介绍Redis如何实现分布式锁的获取和解除以及实现的正确姿势是什么。
获取锁
错误姿势1
在介绍获取锁的正确姿势之前先来个错误姿势。大家都知道Redis的分布式锁是利用了Redis单线程的特性加上 SETNX
命令来实现的。而为什么还会加上一个 EXPIRE
命令是为了防止 SETNX
后key一直存在的问题。
SETNX key value:将key设置值为value,如果key不存在,这种情况下等同SET命令。当key存在时,什么也不做。SETNX是 SET if Not Exists 的简写。
如果key设置成功返回1,否则返回0。
EXPIRE key seconds:设置
key
的过期时间,超过时间后,将会自动删除该key
。如果成功设置过期时间返回1,否则返回0。
很容易的就能写出这样的代码:
/**
* 获取分布式锁
* @param key
* @param timeout
* @param timeUnit
* @return
*/
public static boolean tryLock(String key, int timeout, TimeUnit timeUnit) {
Long result = jedis.setnx(key, "CONSTANT_VALUE");
if (result == 1L) {
Long seconds = timeUnit.toSeconds(timeout);
return jedis.expire(key, seconds.intValue()) == 1L;
}
return false;
}
细心的朋友很快发现了有这么几个问题:
由于
setnx
和expire
是分开两步进行的操作,不具有原子性。如果客户端在执行完setnx
后崩溃了,那么就没有机会执行expire
了,导致它一直持有该锁。setnx
的value这里写死了,到时候解锁的时候就不知道是谁设置的key了,很容易锁被其他请求误解了。
错误姿势2
很多同学知道redis中的 pipeline
可以作为一个管道批量执行命令,错误的以为它的执行是原子的,以至于用它来结合 setnx
和 expire
,这其实也是不对的。
/**
* 获取分布式锁
* @param key
* @param timeout
* @param timeUnit
* @return
*/
public static boolean tryLock(String key, int timeout, TimeUnit timeUnit) {
// 不存在key
if (!jedis.exists(key)) {
Long seconds = timeUnit.toSeconds(timeout);
List<Object> result = setnx(key, UUID.randomUUID().toString(), seconds.intValue());
return Boolean.valueOf(result.get(0).toString()) &&
Boolean.valueOf(result.get(1).toString());
}
return false;
}
private static List<Object> setnx(String key, String value, int seconds) {
List<Object> result = null;
try {
Pipeline pipelined = jedis.pipelined();
// 问题:setNX成功了后redis服务挂了 导致expire失败,一直死锁
pipelined.setnx(key.getBytes(), value.getBytes());
pipelined.expire(key.getBytes(), seconds);
result = pipelined.syncAndReturnAll();
return result;
} catch (Exception e) {
e.printStackTrace();
}
return result;
}
错误姿势3
这个用法在我第一次看见的时候觉得特别精妙,一般比较难以发现问题,而且实现也比较复杂。
实现思路也是利用了 setnx
命令来设置key,不同的地方在于它没有使用 expire
命令来设置过期时间,而在 setnx
的时候把过期时间当做value设置进去,下一次获取的时候比较value和当前时间来决定是否进行覆盖。
/**
* 获取分布式锁
* @param key
* @param timeout
* @param timeUnit
* @return
*/
public static boolean tryLock(String key, int timeout, TimeUnit timeUnit) {
long timeoutSecond = timeUnit.toSeconds(timeout);
// 过期时间
long expireTime = System.currentTimeMillis() + timeoutSecond;
// 如果当前锁不存在,返回加锁成功
if (jedis.setnx(key, String.valueOf(expireTime)) == 1) {
return true;
}
String lastValue = jedis.get(key);
if (lastValue != null && Long.parseLong(lastValue) < System.currentTimeMillis()) {
// 锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间
String oldValue = jedis.getSet(key, String.valueOf(expireTime));
if (oldValue != null && oldValue.equals(lastValue)) {
// 考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才有权利加锁
return true;
}
}
return false;
}
其实仔细看,这段代码还是存在很多问题的:
由于是客户端自己生成过期时间,所以需要强制要求分布式下每个客户端的时间必须同步,这一点的问题可以忽略。
当锁过期的时候,如果多个客户端同时执行
jedis.getSet()
方法,那么虽然最终只有一个客户端可以加锁,但是这个客户端的锁的过期时间可能被其他客户端覆盖。锁不具备拥有者标识,即任何客户端都可以解锁。
正确姿势
那么获取锁的正确姿势究竟是什么呢?Redis在 2.6.12
版本开始,为 SET
命令增加了一系列选项:
SET key value[EX seconds][PX milliseconds][NX|XX]
EX seconds:设置指定的过期时间,单位秒。
PX milliseconds:设置指定的过期时间,单位毫秒。
NX:仅当key不存在时设置值。
XX:仅当key存在时设置值。
可以看出来, SET
命令的天然原子性完全可以取代 SETNX
和 EXPIRE
命令。
/**
* 获取分布式锁
* @param key
* @param uniqueId 请求的唯一值
* @param seconds
* @return
*/
public static boolean tryLock(String key, String uniqueId, int seconds) {
return "OK".equals(jedis.set(key, uniqueId, "NX", "EX", seconds));
}
还在使用 2.6.12
版本之前的同学只能使用另一法宝:Lua脚本来保证原子性了。
/**
* 获取分布式锁
* @param key
* @param uniqueId 请求的唯一值
* @param seconds
* @return
*/
public static boolean tryLock(String key, String uniqueId, int seconds) {
String luaScript = "if redis.call('setnx', KEYS[1], KEYS[2]) == 1 then " +
"redis.call('expire', KEYS[1], KEYS[3]) return 1 else return 0 end";
List<String> keys = new ArrayList<>();
keys.add(key);
keys.add(uniqueId);
keys.add(String.valueOf(seconds));
Object result = jedis.eval(luaScript, keys, new ArrayList<String>());
return result.equals(1L);
}
释放锁
错误姿势1
最常见的解锁代码就是直接使用 jedis.del()
方法删除锁,这种不先判断锁的拥有者而直接解锁的方式,会导致任何客户端都可以随时进行解锁,即使这把锁不是它的。
/**
* 释放分布式锁
* @param key
*/
public static void releaseLock(String key) {
jedis.del(key);
}
错误姿势2
上面已经说过这种写法的 get
和 del
没有在一个原子操作中。
/**
* 释放分布式锁
* @param key
* @param uniqueId
*/
public static void releaseLock(String key, String uniqueId) {
if (uniqueId.equals(jedis.get(key))) {
jedis.del(key);
}
}
正确姿势
同样的,释放锁时设计到多个命令要想保持原子性必须得使用Lua脚本。
/**
* 释放分布式锁
* @param key
* @param uniqueId
*/
public static boolean releaseLock(String key, String uniqueId) {
String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) else return 0 end";
return jedis.eval(luaScript, Collections.singletonList(key), Collections.singletonList(uniqueId)).equals(1L);
}
如果读完觉得有收获的话,欢迎点赞、关注、加公众号【蹲厕所的熊】,查阅更多精彩历史!!!




