【Redis】分布式锁的介绍与演进之路
目录
一、什么是分布式锁
二、分布式锁的实现演进
一、什么是分布式锁
在一个分布式的系统中, 也会涉及到
多个节点访问同一个公共资源的情况. 此时就需要通过 锁 来做互斥控制, 避免出现类似于 "线程安全" 的问题。而 java 的 synchronized 或者 C++ 的 std::mutex, 这样的锁都是只能在当前进程中生效, 在分布式的这种多个进程多个主机的场景下就无能为力了。
本质上就是使用一个公共的服务器, 来记录 加锁状态。这个公共的服务器可以是 Redis, 也可以是其他组件(比如 MySQL 或者 ZooKeeper 等), 还可以是我们自己写的一个服务。
二、分布式锁的实现演进
思路非常简单. 本质上就是通过一个键值对来标识锁的状态。Redis 中提供了 setnx 操作, 正好适合这个场景. 即: key 不存在就设置, 存在则直接失败。但上述方案并不完善,当某个服务器加锁成功后,程序突然崩溃,导致没有执行到解决,就会出现问题。
尝试一:引入过期时间
为了解决上述问题, 可以在设置 key 的同时引入过期时间。可以使用 set ex nx 的方式, 在设置锁的同时把过期时间设置进去(即这个锁最多持有多久, 就应该被释放)
注意:此处的过期时间只能使用一个命令的方式设置。
set nx ex/px
尝试二:引入校验 id(锁重入问题)
对于 Redis 中写入的加锁键值对, 其他的节点也是可以删除的。
比如 服务器1 写入一个 "001": 1 这样的键值对, 服务器2 是完全可以把 "001" 给删除掉的。当然, 服务器2 不会进行这样的 "恶意删除" 操作, 不过不能保证因为一些 bug 导致 服务器2 把锁误删除。
为了解决上述问题, 我们可以引入一个校验 id。比如可以把设置的键值对的值, 不再是简单的设为一个 1, 而是设成服务器的编号. 形如 "001": "服务器1"。这样就可以在删除 key (解锁)的时候, 先校验当前删除 key 的服务器是否是当初加锁的服务器, 如果是,才能真正删除; 不是, 则不能删除。
- 给服务器编号,每个服务器有一个自己的身份标识
- 进行加锁的时候,设置 key-value ,key 针对资源加锁,value存储刚才的服务器编号。
- 解锁的时候,首先查询这个锁对应的服务器编号,然后判定当前执行解锁的服务器编号是否正确。
由于上述步骤不是原子性的,服务器内部可能是多线程的,可能结果会出现错误。
尝试三:通过 Lua 解决(原子性问题)
Lua 也是一个编程语言. 读作 "撸啊". 是葡萄牙语中的 "月亮" 的意思. (出自于 Lua 官方文档Lua: about)
Lua 的语法类似于 JS, 是一个动态弱类型的语言. Lua 的解释器一般使用 C 语言实现。Lua 语法简单精炼, 执行速度快, 解释器也比较轻量(Lua 解释器的可执行程序体积只有 200KB 左右)。因此 Lua 经常作为其他程序内部嵌入的脚本语言. Redis 本身就支持 Lua 作为内嵌脚本。
使用 Lua 脚本完成上述解锁功能:
if redis.call('get',KEYS[1]) == ARGV[1] thenreturn redis.call('del',KEYS[1])
elsereturn 0
end;
上述代码可以编写成一个 .lua 后缀的文件, 由 redis-cli 或者 redis-plus-plus 或者jedis 等客户端加载, 并发送给 Redis 服务器, 由 Redis 服务器来执行这段逻辑。一个 lua 脚本会被 Redis 服务器以原子的方式来执行。
尝试四:引入 watch dog (看门狗)
当我们设置了 key 过期时间之后 (比如 10s), 仍然存在一定的可能性,当任务还没执行完, key 就先过期了,这就导致锁提前失效。
把这个过期时间设置的足够长, 比如 30s, 是否能解决这个问题呢? 很明显, 设置多长时间合适, 是无止境的。即使设置再长, 也不能完全保证就没有提前失效的情况,而且如果设置的太长了, 万一对应的服务器挂了, 此时其他服务器也不能及时的获取到锁。因此相比于设置一个固定的长时间, 不如动态的调整时间更合适。
初始情况下,设置一个过期时间(比如设置 1s)就提前在还剩 300ms 的时候把过期时间续 1s ,等到时间又快到了就再续。往往需要一个专门的线程负责续约这个事情,我们把这个负责的线程叫做“看门狗”。所谓 watch dog(广义的概念,很多场景都会涉及到), 本质上是加锁的服务器上的一个单独的线程, 通过这个线程来对锁过期时间进行 "续约"。
特殊情况:Redlock 算法
实践中的 Redis 一般是以集群的方式部署的 (至少是主从的形式, 而不是单机). 那么就可能出现以下比较极端的大冤种情况:
服务器1 向 master 节点进行加锁操作. 这个写入 key 的过程刚刚完成, master 挂了; slave 节点升级成了新的 master 节点. 但是由于刚才写入的这个 key 尚未来得及同步给 slave 呢, 此时就相当于 服务器1 的加锁操作形同虚设了, 服务器2 仍然可以进行加锁 (即给新的 master 写入 key. 因为新的 master 不包含刚才的 key)。为了解决这个问题, Redis 的作者提出了 Redlock 算法:我们引入一组 Redis 节点. 其中每一组 Redis 节点都包含一个主节点和若干从节点. 并且组和组之间存储的数据都是一致的, 相互之间是 "备份" 关系(而并非是数据集合的一部分, 这点有别于 Redis cluster)。加锁的时候, 按照一定的顺序, 写多个 master 节点. 在写锁的时候需要设定操作的 "超时时间",比如50ms。即如果 setnx 操作超过了 50ms 还没有成功, 就视为加锁失败。
如果给某个节点加锁失败, 就立即再尝试下一个节点,当加锁成功的节点数超过总节点数的一半, 才视为加锁成功。这样的话, 即使有某些节点挂了, 也不影响锁的正确性。同理, 释放锁的时候, 也需要把所有节点都进行解锁操作 (即使是之前超时的节点, 也要尝试解锁, 尽量保证逻辑严密)。