基于redis的分布式锁

分布式锁

  • 前言

    • redis分布式锁的实现
    • 基础版1.0
    • 基础版1.1设置锁的过期时间
    • 基础版1.2 设置带Value的锁
    • 入门版 ->lua 初窥
    • 完美开源解决方案
  • redission实现分布式锁原理

前言

1多线程下的数据一致性问题一直都是热点问题,既要考虑到数据的一致,又要考虑实现的效率,在分布式情况下,这又要成为一种新的难题。 2分布式锁和我们java基础中学习到的synchronized略有不同,在synchronized中我们的锁是个对象,如果一个线程拿到了该锁,别的线程就只 3能等待了。比如购物下单这种业务场景,下单的系统部署在不同的服务实例上,单纯使用synchronized或者lock 已经无法满足对库存一致性的 4判断,所以分布式锁由此诞生,其本质 加锁/释放锁的操作 不再当前服务实现了。 5 6如今常见的锁方案如下: 7基于数据库实现分布式锁 8基于缓存,实现分布式锁,如redis 9基于Zookeeper实现分布式锁 10 11由于实践中使用的是基于redis 的分布式锁, 所以今天主要说说redis 的分布式说 12 13 14

redis分布式锁的实现

1SETNX 2使用redis的SETNX实现分布式锁,多个进程执行以下Redis命令: 3SETNX 4是将 key 的值设为 value,当且仅当 key 不存在。若给定的 key 已经存在,则 SETNX 不做任何动作。 5返回1,说明该进程获得锁, 6返回0,说明其他进程已经获得了锁,进程不能进入临界区。进程可以在一个循环中不断地尝试 SETNX 操作,以获得锁。 7 8

基础版1.0

先来一个最简单版本

1 public void lock() { 2 while(true){ 3 String result = jedis.set(lockKey, "value", NOT_EXIST); 4 if(OK.equals(result)){ 5 System.out.println(Thread.currentThread().getId()+"加锁成功!"); 6 break; 7 } 8 } 9 } 10 11

而unlock方法就是调用DEL命令将键删除。

但是这样会存在一个问题
假如服务器A在加锁成功后 服务器宕机或者 网络断了 等等。。。 总之断开与redis 的连接,那么服务器BCDEF永远都无法获取锁了

带着这个问题 升级到1.1版本

基础版1.1设置锁的过期时间

1public void lock() { 2 while(true){ 3 String result = jedis.set(lockKey, "value", NOT_EXIST,SECONDS,30); 4 if(OK.equals(result)){ 5 System.out.println(Thread.currentThread().getId()+"加锁成功!"); 6 break; 7 } 8 } 9} 10 11 12注:要保证设置过期时间和设置锁具有原子性,防止设置过期时间时前服务器断开连接 13 14

但是这样还是会存在一个问题
步骤如下

1服务器A获取锁成功,过期时间10秒。 2服务器A在某个操作上阻塞了30秒。 310秒时间到了,锁自动释放了。 4服务器B获取到了对应同一个资源的锁。 5服务器A从阻塞中恢复过来,释放掉了服务器B持有的锁。 6 7

基础版1.2 设置带Value的锁

在1.1的基础上 解决自己的锁自己释放 , 即自己的坑自己填
直接上代码

1 public void lock() { 2 while(true){ 3 String result = jedis.set(lockKey, requestId, NOT_EXIST,SECONDS,30); 4 if(OK.equals(result)){ 5 System.out.println(Thread.currentThread().getId()+"加锁成功!"); 6 break; 7 } 8 } 9 } 10 11 12

requestId 就是每个线程自己生成一个唯一标识, 再释放锁的时候 判断这个锁是否是自己加的

1 public void unlock() { 2 String requestId = jedis.get(lockKey); 3 if (requestId .equals(requestId )){ 4 jedis.del(lockKey); 5 } 6 } 7 8 9

看着好像没什么问题,但是, 这里又一个但是,获取值、判断和删除锁这是三步操作,在多线程的情况下,就会出现问题
所以要确保解锁过程是原子型操作。 噔噔噔 上神器 <redis的lua脚本>

