Redis 分布式锁:一行命令抢锁,但你真的会释放吗?(附可运行源码)
作者:鱼宵 | 实战驱动系列 · 第 1 篇
完整可运行工程(pom.xml + 源码 + 运行说明)已开源在 Gitee:https://gitee.com/j67mk2/redis-journey
一、一个真实场景:3 台机器抢同一个库存
你的订单服务上了 3 台机器。用户下单,扣库存:
// 这段代码在 3 台机器上同时跑if(stock>0){stock=stock-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| 参数 | 作用 | 大白话 |
|---|---|---|
NX | Not eXists,key 不存在才设置成功 | 牌子是"有人"就抢不到 |
EX 10 | 10 秒自动过期 | 就算持锁者崩了,锁 10 秒后自动释放,不死锁 |
| 合一条 | 抢占+过期无中间态 | 翻牌子 + 装定时器是同一个动作 |
Java 里核心就三行:
SetParamsparams=newSetParams().nx().ex(expireSeconds);Stringresult=jedis.set(lockKey,requestId,params);return"OK".equals(result)?requestId:null;// 抢到返回唯一标识,没抢到返回 null三、value 为什么要存"唯一标识"?
防误删。场景:线程 A 拿锁,业务跑得太慢,10 秒到了锁自动过期;线程 B 抢到锁;A 终于跑完去解锁——如果它无脑DEL,删掉的是B 的锁!
所以解锁前必须确认:锁的 value 还是不是我当初拿到的 UUID?是我的我才删。
StringrequestId=UUID.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.jedis=jedis;}/** * 加锁。成功返回唯一标识 requestId(解锁时必须带上);失败返回 null */publicStringlock(StringlockKey,intexpireSeconds){StringrequestId=UUID.randomUUID().toString();// nx() = 不存在才设置;ex(秒) = 过期时间。合成一条原子命令SetParamsparams=newSetParams().nx().ex(expireSeconds);Stringresult=jedis.set(lockKey,requestId,params);return"OK".equals(result)?requestId:null;}/** 解锁脚本:判断 + 删除,一个原子操作 */privatestaticfinalStringUNLOCK_LUA="if redis.call('get', KEYS[1]) == ARGV[1] then "+" return redis.call('del', KEYS[1]) "+"else "+" return 0 "+"end";/** * 释放锁。 * @return true=成功删除了自己的锁;false=锁不是自己的(或已过期),没动它 */publicbooleanunlock(StringlockKey,StringrequestId){Objectresult=jedis.eval(UNLOCK_LUA,1,lockKey,requestId);returnresult!=null&&Long.parseLong(result.toString())>=1;}}五、完整演示:两个线程抢同一把锁
importredis.clients.jedis.Jedis;importjava.time.LocalTime;importjava.time.format.DateTimeFormatter;publicclassMain{privatestaticfinalStringHOST="127.0.0.1";privatestaticfinalintPORT=6383;// 本机 Docker 起的 redis:7 容器privatestaticfinalStringLOCK_KEY="lock:product:1001";privatestaticfinalintEXPIRE_SECONDS=10;privatestaticfinalDateTimeFormatterFMT=DateTimeFormatter.ofPattern("HH:mm:ss.SSS");publicstaticvoidmain(String[]args)throwsInterruptedException{try(Jedischeck=newJedis(HOST,PORT)){System.out.println("["+now()+"] 连接 Redis 成功,ping = "+check.ping());check.del(LOCK_KEY);// 清残留锁,保证结果干净}// ===== 线程 A:抢到锁,做 3 秒业务,再释放 =====ThreadthreadA=newThread(()->{Jedisjedis=newJedis(HOST,PORT);DistributedLocklock=newDistributedLock(jedis);StringrequestId=lock.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{booleanreleased=lock.unlock(LOCK_KEY,requestId);System.out.println("["+now()+"] 线程A:释放锁,结果 = "+released);jedis.close();}},"线程A");// ===== 线程 B:反复重试,直到 A 释放 =====ThreadthreadB=newThread(()->{sleepQuietly(200);// 稍晚启动,保证 A 先拿到锁Jedisjedis=newJedis(HOST,PORT);DistributedLocklock=newDistributedLock(jedis);StringrequestId=null;inttryCount=0;while(tryCount<20){tryCount++;requestId=lock.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(requestId==null){System.out.println("["+now()+"] 线程B:试了 20 次都没抢到,放弃");return;}sleepQuietly(500);booleanreleased=lock.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. 运行 Main(IDEA 直接右键运行,或命令行)mvn compile exec:java-Dexec.mainClass=Main实测输出(跑出来应该和这份一致)
[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)改成5000,B 会提前成功还是更晚?为什么? - 如果我把
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 篇,原则:实战驱动、由浅到深、面试向,每篇文章的结论都来自真实运行,完整可运行工程在 Gitee:
👉Redis 实战精通营(10 课):https://gitee.com/j67mk2/redis-journey
本课源码位置:lesson-05/(分布式锁 + 缓存一致性,跑完本文演示后可继续做缓存一致性实验,同样可运行)
后续系列(陆续发布):Dubbo / JVM / JUC / RocketMQ / MySQL / Elasticsearch,每门课配完整可运行工程 + 高频面试 30 问 + 简历包装话术。
下一篇:《缓存一致性:先更新数据库再删缓存,为什么?延迟双删又是干嘛的?》——同样有可运行源码,提前
git clone等着。