随着技术快速发展,数据规模增大,分布式系统越来越普及,一个应用往往会部署在多台机器上(多节点),在有些场景中,为了保证数据不重复,要求在同一时刻,同一任务只在一个节点上运行,即保证某一方法同一时刻只能被一个线程执行。在单机环境中,应用是在同一进程下的,只需要保证单进程多线程环境中的线程安全性,通过 JAVA 提供的 volatile、ReentrantLock、synchronized 以及 concurrent 并发包下一些线程安全的类等就可以做到。而在多机部署环境中,不同机器不同进程,就需要在多进程下保证线程的安全性了。因此,分布式锁应运而生。
场景分析:
假如现在有个卫生间,里面只有一个坑位,此时A、B、C三名同学都想上厕所,A拉粑粑需要30s,B拉粑粑需要40s,C拉粑粑需要50s,于是乎A、B、C三名同学来到卫生间后,只有一名同学能够获得坑位的使用权。假设A先到的,进入坑位后将门关住,表示厕所有人,此时B和C只能在外面等待。但是会遇到如下几种情况:
1、A在进入坑位上厕所时,一不小心掉进坑里了,由于A没有出来,导致门口的锁一直处于被锁住的状态,此时B和C由于锁未释放无法进去,因此只能无限等待,造成了资源的浪费。
2、A进入厕所后对着B和C说我大概20s就好了(对应设置锁过期时间为20s的操作),20s后A还没有上完厕所,此时B或C看到锁释放了,便进入了厕所,导致A和他人共用厕所的情况发生。
3、A在进入厕所后在门口加了一个锁,表示此时是A在厕所里,B和C此时看到A在厕所里,只能在外面等待,后来A上完厕所,释放A的锁,此时B进入厕所加了一个锁表示B在厕所里,但A把B的锁给释放掉了,会导致C以为厕所内现在没有人,C进入厕所,也会导致B和C共有厕所的情况。
以上三种情况对应于分布式锁要解决的三个问题:
1、锁要设置过期时间,不能让某个线程长时间持有锁,会导致资源浪费。
2、在方法未执行完成时,若锁过期,则需要延长锁的过期时间(看门狗机制),直至方法执行完毕。
3、每个线程只能释放掉自己加的锁,不能释放掉其他线程获得锁,如果当前线程对应的锁不存在,说明该锁已过期,不做任何操作即可。
1. Redisson分布式锁
Redisson是基于Rediss的Java库,封装了常用功能(如数据缓存、消息队列等)以及分布式系统开发的工具,如分布式锁、分布式集合、分布式信号量、分布式执行器等,同时也封装了 Redis 中的常见数据结构,如 Map、Set、List、Queue、Deque 等。
示例代码:
@Scheduled(cron = "0 26 16 * * *")
public void doCacheRecommendUser() {
RLock lock = redissonClient.getLock("yupao:precachejob:docache:lock");
try {
//tryLock(long waitTime, long leaseTime, TimeUnit unit)
//waitTime表示 等待获取锁的时间。如果设置为 0,意味着不会等待,会立即尝试获取锁。
//leaseTime表示 锁的租约时间,即锁的有效时间。在 Redisson 中,如果设置为 -1,则表示 锁永不过期,除非显式解锁 (unlock()),否则锁会一直存在。
if (lock.tryLock(0, -1, TimeUnit.MICROSECONDS)) {
System.out.println("getLock: " + Thread.currentThread().getId());
Thread.sleep(40000);
for (Long userId : mainUserList) {
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
Page<User> userPage = userService.page(new Page<>(1, 20), queryWrapper);
String redisKey = String.format("yupao:user:recommend:%s", userId);
ValueOperations<String, Object> valueOperations = redisTemplate.opsForValue();
// 写缓存
try {
valueOperations.set(redisKey, userPage, 30000, TimeUnit.SECONDS);
} catch (Exception e) {
log.error("redis set key error", e);
}
}
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
//只能自己释放自己的锁
if (lock.isHeldByCurrentThread()) {
System.out.println("unlock: " + Thread.currentThread().getId());
lock.unlock();
}else{
System.out.println("当前线程不持有锁,不能释放锁。");
}
}
}
分析:
RLock lock = redissonClient.getLock("yupao:precachejob:docache:lock"), redissonClient.getLock("yupao:precachejob:docache:lock")
只是通过RedissonClient
获取了一个名为"yupao:precachejob:docache:lock"
的锁对象(RLock
),但在实际使用之前,Redis中不会存储该锁的任何信息。
只有当调用lock.lock()
或lock.tryLock()
等方法时,才会在Redis中创建实际的锁。
lock.tryLock(0, -1, TimeUnit.MICROSECONDS)
表示当前线程会尝试获取可重入lock锁,0表示立即尝试获取,不进行等待。-1表示锁的过期时间为-1(无限制),表明不手动设置过期时间,系统会为该锁默认设置30s的过期时间,如果方法实际的运行时间大于30s,会根据看门狗机制来为该锁续期,直至方法执行完毕,调用lock.unlock()方法来手动释放锁。
而在释放锁的过程中需要判断当前线程持有的是哪个锁,每个线程只能释放自己加的锁,不能释放掉其他线程加的锁。
具体实现原理:redissonClient.getLock("yupao:precachejob:docache:lock")
会获取一个名为"yupao:precachejob:docache:lock"
的锁对象(RLock),当调用lock.lock()
或lock.tryLock()
方法时,会创建一个名为lock的map对象,这个map中的key由Redisson客户端id(UUID)和持有锁的线程id构成,value是锁重入计数。这样当释放锁时,先通过lock.isHeldByCurrentThread()
来判断当前线程持有的锁是否是自己的,若是则释放,否则无法释放。
原理图:
2. 看门狗机制
在分布式锁中,看门狗机制通常用于自动续期锁,以确保当任务执行时间超过预期时,锁不会意外过期和被其他进程或线程抢占。
两种锁使用方式对比:
1. 手动设置锁的过期时间(不会自动续期):
当你手动设置锁的过期时间时,例如:
RLock lock = redissonClient.getLock("myLock");
lock.lock(10, TimeUnit.SECONDS); // 手动设置锁过期时间为10秒
try {
// 执行任务
Thread.sleep(20000); // 模拟长时间任务(超过了锁的过期时间)
} finally {
lock.unlock(); // 释放锁
}
在上述例子中,锁的有效期是 10 秒,但任务执行时间是 20 秒。因此,锁会在任务执行期间被 Redis 自动释放,因为它达到了手动设置的过期时间。这时,其他进程或线程可能会获取锁,导致并发冲突。
2. 使用看门狗机制(自动续期锁):
为了确保锁在任务执行过程中不会过期,可以不设置过期时间,这会启用 Redisson 的看门狗机制。看门狗机制会定期检查任务状态,并在任务未完成时自动续期锁的过期时间:
RLock lock = redissonClient.getLock("myLock");
lock.lock(); // 不设置过期时间,启用看门狗机制
try {
// 执行任务
Thread.sleep(20000); // 模拟长时间任务
} finally {
lock.unlock(); // 任务完成后显式释放锁
}
在这种情况下,Redisson 的看门狗机制会在任务执行过程中每隔 10 秒自动续期锁的过期时间(默认是延长 30 秒),直到任务完成并手动释放锁。这样,即使任务执行时间超过了最初的锁的过期时间,锁仍然不会被其他线程抢占。
Redis 分布式锁中的看门狗工作原理
当执行lock.tryLock(0, -1, TimeUnit.MICROSECONDS)
时,当前线程会尝试获取锁,由于锁的过期时间设置为-1,因此会启用看门狗机制,此时该锁的默认过期时间为30000毫秒(30秒),看门狗机制会异步执行一个监听器,每隔internalLockLeaseTime/3之后执行,算下来就是大约10秒钟执行一次,如果当前方法未完成,则会延长锁的过期时间,直至方法完成释放锁。
lock.tryLock(0, 30000, TimeUnit.MICROSECONDS),此时手动设置锁的过期时间为30000毫秒,当30000毫秒后,该锁会自动释放(无论方法是否执行完成),因此会导致线程不安全,引起并发问题。
- 锁的获取:
-
- 当某个客户端成功获取 Redis 分布式锁时,它会为锁设置一个默认的过期时间,例如 30 秒。这意味着如果客户端在 30 秒内没有释放锁,Redis 会自动释放锁,以避免死锁。
- 看门狗的启动:
-
- Redisson 在客户端成功获取锁后,会启动看门狗线程。
- 默认情况下,这个看门狗会在锁的到期时间快到时自动续期锁的过期时间,例如每 10 秒检查一次是否需要将锁的过期时间延长 30 秒。
- 自动续期:
-
- 如果客户端仍在持有锁并且任务还没有完成,看门狗会自动续期,防止锁被 Redis 自动释放,从而确保任务可以在锁的保护下继续执行。
- 如果任务执行时间超出了最初的 30 秒,看门狗会每隔 10 秒续期一次锁的过期时间,确保锁的有效性。
- 锁的释放:
-
- 一旦任务执行完成,客户端会显式调用
unlock()
方法释放锁,锁被释放后,看门狗线程会停止工作,锁的续期也随之停止。 - 如果任务因为某些异常情况未能完成(如客户端崩溃),看门狗机制会确保锁在没有续期的情况下最终被 Redis 释放,防止锁被永远占用
- 一旦任务执行完成,客户端会显式调用
3 RedLock解决Redis主从不一致性问题
场景:根据前面的分析解决了多个服务器之间的线程安全问题,防止了超卖和超买等问题,但是上述方案的前提都是有多个服务器,单Redis服务器的情况,此时所有的资源都存在一个主Redis服务器上,如果主服务器发生宕机,依然会导致线程不安全。
针对于上述问题,当Redis是集群架构时,为防止主从服务器的锁不一致性问题,Redisson使用RedLock来实现,核心思想是:有N个Redis实例时,只有当N/2 + 1
个Redis实例成功获得锁时,才表示当前锁获取成功,否则重新获取。
RedLock 的基本流程:
- 多实例锁获取:
-
- 系统中假设有
N
个 Redis 实例(推荐使用 3 或 5 个 Redis 实例)。客户端(如应用程序中的某个进程)会尝试依次向所有 Redis 实例获取同一个锁。 - 客户端使用相同的锁键(例如
my-lock
)和相同的过期时间来请求每个 Redis 实例。
- 系统中假设有
- 设置锁的唯一标识:
-
- 每个锁请求会生成一个 唯一标识符(通常是 UUID),以确保不同的客户端或进程不会混淆彼此的锁请求。
- 获取多数锁:
-
- 客户端尝试在
N
个 Redis 实例中获取锁,只有当客户端能在超过 半数的 Redis 实例(即至少N/2 + 1
)中成功获得锁时,才认为锁获取成功。 - 锁请求必须在设置的超时时间内完成(通常是 Redis 实例的网络延迟和 RTT 时间的几倍),以确保客户端不会因为网络或实例故障无限期等待锁。
- 客户端尝试在
- 锁的过期时间:
-
- 每个锁设置时都会有一个过期时间,以避免死锁。当客户端获取锁后,如果客户端崩溃或者没有手动释放锁,Redis 会在锁的过期时间到达时自动释放锁。
- 锁的释放:
-
- 一旦客户端完成了对共享资源的操作,它会向所有持有锁的 Redis 实例发送一个解锁命令来释放锁。
- 只有持有该锁唯一标识符的客户端才能成功释放锁,防止其他客户端误释放不属于自己的锁。
RedLock 的过程总结为以下五步:
- 客户端从
N
个 Redis 实例中请求锁,并设置相同的键、值和过期时间。 - 客户端计算从每个 Redis 实例成功获取锁的时间。如果客户端能在大多数(即
N/2 + 1
个)Redis 实例中获取到锁,并且获取锁的总时间小于设定的超时时间,那么客户端成功获得了锁。 - 如果客户端在大多数实例中获取了锁,客户端可以进行对共享资源的操作。
- 如果锁超时后仍未成功获得足够多的实例锁,或者在操作过程中锁过期,则客户端应放弃该锁,等待下一次重试。
- 操作完成后,客户端会向所有 Redis 实例发出解锁命令,以释放锁。