Redis 分布式锁特性:
- 互斥。在任何给定时刻,只有一个客户端可以持有锁。
- 无死锁。最终,即使锁定资源的客户端崩溃或分区,也始终可以获得锁定。
- 容错能力。只要大多数Redis节点都处于运行状态,客户端就可以获取和释放锁。
单例 Redis 分布式锁
获取锁
使用以下方法:
SET resource_name my_random_value NX PX 30000
该命令仅在密钥不存在时才设置密钥(NX选项),并且到期时间为30000毫秒(PX选项)。密钥设置为随机值。该值在所有客户端和所有锁定请求中必须是唯一的。
释放锁
基本上,使用随机值是为了以安全的方式释放锁,并且脚本会告诉Redis:仅当密钥存在且存储在密钥上的值恰好是我期望的值时,才删除该密钥。这是通过以下Lua脚本完成的:
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
为了避免删除另一个客户端创建的锁,这一点很重要。
简单实现代码:
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
import redis.clients.jedis.params.SetParams;
import java.util.Collections;
public class RedisLock {
private JedisPool jedisPool;
public static final String LOCK_KEY = "lock";
public static final int LOCK_EXPIRES = 5000;
public static final long MAX_WAIT = 20000;
public static final SetParams LOCK_PARAMS = SetParams.setParams().nx().px(LOCK_EXPIRES);
public static final String RELEASE_LOCK_SCRIPT =
"if redis.call('get',KEYS[1]) == ARGV[1] then" +
" return redis.call('del',KEYS[1]) " +
"else" +
" return 0 " +
"end";
public RedisLock() {
JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
jedisPoolConfig.setMaxTotal(1024);
jedisPoolConfig.setMaxIdle(100);
jedisPoolConfig.setMaxWaitMillis(100);
jedisPoolConfig.setTestOnBorrow(false);
jedisPoolConfig.setTestOnReturn(true);
jedisPool = new JedisPool(jedisPoolConfig, "127.0.0.1", 6379, 10000);
}
public boolean getLock(String lockId) {
long start = System.currentTimeMillis();
try (Jedis jedis = jedisPool.getResource()) {
for (;;) {
String result = jedis.set(LOCK_KEY, lockId, LOCK_PARAMS);
if ("OK".equals(result)) {
return true;
}
if (System.currentTimeMillis() - start > MAX_WAIT) {
return false;
}
Thread.sleep(10);
}
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
public boolean releaseLock(String lockId) {
try (Jedis jedis = jedisPool.getResource()) {
Object result = jedis.eval(RELEASE_LOCK_SCRIPT, Collections.singletonList(LOCK_KEY), Collections.singletonList(lockId));
return "1".equals(result.toString());
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
}
多实例 Redis 分布式锁
单实例的分布式锁,如果遇到特殊情况,redis 实例不能提供服务,则客户端就不能继续服务。多实例的分布式锁可以用来解决这个问题。
假设存在N个 redis master 实例。
操作:
- 它以毫秒为单位获取当前时间。
- 它尝试在所有N个实例中依次使用所有实例中相同的键名和随机值来获取锁定。在第2步中,在每个实例中设置锁时,客户端使用的超时时间小于总锁自动释放时间,以便获取该超时时间。例如,如果自动释放时间为10秒,则超时时间可能在5到50毫秒之间。这样可以防止客户端长时间与处于故障状态的Redis节点通信时保持阻塞:如果一个实例不可用,我们应该尝试与下一个实例尽快通信。
- 客户端通过从当前时间中减去在步骤1中获得的时间戳,来计算获取锁所花费的时间。 仅当从超过一半的实例中成功获取锁,并且获取锁所花费的总时间小于锁有效时间,则认为已获取锁。
- 如果获取了锁,则将其有效时间视为初始有效时间减去经过的时间,如步骤3中所计算。
- 如果客户端由于某种原因(无法锁定N / 2 + 1实例或有效时间为负数)而未能获得该锁,它将尝试解锁所有实例(即使它认为不是该实例)能够锁定)。
使用于Java 的分布式锁客户端的实现 Redisson
存在的问题
1. 有效时间设置多长,假如我的业务操作比有效时间长,我的业务代码还没执行完就自动给我解锁了,不就完蛋了吗?
- 第一种解决方法就是靠程序员自己去把握,预估一下业务代码需要执行的时间,然后设置有效期时间比执行时间长一些,保证不会因为自动解锁影响到客户端业务代码的执行。但是可能会影响执行效率,而且也不是万全之策,比如网络抖动这种情况是无法预测的,也有可能导致业务代码执行的时间变长,所以并不安全。
- 第二种方法比较靠谱一点,就是给锁续期。在Redisson框架实现分布式锁的思路,就使用watchDog机制实现锁的续期。当加锁成功后,同时开启守护线程,默认有效期是30秒,每隔10秒就会给锁续期到30秒,只要持有锁的客户端没有宕机,就能保证一直持有锁,直到业务代码执行完毕由客户端自己解锁,如果宕机了自然就在有效期失效后自动解锁。
2. 这个锁只能加一次,不可重入?
在Redisson实现可重入锁的思路,使用Redis的哈希表存储可重入次数,当加锁成功后,使用hset
命令,value(重入次数)则是1。
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; "
如果同一个客户端再次加锁成功,则使用hincrby
自增加一。
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);"
解锁时,先判断可重复次数是否大于0,大于0则减一,否则删除键值,释放锁资源。
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; "+
"end; " +
"return nil;",
Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
}
3. 上面的加锁方法是加锁后立即返回加锁结果,如果加锁失败的情况下,总不可能一直轮询尝试加锁,直到加锁成功为止,这样太过耗费性能。所以需要利用发布订阅的机制进行优化。
步骤如下:
当加锁失败后,订阅锁释放的消息,自身进入阻塞状态。
当持有锁的客户端释放锁的时候,发布锁释放的消息。
当进入阻塞等待的其他客户端收到锁释放的消息后,解除阻塞等待状态,再次尝试加锁。
4. 如下图,如果Client1 在获取到锁之后,遇到了长时间的GC,所有线程被挂起,时间超过了锁的过期时间,就有可能导致两个客户端都获取到锁并操作资源。
详细看撕比现场 :https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html http://antirez.com/news/101
所谓的Stop the World机制,简称STW,即在执行垃圾收集算法时,Java应用程序的其他所有除了垃圾收集收集器线程之外的线程都被挂起