当前位置: 首页 > news >正文

Redis分布式锁:从“能用就行”到“稳如老狗”的迭代方案

第一阶段:从 JVM 单机锁到 Redis 分布式锁的初级形态

1. JVM 单机锁:一切的起点

在单体应用中,我们使用 JVM 提供的锁(如 synchronizedReentrantLock)来保证多线程对共享资源的 独占性

比喻: 就像一个办公室里只有 一把钥匙,谁拿到钥匙,谁就能进办公室处理文件,其他人必须在外面等候。

代码(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 锁,无法阻止不同服务器上的线程同时访问同一个共享资源(例如数据库中的库存)。

线程A进入方法
尝试获取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
}
客户端RedisSETNX lockKey “locked”1 (成功)客户端宕机EXPIRE 未执行!lockKey 永不过期死锁形成!客户端Redis

4. Redis 分布式锁 V3.0:解决原子性问题(SET 命令的最佳实践)

原子性问题: V2.0 中 SETNXEXPIRE 是两个独立操作。

解决方案: 利用 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 秒过期。

  1. 客户端 A 业务逻辑执行了 31 秒
  2. 锁自动过期 被释放。
  3. 客户端 B 进来,发现锁已释放,成功拿到锁。
  4. 客户端 A 终于执行完业务,调用 unlock() 方法,它会 错误地删除 客户端 B 刚加上的锁!
  5. 客户端 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 业务执行时间过长,锁过期了怎么办?
客户端ARedis客户端BSET lockKey UUID_A NX EX 30OK执行业务逻辑 (超时)锁过期自动释放SET lockKey UUID_B NX EX 30OK执行Lua脚本 (GET lockKey)UUID_B值不等于UUID_A不执行DEL客户端B的锁未被误删安全!客户端ARedis客户端B

6. Redis 分布式锁 V5.0:实现续期机制(实现高可用)

潜藏的问题(高可用/超时释放): V4.0 的锁超时释放机制是 防死锁 的必要手段,但如果业务执行时间长于 TTL,锁会被自动释放,导致 独占性被破坏(即 V4.0 的问题 4 的来源)。为了保证独占性,我们需要一个 续期 (Watch Dog) 机制

解决方案: 引入一个 看门狗 (Watch Dog) 线程

  1. 客户端 A 成功加锁。
  2. Watch Dog 线程启动,它会周期性(例如每 10 秒)检查客户端 A 的业务是否执行完毕。
  3. 如果业务仍在执行,Watch Dog 就会自动调用 EXPIRE 命令为锁 续期(例如再续 30 秒)。
  4. 当业务执行完毕,主线程会通知 Watch Dog 停止,并执行解锁操作。

比喻: 除了钥匙和闹钟,我们还派了一个 “看门狗”

  • 每隔一段时间,看门狗会跑去看看拿钥匙的人是不是还在工作,如果在,就帮他重新设置闹钟(续期)。
  • 一旦工作完成,告诉看门狗:“工作完成了,你可以下班了。”

工程实践: 这就是 Redisson 等成熟分布式锁框架的核心机制。

客户端A主线程Watch DogRedisSET lockKey UUID_A NX EX 30OK启动看门狗检查业务是否完成未完成EXPIRE lockKey 30OKloop[每10秒检查]业务完成,停止执行Lua脚本解锁客户端A主线程Watch DogRedis

第三阶段:解决重入性问题(最终最优形态)

7. Redis 分布式锁 V6.0:实现重入性(最优形态)

潜藏的问题(重入性): V5.0 的实现,同一个线程(客户端 A)在持有锁的情况下,如果再次尝试加锁,会因为 SETNX 失败而被阻塞,不具备重入性,这不符合 Java ReentrantLock 的行为习惯,容易造成应用死锁。

解决方案: 同样使用 Lua 脚本哈希表 (Hash) 来记录锁信息,实现重入计数。

  • Key: lockKey
  • Hash 字段 (Field): uniqueId(例如:UUID:ThreadID),作为锁的持有者。
  • Hash 值 (Value): 重入次数 (Counter)

加锁逻辑(Lua 脚本):

  1. 尝试加锁: 使用 HSET 尝试将 lockKey 设为 uniqueId -> 1
  2. 判断持有者: 如果 lockKey 存在,且 Field 是当前的 uniqueId,说明是 重入,则 HINCRBYCounter +1
  3. 非重入: 如果 Field 不是当前的 uniqueId,说明锁被别人持有,返回失败。
  4. 设置 TTL: 无论首次加锁还是重入,都需要 重置过期时间(确保锁不会因重入而提前释放)。

解锁逻辑(Lua 脚本):

  1. 判断持有者: 检查锁是否是当前线程持有。
  2. 重入计数 -1: 如果是,则 HINCRBYCounter -1
  3. 真正释放: 如果 Counter 减到 0,说明是最后一次解锁,执行 DEL lockKey 释放锁。
  4. 设置 TTL: 重置锁的过期时间。

实现特性分析(V6.0/Redisson 级别):

  1. 独占性: 实现!SETNX + 唯一标识)
  2. 高可用: 实现! (Watch Dog 续期机制)
  3. 防死锁: 实现! (TTL + Watch Dog 保证续期不会永久持有)
  4. 不乱抢: 实现! (唯一标识 + Lua 脚本原子判断)
  5. 重入性: 实现! (Hash 结构 + 重入计数)

额外优化(RedLock 算法):

虽然 V6.0 在单 Redis 实例上已是优秀实现,但如果您的 单个 Redis 实例宕机,且尚未同步到从节点,会导致锁丢失。RedLock 算法(由 Redis 作者提出)尝试解决这个问题,通过在 N 个独立的 Redis Master 节点 上进行多数派投票加锁,进一步提升锁的 可用性强一致性。不过,RedLock 的复杂性和争议较大,在绝大多数场景下,Watch Dog 机制 的 V6.0 结合 Redis 高可用集群(哨兵/集群模式)已足够满足需求。

