1. 为什么需要分布式锁
随着业务的发展,一个应用可能部署到好几台服务器上,此时若多台机器需要同步访问同一个资源,就需要使用到分布式锁
2. 锁的实现
2.1 基于数据库实现
通过增加递增的版本号字段实现乐观锁:
线程1: amount=10, version=123
select amount,version from bank where id = 1
线程2: amount=10, version=123
select amount,version from bank where id = 1
线程1: update=1,更新成功,更新后version为124
update bank set version = 124, amount = amount-10 where id = 1 and version = 123
线程2: 由于当前的version为124,update=0,更新失败
update bank set version = 124, amount = amount-10 where id = 1 and version = 123
2.2 基于redis
实现
2.2.1 setnx
SET lockKey randomValue NX PX 30000
实现思路:
- 获取锁的时候,使用
setnx
加锁,并使用expire命令为锁添加一个超时时间,超过该时间则自动释放锁,锁的value值为一个随机生成的UUID - 释放锁的时候,通过UUID判断是不是该锁,若是该锁,则释放锁。
try {
lock = redisTemplate.opsForValue().setIfAbsent(lockKey, UUID);
if (lock) {
//成功 设置过期时间
redisTemplate.expire(lockKey,1, TimeUnit.MINUTES);
// TODO
}else {
//没有获取到锁
}
} finally {
if(lock){
//任务结束,释放锁
redisTemplate.delete(lockKey);
}
}
要点:
- 为什么锁要添加一个超时时间?如果A获取了锁,宕机了没有释放锁,会造成死锁。
- 锁的value值为UUID,是为了避免这种情况:假设A获取了锁,过期时间10s,此时15s之后,锁已经自动释放了,A去释放锁,但是此时可能B获取了锁。A就不能删除B的锁了。
还有一些可以完善的地方:如果在第一步setnx
执行成功后,在expire()
命令执行成功前,发生了宕机的现象,那么就会出现死锁的问题
如果考虑redis
的部署问题
- 单机模式:只要
redis
故障了,就不能加锁 master-slave
+sentinel
选举模式:如果master节点故障了,发生主从切换,就有可能出现锁丢失的问题。redis cluster
模式
2.2.2 RedLock
redis
的作者也考虑到上面的问题,提出了一个RedLock
的算法:假设redis
的部署模式是redis cluster
,总共有5个master节点,通过以下步骤获取一把锁:
- 获取当前时间戳,单位是毫秒
- 轮流尝试在每个master节点上创建锁,过期时间设置较短,一般就几十毫秒
- 尝试在大多数节点上建立一个锁,比如5个节点就要求是3个节点(n / 2 +1)
- 客户端计算建立好锁的时间,如果建立锁的时间小于超时时间,就算建立成功了
- 要是锁建立失败了,那么就依次删除这个锁
- 只要别人建立了一把分布式锁,你就得不断轮询去尝试获取锁
但是这样的这种算法还是颇具争议的,可能还会存在不少的问题,无法保证加锁的过程一定正确。
争议:
- 官方的推荐 https://redis.io/topics/distlock
-
Martin Kleppmann
关于Readlock
的评价。
https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html -
redis
作者的回复。
http://antirez.com/news/101
2.2.3 Redisson
Redisson
是一个企业级的开源Redis Client
,也提供了分布式锁的支持。
实现1设置了超时时间,但是超过时间都还没有完成业务逻辑的情况下,key会过期,其他线程有可能会获取到锁。这样一来的话,第一个线程还没执行完业务逻辑,第二个线程进来了也会出现线程安全问题。所以我们还需要额外的去维护这个过期时间,Redisson
帮我们做了这些。
实现细节:
- Redisson
所有指令都通过lua
脚本执行,redis
支持lua
脚本原子性执行
- Redisson
设置一个key的默认过期时间为30s,如果某个客户端持有一个锁超过了30s,Redisson
中有一个watchdog的概念,翻译过来叫看门狗,它会在你获取锁之后,每隔10秒帮你把key的超时时间设为30s。这样就算一直持有锁也不会出现key过期,其他线程获取到锁的问题了。
- “看门狗”逻辑保证了没有死锁发生。如果机器宕机了,看门狗也就没了。此时就不会延长key的过期时间,到了30s之后就会自动过期,其他线程可以获取到锁
另外,Redisson
还提供了对redlock
算法的支持
2.3 基于zookeeper
实现
zookeeper
是一个为分布式应用提供一致性服务的开源组件, 为我们提供了高可用、高性能、稳定的分布式数据一致性解决方案。它内部是一个分层的文件系统目录树结构,规定同一个目录下只能有一个唯一文件名,利用这个特性来实现锁:
让多个客户端同时创建一个临时节点,创建成功的就说明获取到了锁 。然后没有获取到锁的客户端创建一个 watcher
进行节点状态的监听,如果这个互斥锁被释放了,可以调用回调函数重新获得锁。zk
中不需要向 redis
那样考虑锁得不到释放的问题,因为当客户端挂了,节点也挂了,锁也释放了。
如何同时实现 共享锁和独占锁 ?创建有序的临时节点。
- 当读请求(获取共享锁),如果 没有比自己更小的节点,或比自己小的节点都是读请求 ,则可以获取到读锁。如果比自己小的节点中有写请求 ,则只能等待前面的写请求完成。
- 当写请求(获取独占锁),如果 没有比自己更小的节点 ,则表示当前客户端可以直接获取到写锁。如果 有比自己更小的节点,无论是读操作还是写操作,当前客户端都无法获取到写锁 ,等待所有前面的操作完成。
当然还有优化的地方,比如当一个锁得到释放它会通知所有等待的客户端从而造成 惊群效应 。此时你可以通过让等待的节点只监听他们前面的节点,让 读请求监听比自己小的最后一个写请求节点,写请求只监听比自己小的最后一个节点
2.4 锁对比
实现 | 优点 | 缺点 |
---|---|---|
数据库 | 简单,易于理解,系统依赖少 | 1. 性能较差,有锁表的风险 2. 非阻塞,需要轮询,占用CPU资源 |
redis |
性能很高,可以支撑高并发的获取、释放锁操作 | 1. 数据并不是强一致性的,在某些极端情况下,可能会出现问题 2. 锁删除失败,过期时间不好控制 2. 非阻塞,需要轮询,占用CPU资源 |
zk |
1. 简单易用,有较好的性能和可靠性 2. 不用一直轮询,性能消耗较小 3.可解决失效死锁问题 | 1. 性能不如redis 实现,需要动态创建、销毁临时节点,且只能通过 Leader 服务器来执行,然后将数据同步到所有的 Follower 机器 |
3. 分布式锁的要点
3.1 AP 还是 CP
3.1.1 AP模型
例子:Redis
的主备集群做例子
好处:
在接受事务请求(增删改数据)的时候,主Master节点只需要确保自己写入即可立即返回给客户端,复制的过程由于是异步的,客户端延时性上来说影响并不大,相比于CP模型的确保半数提交成功,AP模型的延时性是比较低的,Redis
本身的定位就是要快,所以这相当符合Redis
的设计初衷,如果集群有三个节点,他可以容许宕机两个节点,可以看出来,可用性的容错节点是N-1个,相比于CP模型他的可用性会更高
Redis
作为分布式锁的话有可能会造成数据的不一致,如果你使用分布式锁的场景是为了更好的利用系统资源(CPU、内存),让多节点不做一些重复的工作,并行互斥执行不同的任务,那么不妨将任务做成幂等,这样就算两个节点做同一个任务,任务被执行了两次但是它们是幂等的,其结果也不会被影响
3.1.2 CP模型
例子:zookeeper
(Zab
协议)
存在的问题:
半数以上的节点上确认,延时性就取决于最快的那半数节点的写入性能,而且多加了网络通信来回的开销。微服务链路调用都需要注册中心获取服务的IP地址,并发量大,但IP地址这种东西小概率存在不一致(服务刚上线,但注册中心没有这个服务的IP地址,使得不被访问到)其实是可以接受的。在注册中心的场景下延时性才最重要,这也就是为什么Nacos
的注册中心会选用AP模型,他的延时性相对是要好的。
3.1.3 总结
- 在延迟性要求高、客户端响应不能太慢、性能要求高的场景下,允许牺牲小部分时间的锁失效来换取好的性能,那么建议使用AP模型来实现分布式锁,某些场景可以通过幂等弥补小部分锁失效带来的负面影响
- 在延迟性要求不高、主要保证锁不能失效、高一致性的场景下,允许牺牲一点性能来换取一致性,那么建议使用CP模型来实现分布式锁。选型时注意考虑到并发量极高的情况下可能有问题
3.2 宕机锁释放问题
分布式锁拿到锁的节点意外宕机,拿到锁而不释放锁,从而死锁,这就是一个宕机锁释放问题。
3.2.1 Redis
宕机锁释放问题
在redis
中我们解决宕机锁释放问题通常会在设置锁的同时给他设置一个超时时间,这就有一个问题了,这个超时时间要设置多长?如果这个超时时间太长,那节点宕机没释放锁就只能等待锁超时,死锁时间会变长(服务至少有一段时间的不可用),这是不容许的,那如果超时时间太短,又会造成如果有什么做了很久的业务操作,这边还没执行完,另一个节点却也能获取到锁,造成的锁失效。
Redisson
其实解决了超时时间过短,锁失效的问题,虽然有续租,但是不建议超时时间太长,如果超时时间太长还是会造成死锁的时间(如果超时时间设置1小时。。那还是会有1小时锁无法获取的情况),也不建议太短,万一JVM
进行GC
,整个代码进行停顿,后台线程因此有几秒时间无法续租,锁也会失效被其他节点获取,所以这里建议超时时间设置的不大偏小,3、5分钟左右这样子
3.2.2 zookeeper
的宕机锁释放问题
使用zookeeper
作为分布式锁,客户端会在zk
上创建一个临时节点,获取到锁的客户端会与zk
维持一个心跳连接,如果zk
收不到客户端的心跳就说明客户端宕机了,此时临时节点会自动释放,相当于自动释放了锁
3.3 锁等待问题
A节点获取到锁执行锁区块的业务逻辑,B节点获取不到锁,那么B节点怎么才能知道自己需要阻塞等待多久?这就需要一个通知机制,在锁释放的时候中间件需要通知等待中的节点来获取锁。
3.3.1 redis
中的锁等待问题
Redisson
利用了PubSub
模式完成了一个锁释放的通知机制
- 利用
redis
的PubSub
模式订阅一个LockName
关联的channel
(一把锁对应一个channel
) - 设置一个监听器,监听
PubSub
中名称为刚刚的那个LockName
的channel
发出通知(有锁释放),动作为调用Semaphore
的release
方法释放信号量 - 当前获取不到锁的线程调用
Semaphore
的acquire
方法尝试获取信号量,若没有信号量则阻塞ttl
个时间 - 等待超过
ttl
个时间或者有锁释放通知之后线程唤醒,继续尝试获取锁 - 若获取不到锁,继续调用
Semaphore#acquire
方法阻塞然后获取锁,无限循环直到获取到锁
3.3.2 zookeeper
中的锁等待问题
同样使用通知机制(观察者模式)会比较好解决,在zookeeper
中方案就是Watch机制,监听一个节点是否产生变化,若变化会收到一个通知,当获取不到锁之后监听锁的那个临时节点即可。也可以按顺序来,获取锁失败之后注册一个顺序节点,按照自己的顺序,向前一个节点注册Watch,这样一个个来可解决惊群效应。
3.4 误释放锁
redis
作为分布式锁误释放锁
- A节点获取到锁之后,
redis
挂了,重新选举一个从节点的redis
后由于是AP模型,锁信息不在这个从节点上,B节点此时来获取锁成功,B开始执行业务逻辑,A执行完业务逻辑之后来释放锁,就会把B的锁释放掉了…然后C又来获取锁,B执行完又把C的锁释放掉…以此类推 -
A节点获取到锁之后,因为某种原因(
GC
停顿或者…)没有续租过期时间,锁不小心释放掉了但是业务逻辑还在跑,B节点此时来获取锁成功,B也在跑业务逻辑,A执行完逻辑之后释放锁,把B的锁也给释放掉了…然后C又来获取锁,B执行完又把C的锁释放掉…以此类推
zookeeper
有误释放锁的情况
- 假设A节点获取到锁,此时
GC
停顿,后台线程无法给zookeeper
发送心跳,zk
以为A节点宕机,把临时节点给删了,这样其他节点也会乘虚而入,然后就会出现上面说的循环释放别人锁的情况。
3.4.1 解决方案
借鉴Redisson
的方案,在获取锁的时候将一个唯一标识设置为value(UUID+threadId
),设置一个threadId
还有一个好处,就是可以做可重入锁,当同一个线程再次获取锁的时候就可以以当前threadId
作为依据判断是否是重入情况。