快速读懂Redis分布式锁的实现和原理
前言:
什么是分布式锁:
分布式锁,就是控制分布式系统中不同进程共同访问同一共享资源的一种锁的实现。
所谓当局者迷,旁观者清,先举个生活中的例子,就拿高铁举例,每辆高铁都有自己的运行路线,但这些路线可能会与其他高铁的路线重叠,如果只让高铁内部的司机操控路线,那就可能出现撞车事故,因为司机不知道其他高铁的运行路线是什么。所以,中控室就发挥作用了,中控室会监控每辆高铁,高铁在什么时间走什么样的路线全部由中控室指挥。
分布式锁就是基于这种思想实现的,它需要在我们分布式应用的外面使用一个第三方组件(可以是数据库、Redis、Zookeeper等)进行全局锁的监控,由这个组件决定什么时候加锁,什么时候释放锁。
一、为什么要用分布式锁
我们知道,当多个线程并发操作某个对象时,可以通过synchronized来保证同一时刻只能有一个线程获取到对象锁进而处理synchronized关键字修饰的代码块或方法。既然已经有了synchronized锁,为什么这里又要引入分布式锁呢?
因为现在的系统基本都是分布式部署的,一个应用会被部署到多台服务器上,synchronized只能控制当前服务器自身的线程安全,并不能跨服务器控制并发安全。比如下图,同一时刻有4个线程新增同一件商品,其中两个线程由服务器A处理,另外两个线程由服务器B处理,那么最后的结果就是两台服务器各执行了一次新增动作。这显然不符合预期。
二、Reids如何实现分布式锁
在聊Redis如何实现分布式锁之前,我们要先聊一下redis的一个命令:setnx key value。我们知道,Redis设置一个key最常用的命令是:set key value,该命令不管key是否存在,都会将key的值设置成value,并返回成功:
setnx key value 也是设置key的值为value,不过,它会先判断key是否已经存在,如果key不存在,那么就设置key的值为value,并返回1;如果key已经存在,则不更新key的值,直接返回0:
1、简单实现的办法
基于setnx命令的特性,我们就可以实现一个最简单的分布式锁了。我们通过向Redis发送 setnx 命令,然后判断Redis返回的结果是否为1,结果是1就表示setnx成功了,那本次就获得锁了,可以继续执行业务逻辑;如果结果是0,则表示setnx失败了,那本次就没有获取到锁,可以通过循环的方式一直尝试获取锁,直至其他客户端释放了锁(delete掉key)后,就可以正常执行setnx命令获取到锁。流程如下:
这种方式虽然实现了分布式锁的功能,但有一个很明显的问题:没有给key设置过期时间,万一程序在发送delete命令释放锁之前宕机了,那么这个key就会永久的存储在Redis中了,其他客户端也永远获取不到这把锁了。
2、升级版本
针对上面的问题,我们可以基于setnx key value的基础上,同时给key设置一个过期时间。Redis已经提供了这样的命令:set key value ex seconds nx。其中,ex seconds 表示给key设置过期时间,单位为秒,nx 表示该set命令具备setnx的特性。效果如下:
我们设置name的过期时间为60秒,60秒内执行该set命令时,会直接返回nil。60秒后,我们再执行set命令,可以执行成功
基于这个特性,升级后的分布式锁流程如下:
这种方式虽然解决了一些问题,但却引来了另外一个问题:存在锁误删的情况,也就是把别人加的锁释放了。例如,client1获得锁之后开始执行业务处理,但业务处理耗时较长,超过了锁的过期时间,导致业务处理还没结束时,锁却过期自动删除了(相当于属于client1的锁被释放了),此时,client2就会获取到这把锁,然后执行自己的业务处理,也就在此时,client1的业务处理结束了,然后向Redis发送了delete key的命令来释放锁,Redis接收到命令后,就直接将key删掉了,但此时这个key是属于client2的,所以,相当于client1把client2的锁给释放掉了:
3、升级版本-优化
value使用唯一值,删除锁时判断value是否当前线程的
要解决上面的问题,最省事的做法就是把锁的过期时间设置长一点,要远大于业务处理时间,但这样就会严重影响系统的性能,假如一台服务器在释放锁之前宕机了,而锁的超时时间设置了一个小时,那么在这一个小时内,其他线程访问这个服务时就一直阻塞在那里。所以,一般不推荐使用这种方式。
另一种解决方法就是在set key value ex seconds nx时,把value设置成一个唯一值,每个线程的value都不一样,在删除key之前,先通过get key命令得到value,然后判断value是否是自己线程生成的,如果是,则删除掉key释放锁,如果不是,则不删除key。正常流程如下:
当业务处理还没结束的时候,key自动过期了,也可以正常释放自己的锁,不影响其他线程:
二次升级后的方案看起来似乎已经没什么问题了,但其实不然。仔细分析流程后我们发现,判断锁是否属于当前线程和释放锁两个步骤并不是原子操作。正常来说,如果线程1通过get操作从Redis中得到的value是123,那么就会执行删除锁的操作,但假如在执行删除锁的动作之前,系统卡顿了几秒钟,恰好在这几秒钟内,key自动过期了,线程2就顺利获取到锁开始执行自己的逻辑了,此时,线程1卡顿恢复了,开始继续执行删除锁的动作,那么此时删除的还是线程2的锁。
4、最终优化版本:Lua脚本
针对上述Redis原始命令无法满足部分业务原子性操作的问题,Redis提供了Lua脚本的支持。Lua脚本是一种轻量小巧的脚本语言,它支持原子性操作,Redis会将整个Lua脚本作为一个整体执行,中间不会被其他请求插入,因此Redis执行Lua脚本是一个原子操作。
在上面的流程中,我们把get key value、判断value是否属于当前线程、删除锁这三步写到Lua脚本中,使它们变成一个整体交个Redis执行,改造后流程如下:
这样改造之后,就解决了释放锁时取值、判断值、删除锁等多个步骤无法保证原子操作的问题了。关于Lua脚本的语法可以自行学习,并不复杂,很简单,这里就不做过多讲述。
既然Lua脚本可以在释放锁时使用,那肯定也能在加锁时使用,而且一般情况下,推荐使用Lua脚本,因为在使用上面set key value ex seconds nx命令加锁时,并不能做到重入锁的效果,也就是当一个线程获取到锁后,在没有释放这把锁之前,当前线程自己也无法再获得这把锁,这显然会影响系统的性能。使用Lua脚本就可以解决这个问题,我们可以在Lua脚本中先判断锁(key)是否存在,如果存在则再判断持有这把锁的线程是否是当前线程,如果不是则加锁失败,否则当前线程再次持有这把锁,并把锁的重入次数+1。在释放锁时,也是先判断持有锁的线程是否是当前线程,如果是则将锁的重入次数-1,直至重入次数减至0,即可删除该锁(key)。
实际项目开发中,其实基本不用自己写上面这些分布式锁的实现逻辑,而是使用一些很成熟的第三方工具,当下比较流行的就是Redisson,它既提供了Redis的基本命令的封装,也提供了Redis分布式锁的封装,使用非常简单,只需直接调用相应方法即可。但工具虽然好用,底层原理还是要理解的,这就是本篇文章的目的。