Featured image of post Redis 分布式锁:互斥、安全释放与续期工程实践
Redis

Redis 分布式锁:互斥、安全释放与续期工程实践

Redis 分布式锁:互斥、安全释放与续期工程实践

下单扣库存、定时任务防重、接口幂等窗口、分片任务抢占——多进程/多实例同时触达同一共享资源时,都需要互斥。本地 synchronizedMutex 只覆盖本机;跨进程时,工程上常见做法是把锁状态放到 Redis 这类高可用共享存储里。

本文依据 Redis 官方 Distributed Locks with RedisSET 命令文档(含 Patterns)、Scripting with LuaGETDEL 说明,把单实例正确加解锁、误删他人锁、主从异步复制下的安全边界、锁续期,以及 Redlock/fencing token 的工程取舍讲清楚,并给出可复现命令实验与排错清单。它和本站「缓存穿透/击穿/雪崩」互补:那篇讲缓存一致性与流量保护,本篇讲跨实例互斥原语本身

一、问题背景:分布式互斥要什么

官方把有效的分布式锁最小保证概括为三条(Safety / Liveness):

属性含义工程直觉
互斥(Safety)任意时刻最多一个客户端持有锁两台机器不能同时“以为自己锁住了”
无死锁(Liveness A)持锁客户端崩溃或分区后,锁最终可被再次获取必须有 TTL / 自动释放
容错(Liveness B)多数 Redis 节点存活时仍可加解锁(面向 Redlock 场景)单点故障与多数派策略

只满足“看起来能 SET 成功”不够。真正会在生产踩雷的,通常是:

  1. 加锁非原子SETNX 成功后再 EXPIRE,进程在中间崩溃 → 永久锁死;
  2. 解锁不安全:业务超时后锁已过期被别人抢走,本客户端仍 DEL删掉别人的锁
  3. 无 token 签名:所有客户端写同一个固定 value,无法判断“这把锁还是不是我的”;
  4. 把主从 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

时序灾难:

  1. 客户端 A 加锁,TTL = 30s;
  2. A 业务卡住超过 30s,key 过期;
  3. 客户端 B 成功 SET NX 拿到同一资源;
  4. A 恢复后执行 DELB 的锁被删掉,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 既是:

  1. 自动释放时间(持锁者崩溃后的死锁自愈);
  2. 持锁者必须在此窗口内完成工作的时间预算。

互斥保证是时间窗口内的互斥,不是“进程活着就永远持锁”。业务若可能超过 TTL,要么加大合理 TTL,要么做续期,并在业务层准备好超时后的幂等/回滚

三、主从复制为什么不够“天然安全”

单实例方案在“偶尔双持可接受”的业务里很实用;但若把 Redis 主从 + 自动 failover 当成强一致锁,会踩官方点名的竞态:

  1. 客户端 A 在 master 上拿到锁;
  2. master 在把该写复制到 replica 前崩溃;
  3. replica 升主;
  4. 客户端 B 在新 master 上再次 SET NX 成功 → Safety 被破坏

原因:Redis 复制默认是异步的。官方态度很务实:若故障场景下短暂双持可接受,复制方案可用;否则应认真评估 Redlock 或更强的一致性组件,并在业务侧使用 fencing token

四、Redlock 概要:多数派 + 耗时校验

Redlock 假设有 N 个独立 Redis master(示例 N=5,互不依赖复制协调)。客户端流程:

  1. 记录当前毫秒时间戳 T1
  2. 相同 key 与相同随机 value,在 N 个实例上并行 SET ... NX PX;对每个实例使用远小于锁 TTL 的连接/命令超时(例如 TTL=10s 时超时约 5–50ms),避免卡在不可用节点上;
  3. 计算耗时 elapsed = now - T1仅当在多数实例(至少 N/2+1)加锁成功,且 elapsed < TTL 时,才认为获得锁;
  4. 有效剩余时间约为 TTL - elapsed(再扣一点时钟漂移余量);
  5. 若失败:对所有实例尝试解锁(包括“以为没锁上”的),尽快释放部分锁。

失败重试应加随机退避,降低多客户端同时抢锁导致的“脑裂式空转”。释放时无论是否自认成功,都应尝试释放,降低依赖 TTL 的可用性惩罚。

4.1 续期(lock extension)

若工作可拆成小步骤,可用较短默认 TTL,并在接近过期时向多数实例发送 Lua:

  • 仅当 key 存在且 value 仍是自己的 token 时,延长 TTL;
  • 仅当多数实例续期成功且仍在有效时间内,才认为续期成功;
  • 必须限制最大续期次数,否则可能破坏 liveness(锁永远被同一客户端拖住)。

