Redis分布式锁方案剖析
分布式锁的原则
- 独享:即互斥属性,在同一时刻,一个资源只能有一把锁被一个客户端持有
- 无死锁:当持有锁的客户端奔溃后,锁仍然可以被其它客户端获取
- 容错性:当部分节点失活之后,其余节点客户端依然可以获取和释放锁
- 统一性:即释放锁的客户端只能由获取锁的客户端释放
方案一
使用 Redis 实现分布式锁最简单的方案是获取锁之前先查询该锁为 key 对应的 value 是否存在。如果存在,则说明该锁被其他客户端获取了,否则就尝试获取锁。获取锁的方法是,以该锁为 key,设置一个随机的值就可以。
获取锁的伪代码如下:
public boolean getLock(String key) {
// 如果 key 存在,返回 false,获取锁失败
if(existsKey(key)) {
return false;
}else {
// key 不存在,设置 key,返回 true,获取锁成功
setKey(key);
return true;
}
}
释放锁的伪代码如下:
public void bussiness(String key) {
boolean lock = false;
try{
// 获取锁
lock = getLock(key);
if(lock) {
// 处理业务
doSomeThing();
}
}finally{
if(lock) {
// 释放锁,删除对应的 key,value
releaseLock(key);
}
}
}
以上的写法的好处是在使用锁的过程中出现异常,也能顺利释放锁。
风险与不足
- 如果代码执行到 doSomeThing() 方法时,服务器宕机,这个时候 finally 代码就无法执行,导致锁无法释放而造成死锁。解决方式为 Redis 设置 key 时可以指定一个过期时间,即使服务器宕机也可以保证锁及时释放。
- 获取到的锁不一定是排他锁,也就是说同一把锁同一时间可能被不同客户端获取到。getLock() 方法不是原子性的,当一个客户端检查到锁不存在时,并在执行 setKey() 之前,别的客户端同样检查该锁不存在,也会执行 setKey() 方法。这样就会有两个客户端同时获取锁。
方案二
Redis 提供了一个只有在 key 不存在的情况下才会设置 key 的值的原子命令,该命令也能设置 key 值过期时间,因此使用该命令,不会出现方案一出现的两个问题。
该命令为:
SET key value NX PX milliseconds。 NX 表示只有当键 key 不存在时才会设置 key 的值,PX 表示设置键过期时间,单位是毫秒。
伪代码如下:
public boolean getLock(String key, long timeout) {
return setKeyOnlyIfNotExists(key, timeout);
// redisTemplate.opsForValue().setIfAbsent(key,value)
}
其中,setKeyOnlyIfNotExists() 方法表示的是原子命令 SET key value NX PX milliseconds。现在这样获取锁是原子性的操作没问题了,但是释放锁出现了新的问题。
风险与不足
- 客户端A 获取锁设置 key 的过期时间 2s,客户端A获取锁后,执行业务 doSomeThing() 方法时间为 3s(大于2s),当执行完业务逻辑后,客户端A获取的锁已经被 Redis 过期机制释放掉了。所以客户端A执行完 doSomeThing() 方法后,执行 releaseLock() 释放锁时,可能释放的是客户端B获取的锁。
方案三
方案二出现客户端A释放客户端B获取的锁的情况,我们可以设置 key的时候,将value设置成一个唯一值,当释放锁,也就是删除key时,先比较key是否为之前设置的值,只有两者相等时才删除key。
伪代码如下:
public void releaseLock(String key, String value) {
// 比较 value是否一致
if(getKey(key) == value) {
// 根据key删除
deleteKey(key);
}
}
风险与不足
- 上述释放锁的操作不是原子性的,意味着getKey 方法和 deleteKey 方法之间,其他客户端是可以执行其他命令的。假设客户端A执行完 getKey() 方法,并且value 也和之前一致,准备执行 deleteKey 时,假设由于网络或其他原因导致客户端A在执行完 getKey之后过了1s才执行 deleteKey,在这1s中,该key可能因为过期被Redis清除了。这样客户端B,有可能在这期间获取锁,然后客户端A执行 deleteKey 误释放别的客户端的锁。
方案四
方案三的问题是因为释放锁的方法不是原子性操作导致的,那么我们只要保证释放锁的代码是原子性就能解决问题。我们在此使用Lua脚本来保证操作的原子性。
Lua基本伪代码如下:
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
其中 ARGV[1] 表示设置 key时指定的全局唯一的随机值。由于Lua脚本的原子性,在Redis执行该脚本时,其他客户端的命令都需要等待该Lua脚本执行完毕才能执行。所以使用Lua脚本来进行锁的释放就相对完善了。
风险与不足
- 上述分布式的实现方案中,都是针对 Redis 单节点而言。在实际生产环境中,通常都是Redis集群和主从节点,由于Redis的主从节点复制是异步的,假设客户端A获取锁后,Redis 数据没有同步到从节点,主节点宕机,从节点变成主节点,这个时候客户端A获取的锁就处于无效状态,客户端B照样可以获取锁。
- 当 redis 的架构如上图所示一样是单实例模式时,如果存在主备且可以忍受小概率的锁出错,那么就可以直接使用上述代码,当然最严谨的方式还是使用官方的 Redlock 算法实现。其中 Java 包推荐使用 redisson。