分布式锁
# 分布式锁
# 4. 你是怎么解决集群环境下的并发问题?
原因分析:由于我们部署了多个 Tomcat,每个 Tomcat 都有一个属于自己的 JVM。那么假设在服务器 A 的 Tomcat 内部,有两个线程,即线程 1 和线程 2,这两个线程使用的是同一份代码,那么它们的锁对象是同一个,是可以实现互斥的。但是如果在服务器 B 的 Tomcat 内部,又有两个线程,但是它们的锁对象虽然写的和服务器 A 一样,但是锁对象却不是同一个,所以线程 3 和线程 4 可以实现互斥,但是却无法和线程 1 和线程 2 互斥。

这就是集群环境下 syn 锁失效的原因。在这种情况下,我们需要使用分布式锁来解决这个问题,让锁不存在于每个 JVM 的内部,而是让所有 JVM 公用外部的一把锁(Redis)。
# 1. 分布式锁的实现
分布式锁:满足分布式系统或集群模式下多线程可见并且可以互斥的锁。
分布式锁的核心思想就是让大家共用同一把锁,那么我们就能锁住线程,不让线程进行,让程序串行执行,这就是分布式锁的核心思路。

分布式锁应该满足的条件:
- 可见性:多个线程都能看到相同的结果。注意:这里说的可见性并不是并发编程中指的内存可见性,只是说多个进程之间都能感知到变化的意思。
- 互斥:互斥是分布式锁的最基本条件,使得程序串行执行。
- 高可用:程序不易崩溃,时时刻刻都保证较高的可用性。
- 高性能:由于加锁本身就让性能降低,所以对于分布式锁需要较高的加锁性能和释放锁性能。
- 安全性:安全也是程序中必不可少的一环。
常见的分布式锁有三种:
- MySQL:MySQL 本身就带有锁机制,但是由于 MySQL 的性能一般,所以采用分布式锁的情况下,使用 MySQL 作为分布式锁比较少见。
- Redis:Redis 作为分布式锁是非常常见的一种使用方式,现在企业级开发中基本都是用 Redis 或者 Zookeeper 作为分布式锁。利用
SETNX这个方法,如果插入 Key 成功,则表示获得到了锁;如果有人插入成功,那么其他人就会插入失败,无法获取到锁。利用这套逻辑完成互斥,从而实现分布式锁。 - Zookeeper:Zookeeper 也是企业级开发中较好的一种实现分布式锁的方案,但本文是学 Redis 的,所以这里就不过多阐述了。
| MySQL | Redis | Zookeeper | |
|---|---|---|---|
| 互斥 | 利用 MySQL 本身的互斥锁机制 | 利用 setnx 这样的互斥命令 | 利用节点的唯一性和有序性实现互斥 |
| 高可用 | 好 | 好 | 好 |
| 高性能 | 一般 | 好 | 一般 |
| 安全性 | 断开连接,自动释放锁 | 利用锁超时时间,到期释放 | 临时节点,断开连接自动释放 |
我这里使用的 Redis 的 setnx 命令。
获取锁:
- 互斥:确保只能有一个线程获取锁
- 非阻塞:尝试一次,成功返回 true,失败返回 false
SET lock thread01 NX EX 10
释放锁:
- 手动释放
- 超时释放:获取锁的时候添加一个超时时间
DEL lock
核心思路:我们利用 Redis 的 SETNX 方法,当有多个线程进入时,我们就利用该方法来获取锁。第一个线程进入时,Redis 中就有这个 key 了,返回了 1,如果结果是 1,则表示他抢到了锁,那么他去执行业务,然后再删除锁,退出锁逻辑。没有抢到锁(返回了 0)的线程,等待一定时间之后重试。
# 2. Redis 分布式锁误删情况
逻辑说明:
- 持有锁的线程 1 在锁的内部出现了阻塞,导致他的锁 TTL 到期,自动释放。
- 此时线程 2 也来尝试获取锁,由于线程 1 已经释放了锁,所以线程 2 可以拿到。
- 但是现在线程 1 阻塞完了,继续往下执行,要开始释放锁了。
- 那么此时就会将属于线程 2 的锁释放,这就是误删别人锁的情况。
解决方案:
- 解决方案就是在每个线程释放锁的时候,都判断一下这个锁是不是自己的,如果不属于自己,则不进行删除操作。
- 假设还是上面的情况,线程 1 阻塞,锁自动释放,线程 2 进入到锁的内部执行逻辑。此时线程 1 阻塞完了,继续往下执行,开始删除锁,但是线程 1 发现这把锁不是自己的,所以不进行删除锁的逻辑。当线程 2 执行到删除锁的逻辑时,如果 TTL 还未到期,则判断当前这把锁是自己的,于是删除这把锁。

