您好!
欢迎来到京东云开发者社区
登录
首页
博文
课程
大赛
工具
用户中心
开源
首页
博文
课程
大赛
工具
开源
更多
用户中心
开发者社区
>
博文
>
redis 实现一把分布式锁
分享
打开微信扫码分享
点击前往QQ分享
点击前往微博分享
点击复制链接
redis 实现一把分布式锁
自猿其说Tech
2021-07-19
IP归属:未知
109320浏览
计算机编程
### 1 为何需要锁? 在回答这个问题之前,首先思考下什么是锁,锁就是为了控制共享资源的访问与使用的。现实中经典的案例就是售票,每一张车票都是一个共享资源,车票的属性标志着它在同一空间同一时间段只能有一个人享有并生效,在此规则之外被认为是不合法的,比如一张票被两个人买到。 ![](//img1.jcloudcs.com/developer.jdcloud.com/6dd7be2d-505f-43fd-86f2-55b979ec03c820210719105822.png) 对比在程序中,共享资源可以界定为一段代码、一个对象。多线程多任务处理模式下,对于共享资源只能在空间和时间上由一个线程独占并享有。接着思考一个问题,可以不用锁吗,我们知道一个JVM中每个线程有自己独立的Java虚拟机栈,方法体内部变量的计算很好的避免了资源的竞争与并发问题,但是被线程共享的堆中的数据就不避免不了并发问题了,同一时间是不允许两个线程同时修改数据的,当然除非用空间换时间,为每个变量都重复定义一份数据,用到这种情况的就比较少了。所以在同一JVM中锁是必须的,在这个同一进程中的不同线程大大出手的场合,Java中的synchronized、ReentrantLock都是用来解决这个问题的,这里就不展开了。 ![](//img1.jcloudcs.com/developer.jdcloud.com/f5a05b10-3eb7-4ae6-8ebd-53f57abc95a920210719105829.png) ### 2 什么是分布式锁? 接着上面的问题,在分布式部署和微服务应用大行其道的今天,单机JVM已经不能满足业务需求,Java锁关键字在这种情况下就显的远远不够。分布式锁应运而生,锁还是之前的含义依然是为了保证资源只能在同一时空被一方使用,只不过现在参与竞争的选手是一个个不同进程中的线程,虽然是锁竞争转移了战场,但是想要合理控制这把分布式锁可不是那么容易的,事半功倍与事倍功半就在一念之间。 分布式锁应该具备哪些条件: ① 在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行; ② 高可用的获取锁与释放锁; ③ 高性能的获取锁与释放锁; ④ 具备可重入特性; ⑤ 具备锁失效机制,防止死锁; ⑥ 具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。 目前实现分布式锁的方式主要有三种:Redis分布式锁、数据库分布式锁、Zookeeper分布式锁。使用最多的是Redis分布式锁,也是本文讨论的重点。 ### 3 如何使用redis实现一把分布式锁? #### 3.1 Redis加锁操作 为了保证锁互斥首先会想到使用setnx命令,为了防止因为持有锁的客户端崩溃而没有主动释放锁发生死锁,需要设置一个超时时间 ![](//img1.jcloudcs.com/developer.jdcloud.com/47c5d7cd-5c2a-418c-aff6-3e83e63e8b0820210719105923.png) 在多线程并发环境下,任何非原子性的操作,都可能导致问题。这段代码中,如果设置过期时间时,redis实例崩溃,就无法设置过期时间。如果客户端没有正确的释放锁,那么该锁(永远不会过期),就永远不会被释放。 既然上面操作有问题,考虑到给nx命令的value直接设置为过期时间 ![](//img1.jcloudcs.com/developer.jdcloud.com/6be1d05c-8f81-4fd0-a13a-5fea8b586e6f20210719105942.png) 实际上引入了新的问题: (1) value设置为过期时间,就要求各个客户端严格的时钟同步,这就需要使用到同步时钟。即使有同步时钟,分布式的服务器一般来说时间肯定是存在少许误差的。 (2) 锁过期时,使用 jedis.getSet虽然可以保证只有一个线程设置成功,但是不能保证加锁和解锁为同一个客户端,因为没有标志锁是哪个客户端设置的。 #### 3.2 Redis解锁操作 直接删除加过的锁,如果锁不是自己加的也会删除。 ![](//img1.jcloudcs.com/developer.jdcloud.com/e750f6a7-83c5-41b0-9159-d6ab065ff54420210719110006.png) 判断自己是不是锁的持有者,如果是,则只有持有者才可以释放锁。如果你判断的时候锁是自己持有的,这时锁超时自动释放了。然后又被其他客户端重新上锁,然后当前线程执行到jedis.del(key),这样这个线程不就删除了其他线程上的锁。 ![](//img1.jcloudcs.com/developer.jdcloud.com/634a5cc0-7cc2-4e50-aa78-a880208a085920210719110023.png) ### 4 使用redis实现分布式锁应该注意的点 #### 4.1 如何避免死锁? 方案:申请锁的时候,给锁设置一个租期。 在 Redis 中实现时,就是给这个 key 设置一个过期时间。这里我们假设,操作共享资源的时间不会超过 10s,那么在加锁时,给这个 key 设置 10s 过期即可: ![](//img1.jcloudcs.com/developer.jdcloud.com/9dc0db75-990a-40c0-8855-95043df489a820210719110116.png) 这样一来,无论客户端是否异常,这个锁都可以在 10s 后被「自动释放」,其它客户端依旧可以拿到锁。 但这样真的没问题吗? 还是有问题。 现在的操作,加锁、设置过期是 2 条命令,有没有可能只执行了第一条,第二条却「来不及」执行的情况发生呢?例如: ① SETNX 执行成功,执行 EXPIRE 时由于网络问题,执行失败 ②SETNX 执行成功,Redis 异常宕机,EXPIRE 没有机会执行 ③SETNX 执行成功,客户端异常崩溃,EXPIRE 也没有机会执行 总之,这两条命令不能保证是原子操作(一起成功),就有潜在的风险导致过期时间设置失败,依旧发生「死锁」问题。 怎么办? 在 Redis 2.6.12 版本之前,我们需要想尽办法,保证 SETNX 和 EXPIRE 原子性执行,还要考虑各种异常情况如何处理。 但在 Redis 2.6.12 之后,Redis 扩展了 SET 命令的参数,用这一条命令就可以了: ![](//img1.jcloudcs.com/developer.jdcloud.com/2b5a4691-c8ac-45b7-89ac-e9064a6f22fa20210719110152.png) 这样就解决了死锁问题,也比较简单。 我们再来看分析下,它还有什么问题? 试想这样一种场景: ① 客户端 1 加锁成功,开始操作共享资源 ② 客户端 1 操作共享资源的时间,「超过」了锁的过期时间,锁被「自动释放」 ③ 客户端 2 加锁成功,开始操作共享资源 ④ 客户端 1 操作共享资源完成,释放锁(但释放的是客户端 2 的锁) 看到了么,这里存在两个严重的问题: ① **锁过期:**客户端 1 操作共享资源耗时太久,导致锁被自动释放,之后被客户端 2 持有 ② **释放别人的锁:**客户端 1 操作共享资源完成后,却又释放了客户端 2 的锁 导致这两个问题的原因是什么?我们一个个来看。 **第一个问题,可能是我们评估操作共享资源的时间不准确导致的。** 例如,操作共享资源的时间「最慢」可能需要 15s,而我们却只设置了 10s 过期,那这就存在锁提前过期的风险。 过期时间太短,那增大冗余时间,例如设置过期时间为 20s,这样总可以了吧? 这样确实可以「缓解」这个问题,降低出问题的概率,但依旧无法「彻底解决」问题。 为什么? 原因在于,客户端在拿到锁之后,在操作共享资源时,遇到的场景有可能是很复杂的,例如,程序内部发生异常、网络请求超时等等。 既然是「预估」时间,也只能是大致计算,除非你能预料并覆盖到所有导致耗时变长的场景,但这其实很难。 有什么更好的解决方案吗? 方案是:加锁时,先设置一个过期时间,然后我们开启一个「守护线程」,定时去检测这个锁的失效时间,如果锁快要过期了,操作共享资源还未完成,那么就自动对锁进行「续期」,重新设置过期时间。 这确实一种比较好的方案。 如果你是 Java 技术栈,幸运的是,已经有一个库把这些工作都封装好了:Redisson。 Redisson 是一个 Java 语言实现的 Redis SDK 客户端,在使用分布式锁时,它就采用了「自动续期」的方案来避免锁过期,这个守护线程我们一般也把它叫做「看门狗」线程。后文提到的Redlock也是该SDK的功能。 我们继续来看第二个问题。 **第二个问题在于,一个客户端释放了其它客户端持有的锁。** 想一下,导致这个问题的关键点在哪? 重点在于,每个客户端在释放锁时,都是「无脑」操作,并没有检查这把锁是否还「归自己持有」,所以就会发生释放别人锁的风险,这样的解锁流程,很不「严谨」! #### 4.2 锁被别人释放咋办? 解决办法是:客户端在加锁时,设置一个只有自己知道的「唯一标识」进去。 例如,可以是自己的线程 ID,也可以是一个 UUID(随机且唯一),这里我们以 UUID 举例: ![](//img1.jcloudcs.com/developer.jdcloud.com/46dc53b2-1f43-4b78-ba21-2c15f8e78e2f20210719110254.png) 之后,在释放锁时,要先判断这把锁是否归自己持有,自己持有的才可以释放。 这里释放锁使用的是 GET + DEL 两条命令,这时,又会遇到我们前面讲的原子性问题了。 ① 客户端 1 执行 GET,判断锁是自己的 ② 客户端 2 执行了 SET 命令,强制获取到锁(虽然发生概率比较低,但我们需要严谨地考虑锁的安全性模型) ③ 客户端 1 执行 DEL,却释放了客户端 2 的锁 由此可见,这两个命令还是必须要原子执行才行。 怎样原子执行呢?Lua 脚本。 我们可以把这个逻辑,写成 Lua 脚本,让 Redis 来执行。 因为 Redis 处理每一个请求是「单线程」执行的,在执行一个 Lua 脚本时,其它请求必须等待,直到这个 Lua 脚本处理完成,这样一来,GET + DEL 之间就不会插入其它命令了。 释放锁的LUA脚本如下: ![](//img1.jcloudcs.com/developer.jdcloud.com/cd3b392d-82e9-4ace-a71a-b7efc5c8d5a520210719110349.png) 好了,这样一路优化,整个的加锁、解锁的流程就更「严谨」了。 这里我们先小结一下,基于 Redis 实现的分布式锁,一个严谨的的流程如下: ① 加锁:SET lock_key $unique_id EX $expire_time NX ② 操作共享资源 ③释放锁:Lua 脚本,先 GET 判断锁是否归属自己,再 DEL 释放锁 #### 4.3 主从切换,锁失效问题 我们在使用 Redis 时,一般会采用主从集群 + 哨兵的模式部署,这样做的好处在于,当主库异常宕机时,哨兵可以实现「故障自动切换」,把从库提升为主库,继续提供服务,以此保证可用性。 假设有这样的一个场景: ① 客户端 1 在主库上执行 SET 命令,加锁成功 ② 此时,主库异常宕机,SET 命令还未同步到从库上(主从复制是异步的) ③ 从库被哨兵提升为新主库,这个锁在新的主库上,丢失了! 为此,Redis 的作者提出一种解决方案,就是我们经常听到的 Redlock(红锁)。 Redlock 的方案基于 2 个前提: ① 不再需要部署从库和哨兵实例,只部署主库 ② 但主库要部署多个,官方推荐至少 5 个实例 也就是说,想用使用 Redlock,你至少要部署 5 个 Redis 实例,而且都是主库,它们之间没有任何关系,都是一个个孤立的实例。 Redlock 整体的流程是这样的,一共分为 5 步: ① 客户端先获取「当前时间戳T1」 ② 客户端依次向这 5 个 Redis 实例发起加锁请求(用前面讲到的 SET 命令),且每个请求会设置超时时间(毫秒级,要远小于锁的有效时间),如果某一个实例加锁失败(包括网络超时、锁被其它人持有等各种异常情况),就立即向下一个 Redis 实例申请加锁 ③ 如果客户端从 >=3 个(大多数)以上 Redis 实例加锁成功,则再次获取「当前时间戳T2」,如果 T2 - T1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败 ④ 加锁成功,去操作共享资源(例如修改 MySQL 某一行,或发起一个 API 请求) ⑤ 加锁失败,向「全部节点」发起释放锁请求(前面讲到的 Lua 脚本释放锁) ### 5 redis分布式锁真的安全吗? redis作者在提出了redlock的解决方案后,收到了另外一位分布式专家的质疑,于是他们之间开始了 battle,这段battle堪称神仙打架。 分布式专家Martin主要提出了以下几点质疑: #### 5.1 分布式锁的目的是什么? Martin 表示,你必须先清楚你在使用分布式锁的目的是什么? 他认为有两个目的: **第一,效率**。 使用分布式锁的互斥能力,是避免不必要地做同样的两次工作(例如一些昂贵的计算任务)。如果锁失效,并不会带来「恶性」的后果,例如发了 2 次邮件等,无伤大雅。 **第二,正确性。** 使用锁用来防止并发进程互相干扰。如果锁失效,会造成多个进程同时操作同一条数据,产生的后果是数据严重错误、永久性不一致、数据丢失等恶性问题,就像给患者服用了重复剂量的药物,后果很严重。 他认为,如果你是为了前者——效率,那么使用单机版 Redis 就可以了,即使偶尔发生锁失效(宕机、主从切换),都不会产生严重的后果。而使用 Redlock 太重了,没必要。 而如果是为了正确性,Martin 认为 Redlock 根本达不到安全性的要求,也依旧存在锁失效的问题! #### 5.2 锁在分布式系统中会遇到的问题。 Martin 表示,一个分布式系统,更像一个复杂的「野兽」,存在着你想不到的各种异常情况。 这些异常场景主要包括三大块,这也是分布式系统会遇到的三座大山:NPC。 N:Network Delay,网络延迟 P:Process Pause,进程暂停(GC) C:Clock Drift,时钟漂移 Martin 用一个进程暂停(GC)的例子,指出了 Redlock 安全性问题: ① 客户端 1 请求锁定节点 A、B、C、D、E ② 客户端 1 的拿到锁后,进入 GC(时间比较久) ③ 所有 Redis 节点上的锁都过期了 ④ 客户端 2 获取到了 A、B、C、D、E 上的锁 ⑤ 客户端 1 GC 结束,认为成功获取锁 ⑥ 客户端 2 也认为获取到了锁,发生「冲突」 ![](//img1.jcloudcs.com/developer.jdcloud.com/3701df58-0423-48e4-a144-1acc60aef8ea20210719110543.png) Martin 认为,GC 可能发生在程序的任意时刻,而且执行时间是不可控的 #### 5.3 假设时钟正确的是不合理的。 又或者,当多个 Redis 节点「时钟」发生问题时,也会导致 Redlock 锁失效。 ① 客户端 1 获取节点 A、B、C 上的锁,但由于网络问题,无法访问 D 和 E ② 节点 C 上的时钟「向前跳跃」,导致锁到期 ③ 客户端 2 获取节点 C、D、E 上的锁,由于网络问题,无法访问 A 和 B ④ 客户端 1 和 2 现在都相信它们持有了锁(冲突) Martin 觉得,Redlock 必须「强依赖」多个节点的时钟是保持同步的,一旦有节点时钟发生错误,那这个算法模型就失效了。 ### 6 redis作者的反击 #### 6.1 解决时钟问题 首先,Redis 作者一眼就看穿了对方提出的最为核心的问题:时钟问题。 Redis 作者表示,Redlock 并不需要完全一致的时钟,只需要大体一致就可以了,允许有「误差」。 例如要计时 5s,但实际可能记了 4.5s,之后又记了 5.5s,有一定误差,但只要不超过「误差范围」锁失效时间即可,这种对于时钟的精度要求并不是很高,而且这也符合现实环境。 对于对方提到的「时钟修改」问题,Redis 作者反驳到: ① 手动修改时钟:不要这么做就好了,否则你直接修改 Raft 日志,那 Raft 也会无法工作... ② 时钟跳跃:通过「恰当的运维」,保证机器时钟不会大幅度跳跃(每次通过微小的调整来完成),实际上这是可以做到的 #### 6.2 解决GC,网络延迟问题 之后,Redis 作者对于对方提出的,网络延迟、进程 GC 可能导致 Redlock 失效的问题,也做了反驳: 我们重新回顾一下,Martin 提出的问题假设: ① 客户端 1 请求锁定节点 A、B、C、D、E ② 客户端 1 的拿到锁后,进入 GC ③ 所有 Redis 节点上的锁都过期了 ④ 客户端 2 获取节点 A、B、C、D、E 上的锁 ⑤ 客户端 1 GC 结束,认为成功获取锁 ⑥ 客户端 2 也认为获取到锁,发生「冲突」 ![](//img1.jcloudcs.com/developer.jdcloud.com/49e9fa6e-9943-408d-9138-fc497bb581cd20210719110604.png) Redis 作者反驳到,这个假设其实是有问题的,Redlock 是可以保证锁安全的。 这是怎么回事呢? 还记得前面介绍 Redlock 流程的那 5 步吗?这里我再拿过来让你复习一下。 ① 客户端先获取「当前时间戳T1」 ② 客户端依次向这 5 个 Redis 实例发起加锁请求(用前面讲到的 SET 命令),且每个请求会设置超时时间(毫秒级,要远小于锁的有效时间),如果某一个实例加锁失败(包括网络超时、锁被其它人持有等各种异常情况),就立即向下一个 Redis 实例申请加锁 ③ 如果客户端从 3 个(大多数)以上 Redis 实例加锁成功,则再次获取「当前时间戳T2」,如果 T2 - T1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败 ④ 加锁成功,去操作共享资源(例如修改 MySQL 某一行,或发起一个 API 请求) ⑤ 加锁失败,向「全部节点」发起释放锁请求(前面讲到的 Lua 脚本释放锁) 注意,重点是 1-3,在步骤 3,加锁成功后为什么要重新获取「当前时间戳T2」?还用 T2 - T1 的时间,与锁的过期时间做比较? Redis 作者强调:如果在 1-3 发生了网络延迟、进程 GC 等耗时长的异常情况,那在第 3 步 T2 - T1,是可以检测出来的,如果超出了锁设置的过期时间,那这时就认为加锁会失败,之后释放所有节点的锁就好了! Redis 作者继续论述,如果对方认为,发生网络延迟、进程 GC 是在步骤 3 之后,也就是客户端确认拿到了锁,去操作共享资源的途中发生了问题,导致锁失效,那这不止是 Redlock 的问题,任何其它锁服务例如 Zookeeper,都有类似的问题,这不在讨论范畴内。 这里我举个例子解释一下这个问题: ① 客户端通过 Redlock 成功获取到锁(通过了大多数节点加锁成功、加锁耗时检查逻辑) ② 客户端开始操作共享资源,此时发生网络延迟、进程 GC 等耗时很长的情况 ③ 此时,锁过期自动释放 ④ 客户端开始操作 MySQL(此时的锁可能会被别人拿到,锁失效) Redis 作者这里的结论就是: 客户端在拿到锁之前,无论经历什么耗时长问题,Redlock 都能够在第 3 步检测出来 客户端在拿到锁之后,发生 NPC,那 Redlock、Zookeeper 都无能为力 所以,Redis 作者认为 Redlock 在保证时钟正确的基础上,是可以保证正确性的。 在分布式系统中,一个小小的锁,居然可能会遇到这么多问题场景,影响它的安全性! ###### 自猿其说Tech-JDL京东物流技术发展部 作者:网规技术部 李武
原创文章,需联系作者,授权转载
上一篇:四层负载均衡的NAT模型与DR模型推导
下一篇:强烈推荐!Java开发者入手Kotlin的几大原因(一)构造方法篇
相关文章
Taro小程序跨端开发入门实战
Flutter For Web实践
配运基础数据缓存瘦身实践
自猿其说Tech
文章数
426
阅读量
2149964
作者其他文章
01
深入JDK中的Optional
本文将从Optional所解决的问题开始,逐层解剖,由浅入深,文中会出现Optioanl方法之间的对比,实践,误用情况分析,优缺点等。与大家一起,对这项Java8中的新特性,进行理解和深入。
01
Taro小程序跨端开发入门实战
为了让小程序开发更简单,更高效,我们采用 Taro 作为首选框架,我们将使用 Taro 的实践经验整理了出来,主要内容围绕着什么是 Taro,为什么用 Taro,以及 Taro 如何使用(正确使用的姿势),还有 Taro 背后的一些设计思想来进行展开,让大家能够对 Taro 有个完整的认识。
01
Flutter For Web实践
Flutter For Web 已经发布一年多时间,它的发布意味着我们可以真正地使用一套代码、一套资源部署整个大前端系统(包括:iOS、Android、Web)。渠道研发组经过一段时间的探索,使用Flutter For Web技术开发了移动端可视化编程平台—Flutter乐高,在这里希望和大家分享下使用Flutter For Web实践过程和踩坑实践
01
配运基础数据缓存瘦身实践
在基础数据的常规能力当中,数据的存取是最基础也是最重要的能力,为了整体提高数据的读取能力,缓存技术在基础数据的场景中得到了广泛的使用,下面会重点展示一下配运组近期针对数据缓存做的瘦身实践。
自猿其说Tech
文章数
426
阅读量
2149964
作者其他文章
01
深入JDK中的Optional
01
Taro小程序跨端开发入门实战
01
Flutter For Web实践
01
配运基础数据缓存瘦身实践
添加企业微信
获取1V1专业服务
扫码关注
京东云开发者公众号