REDIS · 跨进程互斥专题

Redis 分布式锁:从 SET NX PX 到续期、误删与 Redlock

分布式锁让不同机器或进程对同一资源一次只允许一个持有者进入。SET 是 Redis 写键命令,NX 表示仅在键不存在时写入,PX 表示同时设置毫秒级到期时间;Redlock 则是在多个独立 Redis 节点取得多数租约的算法。Redis 锁只能在租约有效期内代表所有权,不能自动保证业务结果正确。

单实例正确性租约过期 · 安全释放 · 旧写拦截多节点多数租约边界
一、先看业务:本机 Mutex 为什么管不住两台服务器

凌晨任务要给用户结算积分。服务 A 和服务 B 同时发现任务未完成,各自在自己进程里加 sync.Mutex,仍会各执行一次,因为两把本地锁互不认识。分布式锁把竞争状态放到所有实例都能访问的协调服务中。

三个目标:互斥性——同一时刻最多一个有效持有者;活性——持有者崩溃后锁最终能释放;所有权——只有拿到这次锁的客户端才能释放它。可用性、时钟和故障假设决定这些目标能做到多强。
二、获取锁:SET、NX、PX 必须是一个原子动作

SET lock:order:42 token-A NX PX 30000 的含义是:键不存在时才写入(NX),并同时设置 30000 毫秒到期时间(PX)。TTL(Time To Live,存活时间)就是租约距离自动过期还剩多久;token-A 是本次请求的唯一身份。

SET lock:order:42 8fd2-A NX PX 30000
-- 成功返回 OK:持有一个最长约 30 秒的租约
-- 返回 nil:别人仍持有,调用方应退避、超时或失败

先 SETNX 再 EXPIRE

两条命令之间进程崩溃会留下没有到期时间的锁,其他实例可能永久无法进入。

一个 SET 带 NX 与 PX

创建和到期在 Redis 侧作为一个命令执行,不存在“写了键却没设超时”的中间状态。

不要把 TTL 当执行时长:TTL 是租约上限。客户端暂停、网络延迟或任务变慢,都可能让业务仍在执行时租约已经失效。
三、安全释放:为什么 GET 后 DEL 仍然会误删

释放必须同时满足“键还存在”和“值仍是我的 token”,并把比较与删除放在同一个原子脚本中。

状态来历:旧任务变慢 → 租约过期 → 新主人进入 → 旧主人误删 客户端 ARedis 锁键客户端 B 步骤 1:A 获取token=A,TTL 30s 步骤 2:键=A租约开始倒计时 步骤 3:A 卡顿 35s业务还没结束 租约已过期键自动删除 步骤 4:B 获取token=B 此刻键=BB 才是新持有者 步骤 5:A 直接 DEL B 的锁被误删 正确出口:比较当前值是否等于 A;这里是 B,所以不删除并返回 0。
最应记住:主锁过期不等于旧任务停止,旧客户端醒来时已经不是主人。常见误读是“DEL 的是同一个键,所以没问题”。
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
end
return 0

不能在客户端先 GET 再 DEL:两条命令之间锁仍可能过期并被别人重新取得。Lua 脚本在 Redis 中原子执行,封住这段竞态窗口。

四、续期不是万能药:旧持有者仍可能写入业务系统

自动续期会在任务还活着时延长 TTL,降低慢任务中途失锁的概率;但进程长时间暂停、网络分区或续期失败后,租约仍会过期。锁服务无法强行停止已经进入临界区的旧任务。

Fencing token(栅栏令牌)是每次成功取得锁时生成的单调递增序号;真正写入资源的下游只接受比历史序号更新的请求,因此能够拒绝已经过期却晚到的旧持有者。