解决 Redis 分布式锁误删问题:
- 需求:修改之前的分布式锁实现。
- 满足:在获取锁的时候存入线程标识(用 UUID 标识,在一个 JVM 中,ThreadId 一般不会重复,但是我们现在是集群模式,有多个 JVM,多个 JVM 之间可能会出现 ThreadId 重复的情况),在释放锁的时候先获取锁的线程标识,判断是否与当前线程标识一致。
- 如果一致则释放锁
- 如果不一致则不释放锁
- 核心逻辑:在存入锁的时候,放入自己的线程标识,在删除锁的时候,判断当前这把锁是不是自己存入的。
- 如果是,则进行删除
- 如果不是,则不进行删除
# 3. 分布式锁的原子性问题
更为极端的误删逻辑说明:
- 假设线程 1 已经获取了锁,在判断标识一致之后,准备释放锁的时候,又出现了阻塞(例如 JVM 垃圾回收机制)。
- 于是锁的 TTL 到期了,自动释放了。
- 那么现在线程 2 趁虚而入,拿到了一把锁。
- 但是线程 1 的逻辑还没执行完,那么线程 1 就会执行删除锁的逻辑。
- 但是在阻塞前线程 1 已经判断了标识一致,所以现在线程 1 把线程 2 的锁给删了。
- 那么就相当于判断标识那行代码没有起到作用。
- 这就是删锁时的原子性问题。
- 因为线程 1 的拿锁、判断标识、删锁不是原子操作,所以我们要防止刚刚的情况。