入门版 ->lua 初窥

先说说什么是lua 脚本?

​ Lua 是一个小巧的脚本语言。是巴西里约热内卢天主教大学(Pontifical Catholic University of Rio de Janeiro)里的一个研究小组,由Roberto Ierusalimschy、Waldemar Celes 和 Luiz Henrique de Figueiredo所组成并于1993年开发。 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。Lua由标准C编写而成,几乎在所有操作系统和平台上都可以编译,运行。Lua并没有提供强大的库,这是由它的定位决定的。所以Lua不适合作为开发独立应用程序的语言。Lua 有一个同时进行的JIT项目,提供在特定平台上的即时编译功能。 Lua脚本可以很容易的被C/C++ 代码调用,也可以反过来调用C/C++的函数,这使得Lua在应用程序中可以被广泛应用。不仅仅作为扩展脚本,也可以作为普通的配置文件,代替XML,ini等文件格式,并且更容易理解和维护。 Lua由标准C编写而成,代码简洁优美,几乎在所有操作系统和平台上都可以编译,运行。一个完整的Lua解释器不过200k,在目前所有脚本引擎中,Lua的速度是最快的。这一切都决定了Lua是作为嵌入式脚本的最佳选择。

上面巴拉巴拉说了一堆总结起来就是 体积很小,运行速度很快,并且每次的执行都是作为一个原子事务来执行的。重点,重点,重点,速度快,每次执行都是一个原子事务。 如果能把上面 的取值 判断 删除三步放在一个执行事务中,不就解决问题了吗???

问题1:解决 自己的锁自己释放问题

lua 的作用就是为了解决 在解锁中获取值、判断、删除这三步操作 合成一步执行,在执行lua脚本的时候把requestId 传进去, 作为ARGV[1] , KEYS[1]为锁的key,不多逼逼,上代码

1 //一个脚本 既有取值 又有if else 判断 还有具体操作, 神器啊 2 3 if redis.call("get",KEYS[1]) == ARGV[1] then 4 return redis.call("del",KEYS[1]) 5 else 6 return 0 7 end 8 9

问题2:解决业务操作时间大于自动释放时间

业务操作的时间长短的确具有不确定性,但是按照正常来说,一个业务,开始前先获取锁,成功拿到锁后,执行业务操作,执行完成删除锁。假设想象每个请求都需要执行完成,不在乎它的时间长短,那么在服务步不宕机的情况下,锁必由自己来释放,如果可以启动一个线程,每隔一段时间不断查询这个key是否过期,如果没有,那么就延长一定时间。在验证key 是否过期的时候也必须 考虑多线程访问,所以还是需要使用到lua脚本 获取值 ,判断, 延期,看着是不是和上面的很像,上代码比较比较

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

那么现在只需要启动一个线程 隔断时间查询某个key 都否过期 ,执行上面这段脚本,就可以完美的解决这个问题了。

