Redis 锁过期以后,旧客户端还在执行怎么办?

Redis 分布式锁最危险的时刻,往往不是加锁失败,而是锁已经过期,旧的持有者却还在继续执行。

客户端 A 拿到一把有效期 10 秒的锁,业务执行中因为 GC、网络阻塞或外部接口超时停顿了 15 秒。租约到期后,客户端 B 成功加锁并开始工作。此时 A 恢复运行,两个客户端都会操作同一份资源。即使加锁和解锁命令写得完全正确,这个时间窗口仍然存在。

理解这一点以后,Redis 锁的安全边界才比较清楚:它可以协调并发,却不能独自保证共享资源永远只接受当前持有者的操作。

一把基本可用的 Redis 锁,需要先补上三个细节

最简陋的实现是通过 SETNX 抢占一个 key,业务结束后再用 DEL 删除。它能完成互斥,却很容易留下无法释放的锁:客户端在加锁后崩溃,就没有机会执行 DEL

给锁增加过期时间可以避免无限占用,但 SETNXEXPIRE 不能拆成两条命令。第一条成功后,客户端可能在第二条执行前崩溃,锁仍然没有租期。Redis 提供的 SET 参数可以把“仅在不存在时写入”和“设置过期时间”放在一次操作里:

1
SET order:123 7f5d... NX PX 30000

value 也不能随便写成 1,而要使用每次加锁都不同的随机标识。否则 A 的锁过期、B 重新加锁以后,A 再执行 DEL,就会把 B 的锁一起删掉。

释放锁时,需要在一次原子操作中完成“比较标识”和“删除 key”。常见做法是执行 Lua 脚本:

1
2
3
4
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0

到这里,一把单 Redis 实例上的锁具备了几个基本条件:原子加锁、自带租期、每次持有都有独立标识,并且只允许持有者释放自己的锁。这也是 Redis 官方文档给出的单实例实现基础。

不过,这些措施只保证 Redis 中那条锁记录不会被错误管理。共享资源是否会被旧客户端修改,仍是另一个问题。

过期时间把锁变成了租约

本地互斥锁通常由持有线程主动释放;带 TTL 的分布式锁更像一份租约。客户端只在一段时间内拥有操作资格,时间到了,锁服务就会允许其他客户端接手。

问题在于,Redis 知道 key 已经过期,却无法让正在运行的业务代码停下来:

1
2
3
4
5
6
7
客户端 A:获得锁 token=A,有效期 10 秒

执行中暂停 15 秒
↓ Redis:A 的锁过期
客户端 B:获得 token=B

客户端 A 恢复,继续写共享资源

唯一标识和 Lua 解锁可以防止 A 删除 B 的锁,但不能阻止 A 的业务代码继续写数据库、文件或调用下游接口。A 在写入前再次检查 Redis 也不够,因为检查完成后仍可能暂停,等它恢复时,锁已经换了持有者。

这也是“给 TTL 留足冗余”只能降低概率的原因。任务耗时可以估算,进程暂停、网络延迟和依赖超时却很难给出可靠上界。

自动续期能缓解什么,又不能解决什么

另一种做法是在任务执行期间定时续租。Redisson 的 watchdog 就采用了这个思路:没有显式指定租期时,只要持锁客户端仍在正常工作,它会持续延长锁的有效时间。

这对普通的长任务很有用。业务偶尔从 10 秒变成 30 秒,不必预先设置一个很夸张的 TTL,也不会因为正常执行时间变长而轻易丢锁。

但续期线程本身也运行在客户端。如果进程长时间暂停、客户端与 Redis 失联,或者续期调度没有在租期内完成,锁依然会过期。客户端恢复以后仍可能继续运行旧任务。watchdog 改善的是租期管理,并没有让租约变成永不过期的本地互斥锁。

主从切换会带来另一种锁丢失

前面的时间窗口发生在客户端一侧。Redis 使用主从复制和自动故障转移时,还要考虑锁记录本身可能在切换中消失。

假设 A 在主节点上成功写入锁,主节点在这条命令同步到副本之前宕机。副本被提升为新主节点后并没有这条 key,B 就可以再次获得同一把锁。A 和 B 随后都可能认为自己拥有操作资格。

这是异步复制的直接结果。Redis 官方的分布式锁说明也把它列为单实例加副本方案无法保证互斥的场景。WAIT 一类复制确认可以降低数据未同步的概率,但 Redis 官方明确指出,它不会把异步复制变成强一致复制。

