Redis 分布式锁没这么简单,网上大多数都有 bug

存储 存储软件 分布式 Redis
分布式锁就是用来控制同一时刻,只有一个 JVM 进程中的一个线程「精子」可以访问被保护的资源「卵子」。

[[434468]]

Redis 分布式锁这个话题似乎烂大街了,不管你是面试还是工作,随处可见,「码哥」为啥还要写呢?

因为网上 99.99% 的文章都没有真正的把分布式锁说清楚,存在很多 bug。

今日,「码哥」就跟大家深入分布式锁的 G 点,系统的做一个写好代码掌握技巧的真男人。

在进入「高潮」之前,以下问题就当做「前戏」去思考,你能回答多少?

  • 什么时候需要分布式锁?
  • 加、解锁的代码位置有讲究么?
  • 如何避免出现死锁
  • 超时时间设置多少合适呢?
  • 如何避免锁被其他线程释放
  • 如何实现重入锁?
  • 主从架构会带来什么安全问题?
  • 什么是 Redlock
  • ……

为何需要分布式锁?

码哥,说个通俗的例子讲解下什么时候需要分布式锁呢?

精子喷射那一刻,亿级流量冲向卵子,只有一个精子能获得与卵子结合的幸运。

造物主为了保证只有一个「精子」能获得「卵子」的宠幸,当有一个精子进入后,卵子的外壳就会发生变化,将通道关闭把其余的精子阻挡在外。

亿级别的精子就好比「并发」流量;

卵子就好比是共享资源;

卵子外壳只允许一个精子进入的特殊蛋白就是一把锁。

而多节点构成的集群,就会有多个 JVM 进程,我们获得同样的效果就需要有一个中间人协调,只允许一个 JVM 中的一个线程获得操作共享资源的资格。

分布式锁就是用来控制同一时刻,只有一个 JVM 进程中的一个线程「精子」可以访问被保护的资源「卵子」。

「每一个生命,都是亿级选手中的佼佼者」,加油。

分布式锁入门

分布式锁应该满足哪些特性?

  • 互斥:在任何给定时刻,只有一个客户端可以持有锁;
  • 无死锁:任何时刻都有可能获得锁,即使获取锁的客户端崩溃;
  • 容错:只要大多数 Redis的节点都已经启动,客户端就可以获取和释放锁。

码哥,我可以使用 SETNX key value 命令是实现「互斥」特性。

这个命令来自于SET if Not eXists的缩写,意思是:如果 key 不存在,则设置 value 给这个key,否则啥都不做。

命令的返回值:

  • 1:设置成功;
  • 0:key 没有设置成功。

如下场景:

敲代码一天累了,想去放松按摩下肩颈。

168 号技师最抢手,大家喜欢点,所以并发量大,需要分布式锁控制。

同一时刻只允许一个「客户」预约 168 技师。

肖彩机申请 168 技师成功:

  1. > SETNX lock:168 1 
  2.  
  3. (integer) 1 # 获取 168 技师成功 

谢霸哥后面到,申请失败:

  1. > SETNX lock 2 
  2.  
  3. (integer) 0 # 客户谢霸哥 2 获取失败 

此刻,申请成功的客户就可以享受 168 技师的肩颈放松服务「共享资源」。

享受结束后,要及时释放锁,给后来者享受 168 技师的服务机会。

肖彩机,码哥考考你如何释放锁呢?

很简单,使用 DEL 删除这个 key 就行。

  1. > DEL lock:168 
  2.  
  3. (integer) 1 

码哥,你见过「龙」么?我见过,因为我被一条龙服务过。

肖彩机,事情可没这么简单。

这个方案存在一个存在造成「死锁」的问题,造成该问题的场景如下:

在按摩过程中突然收到线上报警,提起裤子就跑去公司了,没及时执行 DEL 释放锁(客户端处理业务异常,无法正确释放锁);

按摩过程中心肌梗塞嗝屁了,无法执行 DEL指令。

这样,这个锁就会一直占用,其他客户就「再也没有」机会获取 168 技师服务了。

如何避免死锁

码哥,我可以在获取锁成功的时候设置一个「超时时间」

