下单扣库存、定时任务防重、接口幂等窗口、分片任务抢占——多进程/多实例同时触达同一共享资源时,都需要互斥。本地 synchronized 或 Mutex 只覆盖本机;跨进程时,工程上常见做法是把锁状态放到 Redis 这类高可用共享存储里。
本文依据 Redis 官方 Distributed Locks with Redis、SET 命令文档(含 Patterns)、Scripting with Lua 与 GETDEL 说明,把单实例正确加解锁、误删他人锁、主从异步复制下的安全边界、锁续期,以及 Redlock/fencing token 的工程取舍讲清楚,并给出可复现命令实验与排错清单。它和本站「缓存穿透/击穿/雪崩」互补:那篇讲缓存一致性与流量保护,本篇讲跨实例互斥原语本身。
一、问题背景:分布式互斥要什么
官方把有效的分布式锁最小保证概括为三条(Safety / Liveness):
| 属性 | 含义 | 工程直觉 |
|---|---|---|
| 互斥(Safety) | 任意时刻最多一个客户端持有锁 | 两台机器不能同时“以为自己锁住了” |
| 无死锁(Liveness A) | 持锁客户端崩溃或分区后,锁最终可被再次获取 | 必须有 TTL / 自动释放 |
| 容错(Liveness B) | 多数 Redis 节点存活时仍可加解锁(面向 Redlock 场景) | 单点故障与多数派策略 |
只满足“看起来能 SET 成功”不够。真正会在生产踩雷的,通常是:
- 加锁非原子:
SETNX成功后再EXPIRE,进程在中间崩溃 → 永久锁死; - 解锁不安全:业务超时后锁已过期被别人抢走,本客户端仍
DEL→ 删掉别人的锁; - 无 token 签名:所有客户端写同一个固定 value,无法判断“这把锁还是不是我的”;
- 把主从 failover 当强一致:异步复制下会出现双持锁窗口。
二、核心机制:单实例正确加解锁
2.1 原子加锁:SET key token NX PX ttl
官方单实例推荐写法(毫秒 TTL 示例):
SET resource_name my_random_value NX PX 30000
对应语义(见 SET 文档):
| 选项 | 作用 |
|---|---|
NX | 仅当 key 不存在时才设置 |
PX milliseconds | 以毫秒设置过期(也可用 EX seconds) |
| value = 随机 token | 标识“这把锁属于哪个客户端/哪次加锁” |
SET 成功返回 OK 表示抢到锁;key 已存在时(NX 条件不满足)返回空,表示未抢到。NX 与过期时间在同一条命令里完成,避免“加锁成功但没 TTL”的经典坑。
SET 文档的 Patterns 小节也写了同类形态:
SET resource-name anystring NX EX max-lock-time
并强调:简单 DEL 解锁可工作,但更稳健的做法是 token + 条件删除。
2.2 为什么 value 必须是随机 token
官方要求 value 在所有客户端、所有加锁请求间唯一。建议量级约 20 字节随机(如 /dev/urandom);也可用“微秒时间戳 + 客户端 ID”等折中方案(安全性略弱,但多数场景够用)。
token 的唯一用途:解锁时证明“我仍是当前持有者”,避免过期后误删他人锁。
2.3 安全解锁:条件删除,而不是裸 DEL
错误写法:
DEL resource_name
时序灾难:
- 客户端 A 加锁,TTL = 30s;
- A 业务卡住超过 30s,key 过期;
- 客户端 B 成功
SET NX拿到同一资源; - A 恢复后执行
DEL→ B 的锁被删掉,C 又能进入临界区。
官方正确路径有两层:
(1)Redis 8.4+:DELEX 条件删除(文档写法)
DELEX key IFEQ my_random_value
(2)更通用:Lua 脚本(8.4 之前也适用)
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
调用形态:
EVAL "<script>" 1 resource_name token-value
GET 比较与 DEL 在脚本内完成。官方 Scripting with Lua 明确:脚本在服务器侧原子执行,执行期间阻塞其他活动——这对“校验 token 再删除”至关重要,客户端侧的 GET + DEL 两步命令不是安全等价物。
注意:
GETDEL会“读出并删除”,但它不校验 token,不能单独当作安全解锁命令。
2.4 锁有效期(lock validity time)的双重含义
TTL 既是:
- 自动释放时间(持锁者崩溃后的死锁自愈);
- 持锁者必须在此窗口内完成工作的时间预算。
互斥保证是时间窗口内的互斥,不是“进程活着就永远持锁”。业务若可能超过 TTL,要么加大合理 TTL,要么做续期,并在业务层准备好超时后的幂等/回滚。
三、主从复制为什么不够“天然安全”
单实例方案在“偶尔双持可接受”的业务里很实用;但若把 Redis 主从 + 自动 failover 当成强一致锁,会踩官方点名的竞态:
- 客户端 A 在 master 上拿到锁;
- master 在把该写复制到 replica 前崩溃;
- replica 升主;
- 客户端 B 在新 master 上再次
SET NX成功 → Safety 被破坏。
原因:Redis 复制默认是异步的。官方态度很务实:若故障场景下短暂双持可接受,复制方案可用;否则应认真评估 Redlock 或更强的一致性组件,并在业务侧使用 fencing token。
四、Redlock 概要:多数派 + 耗时校验
Redlock 假设有 N 个独立 Redis master(示例 N=5,互不依赖复制协调)。客户端流程:
- 记录当前毫秒时间戳
T1; - 用相同 key 与相同随机 value,在 N 个实例上并行
SET ... NX PX;对每个实例使用远小于锁 TTL 的连接/命令超时(例如 TTL=10s 时超时约 5–50ms),避免卡在不可用节点上; - 计算耗时
elapsed = now - T1;仅当在多数实例(至少N/2+1)加锁成功,且elapsed < TTL时,才认为获得锁; - 有效剩余时间约为
TTL - elapsed(再扣一点时钟漂移余量); - 若失败:对所有实例尝试解锁(包括“以为没锁上”的),尽快释放部分锁。
失败重试应加随机退避,降低多客户端同时抢锁导致的“脑裂式空转”。释放时无论是否自认成功,都应尝试释放,降低依赖 TTL 的可用性惩罚。
4.1 续期(lock extension)
若工作可拆成小步骤,可用较短默认 TTL,并在接近过期时向多数实例发送 Lua:
- 仅当 key 存在且 value 仍是自己的 token 时,延长 TTL;
- 仅当多数实例续期成功且仍在有效时间内,才认为续期成功;
- 必须限制最大续期次数,否则可能破坏 liveness(锁永远被同一客户端拖住)。
这与许多客户端库里的 “watchdog / lock watchdog” 思路一致:续期是持锁期间的重新确认,不是“进程活着就一定安全”。
4.2 时钟与一致性免责声明(官方 disclaimer)
官方在文档末尾明确提醒:
- 若关心正确性,应实现 fencing tokens;不要假设“进程还活着 = 锁一定还在”;
- Redis TTL 不依赖单调时钟,墙钟跳变理论上可能导致异常;应规范运维(避免手工乱改时间、正确配置 NTP),但仍不能把时钟理想化。
Martin Kleppmann 对 Redlock 的分析与 antirez 的回应均被官方列入阅读清单——工程选型时请当作必读辩论,而不是站队口号。
五、工程增强:Fencing Token 与临界区设计
即使锁服务偶发双持,业务仍可把破坏降到最低:
- Fencing token:每次成功加锁从单调递增源(DB 序列、强一致元数据服务等)取一个 token;写共享资源时带上该 token;资源端拒绝小于已接受 token 的写。这样后拿到“旧锁”的客户端写不进去。
- 临界区尽量短:锁内只做“决策 + 提交意图”,重 IO 放到锁外并用幂等键保护。
- 业务幂等:以订单号、任务 ID、版本号做天然去重,锁失败/超时可安全重试。
- 可观测性:记录加锁耗时、失败原因、续期次数、解锁脚本返回 0 的次数(token 不匹配)。
六、可复现实验(单实例)
下面用 redis-cli 演示互斥、过期与误删防护。假设本地 6379 有 Redis。
6.1 互斥:第二个客户端抢不到
# 终端 A:抢锁
redis-cli SET lock:order:1001 "token-A-111" NX PX 15000
# 期望:OK
# 终端 B:同 key 再抢
redis-cli SET lock:order:1001 "token-B-222" NX PX 15000
# 期望:(nil) —— NX 失败
redis-cli GET lock:order:1001
# 期望:token-A-111
redis-cli PTTL lock:order:1001
# 期望:正数毫秒
6.2 安全解锁 vs 危险 DEL
# 正确:仅 token 匹配才删
redis-cli EVAL 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end' 1 lock:order:1001 token-A-111
# 期望:1
# 再抢,模拟 B 持锁
redis-cli SET lock:order:1001 "token-B-222" NX PX 15000
# A 拿着过期旧 token 误删:应失败
redis-cli EVAL 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end' 1 lock:order:1001 token-A-111
# 期望:0 —— 不会删掉 B 的锁
redis-cli GET lock:order:1001
# 期望:仍是 token-B-222
6.3 续期:仅持有者可延长
redis-cli SET lock:job:9 "token-C" NX PX 5000
# 错误 token 续期(应不改 TTL / 不改 value)
redis-cli EVAL 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("pexpire", KEYS[1], ARGV[2]) else return 0 end' 1 lock:job:9 wrong-token 20000
# 期望:0
# 正确 token 续期
redis-cli EVAL 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("pexpire", KEYS[1], ARGV[2]) else return 0 end' 1 lock:job:9 token-C 20000
# 期望:1
redis-cli PTTL lock:job:9
# 期望:接近 20000ms
6.4 伪代码骨架(语言无关)
import secrets, time
def acquire(r, key, ttl_ms=30000, retry=3, backoff=0.05):
token = secrets.token_hex(16)
for _ in range(retry):
if r.set(key, token, nx=True, px=ttl_ms):
return token
time.sleep(backoff)
return None
UNLOCK_LUA = """
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
"""
def release(r, key, token):
return r.eval(UNLOCK_LUA, 1, key, token)
def with_lock(r, key, fn, ttl_ms=30000):
token = acquire(r, key, ttl_ms=ttl_ms)
if not token:
raise RuntimeError("lock not acquired")
try:
return fn(token) # 可把 token 当作 fencing 的一部分上送
finally:
release(r, key, token)
生产中请再补:续期协程/线程、最大持锁时间、指标与超时取消。
七、常见坑与排查清单
| 现象 | 可能原因 | 排查/修复 |
|---|---|---|
| 锁永远解不开 | 只 SETNX 未设过期;或 TTL 极大 | 统一 SET NX PX;审计历史 key |
| 偶发并发进入临界区 | 解锁用了裸 DEL;或业务超时后无 fencing | Lua/DELEX IFEQ;加 fencing token |
| 故障切换后双写 | 依赖异步主从当强一致锁 | 评估是否可接受;否则 Redlock/外部强一致 + fencing |
| 加锁抖动高 | 固定重试无退避;热点锁竞争 | 随机退避 + 缩短临界区 + 锁粒度拆分 |
| 续期把锁续成“永不释放” | 无限 watchdog | 上限次数/墙钟最大持锁时间 |
| 集群下 Lua 访问了未声明的 key | 违反脚本 key 规则 | 所有 key 经 KEYS 传入;遵循 Cluster 槽约束 |
以为 GETDEL 能安全解锁 | GETDEL 不校验 owner | 必须条件删除 |
八、选型建议(务实版)
- 单机房、可接受极低概率异常双持、业务幂等完善:单 Redis +
SET NX PX+ token Lua 解锁通常足够,实现简单、延迟低。 - 对互斥更敏感、可运维多实例独立 Redis:评估 Redlock 及成熟库(官方文档列出 Redisson、Redsync、node-redlock 等),并读完 Kleppmann/antirez 讨论。
- 金融级强一致临界区:不要只靠 Redis TTL 语义;优先考虑带 fencing 的共识/事务系统,Redis 锁最多做“快速失败的互斥提示”。
- 任何方案:临界区短、幂等、可观测、可回滚,比纠结算法名词更重要。
九、总结
- 分布式锁的底线是 互斥 + 可自动释放 + 安全解锁。
- 单实例正确骨架:
SET key token NX PX ttl+ token 条件删除(Lua 或 Redis 8.4DELEX IFEQ)。 - 裸
DEL、拆分的SETNX+EXPIRE、固定 value,是三大经典错误。 - 异步主从 failover 不能保证互斥;是否可接受取决于业务与是否有 fencing。
- Redlock 用多数派与耗时校验提升容错,但涉及时钟漂移、持久化/延迟重启与社区争议——官方自己要求你读分析文章。
- 工程上把锁当作辅助互斥,用 fencing token、幂等与短临界区兜底正确性。
参考资料
- Redis 官方文档:Distributed Locks with Redis(含单实例实现、Redlock、续期、disclaimer)
https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/ - Redis 命令:
SET(NX/PX/EX与 Patterns 中的加解锁说明)
https://redis.io/docs/latest/commands/set/ - Redis 官方文档:Scripting with Lua(脚本原子执行、
EVAL/KEYS/ARGV)
https://redis.io/docs/latest/develop/programmability/eval-intro/ - Redis 命令:
GETDEL(读删一体,但不替代 token 校验解锁)
https://redis.io/docs/latest/commands/getdel/ - Redis 文档源码(可检索原文):distributed-locks markdown
https://raw.githubusercontent.com/redis/docs/main/content/develop/clients/patterns/distributed-locks.md - Martin Kleppmann:How to do distributed locking(Redlock 分析,官方文末引用)
https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html - antirez:Is Redlock safe?(对上述分析的回应,官方文末引用)
http://antirez.com/news/101