在分布式系统中,为了保证集群的数据一致性,我们经常会有使用分布式锁的需求。生产环境实现分布式锁,最常用的还是基于Zookeeper,或者使用Redis来实现。本章,我就来讲讲Redis分布式锁的原理。
一、RedLock
Redis官方支持原生的分布式锁,采用了RedLock算法。这个分布式锁有3个重要的考量点:
- 互斥(只能有一个客户端获取锁);
- 不能死锁;
- 容错( 大部分Redis节点 获得这个锁,就认为加锁成功)。
1.1 使用方法
可以通过以下命令来加锁:
SET [key] [value] NX PX [30000]
NX
的意思是只有key不存在的时候才会设置成功,别人创建的时候如果发现已经有了就不能加锁了;PX 30000
的意思是30秒后锁自动释放。
释放锁其实就是删除key,一般是通过Lua脚本删除,客户端会传送[key]
和[value]
给服务端,服务端执行Lua脚本比较客户端传过来的值与自己保存的是否一致,一致则删除[key]
:
// redis.call("get",KEYS[1])保存的是Redis侧的Key对应的值;ARGV[1]是客户端保存的值
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
这里思考一个问题:为啥客户端释放锁时,除了上送[key],还必须上送[value]呢?
因为如果某个客户端获取到了锁,但是自身执行了很长时间才执行完,此时服务端可能已经自动释放锁了,然后别的客户端已经获取到了这个锁。如果这个时候直接删除key的话会有问题,所以得在Lua脚本中进行[value]的判断。
1.2 缺点
如果是普通的Redis主从架构,由于主从异步复制,当主节点挂了时,key还没同步到从节点,此时从节点切换为主节点,就可能导致重复加锁,出现问题。
二、Redisson客户端
使用Redis分布式锁,最常用的还是直接使用Redisson客户端,非常简便易用。
下面给大家看一段简单的使用代码片段,先直观的感受一下:
RLock lock = redission.getLock("mylock");
try{
lock.lock();
// TODO
}finally{
lock.unlock();
}
接下来,我们来看看Redisson这个开源框架对Redis分布式锁的实现原理。
2.1 加锁原理
我们假设现在有一个Redis Cluster集群,如果某个Redisson客户端1要加锁,它会首先根据hash算法,选择一个Redis节点。然后,发送一段Lua脚本到该节点上,Lua脚本如下所示:
// CASE1:如果服务端不存在锁KEY[1],就加锁,并设置key的过期时间为ARGV[2]
if (redis.call("exist", KEYS[1]) == 0) then
redis.call("hset", KEYS[1], ARGV[2], 1);
redis.call("pexpire", KEYS[1], ARGV[1]);
return nil;
end;
// CASE2:如果服务端存在锁KEY[1],且客户端ID也相同,就重入下锁,然后更新过期时间
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;
// CASE3:加锁失败,返回key剩余过期时间
return redis.call("ptt;", KEY[1]);
上述Lua脚本,第一个 if 就是判断一下,如果你要加锁的key不存在的话,就进行加锁。
KEYS[1]
代表你加锁的那个key,比如说:RLock lock = redisson.getLock("myLock");
那加锁的key就是“myLock”;ARGV[1]
代表的是锁key的生存时间,默认30秒;ARGV[2]
代表的是加锁的客户端ID,类似于下面这样:8743c9c0-0795-4907-87fd-6c719a6b4586:1,Redisson客户端加锁时都会上送自己的客户端ID。
具体如何加锁呢?很简单,用下面的命令:
hset myLock "8743c9c0-0795-4907-87fd-6c719a6b4586:1" 1
Redis服务端通过这个命令设置了一个hash数据结构:
myLock:
{
"8743c9c0-0795-4907-87fd-6c719a6b4586:1" 1
}
上述的“8743c9c0-0795-4907-87fd-6c719a6b4586:1”就代表了:8743c9c0-0795-4907-87fd-6c719a6b4586这个客户端,对“myLock”这个锁,完成了1次加锁。
接着会执行pexpire myLock 30000
命令,设置myLock这个锁key的生存时间是30秒。
为啥要用Lua脚本呢?因为一大坨复杂的业务逻辑,可以通过封装在Lua脚本中发送给Redis,Redis会保证这段复杂业务逻辑执行的原子性。
2.2 锁互斥机制
那么如果此时,另一个客户端2来尝试加锁,执行了同样的一段Lua脚本,会怎么样呢?很简单:
- 首先,第一个if判断会执行“exists myLock”,发现myLock这个锁key已经存在了;
- 接着,第二个if判断,判断一下,myLock锁key的hash数据结构中,是否包含客户端2的ID?但是明显不是的,因为那里包含的是客户端1的ID;
- 最后,客户端2会获取到
pttl myLock
返回的一个数字,这个数字代表了myLock这个锁key的剩余生存时间。此时客户端2会进入一个while循环,不停的尝试加锁。
2.3 watch dog锁延期
客户端1加锁的key默认生存时间才30秒,如果超过了30秒,客户端1还想一直持有这把锁,怎么办呢?
简单!客户端1一旦加锁成功,它就会启动一个watch dog后台线程,会每隔10秒检查一下,如果客户端1还持有锁key,那么就会不断的延长锁key的生存时间。
2.4 可重入锁
那如果客户端1都已经持有了这把锁了,还可以重复加锁吗?比如下面这种代码:
RLock lock = redission.getLock("mylock");
lock.lock();
// DO SOMETHING...
lock.lock();
lock.unlock();
lock.unlock();
这时我们来分析一下最开头那段Lua脚本:
- 第一个if判断肯定不成立,“exists myLock”会显示锁key已经存在了;
- 第二个if判断会成立,因为myLock的hash数据结构中包含的那个ID,就是客户端1的那个ID,也就是“8743c9c0-0795-4907-87fd-6c719a6b4586:1”;
此时就会执行可重入加锁的逻辑,通过下面这个命令,对客户端1的加锁次数累加1:
incrby myLock "8743c9c0-0795-4907-87fd-6c71a6b4586:1" 1
最终,myLock的数据结构变为下面:
myLock:
{
"8743c9c0-0795-4907-87fd-6c719a6b4586:1" 2
}
2.5 释放锁原理
如果执行lock.unlock()
,就可以释放分布式锁,此时的业务逻辑也是非常简单的。说白了就是每次都对myLock数据结构中的那个加锁次数减1。如果发现加锁次数是0了,说明这个客户端已经不再持有锁了,此时就会用del myLock
命令删除这个key。这样其它客户端就可以尝试完成加锁了。
2.6 缺点
上面这种方案最大的问题在于:就是如果你对某个redis master节点加了锁,当redis master宕机时,key还没同步到slave节点,此时slave切换为master,就可能导致重复加锁,也就是多个客户端同时获取了同一把锁。
三、总结
采用Redis分布锁时,其实很难解决因为Master节点宕机而造成的重复加锁问题。所以,生产环境,如果对数据一致性要求不高,可以用Redisson客户端来实现Redis的分布式锁。但是,我们更常用Zookeeper来实现分布式锁,这块内容我将在后续章节详细讲解。