ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

05-分布式锁:从 SETNX 到 Redlock 的可靠性边界

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 30000
  • NX:不存在才写,等价于"抢锁";
  • 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线程 BRedis 里的锁
t1加锁成功,TTL 30s—属于 A
t2GET返回自己的 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 的批评集中在两个假设上:

  1. 时钟假设。Redlock 用"加锁耗时 + 租期"推算锁的剩余有效时间,前提是各节点时钟推进速度接近。若某节点时钟跳变(NTP 校时、虚拟机迁移),推算就失真。
  2. 停顿假设。客户端拿到锁后若发生长时间 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,所有订单接口串行,压测时排队长度直接顶到超时。

五、小结

  1. 最小正确的锁 = 一条SET NX PX+ 唯一 token + Lua 释放,缺一项就有一类固定的故障等着你。
  2. 锁无法替代幂等。它降低的是冲突概率,不是消除竞态;真正的一致性兜底在存储侧(唯一索引、条件更新、fencing token)。
  3. 先问"能不能不用锁"。条件更新能解决的场景,上锁只是给自己增加一个会失效的依赖。

下一篇换个视角:锁内做长事务、对一个大 key 执行删除,都会把 Redis 的主线程按住——锁本身也可能成为让 Redis 变慢的原因。第 06 篇讲线上 Redis 变慢之后,一套可以照着走的排查路径:从客户端到服务端、从SLOWLOG到LATENCY,以及大 key、热 key 的安全探测方式。

自检三问:

  1. 你们代码里的锁,释放时校验 value 了吗?如果锁在业务中途过期,业务会不会察觉?
  2. 锁的 TTL 是按什么定的?能说出这条临界区的 P99 吗?
  3. 如果明天 Redis 主从切一次,窗口里正在执行的那些业务,有幂等兜底吗?
返回列表