消息中间件——Redis—分布式锁(二十)

为什么要用分布式锁?

比如我们用redis做秒杀活动,客户端请求,首先查看库存,库存足够,则下订单,更新库存。然而当并发下,可能会出现库存数据不一致的情形,造成库存超卖。

如果是单台机器:

我们使用java的锁将一系列操作变成原子操作,能够避免并发下的线程安全问题。

如果是多台机器:

单机上的锁只能锁住进程,多个进程之间是无效的,我们需要保证多台机器加的锁是同一把锁。

分布式锁的思路是:在整个系统提供一个全局、唯一的获取锁的“东西”,然后每个系统在需要加锁时,都去问这个“东西”拿到一把锁,这样不同的系统拿到的就可以认为是同一把锁。

至于这个“东西”,可以是 Redis、Zookeeper,也可以是数据库。

setnx

在 Redis 中设置一个值表示加了锁,然后释放锁的时候就把这个 Key 删除。

其实目前通常所说的setnx命令,并非单指redis的setnx key value这条命令。

一般代指redis中对set命令加上nx参数进行使用, set这个命令,目前已经支持这么多参数可选:

SET key value [EX seconds|PX milliseconds] [NX|XX] [KEEPTTL]

消息中间件——Redis—分布式锁(二十)_2020-06-24-15-58-07.png

  1. 那么为什么要使用PX 30000去设置一个超时时间?

如果进程A崩了,直接原地把锁带走了,导致系统中谁也拿不到锁。

  1. 为什么要设置唯一value哪?

如果进程A操作锁内资源超过笔者设置的超时时间,那么就会导致其他进程拿到锁,等进程A回来了,回手就是把其他进程的锁删了。

