为什么需要分布式锁
单机环境下用 synchronized 或 ReentrantLock 就能搞定的事,一旦上了微服务、跑在多台机器上,就立刻变成另一个量级的问题。
举几个最典型的场景:
- 下单扣库存:同一个 SKU 在多实例同时被下单,怎么保证库存不卖超?
- 定时任务防重:每个微服务实例都跑了定时调度,怎么让"全局只有一个执行"?
- 接口幂等:用户连点两次,怎么保证只处理一次?
- 资源独占:同一个用户、同一个文件,同时只能由一个请求修改?
进程内的锁帮不了你,因为它们彼此根本不知道对方存在。要在跨进程、跨机器的环境里"约定一个谁能进谁不能进",就需要一个所有节点都信任的第三方——Redis 是最常见的选择。
但 Redis 分布式锁这件事,看着简单,做对极难。下面我们从最朴素的写法一路演进,把每个版本里"看似能用其实不行"的坑摊开。
姿势一:SETNX + EXPIRE(错误示范)
刚接触分布式锁的人,最常写出来的版本是这样:
| |
逻辑看起来很自然——抢到了就设过期时间防止死锁。但这里藏着一个致命缺陷:
SETNX和EXPIRE是两条命令,不是原子操作。
如果在 SETNX 成功之后、EXPIRE 之前进程崩了、网络抖了、Redis 主从切换了,那这把锁就永远没有过期时间——所有后续请求都会被它挡住,直到运维手动删 key。
姿势二:SET key value NX EX 一条命令搞定
Redis 2.6.12 之后,SET 命令支持了原子的 NX + EX 选项,这就是真正可用的"上锁"操作:
| |
NX 保证只在 key 不存在时才生效,EX 同时设置过期时间,整个操作是原子的,没有了"SETNX 后 EXPIRE 前"那个空窗。
但这只是把"上锁"做对了,“解锁"还藏着一个更隐蔽的坑。
姿势三:解锁的"误删别人锁"问题
一个看似无害的解锁实现:
| |
设想一个时序:
| 时刻 | 客户端 A | 客户端 B |
|---|---|---|
| t1 | 加锁成功,过期时间 10s | |
| t2 | 业务执行卡住了 12s(GC、网络等) | |
| t3 | 锁已过期,B 加锁成功 | |
| t4 | A 醒来,调用 unlock 直接 DEL | |
| t5 | B 还在执行,锁却没了 |
A 删掉了 B 的锁。后续的 C 又能进来,锁的互斥语义彻底失效。
正确做法:解锁前先验证 value
加锁时把 value 设成一个唯一标识(比如 UUID),解锁时验证 value 是自己的才删:
| |
注意:判断 + 删除必须用 Lua 脚本保证原子性。如果分两步——先 GET 再 DEL,又会回到"判断后 DEL 前锁过期了"的老问题。
姿势四:业务执行时间不可控——锁自动续期(看门狗)
即便解锁做对了,“业务执行时间超过锁过期时间"本身就是个隐患。永远把过期时间设很长?万一这个客户端进程真的死了,锁就长时间得不到释放。
更优雅的做法是自动续期:客户端在持有锁期间,启动一个后台线程定时把锁的过期时间往后顺。
伪代码:
| |
业务结束后停掉 watchdog 并解锁。
这套机制如果自己手写很容易出错,所以——绝大多数生产项目直接用 Redisson。
姿势五:Redisson——工业级实现
Redisson 是 Redis 的 Java 客户端,把分布式锁封装得相当成熟,自带可重入、自动续期、公平锁、读写锁等高级特性。
| |
Redisson 内部干了什么:
- 加锁用 Lua 脚本,原子完成"判断 + 设置 + 续期间隔记录”
- 可重入通过 Hash 结构记录"持有者 + 重入次数”
- 看门狗每 1/3 过期时间触发一次续期,业务不结束锁不释放
- 解锁也用 Lua 脚本,安全地把重入计数减一直到删除
业务侧能做到"加锁就像 synchronized 一样自然",这正是它的价值。
⚠️ 看门狗的关键陷阱:看门狗只在你不显式指定
leaseTime时才会启用——也就是lock.lock()/lock.lock(-1, ...)/tryLock(waitTime, unit)。一旦你调用lock(leaseTime, unit)或tryLock(waitTime, leaseTime, unit)显式传了leaseTime,看门狗会被关闭,锁到期后直接释放,不再续期。这是初学者最容易踩的坑——看着代码"用了 Redisson 应该有看门狗",实际锁早就过期了。
姿势六:Redlock 与"红锁争议"
前面所有方案都建立在一个假设上——Redis 单机或主从是可靠的。但现实中有两个隐患:
- 单机 Redis 是单点,挂了所有锁都丢
- Redis 主从有异步复制延迟。主刚加完锁还没复制到从,主挂了,从被选为新主,新主上没有这把锁——新的客户端就能再加一次
Redis 作者 antirez 为此提出了 Redlock 算法:
部署 N 个互相独立的 Redis 实例(比如 5 个),客户端尝试在这 N 个实例上依次加锁。只有在 大多数实例(≥ N/2 + 1) 都加锁成功,且总耗时小于锁过期时间,才认为加锁成功。
Redisson 也实现了 Redlock:RedissonRedLock。
Martin Kleppmann 的反驳
不过 Redlock 在学界一直有争议。著名分布式系统专家 Martin Kleppmann 写过一篇 “How to do distributed locking”,指出 Redlock 在以下场景下并不安全:
- 客户端 GC 暂停——加锁后业务卡了几秒,锁早过期了,业务以为自己还持有锁继续操作
- 时钟漂移——节点之间的系统时钟不一致,过期时间判断会失准
他的结论是:对正确性要求极高的场景,应该用 ZooKeeper / etcd 这类基于一致性协议的系统;Redis 锁更适合"性能优先、偶尔失误可接受"的场景。
工程上多数业务场景用 Redisson 单实例锁就足够——性能高、用法简单;只有对正确性极敏感的少数场景才上 ZooKeeper。
各姿势对比
| 姿势 | 安全性 | 复杂度 | 推荐度 |
|---|---|---|---|
SETNX + EXPIRE | ✗ | 低 | 别用 |
SET NX EX | △ | 低 | 学习用,生产不够 |
| 加 value 校验解锁 | ◯ | 中 | 自己造轮子的最低底线 |
| 加 Watchdog 续期 | ◯ | 高 | 自己造轮子的上限 |
| Redisson 单实例 | ◯ | 低 | 绝大多数业务的最佳选择 |
| Redlock / RedissonRed | ◯+ | 中 | 可用但有争议,慎用 |
| ZooKeeper / etcd 锁 | ✓ | 中高 | 强一致性要求场景 |
实战中的几条建议
- 永远用
tryLock带超时,不要lock()死等 - 加锁粒度尽量细——别把整个订单创建过程锁起来,只锁库存扣减那一段
- 业务和锁解耦——业务方法里只关心
lock.tryLock()/lock.unlock(),不要散落 Redis 命令 - 过期时间 ≥ 业务最长执行时间 × 2,给极端情况留缓冲,再配合 watchdog
- 解锁放
finally——别让一段异常跳过解锁 - 日志里打出 requestId——锁出问题排查链路全靠它
| |
isHeldByCurrentThread() 这个判断很重要——避免在某些异常路径下解了不属于自己的锁。
如果你确实需要"超时强制释放"作为兜底(防止业务永远卡死把锁吃住),就显式传
leaseTime:lock.tryLock(3, 30, TimeUnit.SECONDS)。但要清楚这种写法没有看门狗——leaseTime必须 ≥ 业务最长执行时间 × 2,否则中途锁掉的风险会回来。
小结
Redis 分布式锁这件事,入门一行代码,做对几十行细节。
把这条演进路径压缩成一句话:
从 SETNX 到 Redisson,每一步演进都是在堵一个『看似能用其实不行』的坑。
工程上的选择反而很简单:
- 新项目:直接上 Redisson,别造轮子
- 极致正确性:上 ZooKeeper / etcd
- 学习理解原理:把上面六种姿势从头写一遍
理解了这些演进,下次再有人问你"Redis 锁怎么用",你就不会只回一句 SET NX EX 了——你会先反问:“你的业务能容忍偶尔失效吗?锁持有时间可控吗?需要可重入吗?”