1刷新key的过期时间 2 3 private class ExpirationRenewal implements Runnable{ 4 @Override 5 public void run() { 6 while (isOpenExpirationRenewal){ 7 System.out.println("执行延迟失效时间中..."); 8 9 String checkAndExpireScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " + 10 "return redis.call('expire',KEYS[1],ARGV[2]) " + 11 "else " + 12 "return 0 end"; 13 jedis.eval(checkAndExpireScript, 1, lockKey, requestId , "30"); 14 15 //休眠10秒 16 sleepBySencond(10); 17 } 18 } 19 } 20 21

完美开源解决方案

刚才说了手写一个redis分布式事务锁需要考虑的茫茫多的东西,那么有已经开源已经写好的吗?说实话,如果在公司里落地生产环境用分布式锁的时候,一定是会用开源类库的。

redisson,下面是它的官方wiki ,大家有兴趣可以看看 redisssion 官网wiki

下面给大家看一段简单的使用代码片段,先直观的感受一下:

1RLock lock = redisson.getLock("myLock"); 2// Most familiar locking method 3lock.lock(); 4 5lock.unlock(); 6RLock lock = redisson.getLock("myLock"); 7 8lock.lock(); 9 10lock.unlock(); 11 12

是不是特别清爽,没有自己写的茫茫多的判断,

此外,人家还支持redis单实例、redis哨兵、redis cluster、redis master-slave等各种部署架构,都可以给你完美实现。

redission实现分布式锁原理

随便从网上找的一张图
(1)加锁机制
咱们来看上面那张图,现在某个客户端要加锁。如果该客户端面对的是一个redis cluster集群,他首先会根据hash节点选择一台机器。这里注意,仅仅只是选择一台机器!这点很关键!

紧接着,就会发送一段lua脚本到redis上,那段lua脚本如下所示:

在这里插入图片描述
是不是很眼熟,这就是上面提到的lua脚本

参数解析

KEYS[1] 代表的是你加锁的那个key,比如说:RLock lock = redisson.getLock(“myLock”);这里你自己设置了加锁的那个锁key就是“myLock”。

ARGV[1] 代表的就是锁key的默认生存时间,默认30秒。

ARGV[2] 代表的是加锁的客户端的ID,类似于下面这样:8743c9c0-0795-4907-87fd-6c719a6b4586:1 (本质其实就是uuid+线程id)

这个lua和咱们自己手写的好像还不太一样,咱们一行行的来看看

第一个if

第一段if判断语句,就是用“exists myLock”命令判断一下,如果你要加锁的那个锁key不存在的话,你就进行加锁。

使用到了一个hash的结构 ,加锁命令如下:

1hset myLock 8743c9c0-0795-4907-87fd-6c719a6b4586:1 1 2 3

加完后的数据结构

1myLock:{ 2 "8743c9c0-0795-4907-87fd-6c719a6b4586:1": 1 3} 4 5

接着会执行“pexpire myLock 30000”命令,设置myLock这个锁key的生存时间是30秒。
到此就第一个if 执行完成

好了,ok,加锁完成了。

第二个if

在先说第二个if 前,先看一种情况

1RLock lock = redisson.getLock("myLock"); 2lock.lock(); 3lock.lock(); 4 5lock.unlock(); 6lock.unlock(); 7 8

在这种情况下 第二个if判断会成立,因为myLock的hash数据结构中包含的那个ID,就是客户端1的那个ID,也就是“8743c9c0-0795-4907-87fd-6c719a6b4586:1”

此时就会执行

1incrby myLock 8743c9c0-0795-4907-87fd-6c71a6b4586:1 1 2 3

通过这个命令,对客户端1的加锁次数,累加1。

此时myLock数据结构变为下面这样:

1myLock:{ 2 "8743c9c0-0795-4907-87fd-6c719a6b4586:1": 2 3} 4 5

大家看到了吧,那个myLock的hash数据结构中的那个客户端ID,就对应着加锁的次数

这种情况就是所谓的可重入锁,表现在同一个线程可以多次获得锁,而不同线程依然不可多次获得锁

还有一个return哦

也就是说前面两个if 都不满足的情况下,就会走向第三个return, 想象以下:
不满足第一个if,也就说已经有其他客户端加锁成功,
不满足第二个if,也就说不是这个客户端加的锁
也就说已经有其他客户端加锁成功,这个时候就会获取到pttl myLock返回的一个数字,这个数字代表了myLock这个锁key的剩余生存时间。比如还剩15000毫秒的生存时间。
此时当前会进入一个while循环,不停的尝试加锁。

看门狗

刚才咱们自己实现分布式锁的时候好像还启动一个线程监控自己的key是否实现延期,
其实redission 也有,watchdog机制
一旦加锁成功,就会启动一个watch dog看门狗,他是一个后台线程,会每隔10秒检查一下,如果客户端1还持有锁key,那么就会不断的延长锁key的生存时间。

unlock

如果执行lock.unlock(),就可以释放分布式锁,此时的业务逻辑也是非常简单的。

刚才解析第二个if 我们发现hash 对应的value 就是加锁数量

其实每次unlock ,都对myLock数据结构中的那个加锁次数减1。

如果发现加锁次数是0了,说明这个客户端已经不再持有锁了,此时就会用:

“del myLock”命令,从redis里删除这个key。

代码交流 2021