0.什么是分布式锁
- 在⼀个分布式的系统中,也会涉及到多个节点访问同⼀个公共资源的情况,此时就需要通过锁来做互斥控制,避免出现类似于"线程安全"的问题
- C++的
std::mutex
,只能在当前进程中⽣效,在分布式的这 种多个进程多个主机的场景下就⽆能为⼒了,此时就需要用到分布式锁 - 分布式锁本质:使用一个公共的服务器,来记录加锁状态
- 这个公共的服务器可以是Redis,也可以是其他组件(MySQL、ZooKeeper等),或是自己写的服务
1.分布式锁的基础实现
-
思路⾮常简单,本质上就是通过⼀个键值对来标识锁的状态
-
以经典的买车票为例
-
下图是必然有线程安全问题的,需要用锁来控制
-
在上述架构中,引入一个Redis,作为分布式锁的管理器
- 此时,如果买票服务器1尝试买票,就需要先访问Redis,在Redis上设置⼀个键值对
- 例如:
key
就是⻋次,value
随便设置个值(⽐如1)
- 例如:
- 如果这个操作设置成功,就视为当前没有节点对该001⻋次加锁,就可以进⾏数据库的读写操作,操作完成之后,再把Redis上刚才的这个键值对给删除掉
- 如果在买票服务器1操作数据库的过程中,买票服务器2也想买票,也会尝试给Redis上写⼀个键值对,
key
同样是⻋次,但是此时设置的时候发现该⻋次的key已经存在了,则认为已经有其他服务器正在持有锁,此时服务器2就需要等待或者暂时放弃
- 此时,如果买票服务器1尝试买票,就需要先访问Redis,在Redis上设置⼀个键值对
-
-
Redis中提供了
setnx
操作,正好适合上述场景 -
但是上述方案并不完整
2.引入过期时间
- 问题情景:当服务器1加锁之后,开始处理买票的过程中,如果服务器1意外宕机了,就会导致解锁操作(删除该
key
)不能执⾏,就可能引起其他服务器始终⽆法获取到锁的情况 - 为了解决这个问题,可以在设置
key
的同时引⼊过期时间,即这个锁最多持有多久,就应该被释放- 使用
set ex nx
的方式,在设置锁的同时把过期时间设置进去 - 例如:设置
key
的过期时间为1000,那么意味着即使出现极端情况,某个服务器挂了,没有正确释放锁,这个锁最多保持1000ms,也就会自动释放了
- 使用
- 此处的过期时间只用了一个命令,分开来用
set nx
和expire
可以吗- Redis上的多个命令之间,是无法保证原子性的
- 由于Redis的多个指令之间不存在关联,并且即使使⽤了事务也不能保证这两个操作都⼀定成功,因此就可能出现
setnx
成功,但是expire
失败的情况 - 此时仍然会出现⽆法正确释放锁的问题
- 相比之下,使用一条命令,是更加稳的
3.引入校验ID
- 问题情景:对于Redis中写⼊的加锁键值对,其他的节点也是可以删除的
- 例如:
- 服务器1写⼊⼀个
"001":1
这样的键值对,服务器2是完全可以把"001"
给删除掉的 - 当然,服务器2不会进⾏这样的"恶意删除"操作,不过不能保证因为⼀些bug导致服务器2把锁误删除
- 服务器1写⼊⼀个
- 例如:
- 为了解决上述的问题,可以引入一个检验ID
- 例如:可以把设置的键值对的值,不再是简单的设为⼀个1,⽽是**设成服务器的编号**,如
"001":"服务器 1"
- 这样就可以在删除
key
(解锁)的时候,先校验当前删除key
的服务器是否是当初加锁的服务器,如果是,才能真正删除,不是,则不能删除
- 例如:可以把设置的键值对的值,不再是简单的设为⼀个1,⽽是**设成服务器的编号**,如
- 逻辑用伪代码:
String key = [要加锁的资源id]; String serverId = [服务器的编号]; // 加锁, 设置过期时间为10s redis.set(key, serverId, "NX", "EX", "10s"); // 执⾏各种业务逻辑, ⽐如修改数据库数据 doSomeThing(); // 解锁, 删除key, 但是删除前要检验下serverId是否匹配 if (redis.get(key) == serverId) { redis.del(key); }
- 问题又来了,解锁逻辑是两步操作
get
和del
,这样做并非是原子的 - 此时就会可能这样的情况:
- 服务器2的线程C进行加锁,但是服务器1的线程B就把线程C刚刚加的锁解锁了
- 因为线程B确实也是在服务器1中成功进行了校验,有权利解锁
4.引入Lua
- 为了使解锁操作原子,可以使用Redis的Lua脚本功能
- Lua脚本:
- 类似于JS,是一个动态弱类型的语言
- Lua语法简单精炼,执行速度快,解释器也比较轻量(200KB左右)
- Lua经常作为其他程序内部嵌入的脚本语言,Redis则也支持
- 使用Lua脚本完成上述解锁功能:
if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end;
- 上述代码可以编写成一个
.lua
后缀的文件,由redis-cli
或者redis-plus-plus
等客户端加载,并发送给Redis服务器,由Redis服务器来执行这段逻辑 - 一个Lua脚本会被Redis服务器以原子的方式来执行
- 使用事务就能解决上述问题,为何要用Lua脚本呢?
- 首先明确,Redis事务虽然弱,但是避免插队还是能做到的
- 所以说,Redis事务确实能解决上述问题
- 但是实践中使用的往往是更好的方案
- 有上等马为啥用下等马嘞:)
- Redis官方文档:Lua属于是事物的替代方案
- 首先明确,Redis事务虽然弱,但是避免插队还是能做到的
5.引入Watch Dog(看门狗)
- 上述方案仍然存在一个重要问题,如果设置了
key
过期时间之后,仍然存在一定的可能性,当任务还没执行完,key
就先过期了,这就导致锁提前释放 - 如果把这个过期时间设置的足够长,比如30s,是否能解决这个问题?
- 很明显,设置多少时间合适,是无止境的,即使设置再长,也不能完全保证就没有提前失效的情况
- 而且如果设置的太长了,万一对应的服务器挂了,此时其他的服务器也不能及时获取到所
- 因此相比于设置一个固定的定长时间,不如动态的调整时间更合适
Watch Dog
:本质上是加锁服务器上的一个单独的线程,通过这个线程来对锁过期时间进行“续约”- 注意:这个线程是业务服务器上的,不是Redis服务器的
- 举例助解:
- 初始情况下设置过期时间为10s,同时设定看门狗线程每隔3s检测一次
- 那么当3s时间到的时候,看门狗就会判定当前任务是否完成
- 如果任务已经完成,则直接通过Lua脚本的方式,释放锁(删除
key
) - 如果任务未完成,则把过期时间重写设置为10s(即”续约”)
- 如果任务已经完成,则直接通过Lua脚本的方式,释放锁(删除
- 此时就不担心锁提前失效的问题了,另一方面,如果该服务器挂了,看门狗线程也就随之挂了,此时无人续约,这个
key
自然就可以迅速过期,让其他服务器能够获取到锁了
6.引入Redlock算法
- 问题情景:实践中的Redis一般是以集群的方式部署的(至少是主从的形式,而不是单机),那么就可能出现以下比较极端的冤种情况
- 服务器1向
master
节点进行加锁操作,这个写入key
的过程刚刚完成,master
挂了,slave
升级成了新的master
- 但是由于刚才写入的这个
key
尚未来得及同步给slave
,此时就相当于服务器1的加锁操作形同虚设了 - 服务器2仍然可以进行加锁(即给新的
master
写入key
),因为新的master
不包含刚才的key
- 服务器1向
- 为了解决这个问题,Redis作者提出了
Redlock
算法-
引入一组Redis节点,其中每一组Redis节点都包含一个主节点和若干从节点,并且组和组之间存储的数据都是一致的,互相之间是”备份关系”
- 并非是数据集合的一部分,这点有别于Redis Cluster
-
加锁的时候,按照一定顺序,写多个
master
节点,在写锁的时候需要设定操作的”操作时间”- 例如:50ms,即如果
setnx
操作超过了50ms,还没有成功,就视为加锁失败
- 例如:50ms,即如果
-
如果某个节点加锁失败,就立即再尝试下一个节点
-
当加锁成功的节点数超过总节点数的一半,才视为加锁成功
- 如上图,一共五个节点,三个加锁成功,两个失败,此时视为加锁成功
- 这样的话,即使有某些节点挂了,也不影响锁的正确性
-
同理,释放锁的时候,也需要把所有节点都进行解锁操作
- 即使是之前超时的节点,也要尝试解锁,尽量保证逻辑严密
-
- 此时还可能出现上述节点同时都遇到了”大冤种”情况呢?
- 理论上这件事是可能发生的,但是概率太小了,工程上就可以忽略不计了
- Redlock算法核心:加锁操作不能只写给一个Redis节点,而要写多个,需要冗余
- 分布式系统中,任何一个节点都是不可靠的
- 由于一个分布式系统不至于大部分节点都同时出现故障,因此这样的可靠性要比单个节点来说靠谱不少
- 最终的加锁成功结论是:少数服从多数
7.其他功能
- 上述描述中解释了基于Redis的分布式锁的基本实现原理
- 上述锁只是一个简单的互斥锁,但是实际上在一些特定场景中,还有一些其他特殊的锁,例如:
- 可重入锁
- 公平锁
- 读写锁
- ……
- 实际开发中,也不会真的自己实现一个分布式锁,已经有很多现成的库帮我们封装好了,直接拿来s使用即可
- 例如:C++中的
redis-plus-plus
- 例如:C++中的