Redis 分布式锁全解:从 SETNX 到 Redisson 看门狗,再到 RedLock
单机环境下加把 synchronized 锁就能搞定并发,为什么上了微服务就不灵了?因为 synchronized 的锁是"JVM 进程内的锁",管得了单个进程,管不了多个进程。订单服务一扩容到 3 个实例,三个进程同时扣库存,超卖照样发生——这就是分布式锁要解决的问题。
本文按一条演进链讲透:单机锁为什么不够 → 分布式锁的评判标准 → Redis 实现从 SETNX 到 Redisson 看门狗再到 RedLock 的五代演进 → 常见坑与实战选型。看完你能说清"手写 Redis 锁和 Redisson 到底差在哪"“RedLock 为什么吵了十年还没定论”。
一、先厘清:单机锁为什么不够
1.1 并发问题的本质
并发问题的根源只有一个:多个执行单元同时读写了共享资源,且读写不是原子的。典型场景:
- 电商扣库存:两个请求同时读到库存 1,各自减一,都写回 0——实际卖出 2 件,库存变 0,超卖。
- 重复提交:两个请求同时处理同一笔订单,各执行一遍发货逻辑。
解决思路也只有一个:把"读-改-写"变成互斥的临界区,同一时刻只允许一个执行单元进入。
1.2 synchronized 的边界:进程内锁
synchronized / ReentrantLock 是基于 JVM 内存模型实现的锁,它的作用域是单个 JVM 进程。判断依据很简单:
- 锁对象存在堆内存里,属于某个进程;
- 别的进程既看不到这把锁,也不受它约束。
所以当应用是单实例部署时,进程内锁完全够用;一旦变成多实例 / 微服务部署,同样的业务代码在 N 个 JVM 里各跑各的,进程内锁形同虚设。
🔴 重点:分布式锁的本质,是把"互斥的判定点"从进程内搬到所有进程都能访问的公共组件上(Redis、ZooKeeper、etcd、数据库都行)。
1.3 分布式锁的定义与核心要求
分布式锁:让多个进程在访问共享资源时,通过一个外部协调组件实现互斥的机制。一把合格的分布式锁至少要满足:
| 互斥性 | 任意时刻只有一个客户端持有锁 | 并发进入临界区,数据错乱 |
| 防死锁 | 持有者崩溃后锁能自动释放(过期机制) | 锁永远不释放,业务永久卡死 |
| 防误删 | 只能释放自己持有的锁(唯一标识校验) | A 释放掉 B 的锁,互斥被破坏 |
| 原子性 | 加锁、解锁的关键步骤不可分割 | 中间步骤失败留下脏状态 |
| 可重入/可续期 | 同线程可重入;长任务锁不过期 | 死锁或锁提前失效 |
✅ 一句话:互斥是底线,防死锁是保底,防误删是礼貌,原子性是技术前提。后面每一代 Redis 实现,都是在补齐这几条。
二、基于 Redis 的实现演进(核心章节)
🔴 本章是全文核心。记住一个总览:Redis 分布式锁的每一代演进,都是在补上一代的漏洞——1.0 不防死锁,2.0 防死锁但不防误删,3.0 防误删但不防"业务没跑完锁先过期",4.0(Redisson)解决续期与可重入,5.0(RedLock)试图解决主从切换丢锁。
2.1 1.0 版本:SETNX + 手动 DEL —— 会死锁
Redis 的 SETNX(Set if Not eXists)天然适合做互斥:key 不存在才设置成功,存在就失败。于是第一版锁长这样:
# 加锁:成功返回 1,失败返回 0
> SETNX lock:order 1
(integer) 1
# … 业务逻辑 …
> DEL lock:order # 释放锁
问题:如果业务代码在 DEL 之前抛异常、进程崩溃或机器宕机,锁永远不会被删除,其他进程永远拿不到锁——直接死锁。
⚠️ 避坑:单独调 EXPIRE 也不行。SETNX 和 EXPIRE 是两条独立命令,中间挂了同样死锁。两条命令的"非原子性"本身就是漏洞。
2.2 2.0 版本:SET NX EX —— 防死锁,但不防误删
Redis 2.6.12+ 给 SET 命令加了扩展参数,把"加锁"和"设过期时间"合并成一条原子命令。Redis 官方文档明确给出的锁写法就是:
> SET lock:order 1 NX EX 30
OK # 成功拿到锁,30 秒后自动过期
# … 业务逻辑(若超过 30 秒,锁自动释放)…
> DEL lock:order
- NX:key 不存在才设置(互斥);
- EX 30:30 秒后过期(防死锁,进程崩了锁也会自动释放)。
问题:解锁时直接 DEL,没有校验"这把锁是不是我的"。典型事故场景——进程 A 持锁,业务执行超过 30 秒,锁自动过期;进程 B 拿到锁开始干活;此时 A 终于执行完,一个 DEL 把 B 的锁删了,进程 C 又趁机拿到锁,三个进程同时进临界区。
⚠️ 避坑:锁过期时间设置是门学问。设短了业务没跑完锁就没了,设长了进程崩溃后其他进程要干等。2.0 的解法很粗暴:让业务"必须在锁过期前跑完",做不到就出问题。
2.3 3.0 版本:value 唯一标识 + Lua 脚本解锁 —— 防误删
核心改进两处:
# 加锁:value 放本客户端唯一标识
> SET lock:order 9f8a2b3c-xxxx NX EX 30
OK
# 解锁:Lua 脚本,校验 value 一致才 DEL(原子操作)
— unlock.lua
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
// Java 侧调用示意(jedis)
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
jedis.eval(script, 1, "lock:order", lockValue);
✅ 成功标志:A 现在删不掉 B 的锁了(value 不匹配,Lua 返回 0 不删)。
仍存在的问题:业务执行超过 30 秒时锁照常过期——value 校验再严格,也拦不住"锁提前失效、B 提前进入"。这要等 Redisson 的看门狗来解决。
2.4 4.0 版本:Redisson —— 看门狗自动续期 + 可重入
Redisson 是 Java 生态最主流的 Redis 分布式锁客户端库,它把上面所有补丁做成了开箱即用的封装。两个核心能力:
① 看门狗(Watchdog)自动续期
用 lock() 且不指定 leaseTime 时,Redisson 启动一个后台定时任务:
- 默认锁有效期 30 秒(lockWatchdogTimeout,可配置);
- 后台线程每 10 秒(30 秒的 1/3)检查一次:若当前线程仍持有锁,就把过期时间刷新回 30 秒;
- 只要持有锁的 JVM 还活着,锁就永远不过期;JVM 一旦挂掉,看门狗停止续期,锁在最多 30 秒内自动释放——既不会提前失效,也不会永久死锁。
// Redisson 使用示例
RLock lock = redisson.getLock("lock:order");
lock.lock(); // 不传 leaseTime → 启用看门狗,默认 30s 自动续期
try {
// 业务逻辑,执行多久锁都在
} finally {
lock.unlock(); // 释放锁
}
⚠️ 注意:lock(leaseTime) 指定了过期时间就不会启动看门狗。传了 leaseTime 等于放弃自动续期,回到 3.0 的"必须在期限内跑完"模式。
② 可重入
同一线程可重复获取同一把锁(内部用哈希结构记录持有线程 + 重入次数),解锁时计数减一,为 0 才真正删除——行为和 ReentrantLock 一致,防止自己锁自己。
仍未解决的问题:主从切换丢锁。若 Redis 用主从架构,客户端 A 在 master 上加锁成功,master 还没来得及同步到 slave 就宕机,slave 提升为 master——新的 master 上没有这把锁,客户端 B 就能加锁成功,互斥被打破。这是 RedLock 诞生的直接动因。
2.5 5.0 版本:RedLock —— 多节点半数加锁,以及那场著名论战
RedLock 思路:不再信任单个 Redis 实例,而是向 N 个互相独立的 Redis 节点(官方建议 5 个)同时尝试加锁:
意图:个别节点挂掉、主从切换丢锁都不影响,因为只要多数节点还承认这把锁,互斥就成立。
这场著名的论战(2016 年 2 月,分布式领域名场面):
- Martin Kleppmann(《数据密集型应用系统设计》作者)发表《How to do distributed locking》批评 RedLock,核心两点:
- 时序假设不成立:RedLock 隐含"网络延迟有界、进程暂停有界、时钟同步"三个假设。一次长 GC 暂停(比如 35 秒)就能让客户端以为还持有锁,实际锁早已过期被别人拿走——GC 暂停没法用过期时间防御;
- 没有 fencing token:锁应该携带一个每次获取都递增的令牌,让资源侧校验"我这个操作是不是最新的"。RedLock 只有随机值,资源侧无法拒绝旧客户端的迟到写入。
- antirez(Redis 作者)随即发文《Is Redlock safe?》辩护:论战不是"安全/不安全"的二元问题,而是锁的安全模型与使用场景的匹配问题;对多数应用,RedLock 提供的是"合理范围内的实用安全性"。
✅ 双方罕见的一致:如果资源侧支持 fencing token(每次操作带递增序号,资源侧拒绝旧序号),即使锁失效也能兜底——这才是终极解。Redis 锁提供不了这个,数据库版本号、ZooKeeper 的递增 zxid 可以。
RedLock 的实践结论(务实版):RedLock 成本高、收益有争议,生产环境绝大多数场景不需要上 RedLock;更值得做的是"锁 + 业务幂等 + 资源侧校验"的组合。
三、常见坑与实战建议
| 主从切换丢锁 | master 未同步锁就宕机,新 master 无锁 | RedLock;或用读写分离下的一致性策略;最务实的是接受小概率并用幂等兜底 |
| GC 停顿致锁过期 | 业务线程 STW 35s,锁 30s 过期,别的进程进入 | 看门狗续期只能缓解不能根治(GC 停顿期间续期线程也停了);关键操作配 fencing token 或幂等 |
| 时钟跳跃 | 依赖 TTL 过期,服务器时钟回拨导致锁提前/延后失效 | 避免依赖系统时钟的强一致场景;Redis 侧开启保护,监控时钟偏差 |
| 续期失败 | 网络抖动导致看门狗续期命令失败 | Redisson 看门狗有重试;监控续期失败率 |
| 忘了 finally 解锁 | 异常路径不释放锁 | 一律 try/finally;用 Redisson 的 lock() 时优先 tryLock 带超时 |
实战建议(可照抄):
四、对比与选型
| 手写 SETNX(1.0~3.0) | Redis SET/Lua | 过期时间 | 低(主从切换丢锁) | 低 | 学习、临时脚本、非关键路径 |
| Redisson 单实例 | Redis + Lua + 看门狗 | 过期 + 自动续期 | 中(仍受主从切换影响) | 低 | 绝大多数业务首选 |
| RedLock | 多节点半数加锁 | 过期 + 多数派 | 中(有时序假设争议) | 高 | 要求较高的场景,需评估成本 |
| ZooKeeper | 临时顺序节点 + Watch | 会话超时自动删 | 高(CP 一致性) | 中 | 正确性敏感、对性能不极致敏感 |
| etcd | Lease + Revision 前缀 | 租约过期 | 高(Raft) | 中 | 云原生/K8s 生态,配置与锁并存 |
选型规则(结论前置):
- 默认选 Redisson 单实例(配好主从/哨兵/集群),覆盖 90% 场景;
- 正确性敏感、能接受更重依赖 → ZooKeeper / etcd;
- 追求极致简单、业务可容忍偶发并发 → 手写 3.0 版(原子加锁 + Lua 解锁);
- RedLock:能不上就不上,上了也别把它当"绝对安全"——它解决的是主从丢锁,不是 GC 停顿。
五、总结:你真正需要记住的 N 件事
验证清单
- 能解释"为什么微服务多实例下 synchronized 失效"
- 能手写 3.0 版锁:原子加锁 + Lua 校验解锁
- 能说清看门狗的触发条件(不指定 leaseTime 才启用)和默认参数(30s 锁、10s 续期)
- 能说清主从切换丢锁的原理和 RedLock 的应对思路
- 能复述 Kleppmann 批评 RedLock 的两点核心论据(时序假设、fencing token)
- 能在面试中答出"生产环境你用哪个方案、为什么"
参考资源
- Redis 官方文档:Distributed Locks with Redis(含 RedLock 分析与实现清单)
- Redis 官方文档:SET 命令(SET key value NX EX 官方锁写法)
- Redisson 官方文档:Locks and Synchronizers(看门狗机制与 lockWatchdogTimeout 配置)
- Martin Kleppmann:How to do distributed locking(2016,对 RedLock 的批评)
- antirez:Is Redlock safe?(2016,对批评的回应)
- 《Redis 设计与实现》《深入理解分布式系统》——分布式锁与一致性基础参考