比如设定按摩服务一次 60 分钟,那么在给这个 key 加锁的时候设置 60 分钟过期即可:

  1. > SETNX lock:168 1  // 获取锁 
  2. (integer) 1 
  3. > EXPIRE lock:168 60  // 60s 自动删除 
  4. (integer) 1 

这样,到点后锁自动释放,其他客户就可以继续享受 168 技师按摩服务了。

谁要这么写,就糟透了。

「加锁」、「设置超时」是两个命令,他们不是原子操作。

如果出现只执行了第一条,第二条没机会执行就会出现「超时时间」设置失败,依然出现死锁。

比如以下场景导致无法执行第二条指令:

Redis 异常宕机;

客户端异常崩溃;

码哥,那咋办,我想被一条龙服务,不能出现死锁啊。

Redis 2.6.12 之后,拓展了 SET 命令的参数,满足了当 key 不存在则设置 value,同时设置超时时间的语义,并且满足原子性。

  1. SET resource_name random_value NX PX 30000 

NX:表示只有 resource_name 不存在的时候才能 SET 成功,从而保证只有一个客户端可以获得锁;

PX 30000:表示这个锁有一个 30 秒自动过期时间。

执行时间超过锁的过期时间

这样我能稳妥的享受一条龙服务了么?

No,还有一种场景会导致释放别人的锁:

  • 客户 1 获取锁成功并设置 30 秒超时;
  • 客户 1 因为一些原因导致执行很慢(网络问题、发生 FullGC……),过了 30 秒依然没执行完,但是锁过期「自动释放了」;
  • 客户 2 申请加锁成功;
  • 客户 1 执行完成,执行 DEL 释放锁指令,这个时候就把 客户 2 的锁给释放了。

有两个关键问题需要解决:

  • 如何合理设置过期时间?
  • 如何避免删除别人持有的锁。

正确设置锁超时

锁的超时时间怎么计算合适呢?

这个时间不能瞎写,一般要根据在测试环境多次测试,然后压测多轮之后,比如计算出平均执行时间 200 ms。

那么锁的超时时间就放大为平均执行时间的 3~5 倍。

为啥要放大呢?

因为如果锁的操作逻辑中有网络 IO 操作、JVM FullGC 等,线上的网络不会总一帆风顺,我们要给网络抖动留有缓冲时间。

那我设置更大一点,比如设置 1 小时不是更安全?

不要钻牛角,多大算大?

设置时间过长,一旦发生宕机重启,就意味着 1 小时内,分布式锁的服务全部节点不可用。

你要让运维手动删除这个锁么?

只要运维真的不会打你。

有没有完美的方案呢?不管时间怎么设置都不大合适。

我们可以让获得锁的线程开启一个守护线程,用来给快要过期的锁「续航」。

加锁的时候设置一个过期时间,同时客户端开启一个「守护线程」,定时去检测这个锁的失效时间。

如果快要过期,但是业务逻辑还没执行完成,自动对这个锁进行续期,重新设置过期时间。

这个道理行得通,可我写不出。

别慌,已经有一个库把这些工作都封装好了他叫Redisson。

Redisson 是一个 Java 语言实现的 Redis SDK 客户端,在使用分布式锁时,它就采用了「自动续期」的方案来避免锁过期,这个守护线程我们一般也把它叫做「看门狗」线程。

关于 Redisson 的使用与原理分析由于篇幅有限,大家可关注「码哥字节」且听下回分解。

避免释放别人的锁

出现释放别人锁的关键在于「无脑执行」DEL指令,所以我们要想办法检查下这个锁是不是自己加的。

解铃还须系铃人

码哥,我在加锁的时候设置一个「唯一标识」作为 value 代表加锁的客户端。

在释放锁的时候,客户端将自己的「唯一标识」与锁上的「标识」比较是否相等,匹配上则删除,否则没有权利释放锁。

伪代码如下:

  1. // 比对 value 与 唯一标识 
  2. if (redis.get("lock:168").equals(uuid)){ 
  3.    redis.del("lock:168"); //比对成功则删除 
  4.  } 

有没有想过,这是 GET + DEL 指令组合而成的,这里又会涉及到原子性问题。

