一、引言:为什么分布式锁如此重要
随着互联网业务的不断发展,单体架构逐渐被微服务、分布式架构所取代。在单体应用中,多个线程之间可以通过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:误用SETNX+EXPIRE,两个命令不原子
3.1 问题场景还原
在上面的「朴素」实现中,我们使用了两个独立的命令:SETNX(SET 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等扩展参数,可以在一条命令内同时完成「不存在才设置」和「设置过期时间」两个动作,从而保证原子性:
text
SET key value NX EX 30
各参数含义如下:
NX:仅当 key 不存在时才设置,等价于
SETNX的语义。XX:仅当 key 已存在时才设置(注意与 NX 互斥)。
EX seconds:设置过期时间,单位为秒。
PX milliseconds:设置过期时间,单位为毫秒。
使用 Java 代码(以 Jedis 为例)表示如下:
java
import 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 中同样支持:
java
import 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(RedisCommands<String, 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」中详细展开:
java
import 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 侧调用示例:
java
public 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 问题场景还原
考虑下面这段业务代码:
java
public 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 释放不同资源。
十、坑 8:Redisson 看门狗的正确打开方式
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 使用对比
java
RLock 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 一个生产级加锁模板
java
public 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 才启用看门狗。
锁粒度太粗:按业务维度拆锁。
缺少超时、重试、监控、降级:补齐完整闭环。