【Redis#18】Redis 分布式锁

📃个人主页:island1314
⛺️ 欢迎关注:👍点赞 👂🏽留言 😍收藏 💞 💞 💞
- 生活总是不会一帆风顺,前进的道路也不会永远一马平川,如何面对挫折影响人生走向 – 《人民日报》
🔥 目录
一、什么是分布式锁?
🔥 在分布式系统中,多个服务实例可能同时访问共享资源(如数据库、文件、缓存),为了防止数据竞争和并发修改,需要一种跨节点的 “互斥” 机制(比如 锁,避免出现类似于"线程安全"的问题)。这种机制就叫做 分布式锁(Distributed Lock)
java 的 synchronized 或者 C++ 的 std:mutex,这样的锁都是只能在当前进程中生效,在分布式的这种多个进程多个主机的场景下就无能为力了,此时就需要使用到分布式锁
本质上就是使用一个公共的服务器,来记录 加锁状态。这个公共的服务器可以是 Redis,也可以是其他组件(比如 MySQL或者 ZooKeeper 等),还可以是我们自己写的一个服务
分布式锁的特性如下:
| 特点 | 描述 |
|---|---|
| 跨节点 | 多个服务实例都可以获取这个锁 |
| 互斥性 | 同一时间只能有一个客户端持有锁 |
| 防死锁 | 锁必须有超时机制,避免崩溃后锁不释放 |
| 可重入性(可选) | 支持同一个客户端重复加锁 |
为什么 Redis 适合做分布式锁?
- Redis 是单线程的,天然支持原子操作
- Redis 命令执行快,网络延迟低,适合高频加锁/解锁
- 支持设置过期时间(TTL)
- 支持 Lua 脚本,保证复杂操作的原子性
二、分布式锁的基础实现
思路非常简单,本质上就是通过一个键值对来标识锁的状态举个例子:考虑买票的场景,现在车站提供了若干个车次,每个车次的票数都是固定的。
现在存在多个服务器节点,都可能需要处理这个买票的逻辑:先查询指定车次的余票,如果余票>0,则设置余票值 -= 1.

显然上述的场景是存在“线程安全"问题的,需要使用锁来控制。否则就可能出现"超卖"的情况.
此时如何进行加锁呢? ==> 我们可以在上述架构中引入一个 Redis,作为分布式锁的管理器

