分布式锁:从 SET NX 到 Redlock
多个服务实例同时处理同一个订单、同时刷新同一份缓存——跨进程的互斥需要分布式锁。Redis 因为快、原子命令丰富,成了最流行的分布式锁载体。但"能用"和"正确"之间隔着好几个坑,本章一个一个填。
1. 第一版:SET NX EX
# 进程 A 加锁: key 不存在才能设置成功, 同时带 30 秒过期
SET lock:order:8801 "1" NX EX 30
# 进程 B 来抢同一把锁: 返回 nil, 抢锁失败, 等待重试
SET lock:order:8801 "1" NX EX 30
# 锁会自动过期, 持有者崩溃也不会死锁
TTL lock:order:8801
# A 业务完成, 释放锁
DEL lock:order:8801
# 现在 B 可以抢到了
SET lock:order:8801 "1" NX EX 30三个设计点缺一不可:
- NX:不存在才设置 = 互斥;
- EX 30:过期时间 = 持有者崩溃后锁自动释放,不死锁;
- 一条命令完成:NX 和 EX 必须在同一条 SET 里(原子),分成 SETNX + EXPIRE 两步,中间崩溃就死锁。
2. 第二个坑:锁被别人释放
场景推演:
t0: 进程 A 拿到锁, TTL 30 秒
t1: A 执行业务, 遇到长 GC / 慢 SQL, 耗时 35 秒
t2: 锁在 30 秒时过期, 进程 B 拿到了锁
t3: A 终于干完活, 执行 DEL —— 删掉的是 B 的锁!
t4: 进程 C 又拿到了锁。现在 B 和 C 同时在临界区里问题根源:DEL 不分青红皂白。解法:value 存唯一标识(UUID),释放前先验证是自己的锁。
# 加锁时 value 用本次请求的唯一 ID
127.0.0.1:6379> SET lock:order:8801 "a1b2c3-uuid-of-A" NX EX 30
OK但"GET 判断 + DEL"是两步,判断完、删除前锁恰好过期易主,还是会误删。必须用 Lua 保证原子:
-- unlock.lua: 是自己的锁才删
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end压成一行运行,对比"拿错 uuid"与"拿对 uuid"两种结果:
# A 加锁, value 是 A 的唯一标识
SET lock:order:8801 "uuid-of-A" NX EX 30
# B 拿着自己的 uuid 来释放: 校验不通过, 返回 0, 锁纹丝不动
EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end" 1 lock:order:8801 uuid-of-B
EXISTS lock:order:8801
# A 用自己的 uuid 释放: 返回 1, 释放成功
EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end" 1 lock:order:8801 uuid-of-A
EXISTS lock:order:8801到这里,一个基本正确的单机 Redis 锁已经成型:SET key uuid NX EX ttl 加锁 + Lua 校验释放。
3. 第三个坑:业务没干完锁先过期
TTL 设多长?太短业务没完锁就飞了,太长持有者崩溃后别人干等。工程解法是看门狗(watchdog)续期:
加锁成功, TTL 30 秒
|
+--> 启动后台线程: 每 10 秒 (TTL/3) 检查
业务还在执行? --是--> PEXPIRE 锁 重置为 30 秒
--否/进程已死--> 不续期, 锁自然过期续期同样要校验身份(Lua):
-- renew.lua: 是自己的锁才续期
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('PEXPIRE', KEYS[1], ARGV[2])
else
return 0
endSET lock:order:8801 "uuid-of-A" NX EX 30
TTL lock:order:8801
# A 的看门狗续期: 校验通过, TTL 重置为 60 秒
EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('PEXPIRE', KEYS[1], ARGV[2]) else return 0 end" 1 lock:order:8801 uuid-of-A 60000
TTL lock:order:8801
# 别人的看门狗想续期: 返回 0, 续不了
EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('PEXPIRE', KEYS[1], ARGV[2]) else return 0 end" 1 lock:order:8801 uuid-of-B 60000
TTL lock:order:8801Java 的 Redisson 内置了这一整套(默认 TTL 30 秒、每 10 秒续期),还提供可重入、公平锁、读写锁:
RLock lock = redisson.getLock("lock:order:8801");
try {
// 不带 leaseTime 参数 => 自动启用看门狗续期
if (lock.tryLock(5, TimeUnit.SECONDS)) {
doBusiness();
}
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}自己手写客户端时,别忘了看门狗的反面:业务线程卡死但进程没死,看门狗会无限续期,锁永不释放。所以续期要有最大次数上限。
4. 第四个坑:主从切换丢锁
单机 Redis 锁的天花板:
t0: A 在主库上加锁成功
t1: 主库宕机, 锁的写入还没复制到从库 (异步复制!)
t2: 哨兵切换, 从库升主 —— 新主库上没有这把锁
t3: B 在新主库加锁成功
=> A 和 B 同时持有"同一把锁"这不是实现 bug,是异步复制的固有窗口。两条路:
- 接受它:概率低、业务有兜底(数据库唯一约束、幂等设计)时,单实例锁足够——这是大多数业务的选择;
- Redlock:官方提出的多实例方案。
5. Redlock 及其争议
思路:部署 5 个完全独立的 Redis(不是主从,是 5 个孤立单机),加锁时:
1. 记录起始时间
2. 依次向 5 个实例发 SET key uuid NX PX 30000 (每个有很短的超时)
3. 在多数派 (>=3) 实例上成功, 且总耗时 < 锁有效期
=> 加锁成功, 实际有效期 = 30000 - 加锁耗时
4. 失败 => 向所有实例发释放脚本, 稍后重试单实例宕机不影响多数派,主从切换问题不存在(没有主从)。
但分布式系统专家 Martin Kleinmann 在著名的辩论中指出它的软肋:
- 时钟跳变:某实例的系统时钟被 NTP 大幅回拨/前调,锁提前过期,多数派被破坏;
- 进程暂停:客户端拿到锁后陷入长 GC,醒来时锁早过期了,它却不知道自己已无锁——任何带 TTL 的锁都有这个问题,Redlock 没解决;
- 结论:Redis 锁适合效率场景(防止重复干活,偶尔失效代价可控);正确性场景(绝不允许两个进程同时进临界区,否则钱就错了)应该用有 fencing token 的系统(ZooKeeper/etcd),或者在资源端做防护(数据库乐观锁版本号)。
Redis 作者 antirez 也做了回应与反驳,这场辩论本身就是最好的教材。工程上的务实结论:
| 场景 | 方案 |
|---|---|
| 防缓存重复重建、定时任务防重跑 | 单实例 SET NX EX + Lua 释放,足够 |
| 一般业务互斥(有幂等兜底) | 单实例或主从 + 哨兵,接受极小窗口 |
| 涉及资金等强正确性 | 锁只做优化,最终正确性靠数据库约束/版本号 |
1)NX 与 EX 是否在一条 SET 里?2)value 是否为唯一标识?3)释放是否用 Lua 校验身份?4)TTL 是否大于业务 P99 耗时,或有看门狗?5)获取锁失败的重试是否有退避与上限?6)Cluster 环境锁 key 是否考虑了 hash tag(锁与相关数据同槽以便 Lua 操作)?7)临界区内的操作是否幂等(锁失效的兜底)?
锁 key 要尽量细:锁"订单 8801"(lock:order:8801)而不是锁"所有订单"(lock:orders)。粒度粗一档,并发能力就掉一个量级。
小结
- 正确的单机锁 = SET key uuid NX EX ttl 加锁 + Lua 校验身份释放
- 锁过期与业务超时的矛盾用看门狗续期解决(注意续期上限)
- 主从异步复制导致切换丢锁;Redlock 用 5 个独立实例的多数派规避,但有时钟与进程暂停的理论争议
- 务实原则:效率场景 Redis 锁随便用;正确性场景必须在资源端加最后防线
- 下一章:缓存模式与穿透、击穿、雪崩 →
- 用两个终端演练完整锁流程:A 加锁(带 UUID),B 抢锁失败,A 用 Lua 脚本释放,B 重试成功。
- 复现"误删":A 加锁 TTL 5 秒,等 6 秒后直接 DEL——验证删掉的是 B 的锁;再改用 Lua 校验版验证不会误删。
- 写出你所在业务一个需要分布式锁的场景,按"自查清单"逐项过一遍,判断单实例锁是否够用。