资讯动态

Redis 分布式锁:一行命令抢锁,但你真的会释放吗?(附可运行源码)

发布时间:2026/10/6 2:52:34 来源:尧图企业网站定制
Redis 分布式锁一行命令抢锁但你真的会释放吗附可运行源码作者鱼宵 实战驱动系列 · 第 1 篇完整可运行工程pom.xml 源码 运行说明已开源在 Giteehttps://gitee.com/j67mk2/redis-journey一、一个真实场景3 台机器抢同一个库存你的订单服务上了 3 台机器。用户下单扣库存// 这段代码在 3 台机器上同时跑if(stock0){stockstock-1;// 写回数据库……}单机时代synchronized就够了但它是JVM 级的锁——3 台 Tomcat 就是 3 个 JVM互相管不着。库存剩 1 件可能卖出 3 单。类比多人共用一个公共卫生间门后挂有人/没人的牌子。Redis 就是那块公共牌子。锁要放到所有机器都能看到、都认账的地方 →Redis 分布式锁。二、加锁为什么必须是SET key value NX EX一条命令先看错误写法SETNX lockKey 1 # 第一步抢占 EXPIRE lockKey 10 # 第二步设过期问题两步之间进程一旦崩了刚抢到锁、还没设过期就被 kill这把锁永远没有过期时间 死锁。正确写法——不存在才设置和设过期合成一条原子命令SET lock:product:1001 唯一标识 NX EX 10参数作用大白话NXNot eXistskey 不存在才设置成功牌子是有人就抢不到EX 1010 秒自动过期就算持锁者崩了锁 10 秒后自动释放不死锁合一条抢占过期无中间态翻牌子 装定时器是同一个动作Java 里核心就三行SetParamsparamsnewSetParams().nx().ex(expireSeconds);Stringresultjedis.set(lockKey,requestId,params);returnOK.equals(result)?requestId:null;// 抢到返回唯一标识没抢到返回 null三、value 为什么要存唯一标识防误删。场景线程 A 拿锁业务跑得太慢10 秒到了锁自动过期线程 B 抢到锁A 终于跑完去解锁——如果它无脑DEL删掉的是B 的锁所以解锁前必须确认锁的 value 还是不是我当初拿到的 UUID是我的我才删。StringrequestIdUUID.randomUUID().toString();// 每个持锁者一个专属编号// 解锁时get 出来的 value 等于我的 requestId 才允许 del四、解锁为什么必须用 Lua 脚本错误写法先判断、再删除两条命令if get(lockKey) 我的UUID: # 第一步判断 del(lockKey) # 第二步删除窗口期判断完是我的正要del的瞬间锁刚好到期自动释放、B 抢到了锁 → 你又把 B 的锁删了。Lua 把判断 删除焊死成一个原子操作Redis 执行整段脚本期间不插队任何命令ifredis.call(get,KEYS[1])ARGV[1]thenreturnredis.call(del,KEYS[1])-- 是我的才删elsereturn0-- 不是我的绝不动手end至此三板斧齐了完整工具类importredis.clients.jedis.Jedis;importredis.clients.jedis.params.SetParams;importjava.util.UUID;/** * Redis 分布式锁完整版可直接运行 * 三板斧SET NX EX 原子加锁 value 存唯一标识 Lua 原子解锁 */publicclassDistributedLock{privatefinalJedisjedis;publicDistributedLock(Jedisjedis){this.jedisjedis;}/** * 加锁。成功返回唯一标识 requestId解锁时必须带上失败返回 null */publicStringlock(StringlockKey,intexpireSeconds){StringrequestIdUUID.randomUUID().toString();// nx() 不存在才设置ex(秒) 过期时间。合成一条原子命令SetParamsparamsnewSetParams().nx().ex(expireSeconds);Stringresultjedis.set(lockKey,requestId,params);returnOK.equals(result)?requestId:null;}/** 解锁脚本判断 删除一个原子操作 */privatestaticfinalStringUNLOCK_LUAif redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end;/** * 释放锁。 * return true成功删除了自己的锁false锁不是自己的或已过期没动它 */publicbooleanunlock(StringlockKey,StringrequestId){Objectresultjedis.eval(UNLOCK_LUA,1,lockKey,requestId);returnresult!nullLong.parseLong(result.toString())1;}}五、完整演示两个线程抢同一把锁importredis.clients.jedis.Jedis;importjava.time.LocalTime;importjava.time.format.DateTimeFormatter;publicclassMain{privatestaticfinalStringHOST127.0.0.1;privatestaticfinalintPORT6383;// 本机 Docker 起的 redis:7 容器privatestaticfinalStringLOCK_KEYlock:product:1001;privatestaticfinalintEXPIRE_SECONDS10;privatestaticfinalDateTimeFormatterFMTDateTimeFormatter.ofPattern(HH:mm:ss.SSS);publicstaticvoidmain(String[]args)throwsInterruptedException{try(JedischecknewJedis(HOST,PORT)){System.out.println([now()] 连接 Redis 成功ping check.ping());check.del(LOCK_KEY);// 清残留锁保证结果干净}// 线程 A抢到锁做 3 秒业务再释放 ThreadthreadAnewThread(()-{JedisjedisnewJedis(HOST,PORT);DistributedLocklocknewDistributedLock(jedis);StringrequestIdlock.lock(LOCK_KEY,EXPIRE_SECONDS);System.out.println([now()] 线程A抢到锁成功requestId requestId);try{System.out.println([now()] 线程A拿着锁做业务中模拟 3 秒...);Thread.sleep(3000);System.out.println([now()] 线程A业务做完了);}catch(InterruptedExceptione){Thread.currentThread().interrupt();}finally{booleanreleasedlock.unlock(LOCK_KEY,requestId);System.out.println([now()] 线程A释放锁结果 released);jedis.close();}},线程A);// 线程 B反复重试直到 A 释放 ThreadthreadBnewThread(()-{sleepQuietly(200);// 稍晚启动保证 A 先拿到锁JedisjedisnewJedis(HOST,PORT);DistributedLocklocknewDistributedLock(jedis);StringrequestIdnull;inttryCount0;while(tryCount20){tryCount;requestIdlock.lock(LOCK_KEY,EXPIRE_SECONDS);if(requestId!null){System.out.println([now()] 线程B第 tryCount 次尝试 → 抢到锁了requestId requestId);break;}else{System.out.println([now()] 线程B第 tryCount 次尝试 → 抢锁失败0.5s 后重试);sleepQuietly(500);}}if(requestIdnull){System.out.println([now()] 线程B试了 20 次都没抢到放弃);return;}sleepQuietly(500);booleanreleasedlock.unlock(LOCK_KEY,requestId);System.out.println([now()] 线程B用完锁释放锁结果 released);jedis.close();},线程B);threadA.start();threadB.start();threadA.join();threadB.join();System.out.println([now()] 演示结束。);}privatestaticStringnow(){returnLocalTime.now().format(FMT);}privatestaticvoidsleepQuietly(longms){try{Thread.sleep(ms);}catch(InterruptedExceptione){Thread.currentThread().interrupt();}}}怎么跑# 1. 启动 Redis任选一种dockerrun-d--nameredis-lock-p6383:6379 redis:7# 2. 运行 MainIDEA 直接右键运行或命令行mvn compile exec:java-Dexec.mainClassMain实测输出跑出来应该和这份一致[15:51:04.861] 连接 Redis 成功ping PONG [15:51:04.924] 线程A抢到锁成功requestId eae4577b-b541-48f0-b446-80fb57d4db0a [15:51:04.924] 线程A拿着锁做业务中模拟 3 秒... [15:51:05.092] 线程B第 1 次尝试 → 抢锁失败0.5s 后重试 [15:51:05.595] 线程B第 2 次尝试 → 抢锁失败0.5s 后重试 [15:51:06.098] 线程B第 3 次尝试 → 抢锁失败0.5s 后重试 [15:51:06.599] 线程B第 4 次尝试 → 抢锁失败0.5s 后重试 [15:51:07.112] 线程B第 5 次尝试 → 抢锁失败0.5s 后重试 [15:51:07.615] 线程B第 6 次尝试 → 抢锁失败0.5s 后重试 [15:51:07.930] 线程A业务做完了 [15:51:07.931] 线程A释放锁结果 true [15:51:08.121] 线程B第 7 次尝试 → 抢到锁了 [15:51:08.637] 线程B用完锁释放锁结果 true [15:51:08.637] 演示结束。请盯着这份输出问自己 3 个问题为什么 B 前面6 次都失败、偏偏第 7 次成功答案藏在时间线里如果我把Thread.sleep(3000)改成5000B 会提前成功还是更晚为什么如果我把EX 10改成EX 0.5过期 0.5 秒会发生什么恐怖的事提示误删——亲手跑一次你会永远记住 Lua 那 5 行代码为什么存在跑出来的输出和我这份不一样把环境JDK 版本 / Redis 版本 / Docker发评论区我们一起查。六、跑完之后的加分题生产环境三件事1. 锁的过期时间必须 业务最长耗时否则业务没跑完锁先释放等于没锁。2. Redisson 看门狗watchdog生产环境别手写直接用 Redisson。锁快到期自动帮你续租直到你主动释放。一句话防业务还没跑完锁先被别人抢走。3. 可重入同一个线程已持锁再进一个也要加锁的方法value 里记计数器进来 1、出去 -1减到 0 才真释放。七、面试回答模板背下来面试官Redis 分布式锁怎么实现三板斧① 加锁用SET key 唯一标识 NX EX 秒数一条原子命令NX 保证不存在才设置、EX 保证自动过期杜绝死锁② value 存持锁者唯一标识防止业务超时锁自动过期后误删新持有者的锁③ 解锁用 Lua 脚本把判断 value 删除合成原子操作消除判断到删除之间的竞态窗口。生产环境直接用 Redisson看门狗自动续租、支持可重入。八、总结三板斧解决什么问题不这么做的后果SET key value NX EX抢占 过期原子化分两条命令 死锁value 存唯一标识防误删别人的锁无脑 DEL 误删Lua 判断 删除消除竞态窗口get 再 del 又误删九、关于这个系列本文是「Java 后端实战精通营」系列第 1 篇原则实战驱动、由浅到深、面试向每篇文章的结论都来自真实运行完整可运行工程在 GiteeRedis 实战精通营10 课https://gitee.com/j67mk2/redis-journey本课源码位置lesson-05/分布式锁 缓存一致性跑完本文演示后可继续做缓存一致性实验同样可运行后续系列陆续发布Dubbo / JVM / JUC / RocketMQ / MySQL / Elasticsearch每门课配完整可运行工程 高频面试 30 问 简历包装话术。下一篇《缓存一致性先更新数据库再删缓存为什么延迟双删又是干嘛的》——同样有可运行源码提前git clone等着。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