解决方案:使用 Redis 提供的 Lua 脚本实现原子性
- Redis 提供了 Lua 脚本功能,在一个脚本中编写多条 Redis 命令,确保多条命令执行时的原子性。
- Lua 是一种编程语言,它的基本语法可以上菜鸟教程看看,链接:https://www.runoob.com/lua/lua-tutorial.html (opens new window)
- 这里重点介绍 Redis 提供的调用函数,我们可以使用 Lua 去操作 Redis,而且还能保证它的原子性,这样就可以实现拿锁、判断标识、删锁是一个原子性动作了。
原逻辑:
@Override
public void unlock() {
// 获取当前线程的标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁中的标识
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
// 判断标识是否一致
if (threadId.equals(id)) {
// 释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
2
3
4
5
6
7
8
9
10
11
12
改为 Lua 脚本:
-- 这里的KEYS[1]就是传入锁的key
-- 这里的ARGV[1]就是线程标识
-- 比较锁中的线程标识与线程标识是否一致
if (redis.call('get', KEYS[1]) == ARGV[1]) then
-- 一致则释放锁
return redis.call('del', KEYS[1])
end
return 0
2
3
4
5
6
7
8
利用 Java 代码调用 Lua 脚本改造分布式锁:
在 RedisTemplate 中,可以利用 execute 方法去执行 Lua 脚本:
public <T> T execute(RedisScript<T> script, List<K> keys, Object... args) {
return this.scriptExecutor.execute(script, keys, args);
}
2
3
对应的 Java 代码如下:
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
@Override
public void unlock() {
stringRedisTemplate.execute(UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());
}
2
3
4
5
6
7
8
9
10
11
12
13
14
# 4. 基于 SETNX 实现分布式锁存在的问题
具体视频:https://www.bilibili.com/video/BV1cr4y1671t?p=64 (opens new window)
- 重入问题
- 重入问题是指获取锁的线程,可以再次进入到相同的锁的代码块中。可重入锁的意义在于防止死锁,例如在 HashTable 这样的代码中,它的方法都是使用 synchronized 修饰的,假如它在一个方法内调用另一个方法,如果此时是不可重入的,那就死锁了。所以可重入锁的主要意义是防止死锁,我们的 synchronized 和 Lock 锁都是可重入的。
- 不可重试
- 我们编写的分布式锁只能尝试一次,失败了就返回 false,没有重试机制。但合理的情况应该是:当线程获取锁失败后,他应该能再次尝试获取锁。
- 超时释放
- 我们在加锁的时候增加了 TTL,这样我们可以防止死锁,但是如果卡顿(阻塞)时间太长,也会导致锁的释放。虽然我们采用 Lua 脚本来防止删锁的时候误删别人的锁,但现在的新问题是没锁住,也有安全隐患。
- 主从一致性
- 如果 Redis 提供了主从集群,那么当我们向集群写数据时,主机需要异步地将数据同步给从机。万一在同步之前,主机宕机了(主从同步存在延迟,虽然时间很短,但还是发生了),那么又会出现死锁问题。
那么什么是 Redisson 呢?
- Redisson 是一个在 Redis 的基础上实现的 Java 驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式 Java 常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。
Redis 提供了分布式锁的多种多样功能:
- 可重入锁(Reentrant Lock)
- 公平锁(Fair Lock)
- 联锁(MultiLock)
- 红锁(RedLock)
- 读写锁(ReadWriteLock)
- 信号量(Semaphore)
- 可过期性信号量(PermitExpirableSemaphore)
- 闭锁(CountDownLatch)
# Redisson 可重入锁原理
- 在 Lock 锁中,它是借助于底层的一个 volatile 的 state 变量来记录重入状态的:
- 如果当前没有人持有这把锁,那么 state = 0
- 如果有人持有这把锁,那么 state = 1
- 如果持有这把锁的人再次持有这把锁,那么 state 会 +1
- 如果对于 synchronize 而言,它在 C 语言代码中会有一个 count
- 原理与 state 类似,也是重入一次就 +1,释放一次就 -1,直至减到 0,表示这把锁没有被人持有
- 在 Redisson 中,我们也支持可重入锁
- 在分布式锁中,它采用 hash 结构来存储锁,其中外层 key 表示这把锁是否存在,内层 key 则记录当前这把锁被哪个线程持有
- method1 在方法内部调用 method2,method1 和 method2 处于同一个线程,那么 method1 已经拿到一把锁了,想进入 method2 中拿另外一把锁,必然是拿不到的,于是就出现了死锁

WatchDog 看门狗功能:Redisson 的 WatchDog 是一个用于监听分布式场景下数据变化的组件。它会监控一个或多个 Redis 键的变化,并在发生变化时触发回调函数。WatchDog 可以用来处理一些场景,比如数据变化后需要进行特定的业务处理。WatchDog 可以监控多个 Redis 键,支持多个回调函数,支持异步回调和同步回调,支持对监控频率的控制,可以根据不同的情况下调整监控频率,以达到最优化的性能。同时,加入了 Redisson 的分布式锁功能,能够有效处理分布式场景下的并发更新问题。
# Redisson 锁的 MultiLock 原理
- 为了提高 Redis 的可用性,我们会搭建集群或者主从,现在以主从为例。
- 此时我们去写命令,写在主机上,主机会将数据同步给从机,但是假设主机还没来得及把数据写入到从机去的时候,主机宕机了。
- 哨兵会发现主机宕机了,于是选举一个 slave(从机)变成 master(主机),而此时新的 master(主机)上并没有锁的信息,那么其他线程就可以获取锁,又会引发安全问题。
- 为了解决这个问题,Redisson 提出来了 MultiLock 锁。使用这把锁的话,那我们就不用主从了,每个节点的地位都是一样的,都可以当做是主机。那我们就需要将加锁的逻辑写入到每一个主从节点上,只有所有的服务器都写入成功,此时才是加锁成功。假设现在某个节点挂了,那么他去获取锁的时候,只要有一个节点拿不到,都不能算是加锁成功,就保证了加锁的可靠性。
# 小结
- 不可重入 Redis 分布式锁
- 原理:利用 SETNX 的互斥性;利用 EX 避免死锁;释放锁时判断线程标识
- 缺陷:不可重入、无法重试、锁超时失效
- 可重入 Redis 分布式锁
- 原理:利用 Hash 结构,记录线程标识与重入次数;利用 WatchDog 延续锁时间;利用信号量控制锁重试等待
- 缺陷:Redis 宕机引起锁失效问题
- Redisson 的 MultiLock
- 原理:多个独立的 Redis 节点,必须在所有节点都获取重入锁,才算获取锁成功