一、前言
分布式锁相信大家一定不会陌生, 想要用好或者自己写一个却没那么简单
想要达到上述的条件, 一定要 掌握分布式锁的应用场景, 以及分布式锁的不同实现, 不同实现之间有什么区别
二、分布式锁场景
如果想真正了解分布式锁, 需要结合一定场景; 举个例子, 某夕夕上抢购 AirPods Pro 的 100 元优惠券
如果使用下面这段代码当作抢购优惠券的后台程序, 我们一起看一下, 可能存在什么样的问题
很明显的就是这段流程在并发场景下并不安全, 会导致优惠券发放超过预期, 类似电商抢购超卖问题。分布式情况下只能通过分布式锁 来解决多个服务资源共享的问题了。
三、分布式锁
分布式锁的定义:
保证同一时间只能有一个客户端对共享资源进行操作。
另外有几点要求也是必须要满足的:
- 1、不会发生死锁。 即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁
- 2、具有容错性。 只要大部分的Redis节点正常运行,客户端就可以加锁和解锁
- 3、解铃还须系铃人。 加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了
分布式锁实现大致分为三种, Redis、Zookeeper、数据库, 文章以 Redis 展开分布式锁的讨论。
四、分布式锁演进史
先来构思下分布式锁实现思路
要求:
- 首先我们必须保证同一时间只有一个客户端(部署的优惠券服务)操作数量加减。
- 其次本次客户端操作完成后, 需要让 其它客户端继续执行。
实现:
- 1、客户端一存放一个标志位, 如果添加成功, 操作减优惠券数量操作
- 2、客户端二添加标志位失败, 本次减库存操作失败(或继续尝试获取等)
- 3、客户端一优惠券操作完成后, 需要将标志位释放, 以便其余客户端对库存进行操作
4.1 第一版 setnx
向 Redis 中添加一个 lockKey 锁标志位, 如果添加成功则能够继续向下执行扣减优惠券数量操作, 最后再释放此标志位
由于使用的是 Spring 提供的 Redis 封装的 Start 包, 所有有些命令与 Redis 原生命令不相符
1 setIfAbsent(key, val) -> setnx(key, val)
加了简单的几行代码, 一个简单的分布式锁的雏形就出来了。
4.2 第二版 expire
上面第一版基于 setnx 命令实现分布式锁的缺陷也是很明显的, 那就是一定情况下可能发生死锁
画个图, 举个例子说明哈
上图说明, 线程1在成功获取锁后, 执行流程时异常结束, 没有执行释放锁操作, 这样就会产生死锁。
如果方法执行异常导致的线程被回收, 那么可以将解锁操作放到 finally 块中。但是还有存在死锁问题, 如果获得锁的线程在执行中, 服务被强制停止或服务器宕机, 锁依然不会得到释放。这种极端情况下我们还是要考虑的, 毕竟不能只想着服务没问题对吧。对 Redis 的锁标志位加上过期时间就能很好的防止死锁问题, 继续更改下程序代码。
虽然小红旗处对分布式锁添加了过期时间, 但依然无法避免极端情况下的死锁问题。那就是如果在客户端加锁成功后, 还没有设置过期时间时宕机。如果想要避免添加锁时死锁, 那就对添加锁标志位与添加过期时间命令保证一个原子性, 要么一起成功, 要么一起失败。
4.3 第三版 set
我们的添加锁原子命令就要登场了, 从 Redis 2.6.12 版本起, 提供了可选的 字符串 set 复合命令。
1 SET key value [expiration EX seconds|PX milliseconds] [NX|XX]
可选参数如下:
- EX: 设置超时时间,单位是秒
- PX: 设置超时时间,单位是毫秒
- NX: IF NOT EXIST 的缩写,只有 KEY不存在的前提下 才会设置值
- XX: IF EXIST 的缩写,只有在 KEY存在的前提下 才会设置值
继续完善分布式锁的应用程序, 代码如下:
我使用的 2.0.9.RELEASE 版本的 SpringBoot, RedisTemplate 中不支持 set 复合命令, 所以临时换个 Jedis 来实现。
加锁以及设置过期时间确实保证了原子性, 但是这样的分布式锁就没有问题了么?
我们根据图片以及流程描述设想一下这个场景:
- 1、线程一获取锁成功, 设置过期时间五秒, 接着执行业务逻辑
- 2、接着线程一获取锁后执行业务流程, 执行的时间超过了过期时间, 锁标志位过期进行释放, 此时线程二获取锁成功
- 3、然鹅此时线程一执行完业务后, 开始执行释放锁的流程, 然后顺手就把线程二获取的锁释放了
如果线上真的发生上述问题, 那真的是xxx, 更甚者可能存在线程一将线程二的锁释放掉之后, 线程三获取到锁, 然后线程二执行完将线程三的锁释放。
4.4 第四版 verify value
事当如今, 只能创建辨别客户端身份的唯一值了, 将加锁及解锁归一化, 上代码~
这一版的代码相当于我们添加锁标志位时, 同时为每个客户端设置了 uuid 作为锁标志位的 val, 解锁时需要判断锁的 val 是否和自己客户端的相同, 辨别成功才会释放锁。但是上述代码执行业务逻辑如果抛出异常, 锁只能等待过期时间, 我们可以将解锁操作放到 finally 块。
大眼一看, 上上下下实现了四版分布式锁, 也该没问题了吧。
真相就是: 解锁时, 由于判断锁和删除标志位并不是原子性的, 所以可能还是会存在误删。
- 1、线程一获取锁后, 执行流程balabala… 判断锁也是自家的, 这时 CPU 转头去做别的事情了, 恰巧线程一的锁过期时间到了
- 2、线程二此时顺理成章的获取到了分布式锁, 执行业务逻辑balabala…
- 3、线程一再次分配到时间片继续执行删除操作
解决这种非原子操作的方式只能 将判断元素值和删除标志位当作一个原子操作。
4.5 第五版 lua
很不友好的是, del 删除操作并没有提供原子命令, 所以我们需要想点办法
Redis在 2.6 推出了脚本功能, 允许开发者使用 Lua 语言编写脚本传到 Redis 中执行
4.5.1 使用 Lua 脚本有什么好处呢?
- 1、减少网络开销
原本我们需要向 Redis 服务请求多次命令, 可以将命令写在 Lua 脚本中, 这样执行只会发起一次网络请求
- 2、原子操作
Redis 会将 Lua 脚本中的命令当作一个整体执行, 中间不会插入其它命令
- 3、复用
客户端发送的脚步会存储 Redis 中, 其他客户端可以复用这一脚本而不需要使用代码完成相同的逻辑
那我们编写一个简单的 Lua 脚本实现原子删除操作。
重点就在 Lua 脚本这一块, 重点说一下这块的逻辑
script 脚本就是我们在 Redis 中执行的 Lua 脚本, 后面跟的两个 List 分别是 KEYS、ARGV。
KEYS[1]: lockKey
ARGV[1]: lockValue
代码不是很多, 也比较简单, 就是在 Java 中代码实现的逻辑放到了一个 Lua 脚本中
1 # 获取 KEYS[1] 对应的 Val
2 local cliVal = redis.call(‘get‘, KEYS[1])
3 # 判断 KEYS[1] 与 ARGV[1] 是否保持一致
4 if(cliVal == ARGV[1]) then
5 # 删除 KEYS[1]
6 redis.call(‘del‘, KEYS[1])
7 return ‘OK‘
8 else
9 return nil
10 end
五、Redisson框架
5.1 引言
现在面试,一般都会聊聊分布式系统这块的东西。通常面试官都会从服务框架(Spring Cloud、Dubbo)聊起,一路聊到分布式事务、分布式锁、ZooKeeper等知识。所以咱们这篇文章就来聊聊分布式锁这块知识,具体的来看看Redis分布式锁的实现原理。
说实话,如果在公司里落地生产环境用分布式锁的时候,一定是会用开源类库的,比如Redis分布式锁,一般就是用Redisson框架就好了,非常的简便易用。大家如果有兴趣,可以去看看Redisson的官网,看看如何在项目中引入Redisson的依赖,然后基于Redis实现分布式锁的加锁与释放锁。
下面给大家看一段简单的使用代码片段,先直观的感受一下:
怎么样,上面那段代码,是不是感觉简单的不行!
此外,人家还支持redis单实例、redis哨兵、redis cluster、redis master-slave等各种部署架构,都可以给你完美实现。
5.2 Redisson实现Redis分布式锁的底层原理
好的,接下来就通过一张手绘图,给大家说说Redisson这个开源框架对Redis分布式锁的实现原理。
5.2.1 加锁机制
咱们来看上面那张图,现在某个客户端要加锁。如果该客户端面对的是一个redis cluster集群,他首先会根据hash节点选择一台机器。
这里注意,仅仅只是选择一台机器!这点很关键!
紧接着,就会发送一段lua脚本到redis上,那段lua脚本如下所示:
5.2.1.1 为啥要用lua脚本呢?
因为一大坨复杂的业务逻辑,可以通过封装在lua脚本中发送给redis,保证这段复杂业务逻辑执行的原子性。
5.2.1.2 lua脚本分析
5.2.1.2.1 参数分析
KEYS[1]代表的是你加锁的那个key,比如说:RLock lock = redisson.getLock("myLock");
这里你自己设置了加锁的那个锁key就是“myLock”。
ARGV[1]代表的就是锁key的默认生存时间,默认30秒。
ARGV[2]代表的是加锁的客户端的ID,类似于下面这样:
8743c9c0-0795-4907-87fd-6c719a6b4586:1
5.2.1.2.1 代码执行分析
给大家解释一下,第一段if判断语句,就是用“exists myLock”命令判断一下,如果你要加锁的那个锁key不存在的话,你就进行加锁。
如何加锁呢?很简单,用下面的命令:
hset myLock
8743c9c0-0795-4907-87fd-6c719a6b4586:1 1
通过这个命令设置一个hash数据结构,这行命令执行后,会出现一个类似下面的数据结构:
上述就代表“8743c9c0-0795-4907-87fd-6c719a6b4586:1”这个客户端对“myLock”这个锁key完成了加锁。
接着会执行“pexpire myLock 30000”命令,设置myLock这个锁key的生存时间是30秒。
好了,到此为止,ok,加锁完成了。
5.2.2 锁互斥机制
那么在这个时候,如果客户端2来尝试加锁,执行了同样的一段lua脚本,会咋样呢?
很简单,第一个if判断会执行“exists myLock”,发现myLock这个锁key已经存在了。
接着第二个if判断,判断一下,myLock锁key的hash数据结构中,是否包含客户端2的ID,但是明显不是的,因为那里包含的是客户端1的ID。
所以,客户端2会获取到pttl myLock返回的一个数字,这个数字代表了myLock这个锁key的剩余生存时间。比如还剩15000毫秒的生存时间。
此时客户端2会进入一个while循环,不停的尝试加锁(类似于自旋锁)。
5.2.3 watch dog自动延期机制
客户端1加锁的锁key默认生存时间才30秒,如果超过了30秒,客户端1还想一直持有这把锁,怎么办呢?
简单!只要客户端1一旦加锁成功,就会启动一个watch dog看门狗,他是一个后台线程,会每隔10秒检查一下,如果客户端1还持有锁key,那么就会不断的延长锁key的生存时间。
5.2.4 可重入加锁机制
那如果客户端1都已经持有了这把锁了,结果可重入的加锁会怎么样呢?
比如下面这种代码:
这时我们来分析一下上面那段lua脚本。
第一个if判断肯定不成立,“exists myLock”会显示锁key已经存在了。
第二个if判断会成立,因为myLock的hash数据结构中包含的那个ID,就是客户端1的那个ID,也就是“8743c9c0-0795-4907-87fd-6c719a6b4586:1”
此时就会执行可重入加锁的逻辑,他会用:
incrby myLock
8743c9c0-0795-4907-87fd-6c71a6b4586:1 1
通过这个命令,对客户端1的加锁次数,累加1。
此时myLock数据结构变为下面这样:
大家看到了吧,那个myLock的hash数据结构中的那个客户端ID,就对应着加锁的次数
5.2.5 可重入加锁机制
如果执行lock.unlock(),就可以释放分布式锁,此时的业务逻辑也是非常简单的。
其实说白了,就是每次都对myLock数据结构中的那个加锁次数减1。
如果发现加锁次数是0了,说明这个客户端已经不再持有锁了,此时就会用:
“del myLock”命令,从redis里删除这个key。
然后呢,另外的客户端2就可以尝试完成加锁了。
这就是所谓的分布式锁的开源Redisson框架的实现机制。
一般我们在生产系统中,可以用Redisson框架提供的这个类库来基于redis进行分布式锁的加锁与释放锁。
5.2.6 上述Redis分布式锁的缺点
其实上面那种方案最大的问题,就是如果你对某个redis master实例,写入了myLock这种锁key的value,此时会异步复制给对应的master slave实例。
但是这个过程中一旦发生redis master宕机,主备切换,redis slave变为了redis master。接着就会导致,客户端2来尝试加锁的时候,在新的redis master上完成了加锁,而客户端1也以为自己成功加了锁。因为此时存储分布式锁的哈希结构中的并没有对锁的相关信息进行更正。此时就会导致多个客户端对一个分布式锁完成了加锁。
这时系统在业务语义上一定会出现问题,导致各种脏数据的产生。所以这个就是redis cluster,或者是redis master-slave架构的主从异步复制导致的redis分布式锁的最大缺陷:在redis master实例宕机的时候,可能导致多个客户端同时完成加锁。
六、参考文章
https://mp.weixin.qq.com/s?__biz=MzU0OTk3ODQ3Ng==&mid=2247483893&idx=1&sn=32e7051116ab60e41f72e6c6e29876d9&chksm=fba6e9f6ccd160e0c9fa2ce4ea1051891482a95b1483a63d89d71b15b33afcdc1f2bec17c03c&mpshare=1&scene=23&srcid=1121Vlt0Mey0OD5eYWt8HPyB#rd
https://www.cnblogs.com/williamjie/p/11250679.html