一、引言为什么分布式锁如此重要随着互联网业务的不断发展单体架构逐渐被微服务、分布式架构所取代。在单体应用中多个线程之间可以通过synchronized、ReentrantLock等 JDK 提供的锁机制来保证共享资源的一致性。然而当应用部署到多台机器上之后这些基于 JVM 内部内存的锁就无能为力了因为它们只能作用于同一个进程内的线程无法跨进程、跨主机协调资源访问。想象这样一个典型场景一个电商系统部署了 5 个订单服务实例它们共同连接同一个 MySQL 数据库。当用户发起下单请求时系统需要扣减库存。如果没有分布式锁5 个实例可能同时读取到库存为 1 的记录然后同时执行扣减操作最终导致超卖问题。这种情况下就必须引入一种能够跨进程协调的锁机制也就是分布式锁。分布式锁的目标很简单在分布式环境中保证同一时刻只有一个进程或线程持有某把锁从而实现对共享资源的互斥访问。而 Redis 凭借其高性能、单线程命令执行模型、丰富的数据结构和原子操作能力成为了实现分布式锁最流行的方案之一。然而Redis 分布式锁虽然看起来简单实际上却布满了各种容易踩进去的坑。许多开发者以为「SET 一下加锁、DEL 一下释放锁」就完事了结果在生产环境中频繁遇到死锁、锁误删、锁提前过期、主从切换丢锁等问题甚至引发严重的资金损失事故。本文将从零开始层层递进地剖析 Redis 分布式锁的实现原理并结合大量生产环境中的真实案例详细讲解实现分布式锁之前必须知道的 10 个坑。全文约 2 万字建议收藏后结合代码反复阅读和实践。二、Redis 分布式锁的基础认知2.1 分布式锁应该具备哪些特性在正式动手实现之前我们首先要明确一把合格的分布式锁应该满足哪些特性。只有先建立起正确的认知框架才能在后续的实现中判断某个方案是否可靠。互斥性在任意时刻只能有一个客户端持有锁。这是分布式锁最基本的要求也是它存在的意义。防死锁即使持有锁的客户端崩溃、宕机或者网络中断锁也不会永久占用必须有兜底机制让锁最终能够被释放。锁的持有者身份标识释放锁时必须验证这把锁是不是自己加的不能误删别的客户端持有的锁。原子性加锁和释放锁的操作必须保证原子性。例如加锁时「判断锁不存在 设置锁」必须是不可分割的。高可用锁服务本身不能成为单点故障。如果 Redis 宕机锁服务应该还能继续工作至少不能因为锁服务故障导致业务全挂。可重入性可选但强烈建议同一个线程在持有锁的情况下再次获取同一把锁时应该成功否则嵌套调用容易把自己锁死。自动续期可选但强烈建议当业务执行时间超过锁的过期时间时应该能够延长锁的有效期避免锁提前释放导致并发问题。2.2 为什么选择 Redis实现分布式锁的方案有很多常见的有基于数据库的唯一索引、基于 ZooKeeper 的临时顺序节点、基于 etcd 的租约机制以及本文的主角——基于 Redis 的方案。下表对比了这些方案的优缺点方案实现复杂度性能可靠性典型问题数据库唯一索引低较差一般锁释放不即时、数据库压力大、死锁难处理ZooKeeper中一般高性能偏低、依赖较重、集群维护成本高etcd中一般高与 ZooKeeper 类似生态相对 Redis 小Redis低到中极高中到高取决于架构主从切换可能丢锁、依赖过期时间防死锁Redis 之所以成为分布式锁的首选核心原因在于它具备以下优势性能极高Redis 基于内存运行单实例 QPS 可达 10 万级别加锁、释放锁的延迟通常在亚毫秒级别。命令原子性Redis 单线程执行命令且提供了SET NX PX、Lua脚本等原子操作天然适合构建锁。部署简单、生态成熟几乎所有后端语言都有成熟的 Redis 客户端接入成本低。功能丰富除了 String 类型Redis 还支持 Hash、Set、Sorted Set 等结构可以灵活扩展锁的功能。2.3 一把「朴素」的 Redis 分布式锁长什么样很多人在第一次实现 Redis 分布式锁时会写出下面这样的代码。这也是本文要分析的第一个「坑」的起点java// 错误示范先判断再设置不是原子操作 public boolean tryLock(Jedis jedis, String key, String value, int expireSeconds) { // 判断锁是否存在 Long result jedis.setnx(key, value); if (result 1) { // 锁不存在加锁成功设置过期时间 jedis.expire(key, expireSeconds); return true; } return false; }这段代码的问题非常多我们将在接下来的章节中逐层展开。接下来让我们按照出现顺序依次深入剖析实现 Redis 分布式锁时必须绕开的 10 个大坑。三、坑 1误用SETNXEXPIRE两个命令不原子3.1 问题场景还原在上面的「朴素」实现中我们使用了两个独立的命令SETNXSET if Not eXists和EXPIRE。SETNX的作用是当 key 不存在时设置值并返回 1当 key 已存在时不做任何操作并返回 0。为了避免死锁我们在加锁成功后给 key 设置了一个过期时间。然而问题恰恰出在「两个命令」上。SETNX和EXPIRE之间不是原子操作。考虑这样一个时序客户端 A 执行SETNX key value返回 1加锁成功。在 A 还没来得及执行EXPIRE key 30之前A 所在的服务器突然宕机或者 JVM 崩溃或者进程被 kill -9或者网络断开导致后续命令无法发出。此时Redis 中这把锁没有过期时间会永远存在。其他所有客户端执行SETNX时永远返回 0全部拿不到锁。系统进入死锁状态业务停滞。这是 Redis 分布式锁中最经典、最致命的问题之一。有些人可能会想「我在代码里把SETNX和EXPIRE写在一起中间不 sleep、不阻塞出问题的概率很小吧」实际情况是在生产环境中进程在任意代码行被强制终止的可能性是存在的例如 OOM Killer、容器重建、发布时 kill 进程、服务器断电等。只要存在这种可能性一段时间后问题就一定会发生只是时间早晚。3.2 问题根因分析这个问题本质上源于 Redis 命令的执行模型虽然 Redis 本身是单线程的每条命令都是原子的但「多条命令的组合」并不具备原子性。在没有事务或脚本保护的情况下多个命令之间随时可能被打断。有人可能会想到用 Redis 的MULTI/EXEC事务来包住这两个命令。但要注意Redis 的事务不是传统数据库的原子事务它不能回滚而且EXEC之前SETNX的结果无法在事务内部被下一个命令使用Redis 事务不支持命令之间的依赖。因此用 MULTI/EXEC 并不能优雅地解决这个问题反而增加了理解成本。3.3 正确做法使用扩展的SET命令从 Redis 2.6.12 版本开始SET命令支持了NX和EX/PX等扩展参数可以在一条命令内同时完成「不存在才设置」和「设置过期时间」两个动作从而保证原子性textSET key value NX EX 30各参数含义如下NX仅当 key 不存在时才设置等价于SETNX的语义。XX仅当 key 已存在时才设置注意与 NX 互斥。EX seconds设置过期时间单位为秒。PX milliseconds设置过期时间单位为毫秒。使用 Java 代码以 Jedis 为例表示如下javaimport redis.clients.jedis.Jedis; import redis.clients.jedis.params.SetParams; public class RedisLock { private static final String LOCK_SUCCESS OK; /** * 尝试获取分布式锁原子操作 * * param jedis Redis 客户端 * param lockKey 锁的 key * param requestId 锁持有者唯一标识例如 UUID * param expireSeconds 过期时间秒 * return 是否获取成功 */ public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireSeconds) { SetParams params new SetParams() .nx() .ex(expireSeconds); String result jedis.set(lockKey, requestId, params); return LOCK_SUCCESS.equals(result); } }在 Lettuce 中同样支持javaimport io.lettuce.core.RedisClient; import io.lettuce.core.SetArgs; import io.lettuce.core.api.StatefulRedisConnection; import io.lettuce.core.api.sync.RedisCommands; public class RedisLockLettuce { public static boolean tryGetDistributedLock(RedisCommandsString, String commands, String lockKey, String requestId, int expireSeconds) { SetArgs args SetArgs.Builder.nx().ex(expireSeconds); String result commands.set(lockKey, requestId, args); return OK.equals(result); } }3.4 扩展思考为什么还要设置过期时间有的读者可能会问「如果加锁成功之后业务一定能正常释放锁是不是就可以不设置过期时间了」答案是否定的。分布式系统中没有任何代码能够 100% 保证一定会执行到释放锁的逻辑。以下任一情况都可能导致DEL命令永远无法执行进程在持有锁期间被强制杀死。持有锁的线程抛出Error如OutOfMemoryError而不是可捕获的Exception。服务器断电、断网。服务长时间 Full GC 导致超时被外部系统强制重启。因此过期时间本质上是分布式锁的「兜底机制」是防止死锁的关键保险。没有过期时间的分布式锁就像一个没有安全绳的高空作业者一旦失手就万劫不复。四、坑 2把「锁」和「业务执行时间」混为一谈过期时间拍脑袋设置4.1 问题场景还原解决了原子性问题之后很多人会兴冲冲地写下这样的代码给锁设置一个固定的过期时间比如 10 秒、30 秒。这在大多数情况下不会有问题但如果业务执行时间偶尔超过这个固定值就会引发严重的并发问题。假设我们设置锁的过期时间为 30 秒。业务流程如下客户端 A 在 0 秒时拿到锁预期业务 20 秒完成。但由于数据库查询变慢、下游接口抖动、GC 停顿等原因A 的业务执行到 30 秒仍然没有完成。锁在 30 秒时自动过期Redis 将锁删除。客户端 B 在 30.1 秒时成功拿到同一把锁开始执行同一段业务代码。此时 A 在 32 秒时业务执行完毕调用DEL释放锁。如果不做任何校验A 会把 B 刚加的锁误删即使做了校验A 和 B 也已经同时执行了临界区代码互斥性被破坏。这个时序在实际生产环境中非常常见。特别是当业务高峰期、数据库慢查询、网络抖动叠加在一起时原本「永远 10 秒完成」的业务可能突然变成 40 秒甚至更久。4.2 锁过期时间设置的常见误区设置锁的过期时间时开发者常常会陷入以下几个误区拍脑袋定值不经过压测和日志分析直接写死一个 10 秒或 30 秒的过期时间。过度自信认为自己的业务逻辑一定会在某个时间内完成忽略了 GC、网络、数据库慢查等不可控因素。不考虑极限情况只按平均耗时设置过期时间没有考虑 P99、P999 等长尾延迟。把过期时间当作业务超时时间混淆了「锁的有效期」和「业务允许的执行时长」这两个概念。4.3 解决思路一保守设置过期时间 监控告警最简单的方案是把过期时间设置得足够大比如业务历史最大耗时的 2 倍以上再配合监控告警。这样做虽然简单但有两个明显缺点一旦持有锁的客户端崩溃其他客户端需要等待很久才能拿到锁锁的可用性变差。「足够大」的上限很难确定总有意外的长尾。4.4 解决思路二引入锁续期看门狗机制更优雅的方案是引入「锁续期」机制当锁即将过期而业务还在执行时由后台线程自动延长锁的过期时间。这样业务执行多久锁就持续多久不会提前释放一旦业务进程崩溃续期线程也随之停止锁会在最长一个续期周期后自动释放。这个机制在 Redisson 中被称为「看门狗」Watchdog。这里先给出一个基于 Java 的简易看门狗实现思路帮助读者理解原理。完整的企业级实践我们会在后文「坑 8」中详细展开javaimport java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; public class WatchDogDemo { private final ScheduledExecutorService scheduler Executors.newSingleThreadScheduledExecutor(r - { Thread t new Thread(r, lock-watchdog); t.setDaemon(true); return t; }); private final AtomicBoolean running new AtomicBoolean(false); /** * 启动看门狗定期续期 */ public void start(WatchDogTask task) { if (!running.compareAndSet(false, true)) { return; } scheduler.scheduleAtFixedRate(() - { if (running.get()) { task.renew(); } }, 10, 10, TimeUnit.SECONDS); } public void stop() { running.set(false); scheduler.shutdown(); } public interface WatchDogTask { /** * 续期的具体逻辑例如执行 Lua 脚本 * if redis.call(get, KEYS[1]) ARGV[1] * then return redis.call(pexpire, KEYS[1], ARGV[2]) * else return 0 * end */ void renew(); } }五、坑 3释放锁时直接 DEL误删了别人的锁5.1 问题场景还原前文「坑 2」已经埋下了一个伏笔当业务执行时间超过锁的过期时间锁会自动过期随后被其他客户端抢走。此时如果第一个客户端在业务完成后仍然执行DEL key就会把第二个客户端持有的锁直接删掉。时序如下客户端 A 拿到锁设置过期时间 30 秒。A 业务执行变慢30 秒后锁自动过期。客户端 B 拿到同一把锁开始执行业务。A 业务执行完毕调用DEL lock:order:1001此时删掉的其实是 B 的锁。客户端 C 再次拿到锁于是 B 和 C 同时进入临界区互斥性被彻底破坏。即使业务没有超时网络抖动也可能造成类似问题。例如 A 的释放命令在网络上延迟了很久到达 Redis 时锁已经过期并被 B 抢占。5.2 问题根因分析问题根源在于锁本身没有记录「谁持有它」。DEL是无差别的它不关心 key 的 value 是什么只要 key 存在就会删除。当锁的价值完全依赖「拥有者身份」来保障互斥时这种无差别删除就成了致命漏洞。5.3 正确做法释放前校验持有者并用 Lua 保证原子性正确思路是加锁时把唯一标识requestId写入 value释放锁时先判断 value 是否等于自己的 requestId相等才允许删除。并且「判断 删除」必须使用 Lua 脚本在 Redis 服务端原子完成lua-- 释放锁校验持有者身份后再删除 if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 endJava 侧调用示例javapublic static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) { String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; Object result jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId)); return Long.valueOf(1).equals(result); }这样只有锁的持有者才能释放自己的锁误删问题被彻底解决。六、坑 4忘记实现可重入性嵌套调用把自己锁死6.1 问题场景还原考虑下面这段业务代码javapublic void updateOrder(String orderId) { lock.lock(lock:order: orderId, requestId, 30); try { // 更新订单 updateOrderStock(orderId); } finally { lock.unlock(lock:order: orderId, requestId); } } public void updateOrderStock(String orderId) { lock.lock(lock:order: orderId, requestId2, 30); try { // 扣库存 } finally { lock.unlock(lock:order: orderId, requestId2); } }当updateOrder调用updateOrderStock时方法内部再次尝试获取同一把锁。由于上一次加锁并未释放第二次SET NX会失败线程陷入无限重试或直接报错这就是典型的「自己锁死自己」。6.2 问题根因分析基础版 Redis 锁没有可重入概念。Redis 只看到一个已经存在的 key无法识别「这是同一个线程再次进入」。可重入性虽然看起来只是一个细节但在递归调用、模板方法、事务嵌套等场景中非常常见缺失会导致严重的可用性问题。6.3 正确做法基于 Hash 记录重入次数使用 Hash 结构存储锁大 key 是锁名field 是持有者标识value 是重入次数。加锁时判断是否已被当前持有者持有释放时计数减 1减到 0 才真正删除 key。加锁 Lua 脚本lua-- 可重入加锁 if redis.call(exists, KEYS[1]) 0 then redis.call(hset, KEYS[1], ARGV[1], 1) redis.call(pexpire, KEYS[1], ARGV[2]) return 1 elseif redis.call(hexists, KEYS[1], ARGV[1]) 1 then redis.call(hincrby, KEYS[1], ARGV[1], 1) redis.call(pexpire, KEYS[1], ARGV[2]) return 1 else return 0 end释放 Lua 脚本lua-- 可重入释放 if redis.call(hexists, KEYS[1], ARGV[1]) 0 then return 0 end local count redis.call(hincrby, KEYS[1], ARGV[1], -1) if count 0 then redis.call(pexpire, KEYS[1], ARGV[2]) return 1 else redis.call(del, KEYS[1]) return 1 end七、坑 5可重入锁的释放次数与持有者校验不一致7.1 问题场景还原即使实现了可重入锁仍有两个细节容易踩坑一是加锁 3 次却只释放 2 次导致 key 一直残留二是持有者校验使用了错误标识例如用线程名而不是全局唯一 requestId。7.2 为什么线程名不适合做持有者标识在分布式场景下不同节点上可能存在同名线程Thread.currentThread().getName()完全可能重复。更危险的是如果业务逻辑在线程池中执行同一线程名会被多个任务复用误删风险极高。正确做法是每次加锁都生成「进程标识 UUID」级别的唯一标识。7.3 释放次数管理建议加锁和释放必须成对出现最好用 try/finally 包裹。释放操作通过 Lua 原子完成计数器减到 0 才删除 key。在监控中记录「锁持有时间 重入深度」异常及时告警。八、坑 6主从切换导致锁丢失高可用架构的隐形陷阱8.1 问题场景还原为了高可用很多团队会给 Redis 配置主从 哨兵或者直接使用 Redis Cluster。但 Redis 主从复制默认是异步的。时序如下客户端 A 在 master 上加锁成功。master 还没有把这条数据同步到 slave就宕机了。哨兵把 slave 提升为新的 master。客户端 B 在新 master 上加同一把锁因为新 master 上没有 A 的锁所以加锁成功。A 和 B 同时进入临界区锁失效。8.2 为什么主从架构无法彻底解决Redis 为保证性能采用异步复制即便开启min-replicas-to-write、使用WAIT同步命令也只能降低丢锁概率无法做到严格的强一致。凡是存在主从切换的 Redis 方案理论上都可能丢锁区别只是概率大小。8.3 RedLock 的思路与争议Redis 官方曾提出 RedLock向 N 个独立主节点依次加锁过半成功才算加锁成功。RedLock 能降低单点风险但其正确性在分布式系统社区中一直存在激烈争议包括时钟跳跃、客户端阻塞等问题。除非你对一致性要求极高且愿意承担复杂度否则不建议贸然自研 RedLock。8.4 工程上的务实建议容忍极小丢锁概率大多数互联网业务可以接受配合业务层唯一键、幂等和最终一致性兜底。强一致场景换方案如资金、订单幂等要求极高时优先考虑 ZooKeeper 或 etcd 的租约、顺序节点方案。缩短锁持有时间减少丢锁窗口期的业务影响。九、坑 7续期线程与释放流程的并发竞态9.1 问题场景还原看门狗续期和业务释放如果分别在不同线程执行会出现竞态业务线程判断锁是自己的准备DEL同一时刻看门狗又续期成功DEL执行后锁仍被删除。更隐蔽的是如果释放逻辑先停掉看门狗再DEL但「停看门狗」和DEL之间又发生一次续期仍可能错删。9.2 正确流程设计释放锁时先关闭本地看门狗线程。等待续期任务退出或至少停止后续调度。再用「校验 requestId DEL」的 Lua 脚本原子释放。全程不要复用同一个 requestId 释放不同资源。十、坑 8Redisson 看门狗的正确打开方式10.1 Redisson 看门狗默认行为Redisson 的RLock.lock()在不传 leaseTime 时启用看门狗默认锁过期时间 30 秒每 10 秒检查一次并续期到 30 秒续期间隔是 leaseTime 的 1/3。这样只要业务不结束且 JVM 不崩溃锁会一直续期。10.2 常见误区手动设置 leaseTime 后看门狗失效一旦显式调用lock(leaseTime, timeUnit)Redisson 会认为使用者自己管理过期时间从而关闭看门狗。很多开发者以为传 30 秒也能自动续期结果业务超过 30 秒后锁丢失这正是「坑 2」在 Redisson 里的重现。10.3 使用对比javaRLock lock redissonClient.getLock(lock:order:1001); // 推荐不传 leaseTime启用看门狗自动续期 lock.lock(); try { // 业务逻辑执行多久锁就续多久 } finally { lock.unlock(); } // 慎用显式 leaseTime过期后看门狗不会续期 lock.lock(30, TimeUnit.SECONDS); try { // 业务逻辑必须保证 30 秒内完成 } finally { lock.unlock(); }10.4 释放校验Redisson 的unlock()内部已通过 Lua 校验持有者普通调用即可。但如果当前线程没有持有锁会抛出IllegalMonitorStateException使用时务必保证加锁成功后再进入 finally 释放逻辑。十一、坑 9锁粒度太粗热点资源导致性能雪崩11.1 问题场景还原电商秒杀场景中如果所有下单请求都用一把全局锁例如lock:order那么任意时刻只有一个请求能进入其他请求全部排队性能甚至不如单机加 synchronized。11.2 错误做法示例java// 反例所有订单共用一把锁 lock.lock(lock:order, requestId, 30); try { createOrder(order); } finally { lock.unlock(lock:order, requestId); }11.3 正确做法按业务维度拆锁把锁粒度缩小到具体资源按用户、商品 SKU、订单号等维度拆分。java// 正例按 SKU 维度加锁 String lockKey lock:stock: skuId; lock.lock(lockKey, requestId, 30); try { deductStock(skuId, count); } finally { lock.unlock(lockKey, requestId); }如果单个 SKU 仍过热还可以引入分段锁、异步扣减或限流削峰避免锁成为唯一瓶颈。十二、坑 10缺少获取超时、重试、监控和降级的完整闭环12.1 获取锁不能无限阻塞生产环境必须给「获取锁」设置等待超时。否则一旦某把锁长时间无法获取线程堆积、连接池耗尽最终拖垮整个服务。12.2 重试的注意点采用固定间隔 随机抖动避免多个客户端同时醒来抢锁造成惊群。Redis 客户端连接需要合理的超时与重试配置避免网络抖动直接抛错。获取锁失败要有明确的业务兜底排队、降级或返回失败而不是无限自旋。12.3 监控指标锁等待时长体现竞争激烈程度。锁持有时长识别业务拖沓或死锁风险。续期次数验证看门狗是否工作正常。释放失败数校验 requestId 是否传错或存在并发释放问题。12.4 一个生产级加锁模板javapublic void processOrder(String orderId) { String lockKey lock:order: orderId; String requestId UUID.randomUUID().toString(); boolean locked false; long waitMillis 3000; long deadline System.currentTimeMillis() waitMillis; while (System.currentTimeMillis() deadline) { locked redisLock.tryLock(lockKey, requestId, 30); if (locked) { break; } try { Thread.sleep(50 ThreadLocalRandom.current().nextInt(50)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } if (!locked) { // 获取锁失败执行降级逻辑 throw new BizException(系统繁忙请稍后重试); } try { // 业务逻辑 doBusiness(orderId); } finally { redisLock.unlock(lockKey, requestId); } }十三、总结Redis 分布式锁看起来只是「加锁、解锁」两个动作但真正把它用对、用好需要理解其中的并发模型、原子性约束、高可用边界和降级策略。本文梳理的 10 个坑几乎覆盖了 Redis 分布式锁在生产环境中的所有常见问题SETNX EXPIRE 不原子用SET NX PX一条命令解决。过期时间拍脑袋设置用看门狗自动续期解决。释放锁直接 DEL用 Lua 校验持有者后删除。缺少可重入用 Hash 计数解决。释放次数与持有者校验不一致用唯一 requestId try/finally 解决。主从切换丢锁容忍概率 强一致场景换 ZooKeeper/etcd。续期与释放竞态先停看门狗再原子释放。Redisson 看门狗误用不传 leaseTime 才启用看门狗。锁粒度太粗按业务维度拆锁。缺少超时、重试、监控、降级补齐完整闭环。