为什么要用分布式锁?
比如我们用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]
- 那么为什么要使用PX 30000去设置一个超时时间?
如果进程A崩了,直接原地把锁带走了,导致系统中谁也拿不到锁。
- 为什么要设置唯一value哪?
如果进程A操作锁内资源超过笔者设置的超时时间,那么就会导致其他进程拿到锁,等进程A回来了,回手就是把其他进程的锁删了。
当解锁的时候,先获取value判断是否是当前进程加的锁,再去删除。伪代码:
1 | String uuid = xxxx; |
在finally代码块中,get和del并非原子操作,还是有进程安全问题。
删除锁的正确姿势之一,就是可以使用lua脚本,通过redis的eval/evalsha命令来运行:
1 | -- lua删除锁: |
命令执行脚本,其他客户端是看不到的。
Redisson
Redisson 是 Java 的 Redis 客户端之一,提供了一些 API 方便操作 Redis。
Redisson 帮我们搞了分布式的版本,对主从,哨兵,集群等模式都支持,当然了,单节点模式肯定是支持的。
- Redisson 普通的锁实现源码主要是 RedissonLock 这个类
- 源码中加锁/释放锁操作都是用 Lua 脚本完成的
我们回想以下上一步设置的超时时间是 30s,假如我超过 30s 都还没有完成业务逻辑的情况下,Key 会过期,其他线程有可能会获取到锁。
我们是通过设置唯一value去避免线程安全问题。
我们来看一下Redisson怎么实现?
1 | Config config = new Config(); |
就是这么简单,我们只需要通过它的 API 中的 Lock 和 Unlock 即可完成分布式锁,他帮我们考虑了很多细节:
- Redisson 所有指令都通过 Lua 脚本执行,Redis 支持 Lua 脚本原子性执行。
- Redisson 设置一个 Key 的默认过期时间为 30s,如果某个客户端持有一个锁超过了 30s 怎么办?
- Redisson 中有一个 Watchdog 的概念,翻译过来就是看门狗,它会在你获取锁之后,每隔 10s 帮你把 Key 的超时时间设为 30s
- 帮我们解决了锁的可重入性问题。
另外,Redission还提供了对RedLock的支持。
RedLock
Redlock是Redis的作者 Antirez 提出的集群模式分布式锁,基于N个完全独立的Redis节点实现分布式锁的高可用。
- 获取当前Unix时间,以毫秒为单位。
- 依次尝试从5个实例,使用相同的key和具有唯一性的value获取锁
当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间,这样可以避免客户端死等。 - 客户端使用当前时间减去开始获取锁时间就得到获取锁使用的时间。当且仅当从半数以上的Redis节点取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
- 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间,这个很重要。
- 如果因为某些原因,获取锁失败(没有在半数以上实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁,无论Redis实例是否加锁成功,因为可能服务端响应消息丢失了但是实际成功了,毕竟多释放一次也不会有问题。
redis作为分布式锁面临的问题
锁需要具有唯一性
为了保证同一变量不被多个线程同时访问,确保锁在多线程甚至多进程间访问需要具有唯一性。
锁需要有超时时间,防止死锁
redis释放锁需要客户端的操作,如果此时客户端突然挂了,就没有释放锁的操作了,也意味着其他客户端想要重新加锁,却加不了的问题.
所以,为了避免客户端挂掉或者说是客户端不能正常释放锁的问题,需要在加锁的同时,给锁加上超时时间.
锁的创建和设置锁超时时间需要具备原子性
加锁和设置锁超时必须是原子操作,否则加锁时客户端挂了,此时还未设置超时时间,锁依然得不到释放。
锁的超时问题
虽然上面给锁加上了超时时间,但是客户端并不能一定在超时时间之内完成定时任务,所以,即使当前客户端没有完成任务,此时又会有其他的客户端设置锁成功,此时同一资源将会面临多个客户端同时操作的问题.
客户端可以在锁设置成功之后,进行定时任务,在锁超时之前使用lua脚本删除锁并重新设置锁和超时时间.
Redisson 中有一个 Watchdog 的概念,翻译过来就是看门狗,它会在你获取锁之后,每隔 10s 帮你把 Key 的超时时间设为 30s
锁的可重入性
通常情况下,redis实现的分布式锁不具有可重入性,遇到可重入问题会产生死锁,不过我们可以通过使用threadlocal存储持有锁的信息。
当然一般情况下不需要我们去实现可重入锁,Redission已经帮我们实现了。
集群下分布式锁的问题
如果在多个客户端获取锁的过程中,redis 挂了怎么办呢?
客户端1在Redis的master节点上拿到了锁
Master宕机了,存储锁的key还没有来得及同步到Slave上
master故障,发生故障转移,slave节点升级为master节点
客户端2从新的Master获取到了对应同一个资源的锁
RedLock方案以牺牲性能的代价解决了这个问题。Redission也提供了RedLock的支持。不过这是极端情况下的考虑。
Redis作为分布式锁有以下缺点
- 它获取锁的方式简单粗暴,获取不到锁直接不断尝试获取锁,比较消耗性能。
- 另外来说的话,Redis 的设计定位决定了它的数据并不是强一致性的,在某些极端情况下,可能会出现问题。锁的模型不够健壮。
- 即便使用 Redlock 算法来实现,在某些复杂场景下,也无法保证其实现 100% 没有问题,关于 Redlock 的讨论可以看 How to do distributed locking。
对于 ZK 分布式锁而言:
- ZK 天生设计定位就是分布式协调,强一致性。锁的模型健壮、简单易用、适合做分布式锁。
- 如果获取不到锁,只需要添加一个监听器就可以了,不用一直轮询,性能消耗较小。
但是 ZK 也有其缺点:如果有较多的客户端频繁的申请加锁、释放锁,对于 ZK 集群的压力会比较大。