Redis 实现分布式锁
Redis 实现分布式锁是一个经典且广泛应用的方案,其核心是利用 Redis 单线程执行命令的特性(对于单实例或正确配置的分片集群)和原子性操作来保证互斥性。
一、 核心思想
- 互斥性: 同一时刻,只有一个客户端能成功获取锁。
- 安全性: 锁只能由加锁的客户端释放。
- 容错性: 即使持有锁的客户端崩溃,也要有机制(通常是超时)保证锁最终能被释放,避免死锁。
- 避免误删: 释放锁时需验证持有者身份。
二、推荐实现方式 (基于 SET
命令的 NX
和 PX
选项)
这是目前最可靠、最被广泛接受的 Redis 分布式锁实现方式(Redlock 算法适用于更严格场景,但更复杂)。
加锁
SET lock_key unique_value NX PX milliseconds
lock_key
: 锁的名称(字符串)。unique_value
: 一个唯一的随机值(如 UUID)。这是保证安全释放锁的关键,用于标识锁的持有者。NX
: 表示 “Set if Not eXists”。只有lock_key
不存在时,设置才会成功(获取锁)。PX milliseconds
: 设置锁的过期时间(毫秒)。这是避免死锁的关键。即使客户端崩溃,锁也会在过期后自动释放。
返回值:
OK
: 表示加锁成功。(nil)
: 表示加锁失败(锁已被其他客户端持有)。
示例:
SET myLock 8b1e6c53-ae2a-4fe6-875d-106e3d7a9e01 NX PX 10000
解锁 (使用 Lua 脚本保证原子性)
解锁操作必须是原子的:需要先检查 unique_value
是否匹配,再删除 key。这必须通过 Lua 脚本完成,因为 Redis 命令本身不具备条件删除的原子性。
Lua 脚本 (unlock.lua
):
if redis.call("get", KEYS[1]) == ARGV[1] thenreturn redis.call("del", KEYS[1])
elsereturn 0
end
执行解锁:
EVAL "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end" 1 lock_key unique_value
KEYS[1]
: 锁的名称(lock_key
)。ARGV[1]
: 加锁时设置的unique_value
。1
: 表示后面有 1 个 key (KEYS[1]
)。
返回值:
1
: 解锁成功(找到 key 且 value 匹配,并成功删除)。0
: 解锁失败(key 不存在或 value 不匹配)。这通常发生在锁已过期被释放,或尝试释放非自己持有的锁。
三、关键注意事项与优化
-
过期时间 (
PX
) 设置:- 设置得太短:业务逻辑还没执行完,锁就过期了,可能导致其他客户端获取锁并操作共享资源,造成数据不一致。
- 设置得太长:持有锁的客户端崩溃后,其他客户端需要等待更长时间才能获取锁。
- 建议: 根据业务逻辑的最坏情况执行时间来设置,并适当增加一些缓冲时间。评估业务耗时非常重要。
-
锁续期 (Watchdog):
- 如果业务逻辑执行时间可能超过锁的初始过期时间,需要实现锁续期机制。
- 客户端在获取锁成功后,启动一个后台线程(看门狗),定期(例如在过期时间的 1/3 处)检查锁是否仍持有(通过
GET lock_key
并与自己的unique_value
比较),如果持有,则重新设置过期时间(EXPIRE lock_key new_ttl
或PEXPIRE lock_key new_ttl
)。 - 库支持: Redisson 等库内置了看门狗机制。手动实现需注意线程管理和续期失败处理。
-
获取锁失败处理:
- 直接返回失败: 简单,适用于非关键或快速重试场景。
- 循环重试: 在一定的超时时间内,间隔一定时间(如 50-200ms,最好带随机抖动)不断尝试获取锁。需要设置最大重试次数或总超时时间,避免长时间阻塞。
- 订阅通知 (Pub/Sub): 监听锁释放事件 (
DEL lock_key
),收到通知后再尝试获取。效率更高,但实现稍复杂,且存在通知丢失风险(客户端在等待通知期间断开连接)。Redisson 使用了这种方式。
-
集群环境 (Redis Sentinel / Redis Cluster):
- 主从异步复制: 在主节点获取锁成功后,如果主节点在将锁信息同步给从节点之前发生故障切换,新的主节点可能没有这个锁的信息,导致另一个客户端也能在新主上获取同一个锁,破坏互斥性。
- Redlock 算法: Redis 作者 Antirez 提出的算法,旨在在非强一致性保证的 Redis 集群(如 Sentinel、Cluster)中提供更可靠的分布式锁。它要求客户端依次尝试向 N 个 (通常为 5,且为奇数) 独立的 Redis 主节点申请锁(使用相同的
SET NX PX
命令),当且仅当从超过半数 (N/2 + 1) 的节点上都成功获取锁,并且总耗时小于锁的有效时间时,才算获取成功。释放锁时也需要向所有节点发送释放请求。 - Redlock 争议: Redlock 的实现复杂,性能开销大,且在极端网络分区场景下仍存在争议(如 GC 停顿导致多个客户端同时认为自己持有锁)。对于大多数要求不是极端严格的场景,使用单 Redis 实例或 Sentinel/Cluster 配合上述
SET NX PX
+ Lua 解锁方案,并接受在主从切换时有极低概率失效的风险,通常是可接受的权衡。 如果业务要求绝对可靠,需考虑更严谨的协调服务如 ZooKeeper 或 etcd。
-
unique_value
的重要性: 绝对不能用固定值、线程ID或进程ID。必须使用足够随机且全局唯一的标识(如 UUID)。这是防止客户端误删其他客户端锁的唯一保障。
四、总结最佳实践
- 使用
SET key random_value NX PX ttl
命令进行加锁。 - 使用 Lua 脚本(比较 value 并删除)进行解锁。
- 为每个锁设置一个合理的过期时间。
- 对于可能长时间持有锁的业务,实现锁续期 (看门狗) 机制。
- 处理获取锁失败:根据业务场景选择重试、等待或直接失败。
- 理解集群环境的局限性: 在主从切换时存在失效风险。评估业务是否可接受此风险,如不能则考虑 Redlock(复杂且有争议)或其他分布式协调系统。
- 使用成熟的客户端库(如 Java 的 Redisson)可以简化上述所有复杂逻辑的实现。
五、简单 Java 示例 (使用 Jedis)
import redis.clients.jedis.Jedis;
import redis.clients.jedis.params.SetParams;
import java.util.UUID;public class RedisDistributedLock {private Jedis jedis;private String lockKey;private String lockValue; // 唯一标识private long expireTime; // 锁过期时间(ms)public RedisDistributedLock(Jedis jedis, String lockKey, long expireTime) {this.jedis = jedis;this.lockKey = lockKey;this.expireTime = expireTime;this.lockValue = UUID.randomUUID().toString(); // 生成唯一标识}// 尝试获取锁 (非阻塞)public boolean tryLock() {SetParams params = SetParams.setParams().nx().px(expireTime);String result = jedis.set(lockKey, lockValue, params);return "OK".equals(result);}// 释放锁public boolean unlock() {// 使用 Lua 脚本保证原子性: 检查值并删除String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";Object result = jedis.eval(luaScript, 1, lockKey, lockValue);return Long.valueOf(1L).equals(result); // 1 表示删除成功}// 简单重试获取锁 (示例,生产环境需更完善)public boolean lockWithRetry(int maxRetries, long sleepMillis) throws InterruptedException {int retryCount = 0;while (retryCount < maxRetries) {if (tryLock()) {return true;}retryCount++;Thread.sleep(sleepMillis);}return false;}
}
重要提醒: 此示例非常基础。生产环境请使用成熟的库(如 Redisson)或仔细处理边界条件、异常、连接管理、集群模式、锁续期等复杂问题。