详解 Redis 分布式锁的五种方案
来源:51cto
时间:2023-04-10 15:10:57 126浏览 收藏
大家好,今天本人给大家带来文章《详解 Redis 分布式锁的五种方案》,文中内容主要涉及到分布式、Redis、锁,如果你对数据库方面的知识点感兴趣,那就请各位朋友继续看下去吧~希望能真正帮到你们,谢谢!
本地加锁的方式在分布式的场景下不适用,所以本文我们来探讨下如何引入分布式锁解决本地锁的问题。本篇所有代码和业务基于我的开源项目 PassJava。
本篇主要内容如下:
一、本地锁的问题
首先我们来回顾下本地锁的问题:
目前题目微服务被拆分成了四个微服务。前端请求进来时,会被转发到不同的微服务。假如前端接收了 10 W 个请求,每个微服务接收 2.5 W 个请求,假如缓存失效了,每个微服务在访问数据库时加锁,通过锁(synchronzied 或 lock)来锁住自己的线程资源,从而防止缓存击穿。
这是一种本地加锁的方式,在分布式情况下会带来数据不一致的问题:比如服务 A 获取数据后,更新缓存 key =100,服务 B 不受服务 A 的锁限制,并发去更新缓存 key = 99,最后的结果可能是 99 或 100,但这是一种未知的状态,与期望结果不一致。流程图如下所示:
二、什么是分布式锁
基于上面本地锁的问题,我们需要一种支持分布式集群环境下的锁:查询 DB 时,只有一个线程能访问,其他线程都需要等待第一个线程释放锁资源后,才能继续执行。
生活中的案例:可以把锁看成房门外的一把锁,所有并发线程比作人,他们都想进入房间,房间内只能有一个人进入。当有人进入后,将门反锁,其他人必须等待,直到进去的人出来。
我们来看下分布式锁的基本原理,如下图所示:
我们来分析下上图的分布式锁:
1.前端将 10W 的高并发请求转发给四个题目微服务。
2.每个微服务处理 2.5 W 个请求。
3.每个处理请求的线程在执行业务之前,需要先抢占锁。可以理解为“占坑”。
4.获取到锁的线程在执行完业务后,释放锁。可以理解为“释放坑位”。
5.未获取到的线程需要等待锁释放。
6.释放锁后,其他线程抢占锁。
7.重复执行步骤 4、5、6。
大白话解释:所有请求的线程都去同一个地方“占坑”,如果有坑位,就执行业务逻辑,没有坑位,就需要其他线程释放“坑位”。这个坑位是所有线程可见的,可以把这个坑位放到 Redis 缓存或者数据库,这篇讲的就是如何用 Redis 做“分布式坑位”。
三、Redis 的 SETNX
Redis 作为一个公共可访问的地方,正好可以作为“占坑”的地方。
用 Redis 实现分布式锁的几种方案,我们都是用 SETNX 命令(设置 key 等于某 value)。只是高阶方案传的参数个数不一样,以及考虑了异常情况。
我们来看下这个命令,SETNX是set If not exist的简写。意思就是当 key 不存在时,设置 key 的值,存在时,什么都不做。
在 Redis 命令行中是这样执行的:
set key> value> NX 我们可以进到 redis 容器中来试下 SETNX 命令。 先进入容器: docker exec -it 容器 id> redid-cli 然后执行 SETNX 命令:将 wukong 这个 key 对应的 value 设置成 1111。 set wukong 1111 NX 返回 OK,表示设置成功。重复执行该命令,返回 nil表示设置失败。 我们先用 Redis 的 SETNX 命令来实现最简单的分布式锁。 我们来看下流程图: 代码示例如下,Java 中 setnx 命令对应的代码为 setIfAbsent。 setIfAbsent 方法的第一个参数代表 key,第二个参数代表值。 // 1.先抢占锁 一个小问题:那为什么需要休眠一段时间? 因为该程序存在递归调用,可能会导致栈空间溢出。 青铜之所以叫青铜,是因为它是最初级的,肯定会带来很多问题。 设想一种家庭场景:晚上小空一个人开锁进入了房间,打开了电灯💡,然后突然断电了,小空想开门出去,但是找不到门锁位置,那小明就进不去了,外面的人也进不来。 从技术的角度看:setnx 占锁成功,业务代码出现异常或者服务器宕机,没有执行删除锁的逻辑,就造成了死锁。 那如何规避这个风险呢? 设置锁的自动过期时间,过一段时间后,自动删除锁,这样其他线程就能获取到锁了。 上面提到的青铜方案会有死锁问题,那我们就用上面的规避风险的方案来设计下,也就是我们的白银方案。 还是生活中的例子:小空开锁成功后,给这款智能锁设置了一个沙漏倒计时⏳,沙漏完后,门锁自动打开。即使房间突然断电,过一段时间后,锁会自动打开,其他人就可以进来了。 和青铜方案不同的地方在于,在占锁成功后,设置锁的过期时间,这两步是分步执行的。如下图所示: 清理 redis key 的代码如下 // 在 10s 以后,自动清理 lock 完整代码如下: // 1.先抢占锁 白银方案看似解决了线程异常或服务器宕机造成的锁未释放的问题,但还是存在其他问题: 因为占锁和设置过期时间是分两步执行的,所以如果在这两步之间发生了异常,则锁的过期时间根本就没有设置成功。 所以和青铜方案有一样的问题:锁永远不能过期。 上面的白银方案中,占锁和设置锁过期时间是分步两步执行的,这个时候,我们可以联想到什么:事务的原子性(Atom)。 原子性:多条命令要么都成功执行,要么都不执行。 将两步放在一步中执行:占锁+设置锁过期时间。 Redis 正好支持这种操作: # 设置某个 key 的值并设置多少毫秒或秒 过期。 然后可以通过如下命令查看 key 的变化 ttl key> 下面演示下如何设置 key 并设置过期时间。注意:执行命令之前需要先删除 key,可以通过客户端或命令删除。 # 设置 key=wukong,value=1111,过期时间=5000ms 执行结果如下图所示:每运行一次 ttl 命令,就可以看到 wukong 的过期时间就会减少。最后会变为 -2(已过期)。 黄金方案和白银方案的不同之处:获取锁的时候,也需要设置锁的过期时间,这是一个原子操作,要么都成功执行,要么都不执行。如下图所示: 设置 lock 的值等于 123,过期时间为 10 秒。如果 10 秒 以后,lock 还存在,则清理 lock。 setIfAbsent("lock", "123", 10, TimeUnit.SECONDS); 我们还是举生活中的例子来看下黄金方案的缺陷。 从上面的案例中我们可以知道,因为用户 A 处理任务所需要的时间大于锁自动清理(开锁)的时间,所以在自动开锁后,又有其他用户抢占到了锁。当用户 A 完成任务后,会把其他用户抢占到的锁给主动打开。 这里为什么会打开别人的锁?因为锁的编号都叫做 “123”,用户 A 只认锁编号,看见编号为 “123”的锁就开,结果把用户 B 的锁打开了,此时用户 B 还未执行完任务,当然生气了。 上面的黄金方案的缺陷也很好解决,给每个锁设置不同的编号不就好了~ 如下图所示,B 抢占的锁是蓝色的,和 A 抢占到绿色锁不一样。这样就不会被 A 打开了。 做了个动图,方便理解: 动图演示 静态图更高清,可以看看: 与黄金方案的不同之处: // 1.生成唯一 id 上面的方案看似很完美,但还是存在问题:第 4 步和第 5 步并不是原子性的。 那如何规避这个风险呢?钻石方案登场。 上面的线程 A 查询锁和删除锁的逻辑不是原子性的,所以将查询锁和删除锁这两步作为原子指令操作就可以了。 如下图所示,红色圈出来的部分是钻石方案的不同之处。用脚本进行删除,达到原子操作。 那如何用脚本进行删除呢? 我们先来看一下这段 Redis 专属脚本: if redis.call("get",KEYS[1]) == ARGV[1] 这段脚本和铂金方案的获取key,删除key的方式很像。先获取 KEYS[1] 的 value,判断 KEYS[1] 的 value 是否和 ARGV[1] 的值相等,如果相等,则删除 KEYS[1]。 那么这段脚本怎么在 Java 项目中执行呢? 分两步:先定义脚本;用 redisTemplate.execute 方法执行脚本。 // 脚本解锁 上面的代码中,KEYS[1] 对应“lock”,ARGV[1] 对应 “uuid”,含义就是如果 lock 的 value 等于 uuid 则删除 lock。 而这段 Redis 脚本是由 Redis 内嵌的 Lua 环境执行的,所以又称作 Lua 脚本。 那钻石方案是不是就完美了呢?有没有更好的方案呢? 下篇,我们再来介绍另外一种分布式锁的王者方案:Redisson。 本篇通过本地锁的问题引申出分布式锁的问题。然后介绍了五种分布式锁的方案,由浅入深讲解了不同方案的改进之处。 从上面几种方案的不断演进的过程中,知道了系统中哪些地方可能存在异常情况,以及该如何更好地进行处理。 举一反三,这种不断演进的思维模式也可以运用到其他技术中。 下面总结下上面五种方案的缺陷和改进之处。 青铜方案: 白银方案: 黄金方案: 铂金方案: 钻石方案: 王者方案,下篇见~ 上述所有代码都基于 PassJava 开源项目,后端、前端、小程序都上传到同一个仓库里面了,大家可以通过 github 或 码云访问。地址如下: Github: https://github.com/Jackson0714/PassJava-Platform 码云:https://gitee.com/jayh2018/PassJava-Platform 配套教程:www.passjava.cn 参考资料: http://redis.cn/commands/set.html https://www.bilibili.com/video/BV1np4y1C7Yf 文中关于redis的知识介绍,希望对你的学习有所帮助!若是受益匪浅,那就动动鼠标收藏这篇《详解 Redis 分布式锁的五种方案》文章吧,也可关注golang学习网公众号了解相关技术文章。四、青铜方案
3.1 青铜原理
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "123");
if(lock) {
// 2.抢占成功,执行业务
ListTypeEntity> typeEntityListFromDb = getDataFromDB();
// 3.解锁
redisTemplate.delete("lock");
return typeEntityListFromDb;
} else {
// 4.休眠一段时间
sleep(100);
// 5.抢占失败,等待锁释放
return getTypeEntityListByRedisDistributedLock();
}3.2 青铜方案的缺陷
四、白银方案
4.1 生活中的例子
4.2 技术原理图
4.3 示例代码
redisTemplate.expire("lock", 10, TimeUnit.SECONDS);
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "123");
if(lock) {
// 2.在 10s 以后,自动清理 lock
redisTemplate.expire("lock", 10, TimeUnit.SECONDS);
// 3.抢占成功,执行业务
ListTypeEntity> typeEntityListFromDb = getDataFromDB();
// 4.解锁
redisTemplate.delete("lock");
return typeEntityListFromDb;
}4.4 白银方案的缺陷
五、黄金方案
5.1 原子指令
set key> value> PX 多少毫秒> NX
或
set key> value> EX 多少秒> NX
set wukong 1111 PX 5000 NX
# 查看 key 的状态
ttl wukong5.2 技术原理图
5.3 示例代码
5.4 黄金方案的缺陷
5.4.1 用户 A 抢占锁
5.4.2 用户 B 抢占锁
5.4.3 用户 C 抢占锁
六、铂金方案
6.1 生活中的例子
6.2 技术原理图
6.3 代码示例
String uuid = UUID.randomUUID().toString();
// 2. 抢占锁
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 10, TimeUnit.SECONDS);
if(lock) {
System.out.println("抢占成功:" + uuid);
// 3.抢占成功,执行业务
ListTypeEntity> typeEntityListFromDb = getDataFromDB();
// 4.获取当前锁的值
String lockValue = redisTemplate.opsForValue().get("lock");
// 5.如果锁的值和设置的值相等,则清理自己的锁
if(uuid.equals(lockValue)) {
System.out.println("清理锁:" + lockValue);
redisTemplate.delete("lock");
}
return typeEntityListFromDb;
} else {
System.out.println("抢占失败,等待锁释放");
// 4.休眠一段时间
sleep(100);
// 5.抢占失败,等待锁释放
return getTypeEntityListByRedisDistributedLock();
}6.4 铂金方案的缺陷
七、钻石方案
7.1 技术原理图
7.2 代码示例
then
return redis.call("del",KEYS[1])
else
return 0
end
String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
redisTemplate.execute(new DefaultRedisScriptLong>(script, Long.class), Arrays.asList("lock"), uuid);八、总结
-
220 收藏
-
286 收藏
-
117 收藏
-
384 收藏
-
185 收藏
-
342 收藏
-
361 收藏
-
159 收藏
-
164 收藏
-
221 收藏
-
156 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 542次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 507次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 497次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 484次学习