分布式锁-01丨Redis实现分布式锁

Posted by jiefang on January 5, 2020

Redis分布式锁

生产环境用分布式锁的时候,一定是会用开源类库的,比如Redis分布式锁,一般就是用Redisson框架。

1
2
3
4
5
6
//获取锁
RLock lock = redissonClient.getLock("redisson_lock");
//加锁
lock.lock();
//解锁
rLock.unlock();

实现原理

image

加锁机制

首先会根据hash节点选择一台机器。然后就会发送一段lua脚本到redis上,lua脚本如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//判断锁key是否存在,不存在
if (redis.call('exists', KEYS[1]) == 0) then
    //不存在则调用hset命令,设置锁key
    redis.call('hset', KEYS[1], ARGV[2], 1);
    //设置锁key的生存时间30s
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
end;
//key存在,判断客户端ID是否相同
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    //设置锁keyvalue1
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    //设置锁key的生存时间30s
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
end;
//key的剩余生存时间
return redis.call('pttl', KEYS[1]);

复杂的业务逻辑,通过封装在lua脚本中发送给redis,保证这段复杂业务逻辑执行的原子性

  • KEYS[1]代表的加锁的key。
  • ARGV[1]代表的就是锁key的默认生存时间,默认30秒。
  • ARGV[2]代表的是加锁的客户端的ID,类似于这样:8743c9c0-0795-4907-87fd-6c719a6b4586:1

hset myLock 8743c9c0-0795-4907-87fd-6c719a6b4586:1 1,通过这个命令设置一个hash数据结构,这行命令执行后,会出现一个类似下面的数据结构:

1
2
3
myLock:{
    "8743c9c0-0795-4907-87fd-6c719a6b4586:1": 1
}

锁互斥机制

此时,如果客户端2来尝试加锁,执行了同样的一段lua脚本,根据lua脚本逻辑:

  1. 第一个if判断会执行exists myLock,发现myLock这个锁key已经存在了;
  2. 第二个if判断,判断一下,myLock锁key的hash数据结构中,是否包含客户端2的ID,但是明显不是的,因为那里包含的是客户端1的ID;
  3. 客户端2会获取到pttl myLock返回的一个数字,这个数字代表了myLock这个锁key的剩余生存时间。比如还剩15000毫秒的生存时间;
  4. 此时客户端2会进入一个while循环,不停的尝试加锁;

自动延期机制

客户端1加锁的锁key默认生存时间才30秒,如果超过了30秒,客户端1还想一直持有这把锁,怎么办呢?简单!只要客户端1一旦加锁成功,就会启动一个watch dog看门狗,它是一个后台线程,会每隔10秒检查一下,如果客户端1还持有锁key,那么就会不断的延长锁key的生存时间。

可重入加锁机制

1
2
3
4
5
6
7
8
9
10
//获取锁
RLock lock = redissonClient.getLock("redisson_lock");
//加锁
lock.lock();
//重入锁
lock.lock();
//解锁
rLock.unlock();
//解锁
rLock.unlock();
  • 第一个if判断肯定不成立,“exists myLock”会显示锁key已经存在了;
  • 第二个if判断会成立,因为myLock的hash数据结构中包含的那个ID,就是客户端1的那个ID,也就是“8743c9c0-0795-4907-87fd-6c719a6b4586:1”

此时就会执行可重入加锁的逻辑,他会用: incrby myLock 8743c9c0-0795-4907-87fd-6c71a6b4586:1 1 ,通过这个命令,对客户端1的加锁次数,累加1。此时myLock数据结构变为下面这样:

1
2
3
myLock:{
    "8743c9c0-0795-4907-87fd-6c719a6b4586:1": 2
}

释放锁机制

发送一段lua脚本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//锁已经不存在返回
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then 
    return nil;
end; 
//key重入次数-1
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
//key重入次数大于0,设置存活时间
if (counter > 0) then 
    redis.call('pexpire', KEYS[1], ARGV[2]);
    return 0;
//key重入次数等于0,锁已释放,删除锁key
else
    redis.call('del', KEYS[1]);
    redis.call('publish', KEYS[2], ARGV[1]);
    return 1;
end;
return nil;

使用lock.unlock()释放分布式锁的业务逻辑:对myLock数据结构中的加锁次数减1,如果发现加锁次数是0了,说明这个客户端已经不再持有锁了,此时就会用:del myLock命令,从redis里删除这个key。

缺点

其实上面那种方案最大的问题,就是如果对某个redis master实例,写入了myLock这种锁key的value,此时会异步复制给对应的master slave实例。但是这个过程中一旦发生redis master宕机,主备切换,redis slave变为了redis master。

接着就会导致,客户端2来尝试加锁的时候,在新的redis master上完成了加锁,而客户端1也以为自己成功加了锁。此时就会导致多个客户端对一个分布式锁完成了加锁。这时系统在业务语义上一定会出现问题,导致各种脏数据的产生。

所以这个就是redis cluster,或者是redis master-slave架构的主从异步复制导致的redis分布式锁的最大缺陷:在redis master实例宕机的时候,可能导致多个客户端同时完成加锁

RedLock算法

假设有一个redis cluster,有5个redis master实例。然后执行如下步骤获取一把锁:

1)获取当前时间戳,单位是毫秒;

2)轮流尝试在每个master节点上创建锁,过期时间较短,一般就几十毫秒;

3)尝试在大多数节点上建立一个锁,比如5个节点就要求是3个节点(n / 2 +1);

4)客户端计算建立好锁的时间,如果建立锁的时间小于超时时间,就算建立成功了;

5)要是锁建立失败了,那么就依次删除这个锁;

6)只要别人建立了一把分布式锁,你就得不断轮询去尝试获取锁;

缺点

基于redis多master集群(redis-cluster,或者redis部署多机器,twitter开源的twemproxy做客户端集群分片,豌豆荚开源的codis做集群分片,都行),redlock算法实现过程太复杂繁琐,很脆弱,多节点同时设置分布式锁,但是失效时间都不一样,随着不同的linux机器的时间不同步,以及各种你无法考虑到的问题,很可能出现重复加锁。

所以redlock算法,有两个问题,

  • 第一是实现过程和步骤太复杂,上锁的过程和机制很重,很复杂,导致很脆弱,各种意想不到的情况都可能发生;
  • 第二是并不健壮,不一定能完全实现健壮的分布式锁的语义;