Redis分布式锁八大常见坑:从原理到实战避坑指南
1. 线上事故复盘我是怎么被一把锁锁住的先说说我自己遇到的一次事故。当时我们有个秒杀活动商品库存只有 500 件结果活动刚开始两分钟后台告警就响了——订单量超出了库存超卖了 37 件。查日志发现扣减库存的接口在并发下根本没有被互斥保护多个线程同时读到了库存为 1然后各自扣减成功。当时第一反应是加个分布式锁不就行了嘛结果真的动手改的时候才发现分布式锁这个技术方案表面上一行代码搞定实际用起来坑多得能排队开会。什么是 Redis 分布式锁本质上就是利用 Redis 单线程执行的特性让多个应用实例通过一个共享的 key 竞争一把锁。谁成功写入了这个 key谁就拿到了执行权执行完或者超时后再把 key 删掉让其他人有机会拿到锁。这套机制在高并发场景下确实好用但前提是你得把它的边界情况全部考虑清楚否则轻则锁失效重则直接引发线上事故。这篇文章把我在项目里踩过的、以及排查别人代码时见到的八类高频问题一次性说清楚。每个问题我都会给错误写法、正确写法以及对应的原理说明。如果你是刚接触分布式锁这篇文章能帮你少走很多弯路如果已经在用了我建议你对照每一节检查一下自己的代码说不定里面就埋着雷。2. 翻车现场一SETNX 和 EXPIRE 不是天生一对2.1 典型的错误姿势网上很多老教程教你用两条命令实现分布式锁SETNX lock:product:1001 1 EXPIRE lock:product:1001 30第一行命令的意思是只有这个 key 不存在时才设置成功返回 1 代表抢锁成功返回 0 代表锁已经被别人持有。第二行给 key 加 30 秒过期时间防止持有锁的进程宕机后锁永远不释放。这个方案最大的问题是SETNX和EXPIRE是两条独立的命令中间没有原子性保障。如果执行完SETNX后应用进程突然宕机、或者网络断开EXPIRE根本没有机会执行这个 key 就成了永久 key。所有其他请求都会卡在等待锁上整个分布式系统直接瘫痪。我见过一个真实案例某个内部系统的定时任务模块就是这么写的运维重启了一次机器之后所有定时任务全都静默了排查了半天才发现是锁 key 没有过期时间被一个已经死掉的进程焊死在 Redis 里。2.2 正确的实现方式解决这个问题其实早就有了标准答案用一条 Redis 命令完成加锁 过期时间两个动作。SET lock:product:1001 8f4d2e7a UUID NX EX 30这条命令里NX表示只有当 key 不存在时才写入EX 30表示同时设置 30 秒过期时间。这两个条件在 Redis 内部是原子执行的要么都成功要么都失败不存在中间状态。如果你用的客户端是 Jedis、Lettuce 或者 Redisson对应的方法名基本都能直接支持这个能力。我在项目里一般封装一个tryLock方法public boolean tryLock(String key, String requestId, long expireSeconds) { String result jedis.set(key, requestId, NX, EX, expireSeconds); return OK.equals(result); }requestId这个参数后面会详细讲当前你先记住它的作用是标识这把锁是谁持有的。3. 翻车现场二锁过期了但业务还没跑完3.1 为什么锁过期是个大麻烦如果你以为加了EX参数就万事大吉那接着看这个场景。假设你给锁设置了 10 秒过期时间但业务方法执行了 15 秒。前 10 秒里线程 A 持锁执行任务到第 10 秒时锁自动过期线程 B 抢到了锁也开始执行同一个任务到第 15 秒时线程 A 跑完把锁删掉但此时锁已经是线程 B 的了线程 C 又进来抢锁……这就相当于锁完全没有起到互斥效果同一个业务方法在多个线程里同时执行。因为处理大批量数据、调用外部慢接口、或者做复杂的聚合计算方法执行时间超过锁的过期时间这是非常常见的。我见过很多团队把锁过期时间拍脑袋定成 30 秒结果月底跑批任务遇到数据量大单次执行就要 5 分钟锁早失效了。3.2 续期方案看门狗机制业界对这个问题的主流解法是看门狗机制。你抢到锁之后后台起一个守护任务每隔一段时间检查一下锁是否还在自己手里如果在就自动把过期时间续上。Redisson 的lock()方法默认就带了看门狗锁的默认 leaseTime 是 30 秒每 10 秒检查一次并刷新过期时间。自己实现的话核心思路是用一个定时任务做续期ScheduledExecutorService scheduler Executors.newScheduledThreadPool(1); String lockKey lock:order:1001; String requestId UUID.randomUUID().toString(); // 抢锁 redis.set(lockKey, requestId, NX, EX, 10); // 启动续期线程 scheduler.scheduleAtFixedRate(() - { String luaScript if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(expire, KEYS[1], 10) else return 0 end; redis.eval(luaScript, Collections.singletonList(lockKey), Collections.singletonList(requestId)); }, 5, 5, TimeUnit.SECONDS);注意看这里的 Lua 脚本只有确认当前锁的 value 是自己的requestId时才续期否则说明锁已经被别人抢走不需要再做任何操作。这个先校验再操作的思路会贯穿整个分布式锁设计。但看门狗方案也有一个前提业务进程本身要一直活着。如果持有锁的机器发生了长时间 GC 停顿或者直接被 kill -9续期线程同样无法执行最终锁还是会过期被其他线程抢走。所以真正要求严格互斥的场景还需要业务侧做幂等和兜底这点我会在后面的章节专门解释。4. 翻车现场三删锁删出了大事故4.1 误删他人锁的完整链路前面提到线程 A 执行完业务后直接删锁但此时锁可能已经不是自己的了。完整的误删链路是这样的线程 A 抢到锁持有锁执行业务。线程 A 执行时间过长锁过期自动释放。线程 B 抢到锁开始执行业务。线程 A 终于执行完了执行DEL lock:product:1001。线程 B 的锁被删掉线程 C 也进来了。于是 B 和 C 同时执行了同一段业务逻辑临界区瞬间被撕开口子。如果这段逻辑是扣库存、转账、生成订单号那后果就是超卖、重复转账、订单号冲突。4.2 删锁时的身份校验正确的删锁操作必须带着身份证明。抢锁时给 value 设置一个全局唯一的随机数UUID 就行删除前先检查这个 value 是不是自己的确认是自己的才删。直接用两条命令做先查后删是不行的因为查询和删除之间可能插入其他操作。必须用 Lua 脚本保证原子性-- 删锁脚本 if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end对应的 Java 代码public boolean releaseLock(String key, String requestId) { String luaScript if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; Object result jedis.eval(luaScript, Collections.singletonList(key), Collections.singletonList(requestId)); return Long.valueOf(1L).equals(result); }这个脚本逻辑很简单get出来的值等于我传进去的requestId就del不等于说明锁是别人的不删。整个过程在 Redis 服务端原子执行不会被人打断。很多初学者会忽略这个细节直接用jedis.del(key)就完事了。说实话在低并发、业务逻辑极快的场景下这个问题不容易暴露但只要出现过一次慢查询误删锁的故障就会立刻显现。所以从一开始就养成带身份删除的习惯是最划算的因为你不需要付出额外成本只是一个 UUID 和一段 Lua 而已。5. 翻车现场四同一个线程被自己的锁拦住5.1 不可重入锁的尴尬场景假设你的业务方法实现了加锁逻辑但方法内部又调用了另一个加锁的方法比如public void processOrder(Long orderId) { String lockKey lock:order: orderId; boolean locked tryLock(lockKey, requestId, 30); if (locked) { try { // ... updateOrderStatus(orderId); // 这个方法内部也加了一把同名锁 } finally { releaseLock(lockKey, requestId); } } }如果updateOrderStatus内部恰好也对同一个orderId加了锁这个线程第二次执行SET lock:order:xxx时会发现自己之前写的 key 还在NX条件不满足拿锁失败于是抛出异常或者阻塞等待。如果还加了获取锁失败就自旋重试的逻辑重试到超时甚至直接死锁。5.2 可重入锁的实现思路Redis 本身没有可重入语义需要在客户端实现。思路是给锁记录一个持有者 重入次数。使用 Redis Hash 结构比较合适field 存持有者标识value 存重入次数。抢锁时用 Lua 脚本判断-- 加锁可重入版本 local key KEYS[1] local owner ARGV[1] local ttl ARGV[2] if redis.call(exists, key) 0 then redis.call(hset, key, owner, 1) redis.call(expire, key, ttl) return 1 end if redis.call(hexists, key, owner) 1 then redis.call(hincrby, key, owner, 1) redis.call(expire, key, ttl) return 1 end return 0对应的解锁脚本local key KEYS[1] local owner ARGV[1] if redis.call(hexists, key, owner) 0 then return 0 end local count redis.call(hincrby, key, owner, -1) if count 0 then redis.call(expire, key, ARGV[2]) return 1 else redis.call(del, key) return 1 end如果懒得自己实现这套逻辑直接用 Redisson 的RLock就行它天然支持可重入。Redisson 内部对每个线程维护了一个计数器加锁时1解锁时-1减到 0 才真正删除锁。这个设计避免了很多因为方法嵌套调用导致的死锁问题所以我平时更推荐直接用 Redisson 而不是自己封装原生 Redis 命令。6. 翻车现场五主从切换瞬间锁就丢了6.1 问题出在数据复制上很多团队的 Redis 都做了主从架构一个主节点负责写入多个从节点同步数据主节点挂掉后自动切换到从节点继续服务。这个方案保证了高可用但在分布式锁场景下埋着一个隐患。过程是这样的线程 A 在主节点上成功写入了锁 key但主节点还没来得及把数据同步给从节点主节点宕机了。哨兵检测到主节点不可用把某个从节点提升为新的主节点。此时新的主节点上并没有线程 A 的锁 key于是线程 B 顺利抢到了锁。结果A 和 B 同时认为自己是唯一的锁持有者互斥失效。只要你的 Redis 集群发生过主从切换这个问题就真实存在。如果业务场景对互斥要求极其严格比如金融级资金操作这就是不能忽视的缺陷。6.2 RedLock 算法和它的代价为了解决多节点场景下的锁安全问题Redis 作者提出了 RedLock 算法。核心思路是部署多个互相独立的 Redis 节点通常是奇数个比如 5 个加锁时向所有节点发送SET命令只有成功写入超过半数节点比如 5 个里至少 3 个才算加锁成功。这样即使某个节点宕机了其他节点上依然存在锁信息能够保证互斥。RedLock 的问题在于需要额外部署多套 Redis 实例成本和运维复杂度都上来了而且并不是所有国内外大厂都认可它的绝对安全性Martin Kleppmann《数据密集型应用系统设计》作者就专门发文分析过它在某些极端场景下依然可能失效。所以现实里很多团队会做一个权衡一般业务系统单机 Redis 主从/哨兵 Redisson 已经够用把锁过期时间、看门狗、幂等这些都做好能覆盖 99% 的场景。资金类强一致场景除了 RedLock还要在数据库层面做乐观锁、唯一键等二次校验不能只依赖 Redis 锁。没有完美的分布式锁方案只有最合适当前业务的方案。你要做的不是盲目上最强配置而是识别出业务到底能接受多大的锁失效概率。7. 翻车现场六获取不到锁时你让线程干了什么7.1 自旋等待与快速失败如何取舍很多新手写分布式锁时会这样处理获取锁失败的情况while (true) { if (tryLock()) { break; } Thread.sleep(50); }这种写法在并发量低的场景下问题不大但如果同一把锁的竞争方很多就会陷入大量无意义的自旋每个线程都在循环抢锁、休眠、再抢Redis 的 QPS 被疯狂拉高本来能正常工作的服务反而被拖垮。更合理的做法是先想清楚获取不到锁时业务应该怎么办如果你的业务是一定要执行执行方式可以晚一点比如定时批量任务采用阻塞等待超时上限的控制超过指定时间还没拿到锁就报错退出。如果你的业务是用户请求不能一直等比如秒杀下单直接快速失败返回系统繁忙请稍后重试。我在实际的电商项目里用的是下面这种有限等待策略public boolean tryLockWithTimeout(String key, String requestId, long waitTime, long leaseTime) { long start System.currentTimeMillis(); while (System.currentTimeMillis() - start waitTime) { boolean locked tryLock(key, requestId, leaseTime); if (locked) { return true; } Thread.sleep(50); } return false; }waitTime可以根据业务口子设置一般建议 100ms~2s 之间。超过了就不等了直接返回失败。7.2 锁等待时间与 Redis 连接池的关系还有一个容易忽略的关联问题自旋等待会占用应用线程但更严重的是如果每次抢锁都新建连接而不复用连接池高并发下 Redis 的连接数会瞬间被打满。所以一定要把 Redis 客户端统一封装到连接池Jedis Pool、Lettuce 连接共享都行并且给获取连接加一个超时时间常见设置 2~3 秒避免线程在等待连接池资源时无限期挂起。我观察到一个现象很多项目分布式锁代码本身写得没毛病但一到流量高峰就出现 Redis 超时查到最后都是连接池配置不合理。连接池的maxTotal设为 50~100maxWaitMillis设为 2000ms 是比较常见的起点具体还要结合你的业务并发量去调整。8. 翻车现场七锁的粒度失控8.1 锁了太多不该锁的资源有些团队做订单处理时直接用订单号作为锁 key这问题不大。但更常见的是下面这种// 所有商品共用一把锁 String lockKey lock:product:all;如果有 100 个商品同时有订单进来它们都会被同一把锁串行处理。性能消耗极大本来 100 个订单可以并行处理现在全部排队吞吐量直接下降一两个数量级。锁的粒度太大往往发生在图省事阶段懒得想复杂的 key 规则直接用一个全局锁。正确做法是把锁的粒度细化到具体操作对象。比如扣减某个商品库存用lock:product:{productId}。处理某个用户的订单用lock:order:{orderId}或者lock:user:{userId}。防止用户重复下单用lock:user:{userId}:submit。这样不同商品、不同订单、不同用户的请求之间互不阻塞只有针对同一目标的请求才会排队。8.2 锁粒度细化后的补偿措施细化了锁粒度之后还要注意一个反向问题如果某个productId或userId是高并发热点所有请求都落在同一个 key 上锁竞争一样会很激烈。此时可以在业务上做分桶。举个例子商品库存秒杀场景500 件库存可以拆成 5 个分桶每个分桶 100 件用户请求通过userId % 5之类的方式分流到不同的分桶去扣减。每个分桶用自己独立的锁 key把一把锁扛所有压力变成多把锁共同分摊压力。不过分桶方案要给某桶已扣完但其他桶还有库存留个二次路由逻辑复杂度会上升。所以我建议先评估单把锁的并发压力是否真的成为瓶颈如果不是不要急着分桶如果是再考虑拆分。9. 翻车现场八时钟跳跃与 GC 停顿带来的锁提前失效9.1 物理时钟带来的不确定性Redis 的 key 过期机制依赖的是服务器本地时间。你设置EX 30意思是 30 秒后 key 失效这个30秒是 Redis 服务器根据自己系统时间计算得出的。如果 Redis 服务器的系统时间发生跳跃比如 NTP 时间同步、人为调整系统时间key 的过期时间会被加速或延后计算。假设你加了 30 秒的锁管理员把系统时间往前调了 10 秒锁的有效期实际只剩 20 秒你的业务可能还没跑完锁就没了。这个坑比较隐蔽而且很多线上环境都有 NTP 自动校时一旦出现时间跳变排查起来很费劲。应对方式其实又回到前面讲的看门狗续期只要持有锁的进程还在正常运行就不断地延长过期时间使得锁的生命周期尽量贴合业务的真实执行时长而不是依赖某一个固定的绝对时间点。9.2 应用进程 GC 停顿的致命影响这个问题和 Java 应用尤其相关。假设线程 A 抢到了锁持有锁执行到一半时JVM 发生了一次长时间的 Full GCSTWStop The World暂停了 10 秒。而锁的过期时间是 5 秒于是 GC 结束前锁已经过期了线程 B 抢到锁进来执行。等线程 A 的 GC 结束它根本不知道锁已经被别人拿走了继续执行临界区逻辑。于是 A 和 B 同时在跑。这种情况无法通过看门狗解决因为看门狗线程也被 GC 暂停了。想要彻底解决只能依靠业务侧兜底。比如扣库存之前做一次数据库级别的乐观锁校验或者在消息队列消费端做好幂等保证同一条消息即使被消费两次也不会产生重复数据。说白了分布式锁是第一道防线但它不是银弹业务逻辑的幂等设计才是最后的兜底。10. Redisson 为你做了什么一个现成的安全封装前面几个问题虽然都给了代码思路但真要自己撸一遍所有细节工作量不小。实际项目中我更推荐直接使用 Redisson它是目前 Java 生态里最成熟的 Redis 客户端之一分布式锁的大部分坑都已经帮你堵上了。简单列一下 Redisson 的RLock帮我处理过的事情问题Redisson 的处理方式原子加锁内部使用 Lua 脚本完成加锁 过期时间设置保证原子性锁过期导致业务未完成默认开启看门狗leaseTime 默认 30 秒每 10 秒自动续期误删他人锁删除时用 Lua 脚本校验持有者标识不可重入内部维护线程计数器实现可重入锁语义获取锁失败的处理提供tryLock(waitTime, leaseTime, TimeUnit)方法支持有限等待Redisson 的加锁代码RLock lock redissonClient.getLock(lock:order: orderId); boolean locked false; try { locked lock.tryLock(2, 30, TimeUnit.SECONDS); if (!locked) { throw new RuntimeException(系统繁忙请稍后重试); } // 业务逻辑 } finally { if (locked) { lock.unlock(); } }有一点要特别注意如果你的业务直接用lock.lock()而不是tryLock默认会在等待锁时阻塞这个行为可能不是你能接受的。而且如果业务执行时间过长只要进程不挂Redisson 的看门狗会不断续期这反而可能导致其他线程长时间等不到锁。具体用哪种方式还是要回到业务对响应时间和互斥要求的取舍上来。Redisson 看门狗的续期操作本身也是一个 Lua 脚本每次续期会给 Redis 发一次命令所以持有锁的时间越长对 Redis 的额外请求就越多。如果锁内业务是短平快的几十毫秒内完成可以考虑关闭看门狗手动指定一个足够覆盖业务耗时的过期时间减少续期开销。11. 技术选型与参数配置的经验参考到了最后我把项目里会用到的分布式锁选型思路和参数配置统一整理一下。不同方案的对比方案优点缺点适用场景原生 SET NX Lua 手动封装轻量、无额外依赖要自己处理续期、可重入等边界问题极简场景对 Redis 依赖很少Redisson RLock开箱即用看门狗、可重入都集成好了引入第三方依赖默认行为需要理解绝大多数业务系统首选RedLock 多节点锁节点级故障容错更强部署成本高极端场景仍非绝对安全对锁安全要求极高的场景ZooKeeper Curator 分布式锁用 ZK 临时顺序节点实现安全性高性能不如 Redis另需维护 ZK 集群强一致要求且对性能不太敏感的系统参数配置方面我自己的常用经验值供参考锁 key 的命名统一前缀例如lock:业务:资源ID方便排查定位。过期时间leaseTime简单业务 3~5 秒复杂批量任务 15~30 秒同时开启看门狗。等待时间waitTime接口类请求 100ms~2s定时任务可以设置 5~10s。Redis 连接池maxTotal50~100maxIdle20~50maxWaitMillis2000ms。监控通过 Redis 的slowlog get检查耗时过长的 Lua 脚本通过 INFO 观察内存与连接数变化。另外说一个运维细节在压测环境里可以用并发脚本同时打某个锁资源观察 Redis 的keys lock:*数量是否符合预期以及锁 key 的 TTL 是否稳定在一个设定区间。这类验证虽然简单却能在上线前提前暴露不少配置问题。12. 我个人在反复踩坑之后的一点体悟做分布式锁这么久我最大的体会是不要迷信某一种技术方案能解决所有并发问题也不要因为一个方案有缺陷就否定它的价值。Redis 分布式锁的众多坑本质上都能归结到一句话——分布式系统里任何节点、任何网络、任何时间都可能出错你的锁必须能容忍这些错误并做出正确的降级选择。见惯了各种分布式锁的失败案例之后我自己会坚持三个原则第一加锁前想清楚业务是否可以接受快速失败第二加锁后一定要记得完整释放中间所有分支都必须走 finally第三日志里把锁的 key、requestId、持有者信息全打出来线上出问题的时候能在日志系统里快速串出一条完整的调用链。如果你现在正在写分布式锁我建议你把这篇文章的 8 个问题当成一份 checklist逐条过一遍自己的实现。绝大多数事故都不是因为 Redis 不够好而是实现的人少考虑了某一个不太可能发生的边界情况。把这些边界一个个补上你的锁才算真正能用、敢用。