此时,如果 买票服务器1 尝试买票,就需要先访问 Redis,在 Redis 上设置一个键值对。比如 key 就是车次,value 随便设置个值(比如 1).
- 如果这个操作设置成功,就视为当前没有节点对该 001车次加锁,就可以进行数据库的读写操作,操作完成之后,再把 Redis 上刚才的这个键值对给删除掉
- 如果在 买票服务器1 操作数据库的过程中,买票服务器2 也想买票,也会尝试给 Redis 上写一个键值对key 同样是车次,但是此时设置的时候发现该车次的 key已经存在了,则认为已经有其他服务器正在持有锁,此时 服务器2 就需要等待或者暂时放弃
Redis 中提供了 setnx 操作,正好适合这个场景.即: key 不存在就设置,存在则直接失败
但是上述方案并不完整
三、解决方案
1. 引入过期时间
背景:当 服务器1 加锁之后,开始处理买票的过程中,如果 服务器1意外宕机了,就会导致解锁操作 (删除该key)不能执行。就可能引起其他服务器始终无法获取到锁的情况。
为了解决这个问题,可以在设置 key 的同时引入 过期时间,即这个锁最多持有多久就应该被释放
可以使用
set ex nx的方式,在设置锁的同时把过期时间设置进去,
注意:此处的过期时间只能使用一个命令的方式设置
- 如果分开多个操作,比如 setnx之后再来一个单独的 expire,由于 Redis 的多个指令之间不存在关联,并且即使使用了事务也不能保证这两个操作都一定成功,因此就可能出现 setnx 成功,但是 expire失败的情况.
- 此时仍然会出现无法正确释放锁的问题
2. 引入校验 id
对于 Redis 中写入的加锁键值对,其他的节点也是可以删除的.
-
比如 服务器1 写入一个
001:1这样的键值对,服务器2 是完全可以把001给删除掉的 -
当然,服务器2 不会进行这样的"恶意删除" 操作,不过不能保证因为一些 bug 导致 服务器2 把锁误删
为了解决上述问题,我们可以引入一个校验 id,比如可以把设置的键值对的值,不再是简单的设为一个1,而是设成服务器的编号,形如001:服务器1
- 这样就可以在删除 key (解锁)的时候,先校验当前删除 key 的服务器是否是当初加锁的服务器,如果是才能真正删除;反之不是,则不能删除.
逻辑伪代码描述如下:
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",这样做并非是原子的,
3. 引入 Lua
在解锁的时候,先查询判定,再进行 del,此处是两步操作(不是原子的)。就可能会出现问题~~
- 一个服务器内部,也可能是多线程的。此时就可能同一个服务器/两个线程都在执行上述解锁操作
主要是引入一个新的服务器,执行加锁,就可能出现问题了
在线程 A 执行完 DEL 之后,B 执行 DEL 之前,服务器2 的线程 C 正好要执行 加锁(set),此时由于 A 已经把 锁释放了。C 的加锁是能够成功的! 但是紧接着线程 B DEL 就到来了.就把刚刚服务器 2 的加锁操作给解锁了。服务器1 和 服务器2 进行加锁,key 是资源的编号(比如车次),服务器的id 是 value。
- 使用 事务 能解决上述问题(Redis 事务虽然弱,但是能够避免插队),但是实践中往往更好的方案 Lua 脚本
Lua 是一个编程语言作为 Redis 内嵌的版本(如: MySQL 8 支持 js 作为内嵌语言,Vim 支持使用 vimscript/python 作为内嵌语言)
- Lua 语言特别轻量(实现 Lua 解释器,消耗体积也是非常小的)
- 可以使用 Lua 编写一些逻辑,把这个脚本上传到 redis 服务器上,然后让客户端来控制 redis 执行上述脚本
- Redis 执行 Lua 脚本的过程,也是 原子的,相当于执行 一条命令 一样(实际上 Lua 可以写多个命令)
- Redis 官方文档,也明确表示 lua 属于 事务 替代方案
比如:使用 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 或者jedis 等客户端加载,并发送给 Redis 服务器,由 Redis 服务器来执行这段逻辑.
一个 lua 脚本会被 Redis 服务器以原子的方式来执行,redis-plus-plus 和 jedis 如何调用lua,咱们此处不做过多介绍,具体 api 的写法大家可以自行研究.
4. 引入 watch dog(看门狗)
背景:上述方案仍然存在一个重要问题,当我们设置了 key 过期时间之后(比如 10s),仍然存在一定的可能性,当任务还没执行完,key 就先过期了,这就导致锁提前失效.
- 把这个过期时间设置的足够长,比如 30s,是否能解决这个问题呢?很明显,设置多长时间合适,是无止境的。即使设置再长,也不能完全保证就没有提前失效的情况
- 而且如果设置的太长了,万一对应的服务器挂了,此时其他服务器也不能及时的获取到锁.
- 因此相比于设置一个固定的长时间,不如动态的调整时间更合适,
所谓 watch dog,本质上是 加锁的服务器上的一个单独的线程,通过这个线程来对锁过期时间进行"续约"
- 注意:这个线程是业务服务器上的,不是 Redis 服务器的,
举个具体的例子:初始情况下设置过期时间为 10s,同时设定看门狗线程每隔 3s 检测一次.
- 那么当 3s 时间到的时候,看门狗就会判定当前任务是否完成.
- 如果任务已经完成,则直接通过 lua 脚本的方式释放锁(删除 key).
- 如果任务未完成,则把过期时间重写设置为 10s.(即 “续约")
这样就不担心 锁提前失效 的问题了,而且另一方面如果该服务器挂了,看门狗线程也就随之挂了,此时无人续约,这个 key 自然就可以迅速过期,让其他服务器能够获取到锁了。
5. 引入 Redlock 算法
实践中的 Redis 一般是以 集群 的方式部署的(至少是主从的形式,而不是单机)。那么就可能出现以下比较极端的大冤种情况:
- 服务器1 向 master 节点进行加锁操作,这个写入 key 的过程刚刚完成,master 挂了;
- slave 节点升级成了新的 master 节点,但是由于刚才写入的这个 key 尚未来得及同步给 slave 呢
- 此时就相当于 服务器1 的加锁操作形同虚设了,服务器2仍然可以进行加锁 (即给新的 master 写入 key,因为新的 master 不包含刚才的 key)。
为了解决上述问题, Redis 官方就提出了 Redlock 这种分布式锁算法,适用于多个 Redis 实例的环境,能提高容错性和可用性。
特点如下:
- 客户端向 N 个 Redis 实例发送
SET key val NX PX请求 - 如果大多数实例返回成功,则认为锁获取成功
- 锁的有效时间是总耗时减去网络延迟(保证整体一致性)
我们引入一组 Redis 节点,其中每一组 Redis 节点都包含一个主节点和若干从节点,并且组和组之间存储的数据都是一致的,相互之间是"备份"关系(而并非是数据集合的一部分,这点有别于 Redis cluster)
加锁的时候,按照一定的顺序,写多个 master 节点,在写锁的时候需要设定操作的"超时时间"。比如50ms,即如果 setnx 操作超过了 50ms还没有成功,就视为加锁失败.

如果给某个节点加锁失败,就立即再尝试下一个节点,当加锁成功的节点数超过总节点数的一半,才视为加锁成功.
如上图,一共五个节点,三个加锁成功、两个失败;此时视为加锁成功。这样的话即使有某些节点挂了,也不影响锁的正确性,
那么是否可能出现上述节点都同时遇到了“大冤种"情况呢?
- 理论上这件事是可能发生的,但是概率太小了.工程上就可以忽略不计了
同理,释放锁的时候,也需要把所有节点都进行解锁操作.(即使是之前超时的节点,也要尝试解锁,尽量保证逻辑严密).
简而言之, Redlock 算法的 核心:加锁操作不能只写给一个 Redis 节点,而要写多个!分布式系统中任何一个节点都是不可靠的,最终的加锁成功结论是 “少数服从多数的”
- 由于一个分布式系统不至于大部分节点都同时出现故障,因此这样的可靠性要比单个节点来说靠谱不少.
四、小结
1)实现一个安全的分布式锁需要注意的问题
| 问题 | 描述 | Redis 分布式锁的安全性保障 |
|---|---|---|
| 锁未释放 | 客户端崩溃导致锁一直占用 | 设置PX自动过期时间 |
| 误删其他客户端的锁 | 多个客户端共用一个 key,容易误删 | 使用唯一 ID 标识锁标识 |
| 锁失效时间太短 | 执行时间超过锁有效期 | 使用 Watchdog 来自动续期 |
| 锁不可重入 | 同一线程多次加锁会失败 | 使用 Hash 记录递归次数 |
| 锁的公平性 | 无法保证先到先得 | 配合队列实现 FIFO |
| 网络抖动导致锁丢失 | 客户端与 Redis 断开连接 | 使用 Redlock 算法或多 Redis 实例增强可靠性 |
2)应用场景
| 场景 | 说明 |
|---|---|
| 秒杀系统 | 防止超卖,保证库存一致性 |
| 订单创建 | 防止用户重复提交订单 |
| 支付回调处理 | 防止同一笔订单被多次处理 |
| 定时任务调度 | 防止多个节点同时执行定时任务 |
【★,°:.☆( ̄▽ ̄)/$:.°★ 】那么本篇到此就结束啦,如果有不懂 和 发现问题的小伙伴可以在评论区说出来哦,同时我还会继续更新关于【Redis】的内容,请持续关注我 !!

更多推荐




所有评论(0)