所以,讨论“Redis 锁是否安全”时,必须把部署方式说清楚。单实例、主从加故障转移以及多个独立节点上的 Redlock,并不提供同一组保证。

Redlock 解决的是节点故障,但争议没有消失

Redlock 不依赖一个主节点及其异步副本,而是把锁写入多个彼此独立的 Redis 主节点。以 5 个节点为例,客户端需要在锁的有效期内获得至少 3 个节点的成功响应;未获得多数派,或者总耗时已经超过有效期,都视为加锁失败。释放时则尝试清理所有节点。

这套方案希望在少数 Redis 节点不可用时继续提供锁服务,也避免把一次主从切换直接等同于锁丢失。但它仍然是一种有有效期的租约,正确性依赖网络延迟、进程暂停和时钟漂移相对于 TTL 足够小。

Martin Kleppmann 对 Redlock 的主要质疑也在这里:如果锁承担的是正确性责任,客户端暂停到租约过期后仍可能写入资源;Redlock 又没有提供单调递增的 fencing token,资源无法识别一个迟到的旧持有者。Redis 作者 Salvatore Sanfilippo 的回应则认为,Redlock 在明确的时序与时钟假设下可以工作,并对随机标识、资源侧检查等问题给出了不同解释。

这场讨论不适合压缩成“Redlock 一定安全”或“一定不能用”。更有用的问题是:业务能否接受偶发的重复执行,基础设施能否满足算法依赖的时序假设,以及锁失效后资源层有没有第二道保护。

要保护数据,只检查锁还不够

如果重复执行的后果只是多算一次结果,Redis 锁可以被当作效率优化。锁偶尔失效会浪费资源,却不至于破坏数据。这类场景下,单实例锁往往已经够用,设计和运维也更简单。

如果锁失效会造成库存超卖、余额错误或数据覆盖,就不能把正确性全部交给租约。资源层需要能够拒绝过期持有者,fencing token 是一种常见做法。

每次成功获取锁时,锁服务同时返回一个严格递增的编号:

1
2
3
4
客户端 A 获得 token=33,随后长时间暂停
客户端 B 获得 token=34,并成功写入资源
客户端 A 恢复,携带 token=33 发起写入
资源发现 33 小于已处理的 34,拒绝这次请求

随机 UUID 做不到这一点。它可以用于判断 Redis key 是否仍属于当前客户端,却没有新旧顺序;资源看到两个 UUID 时,不知道哪个请求已经过期。

fencing token 也有明确限制:被保护的数据库或服务必须保存并检查这个编号。对于无法修改的第三方接口、普通文件写入或不支持条件更新的资源,它很难直接落地。此时通常还要依赖唯一约束、版本号条件更新、事务、幂等键或业务状态机来收口,而不是继续给锁增加更多技巧。

换成 ZooKeeper,也不能省掉资源侧保护

ZooKeeper 的锁通常建立在临时顺序节点之上。它能提供有序的协调记录,客户端会话失效后,临时节点也会被清理。这些性质比 Redis 的异步主从切换更适合构建协调服务。

但会话失效并不会强制终止客户端进程。客户端长时间暂停或失联时,ZooKeeper 可以删除它的节点,让下一个客户端获得锁;旧客户端恢复后,仍可能继续操作资源。因此,涉及正确性的写入仍需要 fencing token 或等价的资源侧条件。换一个锁服务可以改变故障模型,不能让迟到请求自动消失。

我现在怎样判断是否该用 Redis 锁

我会先问这把锁是在提升效率,还是在承担正确性。如果只是减少重复任务,Redis 的单实例租约通常简单有效;如果错误执行一次就会破坏数据,锁只能作为入口处的并发削峰,数据库和下游服务还要有自己的约束。

实现层面,我不会再从 SETNXDEL 手写一套锁,而会选用维护成熟的客户端,同时确认它的加锁标识、原子解锁、续期方式、故障转移行为和失败策略。监控也不能只有“加锁是否成功”,还要看到持锁时长、续期失败、等待时间和锁过期后任务是否仍在运行。

Redis 锁可以挡住大多数正常并发,但租约一旦失效,旧客户端不会收到一条能立即停止业务代码的指令。对于数据正确性要求高的场景,最后一道判断必须落在真正被修改的资源上。


参考资料Redis 分布式锁说明Redis WAIT 命令Redisson watchdog 配置Martin Kleppmann 对 Redlock 的分析Salvatore Sanfilippo 的回应ZooKeeper 锁配方