不存在
存在
是本人
是他人
调用 lock.lock
锁Key是否存在?
首次加锁
HSET lockKey UUID:ThreadID 1
检查持有者UUID
重入加锁
HINCRBY +1
加锁失败
设置/刷新TTL
加锁成功

总结:

我们从最简单的 SETNX 开始,通过引入 过期时间 (TTL) 解决死锁,引入 唯一标识 + Lua 脚本 解决误删(不乱抢),引入 Watch Dog 解决业务超时问题(高可用),最终引入 Hash 结构 + 重入计数 实现了重入性,最终达到了您所需的五个特性。这个迭代过程完美体现了分布式系统中解决问题的思路:解决原子性 -> 解决一致性 -> 解决可用性


附加: 重入性问题

什么是“重入性”(Reentrancy)?

通俗比喻:

假设你走进一个 “单人间办公室”(临界区)。

  1. 你手持一把 门禁卡(锁),刷卡进入,锁住了门。
  2. 在办公室里工作时,你需要使用办公室里的 “子功能区”,例如一个打印机。
  3. 如果这个打印机区域也被同一张 门禁卡 锁着,那么具备 重入性 的意思是:你无需将卡片放下再重新拿起来刷一次,而是系统能够识别到:“哦,是同一个卡主,放行!”
  4. 如果你必须放下卡片再重新拿起来刷一次,那在你放下卡片的那一瞬间,其他人就可能抢先进门,独占性就被破坏了

技术定义:

重入性 指的是:同一个线程(客户端)在已经成功获取锁的情况下,可以 重复 地进入同一段代码块(或函数),而不会被自己持有的锁所阻塞。


为什么需要重入性?

在实际的程序设计中,最常见的需要重入性的场景是 递归调用封装的函数相互调用

场景一:函数间的相互调用(业务封装)

假设您正在编写一个订单处理系统:

// 客户端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(); // 第二次解锁}
}

问题出现:

  1. 客户端 A 执行 createOrder()第一次 成功拿到分布式锁 (在 Redis 中设置了 Key)。
  2. 程序运行到 calculateInventory() 函数内部。
  3. calculateInventory() 中,程序 第二次 调用 lock.lock() 尝试加锁。
  4. 此时 Redis 中锁的 Key 已经存在SETNX(或 V5.0 的 SET ... NX)会返回失败。
结果:
  • 如果锁不具备重入性 (V5.0 以前的实现): 客户端 A 会在 calculateInventory() 内部被 自己持有的锁阻塞,形成 死锁createOrder() 永远无法执行完毕,锁永远不会被释放(除非 TTL 超时)。
  • 如果锁具备重入性 (V6.0 实现): 系统识别到尝试加锁的客户端 A 和当前的锁持有者是 同一个,允许它直接进入,并增加 重入计数器。所有业务执行完毕后,计数器减到 0,锁才真正释放。
具备重入性 V6.0
匹配
调用 calculateInventory
createOrder 加锁
计数=1
calculateInventory 尝试加锁
检查持有者UUID
重入计数+1
计数=2
执行calculateInventory
解锁: 计数-1=1
createOrder 解锁
计数-1=0, 释放锁
成功!
不具备重入性 V5.0
调用 calculateInventory
createOrder 加锁
calculateInventory 尝试加锁
锁已被自己持有
SETNX 失败
永久阻塞
死锁!

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 脚本 会检查:

    1. lockKey 是否存在?(存在)
    2. 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 时,锁才会被释放,完美解决了业务封装和递归调用中的死锁问题。

http://www.dtcms.com/a/439522.html

相关文章:

  • 国庆Day3
  • 棋牌类网站开发聚名网官网入口
  • Spring Boot 与数据访问全解析:MyBatis、Thymeleaf、拦截器实战
  • 永久免费个人网站申请注册电子购物网站开发公司
  • 工信部网站黑名单软件开发工程师的岗位职责
  • 阿里云做的网站为啥没有ftp汉南做网站
  • 深度学习是如何收敛的?梯度下降算法原理详解
  • 1.Kali Linux操作系统的下载(2025年10月2日)
  • JVM(七)--- 垃圾回收
  • 专门制作网站郑州男科医院哪家治疗比较好
  • 网站开发 需求文档结构设计网站推荐
  • 自定义异常类中的super(msg)的作用
  • 我想卖自己做的鞋子 上哪个网站好扬州市建筑信息平台
  • 十里河网站建设百度做营销网站多少钱
  • 新版网页传奇网站优化怎么做外链
  • 衡阳网站建设icp备网页设计技术论文范文
  • Linux驱动开发核心概念详解 - 从入门到精通
  • 深圳市建设工程交易服务中心网站在南海建设工程交易中心网站
  • 寻找哈尔滨网站建设服务器内部打不开网站
  • 函数展开成幂级数的方法总结
  • 自己可以做类似拓者的网站吗郑州网站建设行情
  • 中国顶级 GEO 优化专家孟庆涛:用 15 年积淀定义 2025 年 GEO 优化新标准
  • 建筑方案的网站wordpress首页做全屏
  • 建设银行手机官方网站下载安装推荐大良营销网站建设
  • 华为手机网站建设策划方案wordpress文章模块化
  • 修改wordpress用户密码深圳网站营销seo电话
  • 杭州建设企业网站的网络规划设计师考海明码吗
  • DAY24 方法引用、Base64、正则、lombok
  • 大学网站建设包括哪些课程专业网站搭建报价
  • 网站上的图片做多大免费网站整站模板源码