黑马点评 · 分布式锁模块整理

这一章是把上一章那个 synchronized 换掉的过程。要解决的问题只有一个:

synchronized 是 JVM 级别的锁,部署成集群之后,两个节点各锁各的,一人一单又失效了。

整个模块的推进顺序是三版代码 ➡️ 一个已经成熟的 Redisson 框架:

版本做法留下了什么问题
第一版SET NX EX 加锁、DEL 释放会误删别人的锁
第二版值里存线程标识,释放前先比对比对和删除是两步,中间会被打断
第三版用 Lua 把「比对 + 删除」合成一次执行不可重入、不可重试、超时释放
最终换成 Redisson 的 RLock只剩主从一致性问题

一、学习链路与代码落点

学习链路与代码落点

九行课程推进顺序。红色是问题背景,蓝色是概念,绿色是已经写完的三版实现,紫色是 Redisson,灰色是后面才讲的联锁和总结。

代码集中在三个文件:utils/ILock.java(接口)、utils/SimpleRedisLock.java(三版实现都在这)、config/RedissonConfig.java + VoucherOrderServiceImpl(Redisson 的接入点)。


二、为什么要分布式锁

集群下单机锁失效

同一个用户的两个请求被 nginx 分到 8081 和 8082,两个 JVM 各有一把 synchronized 锁,互不相识,于是两个请求都通过了一人一单校验。

分布式锁的定义就是冲着这一点来的:满足分布式系统或集群模式下,多进程可见并且互斥的锁。要满足五点:互斥、多进程可见、高可用、高性能、安全性(异常时能自动释放)。

常见实现有三种,课程选 Redis:

实现互斥方式怎么自动释放性能
MySQL数据库互斥锁 / 唯一索引断开连接自动释放一般
RedisSET NX 这样的互斥命令靠锁的超时时间到期释放好
Zookeeper节点的唯一性和有序性临时节点,断开连接自动释放一般

Zookeeper 略讲不是重点

真正的难点不在”加锁”,而在后面四个坑:超时怎么设、误删怎么防、不可重入和不可重试怎么办、主从切换时锁会不会丢。这一章剩下的篇幅都在填这四个坑。


三、第一版:SET NX + EX

课程先给了一个接口:

ILock 接口

实现的关键就一行 setIfAbsent:

获取锁

三个要点:

① NX 提供互斥。 SET key value NX EX 10 里的 NX 是”只有当 key 不存在时才设置”,谁设置成功谁就拿到锁。对应 Java 里的 setIfAbsent。

② EX 提供安全性,也就是防死锁。 拿到锁的线程如果宕机、或者业务卡死没释放锁,没有超时时间的话这把锁就永远留在 Redis 里,后面所有请求全部抢不到。必须给锁一个”保命”的超时时间,这是分布式锁和 synchronized 最大的不同(JVM 会在进程挂掉时自动释放监视器,Redis 不会)。

③ 非阻塞。 tryLock 尝试一次就返回 true / false,不像 synchronized 会一直等。拿不到锁的请求直接返回”不允许重复下单”,让用户重试。

④ 值存什么? 第一版随便存(比如 "1"),第二版你会发现这里必须存”持有者身份”:

private static final String ID_PREFIX = UUID.randomUUID() + "-";
String threadId = ID_PREFIX + Thread.currentThread().getId();

为什么要 UUID + 线程id 两个一起?线程 id 区分同一个 JVM 内的不同线程,UUID 区分不同的 JVM(集群里每个节点的线程 id 都是从 1、2、3 开始的,光看线程 id 分不清是哪台机器)。合起来才算唯一标识一个持有者。


四、第二版:解决误删

锁误删时序

问题出在一个很隐蔽的时序上:

  1. 线程 1 加锁成功,锁的超时时间是 1 秒
  2. 线程 1 业务卡住了,超过 1 秒
  3. 锁被 Redis 自动删除
  4. 线程 2 加锁成功,开始执行业务
  5. 线程 1 终于跑完,执行 DEL lock —— 删掉的是线程 2 的锁
  6. 线程 3 也能加锁成功了,临界区里同时挤进三个线程

这类问题只在”业务耗时 > 锁超时时间”时出现,本地测试基本测不出来,属于典型的生产事故。

改进思路(PPT 98 页):加锁时把线程标识存进值里,释放时先取出锁里的标识比对,一致才删。你代码里注释掉的那版就是这么写的:

朴素版释放锁

但这一版还有问题:GET 和 DEL 是两条独立的命令,两条命令之间线程可能被切换、业务可能阻塞,中间仍然有被插入的空间,误删还是可能发生。


