转自 https://mp.weixin.qq.com/s/eHsuEc8Dq3h1Kz1uqBWsjA
分布式锁可以解决在分布式环境下的多资源竞争问题,常见的分布式锁实现有以下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);
-
}