Redis如何实现分布式锁的
在分布式系统中,当多个服务实例需要修改同一份数据时,会产生并发问题。传统的本地锁(如 Java 中的 synchronized 或 Lock)只能在单一服务器进程内生效,无法跨服务器实例进行协调。为了解决这个问题,需要引入分布式锁来确保在任何时刻,只有一个客户端能够操作共享资源,从而保证数据的一致性。
Redis 实现分布式锁主要依赖其 SETNX (SET if Not eXists) 命令。
- 加锁:
SETNX key value。这个命令的特性是,只有当key不存在时,才会设置key的值为value并返回成功。如果key已存在,则命令失败。这个原子性的“检查并设置”操作是实现互斥锁的基础。 - 解锁:
DEL key。当业务逻辑处理完毕后,通过删除对应的key来释放锁,让其他等待的线程有机会获取。 - 锁超时:
EXPIRE key timeout。为了防止持有锁的客户端因崩溃或网络问题而未能释放锁,导致死锁,需要为锁设置一个超时时间。过了这个时间,锁会自动被 Redis 删除。
实现中的问题与演进方案
基础的 SETNX + EXPIRE + DEL 组合在实际应用中会遇到多个问题,文章详细介绍了这些问题及其解决方案。
1. SETNX 和 EXPIRE 的非原子性
问题描述:如果在执行 SETNX 成功后,客户端在执行 EXPIRE 之前崩溃,那么这个锁将没有设置超时时间,变成一个永远不会自动释放的“死锁”。
解决方案:将加锁和设置过期时间这两个操作合并成一个原子操作。文章中提到了两种方式:
* Lua 脚本:通过 EVAL 命令执行一段 Lua 脚本,在脚本内依次执行 setnx 和 expire,保证了两个操作的原子性。
if (redis.call('setnx', KEYS[1], ARGV[1]) < 1)
then return 0;
end;
redis.call('expire', KEYS[1], tonumber(ARGV[2]));
return 1;
- SET 命令的扩展参数:更高版本的 Redis 为
SET命令提供了扩展参数,可以直接在设置 key 的同时指定过期时间并且保证仅在 key 不存在时设置,从而原子性地完成加锁操作。
SET key value NX EX 30
这里的 NX 相当于 SETNX,EX 30 表示设置 30 秒的过期时间。
2. 锁被错误解除
问题描述:线程 A 获取了锁,设置了 30 秒超时。但线程 A 的业务执行了超过 30 秒,此时锁已自动过期释放。线程 B 趁机获取了这把锁。随后,线程 A 执行完毕,它执行 DEL 命令,错误地释放了本应由线程 B 持有的锁。
解决方案:在加锁时,将 value 设置为一个唯一的标识(如 UUID),代表当前加锁的线程。在解锁时,必须先验证 key 对应的 value 是否与自己持有的标识一致,只有一致时才能执行删除操作。这个“检查并删除”的过程也必须是原子的,因此需要使用 Lua 脚本。
- 加锁:
SET key uuid NX EX 30 - 解锁 (Lua 脚本):
if (redis.call('get', KEYS[1]) == ARGV[1])
then return redis.call('del', KEYS[1])
else return 0
end
3.因超时解锁导致的并发
问题描述:与上一个问题类似,线程 A 持有的锁因执行时间过长而自动过期,导致线程 B 能够获取到锁。此时,线程 A 的业务逻辑还未执行完,就造成了线程 A 和线程 B 并发执行,破坏了锁的互斥性。
解决方案: 1. 设置足够长的过期时间:评估业务执行所需的最长时间,并设置一个远大于此时间的过期时间。但这只是一种缓解手段,治标不治本。 2. 增加守护线程(Watchdog):为获取锁的线程启动一个守护线程。守护线程会定期检查主线程是否还持有锁,并在锁即将过期但业务尚未完成时,自动延长锁的过期时间。
4. 锁的不可重入
问题描述:一个线程在已经持有锁的情况下,再次尝试获取同一个锁,会因为锁已被自己持有而失败。如果业务逻辑中存在递归调用或方法嵌套,就需要锁是“可重入”的。
解决方案:让锁能够记录持有者和加锁次数。
1. 本地计数:使用 ThreadLocal 在当前线程内记录对某个 key 的重入次数。每次加锁时,先检查 ThreadLocal 中是否已有记录。如果有,则次数加一;如果没有,则向 Redis 请求新锁。解锁时则次数减一,直到计数为 0 时才真正向 Redis 请求删除 key。
2. Redis Hash 结构:使用 Redis 的 Hash 数据结构来存储锁信息。Hash 的 key 是锁名,field 是线程的唯一标识,value 是重入的次数。
* 加锁:检查 key 是否存在。若不存在,则 HSET 创建锁,并将重入次数设为 1。若存在,检查 field 是否为当前线程标识。如果是,则通过 HINCRBY 将重入次数加一,并重置过期时间。
* 解锁:将重入次数减一。如果减后大于 0,则只更新 Hash 值;如果减后等于 0,则 DEL 删除整个 key。
5. 无法等待锁释放
问题描述:上述的加锁操作都是“尝试一次立即返回”,如果获取锁失败,不会等待。
解决方案: 1. 客户端轮询:当获取锁失败后,线程不立即返回,而是等待一小段时间后再次尝试,直到成功获取或达到指定的超时时间。这种方式会消耗较多 CPU 资源。 2. Redis 发布/订阅 (Pub/Sub):获取锁失败的客户端订阅一个特定的频道。当持有锁的客户端释放锁时,它会向该频道发布一条“锁已释放”的消息。所有订阅者收到消息后,再次尝试获取锁。
集群环境下的挑战
在 Redis 集群(主从、哨兵、Cluster)模式下,分布式锁会面临更严峻的挑战。
- 主备切换:客户端 A 在 Master 节点成功加锁,但这条写命令还未异步同步到 Slave 节点时,Master 节点宕机。哨兵或集群机制将一个 Slave 提升为新的 Master。此时,客户端 B 向新的 Master 请求加锁,由于新 Master 上没有锁的数据,B 也能成功加锁,导致两个客户端同时持有同一把锁。
- 集群脑裂:由于网络分区,原 Master 与其他节点失联,但它仍在独立运行。哨兵集群在另一个网络分区中选举出一个新的 Master。这导致集群中同时存在两个 Master 节点,不同的客户端连接到不同的 Master,都能成功获取同一把锁。