五、第三版:用 Lua 保证原子性

Redis 提供了 Lua 脚本功能,一个脚本里的多条命令会被当成一次执行(Redis 执行脚本是单线程、不可中断的),所以只要把”比对 + 删除”写进一个脚本,就变成了原子操作。

unlock.lua

调用方式是把脚本交给 StringRedisTemplate 执行,key 放 KEYS 数组,其它参数放 ARGV 数组:

Lua 版释放锁

几个细节:

  • 脚本用 DefaultRedisScript 加载,setLocation 指定 classpath 下的 unlock.lua,setResultType 指定返回值类型
  • 脚本只加载一次,放在 static 代码块里,不用每次调用都读文件
  • stringRedisTemplate.execute(script, keys, args...):第一个参数是脚本,第二个是 key 列表,后面是 ARGV

到这里,一个”能用的” Redis 分布式锁就完成了:SET NX EX 互斥 + 超时防死锁 + 线程标识防误删 + Lua 保证释放的原子性。


六、但它还有四个问题

以下是SETNX 锁的短板列全了,这也是 Redisson 出场的理由:

问题现象后果
不可重入同一个线程第二次 SET NX 会因为 key 已存在而失败锁里再调用一个同样加锁的方法,自己把自己挡在门外
不可重试抢不到锁只尝试一次就返回 false高并发下大量请求直接失败,而不是等一会儿
超时释放业务没跑完锁就到期了别人能拿到锁,临界区被并发进入(第四节的误删就是这么来的)
主从一致性主节点宕机、锁还没同步到从节点新主上没有这把锁,另一个线程也能加锁成功

前三个是”使用体验”和”正确性”问题,第四个是架构层面的问题,分别由 Redisson 和联锁解决。


七、Redisson:一次把前三个坑填掉

Redisson 不是简单的工具类,而是一个 Redis 客户端,内部提供了各种分布式对象,分布式锁是其中一个。

Redisson 原理

三个能力分别对应三个问题:

① 可重入 —— 用 hash 结构代替简单 string。 锁的 key 还是锁名,但值变成了 hash:field = 客户端UUID:线程id,value = 重入次数。加锁时先判断:锁不存在就 hset 1 并设有效期;锁存在且 field 就是自己,就 hincrby 把次数 +1 放行;是别人就返回剩余 TTL。释放时 hincrby -1,减到 0 才真正删除锁并通知等待的线程。

⚠️ 这也带来一个使用上的坑:多拿一次就必须多 unlock() 一次。如果重入之后漏放了一次,重入次数不会归零,锁不会释放,看门狗还会一直续期——这个用户就被永久锁死了。

② 可重试 —— 用 PubSub 代替”睡一会儿再递归”。 抢不到锁时,Redisson 会订阅”锁被释放”的消息,在等待时间内被唤醒后重试,等到超时才返回 false。API 是 tryLock(等待时间, 释放时间, 单位)。

③ 超时续约 —— 看门狗(watchDog)。 调用 tryLock() 不传 leaseTime(或传 -1)时启用:默认租期 30 秒,每 releaseTime / 3(10 秒)自动续一次,只要 JVM 还活着,锁就不会因为业务没跑完而提前释放。

⚠️ 反过来要注意:一旦显式传了 leaseTime(例如 tryLock(10, TimeUnit.SECONDS)),看门狗就不生效,锁到点就释放,业务超时照样出问题。

接入只需要一个配置类和一个 Bean:

RedissonConfig

使用方式和你现在代码里的一样:

使用方式

课程里用两个测试方法(method1 调用 method2)验证了可重入,你测试类现在还是空的,可以照这个补上:

可重入验证

注意锁的结构没变:锁依然加在事务外层(try/finally 包住对代理对象的调用),这个原则从 synchronized 一路用到 Redisson。


八、最后一个问题:主从一致性

主从一致性

Redis 的主从复制是异步的。线程 1 向 Master 加锁成功,这条数据还没来得及同步到 Slave,Master 就宕机了,Slave 被提升为新的 Master——新主上没有这把锁,线程 2 于是也能加锁成功,两个线程同时认为自己持有同一把锁。

Redisson 的解法是 multiLock(联锁):准备多个相互独立的 Redis 节点(不是主从,是各自独立),加锁时向每个节点都申请,全部成功才算拿到锁,任意一个失败就把已加成功的全部释放。

代价也很明显:节点变多、维护成本上升、加锁耗时变长、整体可用性下降。所以实际项目要权衡——多数业务用单节点 Redisson 锁就够了,只有对一致性要求极高的场景才上联锁。