分布式锁与 SET NX PX
基于 Redis 的分布式锁本质是一条键:加锁成功意味着键不存在并被自己写入,解锁意味着删除这条键。正确的最小实现是一条命令完成的 SET key value NX PX ttl。其中 NX 保证只有键不存在时才写入,PX 同时设置毫秒级过期时间,两者在服务端原子完成,避免了先 SETNX 再 EXPIRE 之间抢锁者崩溃导致死锁的历史问题。value 必须写入调用方唯一标识,后续释放时才能判断锁是不是自己的。
基本信息
| 版本前提 | SET 支持 NX/PX 组合自 Redis 2.6.12 起,7.0 起可用 GETDEL |
|---|---|
| 加锁命令 | SET lock:order:1001 <uuid> NX PX 30000 |
| 释放方式 | Lua 内比较 value 相等后 DEL,两步 GET 加 DEL 不安全 |
| 续期 | 业务未执行完时延长 TTL,通常由看门狗线程按剩余时间续约 |
| 返回值 | 加锁成功返回 OK,失败返回 nil |
典型使用场景
- 同一订单同一时刻只能被一个节点处理,用业务主键做 key 天然互斥
- 定时任务在多实例部署下只允许一台执行,抢到锁的实例负责本轮计算
- 缓存重建互斥:热点键失效时只放一个请求回源,其余等待或复用旧值
- 对上游有严格配额限制的操作做串行化,避免并发超限被封禁
- 跨服务的短临界区保护,锁粒度按业务主键而非全局唯一
加锁与释放
加锁与释放
# 加锁:一条命令原子完成 设置值 + 唯一标识 + 过期时间
redis-cli SET lock:order:1001 d9f2-8c01-4a77 NX PX 30000
# 返回 OK 表示拿到锁;返回 nil 表示已被他人持有,直接放弃或退避重试
redis-cli PTTL lock:order:1001 # 观察剩余租期
# 释放:EVAL 保证「比较 + 删除」原子
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 d9f2-8c01-4a77续期与重试
续期与重试
-- 续期:只有仍持有锁时才延长 TTL,避免给别人的锁续命
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[2]))
else
return 0
end常见写法对错
| 写法 | 问题 | 正确做法 |
|---|---|---|
| SETNX 之后再 EXPIRE | 两步之间进程崩溃则锁永久不过期 | 用 SET key value NX PX ttl 一条命令 |
| DEL 直接删锁 | 可能删掉别人刚抢到的同名锁 | Lua 比较 value 后再 DEL |
| 只设置一次性长 TTL | 业务慢于 TTL 时锁提前失效 | 看门狗按剩余时间续期 |
| 锁 value 用固定值 | 无法区分持有者,释放判断失效 | 写入 UUID 或机器加线程唯一标识 |
TTL 与业务耗时的关系是设计重点:TTL 短于业务耗时会出现两个持有者同时进入临界区,长于业务耗时则崩溃后要等很久才自动解锁。工程上通常把 TTL 设为预估耗时的数倍,再用续期把长任务的锁维持住。
单实例锁在主从切换窗口内可能失效:主节点写入锁后尚未异步复制就宕机,从节点提升为主后锁并不存在,另一个调用方可以拿到同一把锁。对一致性极端敏感的场景可评估多节点多数派加锁,或改用有共识语义的系统。
加锁失败不是错误,而是竞争结果,必须有明确的后续策略:非关键路径直接快速失败返回「处理中」;关键路径按固定间隔退避重试并设总超时;高并发场景在应用内先做一次短等待,减少对 Redis 的无效轮询。
锁只能降低并发概率,不能替代幂等。任何持锁者都可能因为 GC、网络分区或超时而失去锁,临界区内被保护的操作必须设计成可重复执行。