Fencing token:锁外的下游也要识别“谁更新” A 获得序号 41暂停后晚到 B 获得序号 42先写入下游 存储记录 last=42接受 42 A 恢复后带 41 写入 检查 41 < 42拒绝旧写入 序号必须单调递增,并由真正写入资源的下游校验只在 Redis 里保存序号、下游不看它,没有保护效果
最应记住:唯一 token 只防误删,递增 fencing token(栅栏令牌)才能让下游识别并拒绝过期持有者的晚到写入。常见误读是把续期等同于永不失锁。
五、主从切换:为什么异步复制会破坏互斥
  1. A 在主节点写入锁,主节点返回成功。
  2. 锁记录尚未复制到副本,主节点故障。
  3. 缺少这条记录的副本被提升为新主。
  4. B 在新主上也获取同一锁;A、B 都以为自己成功。

Redis 默认复制是异步的,因此“一个主节点加副本”不能在所有故障下保持强互斥。可以接受偶发冲突的效率型任务,与绝不能出现双持有者的资金或主控任务,选型不同。

业务正确性的最后一道门:数据库唯一约束、条件更新、幂等键或 fencing token 应保护最终结果。不要把 Redis 锁当成金融账本的一致性证明。
六、Redlock:多数派租约建立在什么假设上

Redlock 是 Redis 官方描述的多独立实例锁算法。客户端向 N 个互相独立的节点请求相同资源,只有在有效期内取得过半节点成功,才认为拿到锁。

  1. 记录本地开始时间。
  2. 用远小于租约的连接与命令超时,依次或并行尝试 N 个独立节点。
  3. 例如 N=5,至少成功 3 个。
  4. 总耗时必须小于初始租约;剩余有效期要扣除已耗时与时钟漂移余量。
  5. 失败时尽快释放所有尝试过的节点,不能只释放自己记得成功的节点。
得到什么依赖什么仍没有什么
比单主故障切换更强的多数派租约独立故障、有限时钟漂移、任务在有效期内完成不会自动终止过期客户端
部分节点不可用时仍可能成功多数节点可达且总耗时够短网络分区下没有无限可用性
面试表达:不要把争论压成“Redlock 一定安全/一定不安全”。先说业务需要的安全等级和故障模型,再说明租约、时钟假设、下游栅栏与可接受的重复概率。
七、选型:有时根本不需要分布式锁
需求更直接的工具原因
订单号不能重复数据库唯一约束最终写入点直接拒绝重复
库存大于零才扣减条件 UPDATE把检查与修改合成原子语句
消息重复消费幂等表/业务状态机重试是常态,锁不能保留历史
短时避免重复刷新缓存Redis 单实例租约偶发重复通常可接受
严格主节点选举共识协调系统与栅栏需要更明确的一致性模型
// 伪代码:所有等待都必须有总超时
token := randomID()
ok := redis.SetNX(ctx, key, token, 30*time.Second)
if !ok { return ErrBusy }
defer compareAndDelete(ctx, key, token)

fence := nextSequence()
return storage.WriteIfNewer(ctx, resource, fence, result)
八、坑点、面试话术与应用场景

高频坑点

  • SETNX 与 EXPIRE 分两条命令。
  • 所有客户端用相同 value,无法确认所有权。
  • 释放时直接 DEL,误删后继者的锁。
  • 租约过期后仍把旧任务结果无条件写入下游。
  • 失败重试没有随机退避,形成惊群。
  • 业务异常忘记释放,或无限续期掩盖卡死任务。
90 秒回答:Redis 分布式锁应使用单条 SET NX PX 原子创建,并写入每次请求唯一 token;释放要在 Lua 中比较 token 后删除,防止旧客户端误删新锁。TTL 提供崩溃后的活性,但也使锁成为租约:业务超过有效期后旧持有者仍可能运行,所以严格场景要让下游校验单调 fencing token。异步主从切换可能出现双持有者,Redlock 通过多个独立节点的多数派与剩余有效期改善故障模型,但仍依赖有限时钟漂移和任务及时完成。最终是否使用,要从业务约束和可接受的重复风险出发。

应用场景

缓存重建、低风险定时任务去重、短时资源协调适合 Redis 租约,因为偶发重复通常可由业务兜底。能由唯一约束、条件更新或消息幂等直接保证结果的场景,以及要求严格线性一致的核心账务,不应硬用 Redis 锁。

资料依据