Redis分布式锁:从“能用就行”到“稳如老狗”的迭代方案
第一阶段:从 JVM 单机锁到 Redis 分布式锁的初级形态
1. JVM 单机锁:一切的起点
在单体应用中,我们使用 JVM 提供的锁(如 synchronized
或 ReentrantLock
)来保证多线程对共享资源的 独占性。
比喻: 就像一个办公室里只有 一把钥匙,谁拿到钥匙,谁就能进办公室处理文件,其他人必须在外面等候。
代码(java.util.concurrent.locks.ReentrantLock
示例):
import java.util.concurrent.locks.ReentrantLock;public class JvmLockExample {private final ReentrantLock lock = new ReentrantLock();private int counter = 0;public void increment() {// 1. 独占性:获取锁,只允许一个线程进入临界区lock.lock(); try {// 业务逻辑counter++;// 5. 重入性:ReentrantLock 本身就支持重入// 如果同一个线程再次调用 lock.lock(),它不会被阻塞} finally {// 3. 防死锁:在 finally 块中释放锁lock.unlock(); }}
}
问题: 当系统从单体应用扩展为 分布式集群(多台服务器)时,JVM 锁就失效了。每台服务器都有自己的 JVM 锁,无法阻止不同服务器上的线程同时访问同一个共享资源(例如数据库中的库存)。
2. Redis 分布式锁 V1.0:最简实现
为了解决跨机器的并发问题,我们需要一个所有机器都能访问的 共享存储 来充当“锁服务”,Redis 因其高性能和原子操作而成为理想选择。
思路: 使用 Redis 的 SETNX
(SET if Not eXists) 命令,如果键不存在则设置成功,表示加锁成功。
比喻: 办公室的钥匙被放在了一个 公共的保险箱(Redis)里。
- 加锁: 尝试往保险箱里放一把写着“工作中”的钥匙,如果发现里面已经有钥匙了,说明有人在工作,就等一等。
- 解锁: 工作完成后,把自己的钥匙拿出来。
代码(伪代码):
public class RedisLockV1 {private final RedisClient redisClient;private final String lockKey = "resource:lock";/*** 加锁*/public boolean lock() {// SETNX lockKey "locked"// 成功返回 true (1),失败返回 false (0)return redisClient.setnx(lockKey, "locked");}/*** 解锁*/public void unlock() {// DEL lockKeyredisClient.del(lockKey);}
}
实现特性分析:
- 1. 独占性: 基本实现。
SETNX
的原子性保证了在同一时刻只有一个客户端能成功设置键,即拿到锁。 - 2. 高可用: 依赖 Redis 集群。 锁服务本身的高可用依赖于 Redis 集群的稳定性。
- 3. 防死锁: 未实现! 这是 V1.0 的最大问题。
3. Redis 分布式锁 V2.0:解决死锁问题(引入过期时间)
潜藏的问题(死锁): 如果一个客户端拿到锁后,在执行业务逻辑时 宕机 或 网络中断,导致没有执行到 unlock()
(DEL
命令),那么 lockKey
将永远存在于 Redis 中,其他所有尝试加锁的客户端将永久阻塞,造成 死锁。
解决方案: 给锁设置一个 过期时间 (TTL)。即使客户端宕机,Redis 也会在一段时间后自动删除键,释放锁。
比喻: 钥匙放进保险箱时,同时设置一个 闹钟。如果工作超时(例如 30 秒),闹钟会响,即使拿钥匙的人没回来,也会自动把钥匙收走(释放锁)。
代码(伪代码):
public class RedisLockV2 {private final RedisClient redisClient;private final String lockKey = "resource:lock";// 设置一个合理的过期时间,例如 30 秒private final int expireTime = 30; /*** 加锁*/public boolean lock() {// V2.0 仍存在问题:SETNX 和 EXPIRE 并非原子操作!if (redisClient.setnx(lockKey, "locked")) {// 假设:SETNX 成功后,客户端宕机了!// EXPIRE 没有机会执行,还是会造成死锁!redisClient.expire(lockKey, expireTime); return true;}return false;}// ... unlock() 仍是 DEL
}
4. Redis 分布式锁 V3.0:解决原子性问题(SET
命令的最佳实践)
原子性问题: V2.0 中 SETNX
和 EXPIRE
是两个独立操作。
解决方案: 利用 Redis 2.6.12 版本 引入的 SET
命令的扩展参数,实现 设置值 和 设置过期时间 的 原子操作。
比喻: 现在保险箱升级了,放钥匙和设置闹钟这两个动作,必须 同时完成,要么都成功,要么都失败。
代码(伪代码):
public class RedisLockV3 {private final RedisClient redisClient;private final String lockKey = "resource:lock";private final int expireTime = 30; // 30秒/*** 加锁*/public boolean lock() {// 使用 SET key value NX EX seconds// NX: Only set the key if it does not already exist. (实现 SETNX)// EX: Set the specified expire time, in seconds. (实现 EXPIRE)String result = redisClient.set(lockKey, "locked", "NX", "EX", expireTime);return "OK".equals(result);}/*** 解锁*/public void unlock() {// ... 仍是 DEL lockKeyredisClient.del(lockKey);}
}
实现特性分析:
- 1. 独占性: 基本实现。
- 2. 高可用: 依赖 Redis 集群。
- 3. 防死锁: 实现! 引入了原子性的过期时间(TTL)。
- 4. 不乱抢: 未实现! 这是 V3.0 的另一个致命问题。
第二阶段:解决“不乱抢”和“重入性”问题(引入唯一标识和重入计数)
5. Redis 分布式锁 V4.0:解决锁被误删问题(引入唯一标识)
潜藏的问题(锁被误删/不乱抢):
假设 客户端 A 拿到锁并设置了 30 秒过期。
- 客户端 A 业务逻辑执行了 31 秒。
- 锁自动过期 被释放。
- 客户端 B 进来,发现锁已释放,成功拿到锁。
- 客户端 A 终于执行完业务,调用
unlock()
方法,它会 错误地删除 客户端 B 刚加上的锁! - 客户端 C 趁机拿到锁,造成多个客户端同时执行业务,独占性被破坏。
解决方案: 加锁时,value
不再是简单的 "locked"
,而是一个 唯一标识(例如 UUID 或 线程 ID + UUID)。解锁时,必须先判断当前锁的值是否是自己设置的那个值,只有是自己加的锁才能删除。
比喻: 每个人放进保险箱的钥匙上都刻着自己的 工号(唯一标识)。
- 加锁: 客户端 A 放刻着 “A工号” 的钥匙。
- 解锁: 客户端 A 工作完了,要解锁时,必须确认保险箱里的钥匙是 “A工号” 才能拿走。如果是 “B工号” 的钥匙(说明锁已经过期被 B 抢走了),则不能动。
代码(伪代码):
// 每个线程/客户端的唯一标识
private static final String THREAD_ID = UUID.randomUUID().toString(); public class RedisLockV4 {// ... 其他属性不变/*** 加锁 (V4.0)*/public boolean lock() {String uniqueValue = THREAD_ID + ":" + System.currentTimeMillis();// 设置唯一标识作为 ValueString result = redisClient.set(lockKey, uniqueValue, "NX", "EX", expireTime);return "OK".equals(result);}/*** 解锁 (V4.0)*/public void unlock() {// 核心:判断 + 删除 必须是原子操作,否则并发情况下仍可能误删!// 1. 获取锁的值 (GET)// 2. 判断是否是自己的 (IF)// 3. 是自己的才删除 (DEL)// 使用 Lua 脚本保证原子性!String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] " +"then " +" return redis.call('del', KEYS[1]) " +"else " +" return 0 " +"end";String uniqueValue = THREAD_ID + ":" + "..."; // 保证和加锁时的值一致// KEYS[1] = lockKey, ARGV[1] = uniqueValueredisClient.eval(luaScript, Collections.singletonList(lockKey), Collections.singletonList(uniqueValue));}
}
实现特性分析:
- 4. 不乱抢: 实现! 引入唯一标识 + Lua 脚本原子性 保证不会误删别人的锁。
- 问题: 客户端 A 业务执行时间过长,锁过期了怎么办?
6. Redis 分布式锁 V5.0:实现续期机制(实现高可用)
潜藏的问题(高可用/超时释放): V4.0 的锁超时释放机制是 防死锁 的必要手段,但如果业务执行时间长于 TTL,锁会被自动释放,导致 独占性被破坏(即 V4.0 的问题 4 的来源)。为了保证独占性,我们需要一个 续期 (Watch Dog) 机制。
解决方案: 引入一个 看门狗 (Watch Dog) 线程。
- 客户端 A 成功加锁。
- Watch Dog 线程启动,它会周期性(例如每 10 秒)检查客户端 A 的业务是否执行完毕。
- 如果业务仍在执行,Watch Dog 就会自动调用
EXPIRE
命令为锁 续期(例如再续 30 秒)。 - 当业务执行完毕,主线程会通知 Watch Dog 停止,并执行解锁操作。
比喻: 除了钥匙和闹钟,我们还派了一个 “看门狗”。
- 每隔一段时间,看门狗会跑去看看拿钥匙的人是不是还在工作,如果在,就帮他重新设置闹钟(续期)。
- 一旦工作完成,告诉看门狗:“工作完成了,你可以下班了。”
工程实践: 这就是 Redisson 等成熟分布式锁框架的核心机制。
第三阶段:解决重入性问题(最终最优形态)
7. Redis 分布式锁 V6.0:实现重入性(最优形态)
潜藏的问题(重入性): V5.0 的实现,同一个线程(客户端 A)在持有锁的情况下,如果再次尝试加锁,会因为 SETNX
失败而被阻塞,不具备重入性,这不符合 Java ReentrantLock
的行为习惯,容易造成应用死锁。
解决方案: 同样使用 Lua 脚本 和 哈希表 (Hash) 来记录锁信息,实现重入计数。
- Key:
lockKey
- Hash 字段 (Field):
uniqueId
(例如:UUID:ThreadID
),作为锁的持有者。 - Hash 值 (Value): 重入次数 (Counter)。
加锁逻辑(Lua 脚本):
- 尝试加锁: 使用
HSET
尝试将lockKey
设为uniqueId
->1
。 - 判断持有者: 如果
lockKey
存在,且Field
是当前的uniqueId
,说明是 重入,则HINCRBY
将Counter
+1。 - 非重入: 如果
Field
不是当前的uniqueId
,说明锁被别人持有,返回失败。 - 设置 TTL: 无论首次加锁还是重入,都需要 重置过期时间(确保锁不会因重入而提前释放)。
解锁逻辑(Lua 脚本):
- 判断持有者: 检查锁是否是当前线程持有。
- 重入计数 -1: 如果是,则
HINCRBY
将Counter
-1。 - 真正释放: 如果
Counter
减到 0,说明是最后一次解锁,执行DEL lockKey
释放锁。 - 设置 TTL: 重置锁的过期时间。
实现特性分析(V6.0/Redisson 级别):
- 独占性: 实现! (
SETNX
+ 唯一标识) - 高可用: 实现! (Watch Dog 续期机制)
- 防死锁: 实现! (TTL + Watch Dog 保证续期不会永久持有)
- 不乱抢: 实现! (唯一标识 + Lua 脚本原子判断)
- 重入性: 实现! (Hash 结构 + 重入计数)
额外优化(RedLock 算法):
虽然 V6.0 在单 Redis 实例上已是优秀实现,但如果您的 单个 Redis 实例宕机,且尚未同步到从节点,会导致锁丢失。RedLock 算法(由 Redis 作者提出)尝试解决这个问题,通过在 N 个独立的 Redis Master 节点 上进行多数派投票加锁,进一步提升锁的 可用性 和 强一致性。不过,RedLock 的复杂性和争议较大,在绝大多数场景下,Watch Dog 机制 的 V6.0 结合 Redis 高可用集群(哨兵/集群模式)已足够满足需求。
总结:
我们从最简单的 SETNX
开始,通过引入 过期时间 (TTL) 解决死锁,引入 唯一标识 + Lua 脚本 解决误删(不乱抢),引入 Watch Dog 解决业务超时问题(高可用),最终引入 Hash 结构 + 重入计数 实现了重入性,最终达到了您所需的五个特性。这个迭代过程完美体现了分布式系统中解决问题的思路:解决原子性 -> 解决一致性 -> 解决可用性。
附加: 重入性问题
什么是“重入性”(Reentrancy)?
通俗比喻:
假设你走进一个 “单人间办公室”(临界区)。
- 你手持一把 门禁卡(锁),刷卡进入,锁住了门。
- 在办公室里工作时,你需要使用办公室里的 “子功能区”,例如一个打印机。
- 如果这个打印机区域也被同一张 门禁卡 锁着,那么具备 重入性 的意思是:你无需将卡片放下再重新拿起来刷一次,而是系统能够识别到:“哦,是同一个卡主,放行!”。
- 如果你必须放下卡片再重新拿起来刷一次,那在你放下卡片的那一瞬间,其他人就可能抢先进门,独占性就被破坏了。
技术定义:
重入性 指的是:同一个线程(客户端)在已经成功获取锁的情况下,可以 重复 地进入同一段代码块(或函数),而不会被自己持有的锁所阻塞。
为什么需要重入性?
在实际的程序设计中,最常见的需要重入性的场景是 递归调用 或 封装的函数相互调用。
场景一:函数间的相互调用(业务封装)
假设您正在编写一个订单处理系统:
// 客户端A 正在执行
public void createOrder(long userId) {lock.lock(); // 第一次加锁try {// 1. 执行核心业务逻辑 A (例如:计算价格)System.out.println("第一次加锁成功,执行核心业务 A...");// 2. 调用另一个封装好的函数 B,这个函数也需要锁保护calculateInventory(userId); } finally {lock.unlock(); // 第一次解锁}
}// 这是一个被上层调用的函数
public void calculateInventory(long userId) {lock.lock(); // 第二次尝试加锁(但锁已经被客户端A持有)try {// 3. 执行业务逻辑 B (例如:扣减库存)System.out.println("第二次尝试加锁,执行业务 B...");} finally {lock.unlock(); // 第二次解锁}
}
问题出现:
- 客户端 A 执行
createOrder()
,第一次 成功拿到分布式锁 (在 Redis 中设置了 Key)。 - 程序运行到
calculateInventory()
函数内部。 - 在
calculateInventory()
中,程序 第二次 调用lock.lock()
尝试加锁。 - 此时 Redis 中锁的 Key 已经存在,
SETNX
(或 V5.0 的SET ... NX
)会返回失败。
结果:
- 如果锁不具备重入性 (V5.0 以前的实现): 客户端 A 会在
calculateInventory()
内部被 自己持有的锁阻塞,形成 死锁。createOrder()
永远无法执行完毕,锁永远不会被释放(除非 TTL 超时)。 - 如果锁具备重入性 (V6.0 实现): 系统识别到尝试加锁的客户端 A 和当前的锁持有者是 同一个,允许它直接进入,并增加 重入计数器。所有业务执行完毕后,计数器减到 0,锁才真正释放。
V6.0 是如何解决重入性问题的?
在 V6.0 的实现中,我们不再只用一个简单的 Key 来表示锁,而是使用 Redis 的 Hash 结构 来记录锁的持有状态和重入次数。
1. 识别持有者
V6.0 使用 UUID:ThreadID
作为 Hash 的字段 (Field),来唯一标识一个客户端和一个线程。
-
第一次加锁: 客户端 A 发现 Hash Key 不存在,成功加锁,并记录:
HSET lockKey "A_UUID:Thread1" 1 EXPIRE lockKey 30
(重入计数为 1)
2. 实现重入
客户端 A 在 calculateInventory()
中 第二次 尝试加锁时:
-
Lua 脚本 会检查:
lockKey
是否存在?(存在)- Hash Key 中是否存在 当前线程的 Field (“A_UUID:Thread1”)?(存在)
-
判断结果: 是同一个线程,允许重入!
-
执行操作: 脚本执行
HINCRBY
将重入计数 +1,并 重置 TTL。HINCRBY lockKey "A_UUID:Thread1" 1 // 重入计数变为 2 EXPIRE lockKey 30
3. 安全释放锁
客户端 A 两次调用 lock.unlock()
:
- 第一次解锁:
HINCRBY
将计数从 2 变为 1。- 计数不为 0,不执行 DEL。
- 第二次解锁 (createOrder 结束时):
HINCRBY
将计数从 1 变为 0。- 计数为 0,执行 DEL 真正释放锁。
通过这种方式,只有当最外层的函数执行完毕,将重入计数减到 0 时,锁才会被释放,完美解决了业务封装和递归调用中的死锁问题。