【Redis实战篇】基于Redis的分布式锁的原理及实现

【Redis实战篇】基于Redis的分布式锁的原理及实现 温馨提示建议在PC端浏览~分布式锁过程分析分布式锁满足分布式系统或集群模式下多进程可见并且互斥的锁。分布式锁的特点分布式锁的实现分布式锁的核心是实现多进程之间互斥而满足这一点的方式有很多常见的有三种基于Redis的分布式锁实现分布式锁时需要实现的两个基本方法1、获取锁互斥确保只能有一个线程获取锁非阻塞尝试一次成功返回true失败返回false但是上图将获取锁与给锁设置过期时间分成了两步操作这样还是存在获取锁后无法释放的隐患。例如当执行第一条setnx语句成功获取锁后Redis服务器刚好宕机了此时还未来得及给锁设置过期时间锁就一直无法被释放。为了解决这个问题我们可以将获取锁和给锁设置过期时间合并成一步操作2、释放锁手动释放超时释放获取锁时添加一个超时时间流程梳理基于Redis实现分布式锁初级版本需求定义一个类实现下面接口利用Redis实现分布式锁功能。代码示例//ILockpublicinterfaceILock{/** * 尝试获取锁 * param timeoutSec 锁的过期时间 * return */booleantryLock(LongtimeoutSec);/** * 释放锁 */voidunLock();}//RedisSimpleLockpublicclassRedisSimpleLockimplementsILock{privatefinalStringRedisTemplatestringRedisTemplate;privatefinalStringname;privatestaticfinalStringKEY_PREFIXlock:;publicRedisSimpleLock(StringRedisTemplatestringRedisTemplate,Stringname){this.stringRedisTemplatestringRedisTemplate;this.namename;}/** * 尝试获取锁 * param timeoutSec 锁的过期时间 * return */OverridepublicbooleantryLock(LongtimeoutSec){//获取线程标识longthreadIdThread.currentThread().getId();BooleansuccessstringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIXname,String.valueOf(threadId),timeoutSec,TimeUnit.SECONDS);//防止自动拆箱时发生空指针异常returnBoolean.TRUE.equals(success);}/** * 释放锁 */OverridepublicvoidunLock(){stringRedisTemplate.delete(KEY_PREFIXname);}}分布式锁初级版本存在的并发问题锁超时释放导致误删并发问题说明线程1成功获取锁后去执行业务但是在执行业务的过程中因为某些原因导致业务阻塞直到锁超时被自动释放这时候线程2来了并成功获取到锁所以线程2就去执行自己的业务了这时候线程1又醒来并执行完业务然后直接去释放锁就导致线程2的锁被释放了其他线程过来也可以成功获取锁这就造成了并发问题。如下图所示并发问题解决办法并发问题产生的根本原因是线程1误删了线程2的锁所以我们可以在释放锁之前根据线程标识判断一下这个锁是不是当前线程的是的话才能释放锁。如下图所示流程图改进Redis的分布式锁需求修改之前的分布式锁实现满足1、在获取锁时存入线程标示可以用“UUID-线程ID”表示2、在释放锁时先获取锁中的线程标示判断是否与当前线程标示一致如果一致则释放锁如果不一致则不释放锁代码示例publicclassRedisSimpleLockimplementsILock{privatefinalStringRedisTemplatestringRedisTemplate;privatefinalStringname;privatestaticfinalStringKEY_PREFIXlock:;privatestaticfinalStringID_PREFIXUUID.randomUUID().toString(true)-;publicRedisSimpleLock(StringRedisTemplatestringRedisTemplate,Stringname){this.stringRedisTemplatestringRedisTemplate;this.namename;}/** * 尝试获取锁 * param timeoutSec 锁的过期时间 * return */OverridepublicbooleantryLock(LongtimeoutSec){//获取线程标识StringthreadIdID_PREFIXThread.currentThread().getId();BooleansuccessstringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIXname,threadId,timeoutSec,TimeUnit.SECONDS);//防止自动拆箱时发生空指针异常returnBoolean.TRUE.equals(success);}/** * 释放锁 */OverridepublicvoidunLock(){//获取线程标识StringthreadIdID_PREFIXThread.currentThread().getId();//获取锁中的标识StringlockIdstringRedisTemplate.opsForValue().get(KEY_PREFIXname);//判断标识是否一致if(threadId.equals(lockId)){//标识一致释放锁stringRedisTemplate.delete(KEY_PREFIXname);}}}改进后的Redis分布式锁存在的问题如下图所示极端情况下线程1在执行业务直至判断线程标识与锁中标识是否一致都未发生阻塞但是在释放锁时却发生了阻塞虽然这中间没有代码要执行但是JVM本身的垃圾回收机制或者其他情况也会导致线程阻塞在阻塞的这段时间内线程1的锁超时失效了这时候线程2过来并成功获取锁然后去执行它的业务但在线程2执行业务的期间线程1醒过来并继续执行释放锁的操作仍旧导致了锁的误删问题。造成这个问题的原因是因为判断锁和释放锁是分开的两个操作在这两个操作之间就很容易发生阻塞从而产生并发问题。所以我们要想解决这个问题就要确保判断锁和释放锁成一个原子性的操作一起执行不能出现间隔。Redis的Lua脚本Redis提供了Lua脚本功能在一个脚本中编写多条Redis命令确保多条命令执行时的原子性。Lua是一种编程语言它的基本语法可以参考网站https://www.runoob.com/lua/lua-tutorial.html这里重点介绍Redis提供的调用函数语法如下-- 执行redis命令redis.call(命令名称,key,其他参数,...)-- 例如我们要执行set name jack则脚本是这样redis.call(set,name,jack)-- 例如我们要先执行set name Rose再执行get name最后返回name则脚本如下redis.call(set,name,Rose)localnameredis.call(get,name)returnname写好脚本以后需要用Redis命令来调用脚本调用脚本的常见命令如下例如我们要执行redis.call(‘set’, ‘name’, ‘jack’)这个脚本语法如下如果脚本中的key、value不想写死可以作为参数传递。key类型参数会放入KEYS数组其它参数会放入ARGV数组在脚本中可以从KEYS和ARGV数组获取这些参数注意Lua语言中数组的索引从1开始。释放锁的业务流程是这样的1、获取锁中的线程标示2、判断是否与指定的标示当前线程标示一致3、如果一致则释放锁删除4、如果不一致则什么都不做如果用Lua脚本来表示则是这样的-- 判断锁中的线程标识是否与当前线程标识一致if(redis.call(GET,KEYS[1])ARGV[1])then-- 标识一致释放锁returnredis.call(DEL,KEYS[1])end-- 不一致则直接返回return0再次改进Redis的分布式锁需求基于Lua脚本实现分布式锁的释放锁逻辑。提示RedisTemplate调用Lua脚本的API如下基于Lua脚本改进Redis的分布式锁的步骤1、在resource下新建Lua文件并写入之前的Lua脚本。注意创建Lua脚本需要先下载EmmyLua插件。2、在RedisSimpleLock的unlock方法中通过调用StringRedisTemplate的execute方法执行Lua脚本//RedisSimpleLockprivatestaticfinalDefaultRedisScriptLongunlockScript;// 初始化lua脚本静态代码块只会在类加载时执行一次static{unlockScriptnewDefaultRedisScript();unlockScript.setLocation(newClassPathResource(unlock.lua));unlockScript.setResultType(Long.class);}/** * 释放锁 */OverridepublicvoidunLock(){//调用Lua脚本stringRedisTemplate.execute(unlockScript,Collections.singletonList(KEY_PREFIXname),ID_PREFIXThread.currentThread().getId());}小结基于Redis的分布式锁实现思路利用set nx ex获取锁并设置过期时间保存线程标示。释放锁时先判断线程标示是否与自己一致一致则删除锁。特性利用set nx满足互斥性。利用set ex保证故障时锁依然能释放避免死锁提高安全性。利用Redis集群保证高可用和高并发特性。基于Redis的分布式锁优化Redisson基于setnx实现的分布式锁存在下面的问题1、不可重入同一个线程无法多次获取同一把锁。举例假如方法A获取了一把锁然后去调用方法B但是方法B也需要获取同一把锁才能继续执行由于基于setnx实现的分布式锁的不可重入性就导致B等待锁释放后获得锁才能继续执行而A又需要等待B执行完才能继续执行从而释放锁这就形成了A和B互相等待的局面造成死锁问题。2、不可重试获取锁只尝试一次就返回false没有重试机制。3、超时释放锁超时释放虽然可以避免死锁但如果是业务执行耗时较长也会导致锁释放存在安全隐患。4、主从一致性如果Redis提供了主从集群主从同步存在延迟当主宕机时如果从并同步主中的锁数据则会出现锁实现。RedissonRedisson是一个在Redis的基础上实现的Java驻内存数据网格In-Memory Data Grid。它不仅提供了一系列的分布式的Java常用对象还提供了许多分布式服务其中就包含了各种分布式锁的实现。官网地址https://redisson.orgGitHub地址https://github.com/redisson/redissonRedisson入门1、引入依赖dependencygroupIdorg.redisson/groupIdartifactIdredisson/artifactIdversion3.13.6/version/dependency2、配置Redisson客户端ConfigurationpublicclassRedisConfig{BeanpublicRedissonClientredissonClient(){// 配置类ConfigconfignewConfig();// 添加redis地址这里添加了单节点的地址也可以使用config.useClusterServers()添加集群地址config.useSingleServer().setAddress(redis://192.168.150.101:6379).setPassword(123321);// 创建客户端returnRedisson.create(config);}}3、使用Redisson的分布式锁ResourceprivateRedissonClientredissonClient;TestvoidtestRedisson()throwsInterruptedException{// 获取锁可重入指定锁的名称RLocklockredissonClient.getLock(anyLock);// 尝试获取锁参数分别是获取锁的最大等待时间期间会重试锁自动释放时间时间单位booleanisLocklock.tryLock(1,10,TimeUnit.SECONDS);// 判断释放获取成功if(isLock){try{System.out.println(执行业务);}finally{// 释放锁lock.unlock();}}}Redisson可重入锁原理之前我们自己设计的分布式锁使用的是String类型来记录锁其value为线程标识。但是为了实现锁的可重入性我们不仅要记录线程的标识还要记录重入次数所以最终选择使用Hash结构来记录锁的线程标识和重入次数。Hash结构中value的field记录线程标识、value记录重入次数如下图可重入锁实现原理的流程图补充为了确保获取锁与释放锁操作的原子性需要使用Lua脚本来执行上图这些步骤。获取锁的Lua脚本localkeyKEYS[1];-- 锁的keylocalthreadIdARGV[1];-- 线程唯一标识localreleaseTimeARGV[2];-- 锁的自动释放时间-- 判断是否存在if(redis.call(exists,key)0)then-- 不存在获取锁redis.call(hset,key,threadId,1);-- 设置有效期redis.call(expire,key,releaseTime);return1;-- 返回结果end;-- 锁已经存在判断threadId是否是自己if(redis.call(hexists,key,threadId)1)then-- threadId是自己获取锁重入次数1redis.call(hincrby,key,threadId,1);-- 重置有效期redis.call(expire,key,releaseTime);return1;-- 返回结果end;return0;-- 代码走到这里,说明获取锁的不是自己获取锁失败释放锁的Lua脚本localkeyKEYS[1];-- 锁的keylocalthreadIdARGV[1];-- 线程唯一标识localreleaseTimeARGV[2];-- 锁的自动释放时间-- 判断当前锁是否还是被自己持有if(redis.call(HEXISTS,key,threadId)0)thenreturnnil;-- 如果已经不是自己则直接返回end;-- 是自己的锁则重入次数-1localcountredis.call(HINCRBY,key,threadId,-1);-- 判断是否重入次数是否已经为0if(count0)then-- 大于0说明不能释放锁重置有效期然后返回redis.call(EXPIRE,key,releaseTime);returnnil;else-- 等于0说明可以释放锁直接删除redis.call(DEL,key);returnnil;end;Redisson分布式锁原理小结Redisson分布式锁原理可重入利用hash结构记录线程id和重入次数。可重试利用信号量和PubSub功能实现等待、唤醒获取锁失败的重试机制。超时续约利用watchDog每隔一段时间releaseTime/3重置超时时间。Redisson分布式锁主从一致性问题multiLock问题产生原因分析一开始Java应用向Master发起一个请求并成功获取锁但是在Master的数据还未同步至Slave时Master宕机了这时Redis的哨兵会从Slave中选取一个当作新的Master但是由于之前的Master数据未能同步所以之前的锁就失效了此时若有其他线程来获取锁也是可以成功的就出现了并发安全问题。问题解决办法我们可以用多个独立的Redis节点构建联合锁以下图中3个独立的Redis节点为例。当Java应用发起请求获取锁时需要从三个独立的Redis节点都获取一把锁组成联合锁只有当三个Redis节点的锁都获取成功才算成功获取锁成功获取锁后只要还有一个节点是在正常运行的那么这个锁就是生效的。就算其中一个Redis节点宕机且未来得及同步数据其他线程想要乘虚而入也只能成功获取到新节点的这一把锁而无法成功获取联合锁。这样就不会产生并发安全问题且由于这三个Redis节点都是相互独立的也就没有主从一致性问题。小结不可重入Redis分布式锁原理利用setnx的互斥性利用ex避免死锁释放锁时判断线程标示。缺陷不可重入、无法重试、锁超时失效。可重入的Redis分布式锁原理利用hash结构记录线程标示和重入次数利用watchDog延续锁时间利用信号量控制锁重试等待。缺陷redis宕机引起锁失效问题。Redisson的multiLock原理多个独立的Redis节点必须在所有节点都获取重入锁才算获取锁成功。缺陷运维成本高、实现复杂。