这与许多客户端库里的 “watchdog / lock watchdog” 思路一致:续期是持锁期间的重新确认,不是“进程活着就一定安全”。

4.2 时钟与一致性免责声明(官方 disclaimer)

官方在文档末尾明确提醒:

  1. 若关心正确性,应实现 fencing tokens;不要假设“进程还活着 = 锁一定还在”;
  2. Redis TTL 不依赖单调时钟,墙钟跳变理论上可能导致异常;应规范运维(避免手工乱改时间、正确配置 NTP),但仍不能把时钟理想化。

Martin Kleppmann 对 Redlock 的分析与 antirez 的回应均被官方列入阅读清单——工程选型时请当作必读辩论,而不是站队口号。

五、工程增强:Fencing Token 与临界区设计

即使锁服务偶发双持,业务仍可把破坏降到最低:

  1. Fencing token:每次成功加锁从单调递增源(DB 序列、强一致元数据服务等)取一个 token;写共享资源时带上该 token;资源端拒绝小于已接受 token 的写。这样后拿到“旧锁”的客户端写不进去。
  2. 临界区尽量短:锁内只做“决策 + 提交意图”,重 IO 放到锁外并用幂等键保护。
  3. 业务幂等:以订单号、任务 ID、版本号做天然去重,锁失败/超时可安全重试。
  4. 可观测性:记录加锁耗时、失败原因、续期次数、解锁脚本返回 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;或业务超时后无 fencingLua/DELEX IFEQ;加 fencing token
故障切换后双写依赖异步主从当强一致锁评估是否可接受;否则 Redlock/外部强一致 + fencing
加锁抖动高固定重试无退避;热点锁竞争随机退避 + 缩短临界区 + 锁粒度拆分
续期把锁续成“永不释放”无限 watchdog上限次数/墙钟最大持锁时间
集群下 Lua 访问了未声明的 key违反脚本 key 规则所有 key 经 KEYS 传入;遵循 Cluster 槽约束
以为 GETDEL 能安全解锁GETDEL 不校验 owner必须条件删除

八、选型建议(务实版)

  1. 单机房、可接受极低概率异常双持、业务幂等完善:单 Redis + SET NX PX + token Lua 解锁通常足够,实现简单、延迟低。
  2. 对互斥更敏感、可运维多实例独立 Redis:评估 Redlock 及成熟库(官方文档列出 Redisson、Redsync、node-redlock 等),并读完 Kleppmann/antirez 讨论。
  3. 金融级强一致临界区:不要只靠 Redis TTL 语义;优先考虑带 fencing 的共识/事务系统,Redis 锁最多做“快速失败的互斥提示”。
  4. 任何方案:临界区短、幂等、可观测、可回滚,比纠结算法名词更重要。

九、总结

  • 分布式锁的底线是 互斥 + 可自动释放 + 安全解锁
  • 单实例正确骨架:SET key token NX PX ttl + token 条件删除(Lua 或 Redis 8.4 DELEX IFEQ)。
  • DEL、拆分的 SETNX+EXPIRE、固定 value,是三大经典错误。
  • 异步主从 failover 不能保证互斥;是否可接受取决于业务与是否有 fencing。
  • Redlock 用多数派与耗时校验提升容错,但涉及时钟漂移、持久化/延迟重启与社区争议——官方自己要求你读分析文章
  • 工程上把锁当作辅助互斥,用 fencing token、幂等与短临界区兜底正确性。

参考资料

  1. Redis 官方文档:Distributed Locks with Redis(含单实例实现、Redlock、续期、disclaimer)
    https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/
  2. Redis 命令:SETNX / PX / EX 与 Patterns 中的加解锁说明)
    https://redis.io/docs/latest/commands/set/
  3. Redis 官方文档:Scripting with Lua(脚本原子执行、EVAL/KEYS/ARGV
    https://redis.io/docs/latest/develop/programmability/eval-intro/
  4. Redis 命令:GETDEL(读删一体,但替代 token 校验解锁)
    https://redis.io/docs/latest/commands/getdel/
  5. Redis 文档源码(可检索原文):distributed-locks markdown
    https://raw.githubusercontent.com/redis/docs/main/content/develop/clients/patterns/distributed-locks.md
  6. Martin Kleppmann:How to do distributed locking(Redlock 分析,官方文末引用)
    https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html
  7. antirez:Is Redlock safe?(对上述分析的回应,官方文末引用)
    http://antirez.com/news/101
使用 Hugo 构建
主题 StackJimmy 设计