05-分布式锁:从 SETNX 到 Redlock 的可靠性边界
分布式锁:从 SETNX 到 Redlock 的可靠性边界
一、切换窗口里的那一次超卖
第 04 篇结尾说:高可用解决了机器故障,但主从切换是有窗口的,窗口里的并发写依然有风险。这句话在我们这里兑现成了一次超卖。
秒杀开抢前一分钟,主库所在机器故障,哨兵完成切换。故障前那笔扣减已经在新主上成功,但另一笔请求在切换瞬间打到了还没被降级的旧主——旧主此刻仍认为自己是主,照常执行。两笔扣减各自"成功",库存被多扣了两件。事后再查,两个客户端都"持有锁",都认为自己独占。
这就是本篇要反复回到的那个事实:锁的正确性,最终取决于锁存在哪里、以及那把锁在故障时还在不在。
先说清楚锁在这套系统里的两种用法,术语沿用前几篇:缓存击穿时抢重建权叫互斥重建(第 02 篇);库存扣减、订单状态流转这类"必须串行"的场景用的是同一把锁,但要求完全不同——前者丢锁最多多查一次库,后者丢锁就是超卖。
二、从SET NX到看门狗
2.1 单机的锁为什么不够
synchronized和ReentrantLock的互斥范围是一个 JVM 内的对象。服务一上两副本,两个 JVM 各有一把锁,互不认识;容器重启后锁状态直接消失。锁必须放到所有实例都能看到的地方,于是落到 Redis 上。
2.2 最小正确实现:一条命令,三个要素
SET lock:stock:1001 <token> NX PX 30000NX:不存在才写,等价于"抢锁";PX 30000:必须和 NX 在同一条命令里。分成SETNX+EXPIRE两条,中间进程崩溃就是永久死锁(SETNX在 2.6.12 起被SET的NX取代,7.x 里只有老代码还在用);<token>:唯一标识,不能写死"1"。推荐UUID + 线程标识,因为释放时要靠它证明"这把锁是我的"。
Stringtoken=UUID.randomUUID()+":"+Thread.currentThread().getId();2.3 释放为什么必须用 Lua:一个真实存在的时序
直觉写法是"先GET判断是不是自己的,再DEL"。这两步之间,锁可能已经过期了:
| 时刻 | 线程 A | 线程 B | Redis 里的锁 |
|---|---|---|---|
| t1 | 加锁成功,TTL 30s | — | 属于 A |
| t2 | GET返回自己的 token,“确认是我的” | — | 属于 A |
| t3 | 被 GC 卡住 | — | 到期自动删除,锁没了 |
| t4 | 还在卡住 | 加锁成功 | 属于 B |
| t5 | 恢复,执行DEL | 以为自己独占 | 被 A 删掉了 |
问题就在 t2 和 t5 之间:判断发生在锁还属于 A 的时候,删除发生在锁已经属于 B 的时候。两个命令之间没有原子性,A 删掉的是 B 的锁。B 毫不知情,第三个线程进来时它毫无防备。
[图:GET 与 DEL 之间的窗口,以及 Lua 如何把两步压成一步]
修法是把"比较 + 删除"交给 Lua,Redis 单线程执行脚本,中间不会插入其他命令:
ifredis.call('get',KEYS[1])==ARGV[1]thenreturnredis.call('del',KEYS[1])elsereturn0end返回 0 不是失败,是信号:说明锁已经不属于你,业务此时不应该继续写——很多实现把 0 当成"没事"忽略了。
2.4 超时的两难:30 秒不是拍脑袋
- 太短:业务还没跑完,锁自动释放,第二个线程进场,互斥失效;
- 太长:持锁进程崩了,其他人要等到 TTL 到期才能继续,故障恢复时间被锁拉长。
正确的定法只有一个:取这条临界区链路的 P99 耗时,再留出余量。P99 从你的监控来,不是从别人的博客来;没有监控就先在临界区前后打点,跑真实流量看分位数。
30000ms是 Redisson 的lockWatchdogTimeout默认值(默认值,非实测),不是"推荐值"。
2.5 看门狗:解决了什么,又引入了什么
Redisson 的做法:加锁时不指定leaseTime(或传-1),就用lockWatchdogTimeout作为租期,并起一个后台任务定期把锁的过期时间重置回该值——续期间隔是lockWatchdogTimeout / 3(源码行为,不是配置项)。
这解决了"业务没跑完锁就到期",但引入了两个反向风险:
- 进程假死而调度线程还活着:业务线程卡在下游 RPC(没设超时)、线程池耗尽,看门狗照样续期,锁变成"永不释放",其他实例只能一直等。这是我在一次线上事故里见到的形态——锁没丢,但业务全堵住了。
- 整机 STW:JVM 全面停顿,续期任务也停,锁到期释放;业务恢复后它自己并不知道锁已经没了,继续往临界区里写。
第二条是分布式锁的通用缺陷,不是 Redisson 的 bug:客户端无法可靠地知道自己是否还持有锁。后面讲的 fencing token 就是为这件事准备的。
那工程上怎么防?不能只靠看门狗续期,至少要补两道检查:
- 提交前再确认一次:重读锁的 value,比对还是不是自己的 token。不一样就说明中途丢过锁,手里的数据可能已被别人改过,必须回滚。
- 给锁带单调递增的版本号:一并写进业务数据,让存储侧做最后拦截(2.8 细讲)。这比"自己检查自己"可靠,因为判断权交给了不会停顿的一方。
这两道检查都只能解决"发现",不能解决"不发生"。真需要正确性的场景,兜底仍然在锁之外——幂等表或条件更新(2.11)。
2.6 可重入:用 Hash 存重入次数
同一线程重复加锁不该被自己挡住。String 只能存一个 token,所以可重入锁换成 Hash:
HSET lock:order:9001 <uuid:threadId> 1 -- 第一次 HINCRBY lock:order:9001 <uuid:threadId> 1 -- 重入,计数 +1释放时按次数递减,减到 0 才真正DEL。Redisson 的RLock就是这个结构,lock()几次就要unlock()几次,少调一次锁就永远留着;多调一次会抛IllegalMonitorStateException。所以释放前先判断:
if(lock.isHeldByCurrentThread())lock.unlock();2.7 四种锁形态,各解决一个问题
| 形态 | Redisson API | 解决什么 | 代价 |
|---|---|---|---|
| 可重入锁 | getLock() | 同一线程重复进入 | — |
| 公平锁 | getFairLock() | 按请求顺序拿锁,避免饿死 | 排队有额外开销,吞吐下降 |
| 读写锁 | getReadWriteLock() | 读读并行、读写互斥 | 实现复杂,写饥饿需注意 |
| 联锁 | getMultiLock() | 同时锁多个资源,全成功才算成功 | 任一失败要全部回滚释放 |
| 红锁 | getRedLock() | 多套独立主节点,防单点故障 | 部署成本高,仍有理论缺陷 |
前四种是"同一把锁的不同语义",只有红锁改变的是可靠性假设——它不再假设单个 Redis 可靠。
2.8 Redlock 的争议:它解决了什么,又假设了什么
Redlock 的流程:准备 N 个(通常 5 个)完全独立的 Redis 主节点(不是主从,不共享故障域),客户端依次加锁,只有在多数派(N/2+1)成功且总耗时小于锁有效期时才算加锁成功;失败则向所有节点发起释放。
它的价值要肯定:它把"单点故障导致锁丢失"这件事的概率压下去了。单主 + 主从的结构里,主库一宕锁就没了;Redlock 要求多数派同时出问题,才可能丢锁——这在工程上是实打实的改进。
Martin Kleppmann 的批评集中在两个假设上:
- 时钟假设。Redlock 用"加锁耗时 + 租期"推算锁的剩余有效时间,前提是各节点时钟推进速度接近。若某节点时钟跳变(NTP 校时、虚拟机迁移),推算就失真。
- 停顿假设。客户端拿到锁后若发生长时间 GC 停顿,它以为自己还持有锁,实际锁早已过期、被别人拿走。停顿期间它什么都不知道,恢复后照常往临界区写。
第二条无法靠"更严谨的 Redis 实现"解决,因为问题在客户端的时间感知。
面对这两条,antirez 的回应是把争论拉回到业务定位上:如果这把锁只是为了防止并发重复扣减——那是一种效率优化,偶尔失效的后果是多扣一次、被对账发现、补一笔冲正;但如果要的是正确性,那 Redis 锁本来就不该出现在这个位置,该用的是带事务的数据库或共识系统(如 etcd)。锁不是事务的替代品。
这场辩论真正的落点不是"Redlock 安不安全",而是"你的业务能容忍几次错误":容忍度高,用 Redlock 换部署简单是合理的;容忍度为零,换什么锁都没用,得换存储。
fencing token(栅栏令牌)是对症的办法:每次加锁返回一个单调递增的版本号,写存储时带上它,存储侧只接受比已见过的更大 token 的写入。
加锁 → token=33 → 写 DB 时带 token=33 → DB 记录 last_token=33 GC 停顿 → 锁过期 → 另一客户端 token=34 → 写 DB(34 > 33,接受) 旧客户端恢复 → 带 33 写 DB → 33 < 34,拒绝[图:Redlock 多数派加锁,与 fencing token 在存储侧的单调校验]
兜底不在锁上,在存储上。这也是本篇的核心结论:锁只能降低并发冲突的概率,正确性最终要由幂等或条件更新来保证。
2.9 主从切换为什么必然丢锁
SET NX PX在主库成功后,复制是异步的(第 04 篇)。主库在把这条命令同步给从库之前宕机,从库被提升为新主——新主上根本没有这把锁。另一个客户端此时加锁会成功,两个客户端同时持锁,且双方都不知道。
[图:主库写入锁 → 未同步即宕机 → 新主上无此锁 → 两个客户端同时持锁]
这不是配置问题,是架构问题:Redis 的锁是"单点上的互斥",不是"共识"。想要共识语义,得换 Raft/Paxos 系的存储(etcd、ZooKeeper);在 Redis 上能做的只是缩小窗口加兜底,不能假装它不存在。
2.10 粒度与临界区纪律
- 按业务 ID 加锁:
lock:stock:{skuId},不是lock:stock,更不是"全站一把锁"——全局锁把并发打成串行。 - 临界区只放必须互斥的部分。锁内做 RPC、发消息、开长事务,等于把持锁时间交给下游的抖动决定——这也是下一篇要讲的问题。
- 锁的 TTL 与业务超时要配套:锁 TTL 30 秒,但下游 RPC 超时设了 60 秒,等于必然失效。
2.11 锁之外:三种更可靠的方案
| 方案 | 做法 | 优势 | 局限 |
|---|---|---|---|
| 唯一索引 | INSERT冲突抛DuplicateKeyException | 数据库保证,无锁失效问题 | 只适合"新增"语义 |
| 状态机条件更新 | UPDATE ... WHERE id=? AND status='待支付' | 影响行数=0 即已流转,天然幂等 | 需状态设计清晰 |
| 幂等表 | 唯一键 + 处理状态,先占位再执行 | 可覆盖"执行到一半失败" | 多一次写,需清理历史 |
判断顺序建议:能靠唯一索引或条件更新解决的,不要上锁;只有"必须跨多步、且外部系统不支持幂等"时,才用锁。锁是最后一道,不是第一道。
2.12 阻塞还是非阻塞
tryLock的两个时间参数含义完全不同:等待时间(waitTime)是"愿意等多久抢锁",租期(leaseTime)是"拿到后多久自动释放"。常见错误是只给一个数,把等待当成租期。
失败后的降级路径必须提前设计:返回"系统繁忙"、进本地队列稍后重试、还是直接拒绝。没有降级路径的锁,等于把可用性绑在了锁上。
2.13 锁也要被监控
多数人只关心"锁有没有生效",不关心"锁用得好不好"。可锁的失效是静默的,不监控就只能等数据出问题才发现。三个指标足够定位大多数问题:
| 指标 | 异常表现说明什么 |
|---|---|
| 获取失败率 | 飙升说明并发超出预期,或者临界区执行变慢了(锁被占得更久) |
| 持锁时长(P99) | 持续贴近租期上限,说明临界区里在做慢操作,通常是没设超时的下游调用 |
unlock返回 0 的次数 | 大于 0 就是事故信号:锁在业务中途丢过,那段时间写的数据不可信 |
还有一个容易漏的观察点:如果发现锁的过期时间经常被重置回上限,说明看门狗在疯狂续期——反向推回去,就是某个线程卡在临界区里出不来。这时候查的不是 Redis,是那段业务代码调的下游。
把这几个点打进监控大盘,"并发问题"才从玄学变成可排查的对象。排查这类问题的第一步不是翻日志,是先看锁的获取失败率。
三、代码与实验
3.1 手写一把锁,并复现"误删他人锁"
// 位于 @Service:StringRedisTemplate redis 由构造注入,log 由 @Slf4j 生成privatestaticfinalRedisScript<Long>UNLOCK=newDefaultRedisScript<>("if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end",Long.class);publicStringtryLock(Stringkey,Durationttl){Stringtoken=UUID.randomUUID()+":"+Thread.currentThread().getId();Booleanok=redis.opsForValue().setIfAbsent(key,token,ttl);// SET key token NX PX ttlreturnBoolean.TRUE.equals(ok)?token:null;// null = 没抢到,由调用方决定降级}/** * 释放锁。 * 返回 false 不是"清理失败"这种小事,而是"锁在业务执行期间就已经丢了"的信号—— * 调用方必须把它当作业务错误(抛异常、回滚、走对账),绝不能当没发生过直接返回成功。 */publicbooleanunlock(Stringkey,Stringtoken){if(token==null){returnfalse;}Longdeleted=redis.execute(UNLOCK,Collections.singletonList(key),token);// 返回 0 有两种可能:锁已过期且被别人抢走,或者锁压根不存在。// 两种情况都意味着"我写数据的时候,可能已经不是在独占状态下了",所以要留痕并上报给调用方。if(deleted==null||deleted==0L){log.warn("lock lost before unlock, data may be dirty. key={}",key);returnfalse;}returntrue;}# 复现:A 的锁过期后,A 的 DEL 删掉了 B 的锁redis-cli SET lock:demo A NX PX3000# A 加锁,3 秒后自动释放sleep4# A 的业务还在跑redis-cli SET lock:demo B NX PX3000# B 拿到锁(A 的已过期)redis-cli DEL lock:demo# 若 A 此时执行 DEL,删的是 B 的锁redis-cli GET lock:demo# (nil) —— B 以为自己持有,实际已经没了最后一行就是最危险的状态:B 认为自己独占,但锁已经不在 Redis 里。用上面的 Lua 版本重跑,DEL会被拒绝,unlock返回false并打日志。
unlock返回false时必须停下来想一件事:业务代码此刻是不是已经执行完了?如果已经写完库、发完消息才去解锁,那"锁中途丢了"意味着这段时间里可能有第二个线程也在写同一份数据——数据很可能已经错了,只是还没人发现。所以这个false不能只打成一条 warn 日志,调用方要把它转成异常,让事务回滚、让上游知道这次请求不可信。
3.2 Redisson:三个参数别传错
RLocklock=redisson.getLock("lock:stock:"+skuId);booleanok=false;try{ok=lock.tryLock(3,-1,TimeUnit.SECONDS);// waitTime=3s;leaseTime=-1 → 看门狗续期if(!ok){returnResult.busy("系统繁忙,请稍后重试");// 明确降级,不抛异常给前端}deduct(skuId,n);returnResult.ok();}catch(InterruptedExceptione){Thread.currentThread().interrupt();// 恢复中断标志,别吞掉returnResult.busy("请求被中断");}finally{if(ok&&lock.isHeldByCurrentThread())lock.unlock();// 没拿到锁就不要 unlock}常见错误组合:
| 写法 | 实际效果 |
|---|---|
tryLock(3, -1, SECONDS) | 等 3 秒,拿到后看门狗续期(推荐) |
tryLock(3, 10, SECONDS) | 等 3 秒,租期固定 10 秒,不续期,业务超 10 秒就失效 |
tryLock() | 不等待、租期用默认lockWatchdogTimeout并续期 |
lock.lock() | 一直阻塞直到拿到,无超时,容易把线程池拖死 |
忘记unlock() | 锁留到租期结束,期间所有人阻塞 |
3.3 库存扣减的三种写法
// A. 分布式锁:适合"扣减前要读多张表、做业务校验"的复杂场景StringlockKey="lock:stock:"+skuId;Stringtoken=tryLock(lockKey,Duration.ofSeconds(10));if(token==null){returnResult.busy("稍后重试");}booleanlockHeld=true;try{intstock=stockMapper.selectStock(skuId);if(stock<n){returnResult.fail("库存不足");// 没改任何数据,锁中途丢了也不影响结果}stockMapper.updateStock(skuId,stock-n);}finally{lockHeld=unlock(lockKey,token);}// 能走到这里说明已经扣减成功。如果锁在过程中丢了,这次扣减依据的 stock 可能已经被人改过,// 结果可能是超卖 —— 所以不能返回成功,必须抛出去让上游回滚和对账。if(!lockHeld){thrownewIllegalStateException("扣减已完成但锁已失效,库存可能不准,请对账. skuId="+skuId);}returnResult.ok();// B. 条件更新:一条 SQL 解决,不依赖任何锁// UPDATE stock SET num = num - #{n} WHERE sku_id = #{id} AND num >= #{n}introws=stockMapper.deduct(skuId,n);if(rows==0)returnResult.fail("库存不足");// 影响行数为 0 就是"没扣到"// C. Redis 预扣 + 异步落库:抗住瞬时流量,但存在少卖// 为什么不能写成 DECR → 判断 < 0 → INCR 回滚:这三步各自原子,合起来不原子,// 在"判断"和"回滚"之间一旦抖动或崩溃,库存就永久为负且没人补回来。// 所以把"判断 + 扣减"压进同一段 Lua;脚本可内联,也可按第 01 篇的 buildScript(String path) 从 classpath 加载。//// 返回值约定:-2 = key 不存在(库存没预热,属于系统异常);-1 = 库存不足;>= 0 = 扣减后的余量privatestaticfinalRedisScript<Long>DEDUCT=newDefaultRedisScript<>("local stock = redis.call('get', KEYS[1])\n"+"if not stock then return -2 end\n"// key 不存在时 get 返回 false,直接 tonumber 会得到 nil+"local stockNum = tonumber(stock)\n"+"local n = tonumber(ARGV[1])\n"+"if stockNum < n then return -1 end\n"+"return redis.call('decrby', KEYS[1], n)\n",Long.class);Longleft=redis.execute(DEDUCT,Collections.singletonList("stock:"+skuId),String.valueOf(n));// 三种非正常返回要分开处理,不能一律当成"库存不足"——否则库存没预热时用户看到的是"卖完了",排查会被带偏if(left==null){returnResult.fail("系统异常");// 脚本没按约定返回,多半是 Redis 侧出了问题}if(left==-2L){log.error("stock key not initialized. skuId={}",skuId);// 该预热没预热,是流程缺失,要报警returnResult.fail("系统异常");}if(left==-1L){returnResult.fail("库存不足");}mq.send(newStockDeductMsg(skuId,n));// 异步落库,失败要有补偿| 方案 | 超卖风险 | 少卖风险 | 可用性 | 适用 |
|---|---|---|---|---|
| A 分布式锁 | 锁失效窗口内可能超卖(切主、GC、TTL 过短) | 低 | 依赖 Redis | 复杂业务逻辑 |
| B 条件更新 | 无(数据库行锁保证) | 低 | 依赖 DB | 简单扣减,首选 |
| C Redis 预扣 | 无(Lua 保证"比较 + 扣减"原子) | 有(异步落库失败) | 最高 | 秒杀预扣 + 补偿 |
结论是先看 B 够不够。只有当扣减前后还有一堆业务判断时,A 才有意义;C 只适合"少卖可以接受、超卖绝对不行"的场景。
3.4 用幂等表替代"用锁防重复提交"
CREATETABLEidem_record(biz_keyVARCHAR(64)NOTNULLCOMMENT'业务唯一键,如 order:9001:pay',statusTINYINTNOTNULLDEFAULT0COMMENT'0 处理中 1 成功 2 失败',created_atDATETIMENOTNULL,PRIMARYKEY(biz_key));try{idemMapper.insert(newIdemRecord(bizKey,0));// 唯一键冲突 = 已有人处理}catch(DuplicateKeyExceptione){IdemRecordold=idemMapper.selectById(bizKey);if(old.getStatus()==1)returnResult.ok("已处理");// 幂等返回if(old.getStatus()==0)returnResult.busy("处理中");// 前一个还没跑完// status=2:允许重试,用条件更新占位}try{doBiz();idemMapper.finish(bizKey);// UPDATE ... SET status=1 WHERE biz_key=? AND status=0}catch(Exceptione){idemMapper.fail(bizKey);// status=2,允许下次重试throwe;}这套东西的价值在于:它不依赖任何"锁还活着"的假设。锁丢了、切主了、GC 了,唯一索引照样能把重复请求挡在第二次写入之前。
3.5 复现:租期内没跑完,锁提前释放
RLocklock=redisson.getLock("lock:demo");lock.tryLock(0,5,TimeUnit.SECONDS);// 租期固定 5 秒,明确不使用看门狗try{Thread.sleep(8000);// 业务跑了 8 秒log.info("critical work done");// 此时锁早已释放,第 6 秒起别人就能进来}finally{if(lock.isHeldByCurrentThread())lock.unlock();// 大概率 false,可能抛 IllegalMonitorStateException}观测手段:另开一个线程每 500ms 打一次GET lock:demo,能看到第 5 秒后 key 消失、随后被第二个线程写入新的 token。用tryLock(0, -1, SECONDS)重跑,锁会一直被续期到业务结束——两种写法的差别就是"业务耗时是否可预期"。
【本机实测:待补】:把上面两段跑一遍,记录"业务耗时 8 秒、租期 5 秒"时第二个线程真正进入临界区的时间点,以及改用看门狗后锁的存活时长。压测方法:两个线程循环 100 次抢同一把锁,记录每次tryLock的等待时间分布。
四、踩坑清单
SETNX之后忘了EXPIRE—— 我写过。两条命令之间进程一崩,这把锁就永久留在 Redis 里,只能人工DEL。现在一律用一条SET NX PX。- 释放锁不校验 value—— 3.1 的脚本能挡住,但我在代码评审里见过三次直接
delete(key)。 - 把
unlock的false丢掉—— 最典型的是finally { unlock(key, token); },返回值没人接。锁中途丢了,写出去的数据可能已经脏了,接口却照样返回 200。 - 把锁的过期时间当业务超时用—— 业务没跑完锁就没了,日志里毫无异常,只有数据不对。
- 认为加了锁就没有并发问题—— 锁只保护临界区。锁外的读写(缓存回填、异步补偿、从库读)照样竞态。
- 锁内做 RPC / 发消息 / 长事务—— 持锁时间由下游决定,抖动一次锁就超时一次。
- 认为 Redlock"绝对安全"—— 它提高的是"多节点同时故障"的门槛,不解决客户端停顿和时钟跳变。
- 用锁实现幂等—— 锁失效窗口里的重复请求会照常执行。幂等要靠唯一键或状态机。
- 全站一把全局锁—— 见过最夸张的是
lock:order,所有订单接口串行,压测时排队长度直接顶到超时。
五、小结
- 最小正确的锁 = 一条
SET NX PX+ 唯一 token + Lua 释放,缺一项就有一类固定的故障等着你。 - 锁无法替代幂等。它降低的是冲突概率,不是消除竞态;真正的一致性兜底在存储侧(唯一索引、条件更新、fencing token)。
- 先问"能不能不用锁"。条件更新能解决的场景,上锁只是给自己增加一个会失效的依赖。
下一篇换个视角:锁内做长事务、对一个大 key 执行删除,都会把 Redis 的主线程按住——锁本身也可能成为让 Redis 变慢的原因。第 06 篇讲线上 Redis 变慢之后,一套可以照着走的排查路径:从客户端到服务端、从SLOWLOG到LATENCY,以及大 key、热 key 的安全探测方式。
自检三问:
- 你们代码里的锁,释放时校验 value 了吗?如果锁在业务中途过期,业务会不会察觉?
- 锁的 TTL 是按什么定的?能说出这条临界区的 P99 吗?
- 如果明天 Redis 主从切一次,窗口里正在执行的那些业务,有幂等兜底吗?