当解锁的时候,先获取value判断是否是当前进程加的锁,再去删除。伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
String uuid = xxxx;
// 伪代码,具体实现看项目中用的连接工具
// 有的提供的方法名为set 有的叫setIfAbsent
set Test uuid NX PX 3000
try{
// biz handle....
} finally {
// unlock
if(uuid.equals(redisTool.get('Test')){
redisTool.del('Test');
}
}

在finally代码块中,get和del并非原子操作,还是有进程安全问题。

删除锁的正确姿势之一,就是可以使用lua脚本,通过redis的eval/evalsha命令来运行:

1
2
3
4
5
6
7
8
9
10
11
-- lua删除锁:
-- KEYS和ARGV分别是以集合方式传入的参数,对应上文的Test和uuid。
-- 如果对应的value等于传入的uuid。
if redis.call('get', KEYS[1]) == ARGV[1]
then
-- 执行删除操作
return redis.call('del', KEYS[1])
else
-- 不成功,返回0
return 0
end

命令执行脚本,其他客户端是看不到的。

Redisson

Redisson 是 Java 的 Redis 客户端之一,提供了一些 API 方便操作 Redis。

Redisson 帮我们搞了分布式的版本,对主从,哨兵,集群等模式都支持,当然了,单节点模式肯定是支持的。

  1. Redisson 普通的锁实现源码主要是 RedissonLock 这个类
  2. 源码中加锁/释放锁操作都是用 Lua 脚本完成的

我们回想以下上一步设置的超时时间是 30s,假如我超过 30s 都还没有完成业务逻辑的情况下,Key 会过期,其他线程有可能会获取到锁。

我们是通过设置唯一value去避免线程安全问题。

我们来看一下Redisson怎么实现?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Config config = new Config(); 
config.useClusterServers()
.addNodeAddress("redis://192.168.31.101:7001")
.addNodeAddress("redis://192.168.31.101:7002")
.addNodeAddress("redis://192.168.31.101:7003")
.addNodeAddress("redis://192.168.31.102:7001")
.addNodeAddress("redis://192.168.31.102:7002")
.addNodeAddress("redis://192.168.31.102:7003");

RedissonClient redisson = Redisson.create(config);


RLock lock = redisson.getLock("anyLock");
lock.lock();
lock.unlock();

就是这么简单,我们只需要通过它的 API 中的 Lock 和 Unlock 即可完成分布式锁,他帮我们考虑了很多细节:

  1. Redisson 所有指令都通过 Lua 脚本执行,Redis 支持 Lua 脚本原子性执行。
  2. Redisson 设置一个 Key 的默认过期时间为 30s,如果某个客户端持有一个锁超过了 30s 怎么办?
  3. Redisson 中有一个 Watchdog 的概念,翻译过来就是看门狗,它会在你获取锁之后,每隔 10s 帮你把 Key 的超时时间设为 30s
  4. 帮我们解决了锁的可重入性问题。

另外,Redission还提供了对RedLock的支持。

RedLock

Redlock是Redis的作者 Antirez 提出的集群模式分布式锁,基于N个完全独立的Redis节点实现分布式锁的高可用。

  1. 获取当前Unix时间,以毫秒为单位。
  2. 依次尝试从5个实例,使用相同的key和具有唯一性的value获取锁
    当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间,这样可以避免客户端死等。
  3. 客户端使用当前时间减去开始获取锁时间就得到获取锁使用的时间。当且仅当从半数以上的Redis节点取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
  4. 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间,这个很重要。
  5. 如果因为某些原因,获取锁失败(没有在半数以上实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁,无论Redis实例是否加锁成功,因为可能服务端响应消息丢失了但是实际成功了,毕竟多释放一次也不会有问题。

redis作为分布式锁面临的问题

  1. 锁需要具有唯一性

    为了保证同一变量不被多个线程同时访问,确保锁在多线程甚至多进程间访问需要具有唯一性。

  2. 锁需要有超时时间,防止死锁

    redis释放锁需要客户端的操作,如果此时客户端突然挂了,就没有释放锁的操作了,也意味着其他客户端想要重新加锁,却加不了的问题.

    所以,为了避免客户端挂掉或者说是客户端不能正常释放锁的问题,需要在加锁的同时,给锁加上超时时间.

  3. 锁的创建和设置锁超时时间需要具备原子性

    加锁和设置锁超时必须是原子操作,否则加锁时客户端挂了,此时还未设置超时时间,锁依然得不到释放。

  4. 锁的超时问题

    虽然上面给锁加上了超时时间,但是客户端并不能一定在超时时间之内完成定时任务,所以,即使当前客户端没有完成任务,此时又会有其他的客户端设置锁成功,此时同一资源将会面临多个客户端同时操作的问题.

    客户端可以在锁设置成功之后,进行定时任务,在锁超时之前使用lua脚本删除锁并重新设置锁和超时时间.

    Redisson 中有一个 Watchdog 的概念,翻译过来就是看门狗,它会在你获取锁之后,每隔 10s 帮你把 Key 的超时时间设为 30s

  5. 锁的可重入性

    通常情况下,redis实现的分布式锁不具有可重入性,遇到可重入问题会产生死锁,不过我们可以通过使用threadlocal存储持有锁的信息。

    当然一般情况下不需要我们去实现可重入锁,Redission已经帮我们实现了。

  6. 集群下分布式锁的问题

    如果在多个客户端获取锁的过程中,redis 挂了怎么办呢?

    1. 客户端1在Redis的master节点上拿到了锁

    2. Master宕机了,存储锁的key还没有来得及同步到Slave上

    3. master故障,发生故障转移,slave节点升级为master节点

    4. 客户端2从新的Master获取到了对应同一个资源的锁

      RedLock方案以牺牲性能的代价解决了这个问题。Redission也提供了RedLock的支持。不过这是极端情况下的考虑。

Redis作为分布式锁有以下缺点

  • 它获取锁的方式简单粗暴,获取不到锁直接不断尝试获取锁,比较消耗性能。
  • 另外来说的话,Redis 的设计定位决定了它的数据并不是强一致性的,在某些极端情况下,可能会出现问题。锁的模型不够健壮。
  • 即便使用 Redlock 算法来实现,在某些复杂场景下,也无法保证其实现 100% 没有问题,关于 Redlock 的讨论可以看 How to do distributed locking。

对于 ZK 分布式锁而言:

  • ZK 天生设计定位就是分布式协调,强一致性。锁的模型健壮、简单易用、适合做分布式锁。
  • 如果获取不到锁,只需要添加一个监听器就可以了,不用一直轮询,性能消耗较小。

但是 ZK 也有其缺点:如果有较多的客户端频繁的申请加锁、释放锁,对于 ZK 集群的压力会比较大。

文章目录
  1. 1. 为什么要用分布式锁?
  2. 2. setnx
  3. 3. Redisson
  4. 4. RedLock
  5. 5. redis作为分布式锁面临的问题
  6. 6. Redis作为分布式锁有以下缺点
|