
分布式锁从 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 在同一条命令里。分成SETNXEXPIRE两条中间进程崩溃就是永久死锁SETNX在 2.6.12 起被SET的NX取代7.x 里只有老代码还在用token唯一标识不能写死1。推荐UUID 线程标识因为释放时要靠它证明这把锁是我的。StringtokenUUID.randomUUID():Thread.currentThread().getId();2.3 释放为什么必须用 Lua一个真实存在的时序直觉写法是先GET判断是不是自己的再DEL。这两步之间锁可能已经过期了时刻线程 A线程 BRedis 里的锁t1加锁成功TTL 30s—属于 At2GET返回自己的 token“确认是我的”—属于 At3被 GC 卡住—到期自动删除锁没了t4还在卡住加锁成功属于 Bt5恢复执行DEL以为自己独占被 A 删掉了问题就在 t2 和 t5 之间判断发生在锁还属于 A 的时候删除发生在锁已经属于 B 的时候。两个命令之间没有原子性A 删掉的是 B 的锁。B 毫不知情第三个线程进来时它毫无防备。[图GET 与 DEL 之间的窗口以及 Lua 如何把两步压成一步]修法是把比较 删除交给 LuaRedis 单线程执行脚本中间不会插入其他命令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没设超时、线程池耗尽看门狗照样续期锁变成永不释放其他实例只能一直等。这是我在一次线上事故里见到的形态——锁没丢但业务全堵住了。整机 STWJVM 全面停顿续期任务也停锁到期释放业务恢复后它自己并不知道锁已经没了继续往临界区里写。第二条是分布式锁的通用缺陷不是 Redisson 的 bug客户端无法可靠地知道自己是否还持有锁。后面讲的 fencing token 就是为这件事准备的。那工程上怎么防不能只靠看门狗续期至少要补两道检查提交前再确认一次重读锁的 value比对还是不是自己的 token。不一样就说明中途丢过锁手里的数据可能已被别人改过必须回滚。给锁带单调递增的版本号一并写进业务数据让存储侧做最后拦截2.8 细讲。这比自己检查自己可靠因为判断权交给了不会停顿的一方。这两道检查都只能解决发现不能解决不发生。真需要正确性的场景兜底仍然在锁之外——幂等表或条件更新2.11。2.6 可重入用 Hash 存重入次数同一线程重复加锁不该被自己挡住。String 只能存一个 token所以可重入锁换成 HashHSET 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/21成功且总耗时小于锁有效期时才算加锁成功失败则向所有节点发起释放。它的价值要肯定它把单点故障导致锁丢失这件事的概率压下去了。单主 主从的结构里主库一宕锁就没了Redlock 要求多数派同时出问题才可能丢锁——这在工程上是实打实的改进。Martin Kleppmann 的批评集中在两个假设上时钟假设。Redlock 用加锁耗时 租期推算锁的剩余有效时间前提是各节点时钟推进速度接近。若某节点时钟跳变NTP 校时、虚拟机迁移推算就失真。停顿假设。客户端拿到锁后若发生长时间 GC 停顿它以为自己还持有锁实际锁早已过期、被别人拿走。停顿期间它什么都不知道恢复后照常往临界区写。第二条无法靠更严谨的 Redis 实现解决因为问题在客户端的时间感知。面对这两条antirez 的回应是把争论拉回到业务定位上如果这把锁只是为了防止并发重复扣减——那是一种效率优化偶尔失效的后果是多扣一次、被对账发现、补一笔冲正但如果要的是正确性那 Redis 锁本来就不该出现在这个位置该用的是带事务的数据库或共识系统如 etcd。锁不是事务的替代品。这场辩论真正的落点不是Redlock 安不安全而是你的业务能容忍几次错误容忍度高用 Redlock 换部署简单是合理的容忍度为零换什么锁都没用得换存储。fencing token栅栏令牌是对症的办法每次加锁返回一个单调递增的版本号写存储时带上它存储侧只接受比已见过的更大 token 的写入。加锁 → token33 → 写 DB 时带 token33 → DB 记录 last_token33 GC 停顿 → 锁过期 → 另一客户端 token34 → 写 DB34 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 手写一把锁并复现误删他人锁// 位于 ServiceStringRedisTemplate redis 由构造注入log 由 Slf4j 生成privatestaticfinalRedisScriptLongUNLOCKnewDefaultRedisScript(if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end,Long.class);publicStringtryLock(Stringkey,Durationttl){StringtokenUUID.randomUUID():Thread.currentThread().getId();Booleanokredis.opsForValue().setIfAbsent(key,token,ttl);// SET key token NX PX ttlreturnBoolean.TRUE.equals(ok)?token:null;// null 没抢到由调用方决定降级}/** * 释放锁。 * 返回 false 不是清理失败这种小事而是锁在业务执行期间就已经丢了的信号—— * 调用方必须把它当作业务错误抛异常、回滚、走对账绝不能当没发生过直接返回成功。 */publicbooleanunlock(Stringkey,Stringtoken){if(tokennull){returnfalse;}Longdeletedredis.execute(UNLOCK,Collections.singletonList(key),token);// 返回 0 有两种可能锁已过期且被别人抢走或者锁压根不存在。// 两种情况都意味着我写数据的时候可能已经不是在独占状态下了所以要留痕并上报给调用方。if(deletednull||deleted0L){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三个参数别传错RLocklockredisson.getLock(lock:stock:skuId);booleanokfalse;try{oklock.tryLock(3,-1,TimeUnit.SECONDS);// waitTime3sleaseTime-1 → 看门狗续期if(!ok){returnResult.busy(系统繁忙请稍后重试);// 明确降级不抛异常给前端}deduct(skuId,n);returnResult.ok();}catch(InterruptedExceptione){Thread.currentThread().interrupt();// 恢复中断标志别吞掉returnResult.busy(请求被中断);}finally{if(oklock.isHeldByCurrentThread())lock.unlock();// 没拿到锁就不要 unlock}常见错误组合写法实际效果tryLock(3, -1, SECONDS)等 3 秒拿到后看门狗续期推荐tryLock(3, 10, SECONDS)等 3 秒租期固定 10 秒不续期业务超 10 秒就失效tryLock()不等待、租期用默认lockWatchdogTimeout并续期lock.lock()一直阻塞直到拿到无超时容易把线程池拖死忘记unlock()锁留到租期结束期间所有人阻塞3.3 库存扣减的三种写法// A. 分布式锁适合扣减前要读多张表、做业务校验的复杂场景StringlockKeylock:stock:skuId;StringtokentryLock(lockKey,Duration.ofSeconds(10));if(tokennull){returnResult.busy(稍后重试);}booleanlockHeldtrue;try{intstockstockMapper.selectStock(skuId);if(stockn){returnResult.fail(库存不足);// 没改任何数据锁中途丢了也不影响结果}stockMapper.updateStock(skuId,stock-n);}finally{lockHeldunlock(lockKey,token);}// 能走到这里说明已经扣减成功。如果锁在过程中丢了这次扣减依据的 stock 可能已经被人改过// 结果可能是超卖 —— 所以不能返回成功必须抛出去让上游回滚和对账。if(!lockHeld){thrownewIllegalStateException(扣减已完成但锁已失效库存可能不准请对账. skuIdskuId);}returnResult.ok();// B. 条件更新一条 SQL 解决不依赖任何锁// UPDATE stock SET num num - #{n} WHERE sku_id #{id} AND num #{n}introwsstockMapper.deduct(skuId,n);if(rows0)returnResult.fail(库存不足);// 影响行数为 0 就是没扣到// C. Redis 预扣 异步落库抗住瞬时流量但存在少卖// 为什么不能写成 DECR → 判断 0 → INCR 回滚这三步各自原子合起来不原子// 在判断和回滚之间一旦抖动或崩溃库存就永久为负且没人补回来。// 所以把判断 扣减压进同一段 Lua脚本可内联也可按第 01 篇的 buildScript(String path) 从 classpath 加载。//// 返回值约定-2 key 不存在库存没预热属于系统异常-1 库存不足 0 扣减后的余量privatestaticfinalRedisScriptLongDEDUCTnewDefaultRedisScript(local stock redis.call(get, KEYS[1])\nif not stock then return -2 end\n// key 不存在时 get 返回 false直接 tonumber 会得到 nillocal stockNum tonumber(stock)\nlocal n tonumber(ARGV[1])\nif stockNum n then return -1 end\nreturn redis.call(decrby, KEYS[1], n)\n,Long.class);Longleftredis.execute(DEDUCT,Collections.singletonList(stock:skuId),String.valueOf(n));// 三种非正常返回要分开处理不能一律当成库存不足——否则库存没预热时用户看到的是卖完了排查会被带偏if(leftnull){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,statusTINYINTNOTNULLDEFAULT0COMMENT0 处理中 1 成功 2 失败,created_atDATETIMENOTNULL,PRIMARYKEY(biz_key));try{idemMapper.insert(newIdemRecord(bizKey,0));// 唯一键冲突 已有人处理}catch(DuplicateKeyExceptione){IdemRecordoldidemMapper.selectById(bizKey);if(old.getStatus()1)returnResult.ok(已处理);// 幂等返回if(old.getStatus()0)returnResult.busy(处理中);// 前一个还没跑完// status2允许重试用条件更新占位}try{doBiz();idemMapper.finish(bizKey);// UPDATE ... SET status1 WHERE biz_key? AND status0}catch(Exceptione){idemMapper.fail(bizKey);// status2允许下次重试throwe;}这套东西的价值在于它不依赖任何锁还活着的假设。锁丢了、切主了、GC 了唯一索引照样能把重复请求挡在第二次写入之前。3.5 复现租期内没跑完锁提前释放RLocklockredisson.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 主从切一次窗口里正在执行的那些业务有幂等兜底吗