Redis 分布式锁实现流程

张开发
2026/5/22 7:24:56 15 分钟阅读
Redis 分布式锁实现流程
Redis 分布式锁初级实现的问题与分析问题userId.toString().intern()是在同一个jvm中的常量池中同一个对象获取的锁才是相同的。如果是在 集群环境下由于部署了多个tomcat每个tomcat都有一个属于自己的jvm同一个用户在不同jvm获取的锁也就是不同的了。集群环境下的并发问题通过加锁synchronized可以解决在单机情况下的一人一单安全问题但是在集群模式下就不行了。1. 代码初步实现加锁逻辑 (tryLock)利用 Redis 的setnx(setIfAbsent) 实现互斥。OverridepublicbooleantryLock(longtimeoutSec){// 获取线程标识longthreadIdThread.currentThread().getId();// set lock threadId nx ex timeoutSec// nx: 互斥 (不存在才设置)// ex: 设置超时时间 (防止死锁)BooleansuccessstringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIXname,threadId,timeoutSec,TimeUnit.SECONDS);// 防止拆箱空指针异常returnBoolean.TRUE.equals(success);}释放锁逻辑 (unlock)直接删除 Redis 中的 key。Overridepublicvoidunlock(){stringRedisTemplate.delete(KEY_PREFIXname);}调用传入用户主键做key//创建锁对象SimpleRedisLocklocknewSimpleRedisLock(stringRedisTemplate,order:userId);booleanisLocklock.tryLock(120);//超时自动释放if(!isLock){//获取锁失败 返回错误或重试returnResult.fail(一个人只允许下一单);}//获取成功try{//事务获取代理对象IVoucherOrderServiceproxy(IVoucherOrderService)AopContext.currentProxy();//创建订单一人一单returnproxy.creatVoucherOrder(voucherId);}finally{lock.unlock();}2. 当前代码引发的三大核心问题问题一锁超时释放业务未执行完现象线程 1 获取锁业务执行时间150s超过了锁的超时时间120s。后果Redis 自动释放锁此时线程 1 的业务还在跑锁已经没了。问题二并发安全被破坏现象线程 1 锁超时被自动释放。线程 2 趁机获取锁开始执行业务。此时线程 1 和线程 2 同时在操作同一资源。后果一人一单规则失效可能产生数据错乱或超卖。问题三误删别人的锁最严重现象线程 1 业务执行完毕。线程 1 执行unlock()。但此时锁已经被线程 2 持有了。后果线程 1 删掉了线程 2 的锁。线程 3 进来再次获取锁并发问题雪上加霜。3. 问题根源分析锁超时与业务时间不匹配无法准确预估业务执行时间固定超时时间难以应对所有情况。无「锁归属校验」unlock()时只管删除 key不判断这把锁是不是自己加的极易误删。缺「锁续期」机制业务没跑完锁不会自动续命。4. 解决方案方案一改进释放锁增加归属判断在删除锁之前判断 Redis 中的 value 是否为当前线程 ID。publicvoidunlock(){StringkeyKEY_PREFIXname;StringcurrentValuestringRedisTemplate.opsForValue().get(key);StringthreadIdThread.currentThread().getId();// 判断锁是不是自己的if(threadId.equals(currentValue)){stringRedisTemplate.delete(key);}}⚠️注意虽然解决了误删但判断和删除是分两步执行的非原子操作在高并发下仍有极小概率出错。方案二引入锁续期看门狗机制原理开启一个后台线程守护线程定期如每 10 秒检查业务是否还在执行。动作如果业务未结束自动重置锁的过期时间。效果保证业务执行完之前锁永远不会过期。方案三设置合理的超时时间根据业务平均耗时设置较长的超时时间如 30s但这只是缓解不能彻底解决问题。5. 一句话总结当前初级实现会导致「锁提前过期 → 多线程并发 → 误删他人锁」的连锁反应直接破坏分布式锁的互斥性。必须引入标识校验和锁续期机制才能在生产环境安全使用。 为什么不用threadId而推荐用 UUID核心原因threadId在分布式场景下存在重复风险无法保证全局唯一而 UUID 可以做到绝对唯一。Redis 是单线程执行命令的绝对串行redis锁的 key 是 order: userId也就是说同一个用户 ID全局共用 同一把 Redis 锁不同用户 ID用 不同的锁互不干扰这解决了集群环境下一个用户超卖的情况。1. 单机 vs 分布式的差异单机环境同一时刻JVM 内的threadId是唯一的不会重复。分布式环境多实例/多机器不同 JVM 进程、不同机器上的线程threadId完全可能重复。比如机器A的线程ID是1001机器B的线程ID也可能是1001。unLock():StringkeyKEY_PREFIXname(就是userId);StringcurrentValuestringRedisTemplate.opsForValue().get(key);StringthreadIdThread.currentThread().getId();// 判断锁是不是自己的if(threadId.equals(currentValue)){stringRedisTemplate.delete(key);}→ 这就会导致不同机器的不同线程却持有相同的threadId锁归属判断直接失效。2. 你提到的那句话的影响“但当一个线程终止后JVM 可能会在未来将它的 ID 分配给一个新的线程。”这句话是单机层面的风险线程A结束后JVM 可能把threadId1001回收再分配给新线程B。如果锁还没过期线程B就会误以为自己持有线程A的锁导致误删。结合分布式场景后问题更严重单机内threadId会复用多机间threadId会重复→ 最终导致锁归属判断完全不可靠。3. UUID 为什么更安全全局唯一UUID 是基于时间、机器、随机数生成的在分布式环境下几乎不可能重复。线程实例唯一可以用UUID threadId组合进一步保证“某个实例的某个线程”的绝对唯一标识。避免复用风险UUID 不会被 JVM 回收复用彻底解决线程ID复用导致的误判问题。4. 代码层面的对比❌ 旧版用 threadId有风险longthreadIdThread.currentThread().getId();BooleansuccessstringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIXname,threadId,timeoutSec,TimeUnit.SECONDS);分布式下threadId可能重复单机下threadId可能被复用✅ 推荐版用 UUID安全// 生成全局唯一的线程标识StringthreadIdUUID.randomUUID().toString();BooleansuccessstringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIXname,threadId,timeoutSec,TimeUnit.SECONDS);分布式环境下绝对唯一不会被 JVM 复用锁归属判断 100% 可靠publicinterfaceILock{/** * 尝试获取锁 * param timeoutSec 锁持有的超时时间, 过期后自动释放 * return true代表获取锁成功;false代表获取锁失败 */booleantryLock(longtimeoutSec);/** * 释放锁,解决了锁误删 */voidunlock();}这里改进了用UUID threadId做redis的value值确保集群环境中不会unlock别人的锁。publicclassSimpleRedisLockimplementsILock{privateStringRedisTemplatestringRedisTemplate;privatestaticfinalStringKEY_PREFIXlock:;privatestaticfinalStringID_PREFIXUUID.randomUUID().toString(true)-;privateStringname;publicSimpleRedisLock(StringRedisTemplatestringRedisTemplate,Stringname){this.stringRedisTemplatestringRedisTemplate;this.namename;}OverridepublicbooleantryLock(longtimeoutSec){//获取线程标识: UUID 线程IdStringthreadIdID_PREFIXThread.currentThread().getId();//set lock thread1 nx ex 10 (nx是互斥ex是设置超时时间)//这里redis存的value 一石二鸟 解决了释放锁时判断是不是自己所属的问题。BooleansuccessstringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIXname,threadId,timeoutSec,TimeUnit.SECONDS);//不用判断获取锁是否成功// 但返回包装类自动拆箱有风险若是null,得返回false啊returnBoolean.TRUE.equals(success);}Overridepublicvoidunlock(){//获取线程标识StringthreadIdThread.currentThread().getId()ID_PREFIX;//获取锁中的标识StringthreadValuestringRedisTemplate.opsForValue().get(KEY_PREFIXthreadId);//一致才释放锁if(threadId.equals(threadValue)){stringRedisTemplate.delete(KEY_PREFIXname);}}}其实这里还是存在问题查锁的归属、判断 和 释放锁这三者不能同步进行也就是无法保证原子性这里先使用lua脚本实现Lua脚本解决多条命令原子性问题Redis提供了Lua脚本功能在一个脚本中编写多条Redis命令确保多条命令执行时的原子性。Lua是一种编程语言它的基本语法大家可以参考网站https://www.runoob.com/lua/lua-tutorial.html这里重点介绍Redis提供的调用函数我们可以使用lua去操作redis又能保证他的原子性这样就可以实现拿锁比锁删锁是一个原子性动作了作为Java程序员这一块并不作一个简单要求并不需要大家过于精通只需要知道他有什么作用即可。这里重点介绍Redis提供的调用函数语法如下redis.call(命令名称,key,其它参数,...)例如我们要执行set name jack则脚本是这样#执行 set name jack redis.call(set,name,jack)例如我们要先执行set name Rose再执行get name则脚本如下#先执行 set name jack redis.call(set,name,Rose)#再执行 get namelocalnameredis.call(get,name)#返回returnname写好脚本以后需要用Redis命令来调用脚本调用脚本的常见命令如下利用Java代码调用Lua脚本改造分布式锁1. 核心改进Lua脚本保证原子性1.1 问题回顾在前面的迭代中我们通过“获取锁中的线程标示 → 判断是否一致 → 删除锁”的逻辑解决了误删锁的问题。但是这个逻辑在多线程并发场景下由于判断和删除是两个独立的动作缺乏原子性保护仍存在安全隐患。1.2 解决方案Lua脚本Redis提供了Lua脚本功能能够确保脚本内的多条命令作为一个原子操作执行中间不会被其他命令插入。Lua脚本逻辑 (unlock.lua)-- KEYS[1]: 锁的key-- ARGV[1]: 当前线程的唯一标示-- 1. 获取锁中的标示if(redis.call(GET,KEYS[1])ARGV[1])then-- 2. 如果标示一致则删除锁returnredis.call(DEL,KEYS[1])end-- 3. 如果标示不一致直接返回0表示删除失败return0Java代码调用实现privatestaticfinalDefaultRedisScriptLongUNLOCK_SCRIPT;static{UNLOCK_SCRIPTnewDefaultRedisScript();// 加载类路径下的lua脚本UNLOCK_SCRIPT.setLocation(newClassPathResource(unlock.lua));// 设置返回值类型UNLOCK_SCRIPT.setResultType(Long.class);}publicvoidunlock(){// 执行脚本传入key和线程标示stringRedisTemplate.execute(UNLOCK_SCRIPT,Collections.singletonList(KEY_PREFIXname),// 锁的KeyID_PREFIXThread.currentThread().getId());// 线程标示}1.3 改进效果通过Lua脚本我们将“拿锁、比锁、删锁”三个动作合并为一个不可分割的原子性动作彻底解决了并发情况下的误删问题。2. Redis分布式锁实现原理总结基于目前的迭代我们已经形成了一套较为完善的Redis分布式锁方案。2.1 实现思路获取锁利用SET NX EX命令。NX互斥性只有Key不存在时才能设置成功。EX设置过期时间防止服务宕机导致死锁。同时存储线程唯一标示用于后续判断锁的归属。释放锁利用 Lua 脚本。先判断锁的标示是否属于当前线程。若属于则删除若不属于则不做操作。2.2 特性分析互斥性通过SET NX保证同一时刻只有一个线程能获取锁。安全性通过EX过期时间避免死锁服务宕机锁会自动释放。通过 Lua 脚本解决并发误删问题保证原子性。高可用与高并发依赖于 Redis 集群架构。3. 遗留问题锁超时锁不住3.1 问题场景虽然解决了误删和原子性问题但仍存在**“锁不住”**的情况假设锁的过期时间设置为 10秒。线程A获取锁后业务逻辑执行耗时超过 10秒。锁因过期自动释放。线程B趁虚而入获取锁。此时线程A和B同时持有锁实际上A的锁已过期B持有新锁导致并发安全问题。3.2 解决思路锁续期核心思想类似于“网吧续费”当线程发现业务还没执行完但锁快过期了就主动给锁延长过期时间。例如业务没结束续期 30s。3.3 后续方案手动实现续期机制较为复杂需要开启守护线程定时检测。在下一阶段将引入Redisson框架它提供了完善的“看门狗Watchdog”机制自动解决锁续期问题。4. 测试逻辑验证针对当前的Lua脚本方案测试验证流程如下场景模拟线程1 获取锁。手动删除锁或等待锁超时模拟锁失效。线程2 通过 Lua 脚本抢锁并获取成功。原子性验证线程1 尝试释放锁执行 Lua 脚本。Lua 脚本会判断线程标示不一致拒绝删除返回 0。结果线程1无法删除线程2的锁。并发安全性线程2 释放锁时Lua 脚本判断标示一致成功删除。结论Lua 脚本完美解决了原子性问题杜绝了误删别人的锁。

更多文章