复现下情况:

  • 客户端 1 第一步对比成功后,第二步还没来得及执行,这时候锁到期了。
  • 客户端 2 获取锁成功,将自己的 「uuid」设置进去。
  • 这时候客户端 1 执行第二步进行释放锁,这肯定是错误的。

我们是追求极致的男人,所以这里通过 Lua 脚本来实现,这样判断和删除的过程就是原子操作了。

  1. if redis.call("get",KEYS[1]) == ARGV[1] then 
  2.     return redis.call("del",KEYS[1]) 
  3. else 
  4.     return 0 
  5. end 

一路优化下来,方案似乎比较「严谨」了,抽象出对应的模型如下。

通过 SET lock_resource_name $unique_id NX PX $expire_time,同时启动守护线程为快要过期单还没执行完毕的客户端的锁续命;

客户端执行业务逻辑操作共享资源;

通过 Lua 脚本释放锁,先 get 判断锁是否是自己加的,再执行 DEL。

加解锁代码位置有讲究

根据前面的分析,我们已经有了一个「相对严谨」的分布式锁了。

于是「谢霸哥」就写了如下代码将分布式锁运用到项目中,以下是伪代码逻辑:

  1. public void doSomething() { 
  2.     try { 
  3.         redisLock.lock(); // 上锁 
  4.         // 处理业务 
  5.         redisLock.unlock(); // 释放锁 
  6.     } catch (Exception e) { 
  7.         e.printStackTrace(); 
  8.     } 

一旦执行业务逻辑过程中抛出异常,程序就无法走下一步释放锁的流程。

所以释放锁的代码一定要放在 finally{} 块中。

加锁的位置也有问题,如果执行 redisLock.lock() 加锁异常,那么就会执行 finally{} 代码块指令执行解锁,这个时候锁并没有申请成功。

所以 redisLock.lock();应该放在 try 外面。

综上所述,正确代码位置如下 :

  1. public void doSomething() { 
  2.    // 上锁 
  3.    redisLock.lock(); 
  4.     try { 
  5.         // 处理业务 
  6.         ... 
  7.     } catch (Exception e) { 
  8.         e.printStackTrace(); 
  9.     } finally { 
  10.       // 释放锁 
  11.       redisLock.unlock(); 
  12.     } 

实现可重入锁

可重入锁要如何实现呢?重入之后,超时时间如何设置呢?

当一个线程执行一段代码成功获取锁之后,继续执行时,又遇到加锁的代码,可重入性就就保证线程能继续执行,而不可重入就是需要等待锁释放之后,再次获取锁成功,才能继续往下执行。

用一段代码解释可重入:

  1. public synchronized void a() { 
  2.     b(); 
  3. public synchronized void b() { 
  4.     // pass 

假设 X 线程在 a 方法获取锁之后,继续执行 b 方法,如果此时不可重入,线程就必须等待锁释放,再次争抢锁。

锁明明是被 X 线程拥有,却还需要等待自己释放锁,然后再去抢锁,这看起来就很奇怪,我释放我自己~

Redis Hash 可重入锁

Redisson 类库就是通过 Redis Hash 来实现可重入锁,未来码哥会专门写一篇关于 Redisson 的使用与原理的文章……

当线程拥有锁之后,往后再遇到加锁方法,直接将加锁次数加 1,然后再执行方法逻辑。

退出加锁方法之后,加锁次数再减 1,当加锁次数为 0 时,锁才被真正的释放。

可以看到可重入锁最大特性就是计数,计算加锁的次数。

所以当可重入锁需要在分布式环境实现时,我们也就需要统计加锁次数。

加锁逻辑

我们可以使用 Redis hash 结构实现,key 表示被锁的共享资源, hash 结构的 fieldKey 的 value 则保存加锁的次数。

通过 Lua 脚本实现原子性,假设 KEYS1 = 「lock」, ARGV「1000,uuid」:

  1. ---- 1 代表 true 
  2. ---- 0 代表 false 
  3.  
  4. if (redis.call('exists', KEYS[1]) == 0) then 
  5.     redis.call('hincrby', KEYS[1], ARGV[2], 1); 
  6.     redis.call('pexpire', KEYS[1], ARGV[1]); 
  7.     return 1; 
  8. end ; 
  9. if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then 
  10.     redis.call('hincrby', KEYS[1], ARGV[2], 1); 
  11.     redis.call('pexpire', KEYS[1], ARGV[1]); 
  12.     return 1; 
  13. end ; 
  14. return 0; 

加锁代码首先使用 Redis exists 命令判断当前 lock 这个锁是否存在。

如果锁不存在的话,直接使用 hincrby创建一个键为 lock hash 表,并且为 Hash 表中键为 uuid 初始化为 0,然后再次加 1,最后再设置过期时间。

如果当前锁存在,则使用 hexists判断当前 lock 对应的 hash 表中是否存在 uuid 这个键,如果存在,再次使用 hincrby 加 1,最后再次设置过期时间。

最后如果上述两个逻辑都不符合,直接返回。

解锁逻辑

  1. -- 判断 hash set 可重入 key 的值是否等于 0 
  2. -- 如果为 0 代表 该可重入 key 不存在 
  3. if (redis.call('hexists', KEYS[1], ARGV[1]) == 0) then 
  4.     return nil; 
  5. end ; 
  6. -- 计算当前可重入次数 
  7. local counter = redis.call('hincrby', KEYS[1], ARGV[1], -1); 
  8. -- 小于等于 0 代表可以解锁 
  9. if (counter > 0) then 
  10.     return 0; 
  11. else 
  12.     redis.call('del', KEYS[1]); 
  13.     return 1; 
  14. end ; 
  15. return nil; 

首先使用 hexists 判断 Redis Hash 表是否存给定的域。

如果 lock 对应 Hash 表不存在,或者 Hash 表不存在 uuid 这个 key,直接返回 nil。

若存在的情况下,代表当前锁被其持有,首先使用 hincrby使可重入次数减 1 ,然后判断计算之后可重入次数,若小于等于 0,则使用 del 删除这把锁。

解锁代码执行方式与加锁类似,只不过解锁的执行结果返回类型使用 Long。这里之所以没有跟加锁一样使用 Boolean ,这是因为解锁 lua 脚本中,三个返回值含义如下:

  • 1 代表解锁成功,锁被释放
  • 0 代表可重入次数被减 1
  • null 代表其他线程尝试解锁,解锁失败

主从架构带来的问题

码哥,到这里分布式锁「很完美了」吧,没想到分布式锁这么多门道。

路还很远,之前分析的场景都是,锁在「单个」Redis 实例中可能产生的问题,并没有涉及到 Redis 的部署架构细节。

我们通常使用「Cluster 集群」或者「哨兵集群」的模式部署保证高可用。

这两个模式都是基于「主从架构数据同步复制」实现的数据同步,而 Redis 的主从复制默认是异步的。

我们试想下如下场景会发生什么问题:

如果客户端 1 刚往 master 节点写入一个分布式锁,此时这个指令还没来得及同步到 slave 节点。

此时,master 节点宕机,其中一个 slave 被选举为新 master,这时候新 master 是没有客户端 1 写入的锁,锁丢失了。

此刻,客户端 2 线程来获取锁,就成功了。

虽然这个概率极低,但是我们必须得承认这个风险的存在。

Redis 的作者提出了一种解决方案,叫 Redlock(红锁)

Redis 的作者为了统一分布式锁的标准,搞了一个 Redlock,算是 Redis 官方对于实现分布式锁的指导规范,https://redis.io/topics/distlock,但是这个 Redlock 也被国外的一些分布式专家给喷了。

因为它也不完美,有“漏洞”。

什么是 Redlock

红锁是不是这个?

泡面吃多了你,Redlock 红锁是为了解决主从架构中锁丢失而提出的一种算法。

Redlock 的方案基于 2 个前提:

  • 不需要部署从库和哨兵实例,只部署主库
  • 但主库要部署多个,官方推荐至少 5 个实例,这样可以保证他们不会同时宕机。

也就是说,想用使用 Redlock,你至少要部署 5 个 Redis 实例,而且都是主库,它们之间没有任何关系,都是一个个孤立的实例。

一个客户端要获取锁有 5 个步骤:

  • 客户端获取当前时间 T1(毫秒级别);
  • 使用相同的 key和 value顺序尝试从 N个 Redis实例上获取锁。
    • 每个请求都设置一个超时时间(毫秒级别),该超时时间要远小于锁的有效时间,这样便于快速尝试与下一个实例发送请求。
    • 比如锁的自动释放时间 10s,则请求的超时时间可以设置 5~50 毫秒内,这样可以防止客户端长时间阻塞。
  • 客户端获取当前时间 T2 并减去步骤 1 的 T1 来计算出获取锁所用的时间(T3 = T2 -T1)。当且仅当客户端在大多数实例(N/2 + 1)获取成功,且获取锁所用的总时间 T3 小于锁的有效时间,才认为加锁成功,否则加锁失败。
  • 如果第 3 步加锁成功,则执行业务逻辑操作共享资源,key 的真正有效时间等于有效时间减去获取锁所使用的时间(步骤 3 计算的结果)。
  • 如果因为某些原因,获取锁失败(没有在至少 N/2+1 个 Redis 实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的 Redis 实例上进行解锁(即便某些 Redis 实例根本就没有加锁成功)。

为什么要部署多个实例并加锁呢?

本质是为了高可用和容错,即使部分实例宕机,大多数实例加锁成功,整个分布式锁服务依然可用。

为啥在第三步要计算加锁的累计时间?

因为多个节点加锁,耗时可能会比较长,网络中可能存在丢包、超时等现象。

即使大多数节点获取锁成功,假如获取锁的总时间已经超过锁的有效时间,这个锁已经没有意义了。

为什么释放锁要操作所有节点,即使有的节点加锁未成功?

因为有可能客户端在 Redis 实例上加锁成功,只是客户端读取响应的时候失败导致客户端以为加锁失败。

为了安全的清理锁,就需要向每个节点发送释放锁的请求。

Redlock 这么完美?那他解决了 Redis 主从架构节点异常宕机导致锁丢失的问题了么?

事情可没这么简单,Redis 作者把这个方案提出后,受到了业界著名的分布式系统专家的质疑。

两人好比神仙打架,两人一来一回论据充足的对一个问题提出很多论断……

由于篇幅原因,关于 两人的争论分析以及 Redssion 对分布式锁的封装以及 Redlock 的实现我们下期再见。

预知后事如何,且听下回分解…

总结

完工,我建议你合上屏幕,自己在脑子里重新过一遍,每一步都在做什么,为什么要做,解决什么问题。

我们一起从头到尾梳理了一遍 Redis 分布式锁中的各种门道,其实很多点是不管用什么做分布式锁都会存在的问题,重要的是思考的过程。

对于系统的设计,每个人的出发点都不一样,没有完美的架构,没有普适的架构,但是在完美和普适能平衡的很好的架构,就是好的架构。

关于 Redlock 的争论主要集中在如下几点:

  • Redlock 效率太差、太重,对于提升效率的场景下,使用分布式锁,允许锁的偶尔失效,那么使用单 Redis 节点的锁方案就足够了,简单而且效率高。
  • 对于正确性要求高的场景下,它是依赖于时间的,不是一个足够强的算法。Redlock 并没有保住正确性。

 

责任编辑:武晓燕 来源: 码哥字节
相关推荐

2020-07-23 09:10:09

Redis分布式锁漏洞

2023-09-22 08:00:00

分布式锁Redis

2022-07-22 06:55:20

Redis分布式锁

2021-06-10 06:57:39

Redis存储数据库

2019-06-19 15:40:06

分布式锁RedisJava

2021-02-02 16:37:25

Redis分布式

2009-07-14 15:39:34

Swing大多数控件

2022-03-08 07:22:48

Redis脚本分布式锁

2023-10-10 18:26:58

分布式缓存

2014-01-02 10:34:54

设计设计师

2019-02-26 09:51:52

分布式锁RedisZookeeper

2023-08-21 19:10:34

Redis分布式

2022-01-06 10:58:07

Redis数据分布式锁

2011-05-26 10:50:31

2016-10-26 09:42:13

2020-11-16 12:55:41

Redis分布式锁Zookeeper

2022-09-19 08:17:09

Redis分布式

2021-06-16 07:56:21

Redis分布式

2019-07-16 09:22:10

RedisZookeeper分布式锁

2022-06-16 08:01:24

redis分布式锁
点赞
收藏

51CTO技术栈公众号