分布式锁
一个正确的锁需要满足什么
- 互斥 —— 同一时刻只有一个客户端持有
- 不死锁 —— 持有者崩溃也能自动释放
- 只能释放自己的锁 —— 不能误删别人的
- 可重入(可选)—— 同一线程能重复获取
从错误写法演进到正确写法
❌ 错误 1:SETNX + EXPIRE 两条命令
SETNX lock_key 1
EXPIRE lock_key 10
两条命令之间崩溃 → 锁没有过期时间 → 永久死锁。
❌ 错误 2:加锁原子了,但释放时直接 DEL
SET lock_key 1 NX EX 10
# ... 业务执行超过 10 秒,锁已自动过期,别人拿到了锁
DEL lock_key # 删掉的是别人的锁!
✅ 正确写法:唯一标识 + Lua 原子释放
加锁(一条命令,原子):
SET lock_key <unique_value> NX EX 10
unique_value 用 UUID 或 机器ID:线程ID,用于证明锁的归属。
释放(Lua 脚本保证「判断 + 删除」原子):
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
为什么必须用 Lua:如果分成 GET 判断再 DEL,两步之间锁可能过期并被别人获取,DEL 依然会误删。Redis 执行 Lua 脚本是单线程原子的。
锁过期但业务没执行完
这是分布式锁最难的问题。TTL 设短了业务没跑完锁就没了(互斥被破坏),设长了持有者崩溃后别人要等很久。
解法:看门狗(watchdog)续期。Redisson 的做法:
- 加锁时不设固定长 TTL,默认 30 秒
- 后台起一个定时任务,每
TTL/3(10 秒)检查一次,如果锁还被当前线程持有就重置 TTL 为 30 秒 - 客户端崩溃 → 续期任务也随之消失 → 锁在 30 秒内自动释放
仍无法完全解决的情况:客户端发生长时间 GC 停顿(STW)或网络分区,续期没发出去,锁过期,别人拿到锁,而原客户端恢复后以为自己还持有锁继续写数据 → 两个客户端同时在临界区。
对此唯一可靠的兜底是在下游做幂等或 fencing token:每次获锁时拿一个单调递增的版本号,写存储时带上这个号,存储层拒绝比已见过的更小的版本。这样过期的持有者即使恢复也写不进去。
:::warning 这是重点追问 「锁过期了但业务没执行完怎么办」几乎必问。答「watchdog 续期」只是一半,能主动补上「续期也不能 100% 保证,需要下游幂等 / fencing token 兜底」会明显拉开差距。 :::
Redlock 与它的争议
Redlock 算法(Redis 作者 antirez 提出,用于多个独立 Redis 节点,不是主从):
- 获取当前时间
T1 - 依次向 N 个(通常 5 个)相互独立的 master 请求加锁,每次请求设置很短的超时
- 当且仅当从超过半数(N/2+1)节点拿到锁,且总耗时
T2 - T1小于锁的 TTL,才算加锁成功 - 锁的真实有效期要减去获取锁消耗的时间
- 失败则向所有节点发起释放
为什么需要它:单主从架构下,主库加锁成功、还没同步给从库就宕机,哨兵把从库提升为主库,新主库上没有这把锁 → 两个客户端同时持有锁。Redlock 用多数派规避了这个问题。
Martin Kleppmann 的批评(经典争论,值得知道):
- 分布式锁有两种用途:效率(避免重复做同一件事,偶尔重复无妨)和正确性(重复执行会导致数据错误)。对效率场景单节点 Redis 就够了;对正确性场景 Redlock 也不够
- Redlock 依赖各节点时钟不发生跳变。如果某节点时钟被 NTP 大幅校正,锁可能提前过期
- GC 停顿和网络延迟使得「我持有锁」这个判断在任意时刻都可能已经失效,仅靠锁服务无法保证正确性
- 结论:要正确性就必须在存储层做 fencing,那么锁本身的强度就不那么关键了
antirez 的回应是:Redlock 的时钟假设是「不发生大幅跳变」而非「完美同步」,这在实践中可接受;fencing token 的要求对很多存储来说不现实。
面试怎么答:说清两种用途的区分 + 存储层 fencing 才是正确性的终点,比站队哪一方更重要。真要强一致的锁,用 ZooKeeper 或 etcd(基于 Raft,有 lease 和 revision 机制,revision 天然就是 fencing token)。
工程实践要点
- 别自己手写,用 Redisson(Java)或成熟库,它们已处理续期、可重入、公平锁等细节
- 可重入靠 Hash 结构实现:
HSET lock_key <client_id> <重入次数>,释放时递减到 0 才真删 - 锁粒度要细 —— 锁用户维度而不是全局,否则全部请求串行
- 先尝试获取再快速失败,别无限阻塞等待,配合超时和重试次数
- 临界区里别做 RPC 和 IO —— 拉长持有时间,放大所有风险