随着技术快速发展,数据规模增大,分布式系统越来越普及,一个应用往往会部署在多台机器上(多节点),在有些场景中,为了保证数据不重复,要求在同一时刻,同一任务只在一个节点上运行,即保证某一方法同一时刻只能被一个线程执行。在单机环境中,应用是在同一进程下的,只需要保证单进程多线程环境中的线程安全性,通过 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、每个线程只能释放掉自己加的锁,不能释放掉其他线程获得锁,如果当前线程对应的锁不存在,说明该锁已过期,不做任何操作即可。
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 queryWrapper = new QueryWrapper<>();
Page userPage = userService.page(new Page<>(1, 20), queryWrapper);
String redisKey = String.format("yupao:user:recommend:%s", userId);
ValueOperations 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()
来判断当前线程持有的锁是否是自己的,若是则释放,否则无法释放。
原理图:
在分布式锁中,看门狗机制通常用于自动续期锁,以确保当任务执行时间超过预期时,锁不会意外过期和被其他进程或线程抢占。
当你手动设置锁的过期时间时,例如:
RLock lock = redissonClient.getLock("myLock");
lock.lock(10, TimeUnit.SECONDS); // 手动设置锁过期时间为10秒
try {
// 执行任务
Thread.sleep(20000); // 模拟长时间任务(超过了锁的过期时间)
} finally {
lock.unlock(); // 释放锁
}
在上述例子中,锁的有效期是 10 秒,但任务执行时间是 20 秒。因此,锁会在任务执行期间被 Redis 自动释放,因为它达到了手动设置的过期时间。这时,其他进程或线程可能会获取锁,导致并发冲突。
为了确保锁在任务执行过程中不会过期,可以不设置过期时间,这会启用 Redisson 的看门狗机制。看门狗机制会定期检查任务状态,并在任务未完成时自动续期锁的过期时间:
RLock lock = redissonClient.getLock("myLock");
lock.lock(); // 不设置过期时间,启用看门狗机制
try {
// 执行任务
Thread.sleep(20000); // 模拟长时间任务
} finally {
lock.unlock(); // 任务完成后显式释放锁
}
在这种情况下,Redisson 的看门狗机制会在任务执行过程中每隔 10 秒自动续期锁的过期时间(默认是延长 30 秒),直到任务完成并手动释放锁。这样,即使任务执行时间超过了最初的锁的过期时间,锁仍然不会被其他线程抢占。
当执行lock.tryLock(0, -1, TimeUnit.MICROSECONDS)
时,当前线程会尝试获取锁,由于锁的过期时间设置为-1,因此会启用看门狗机制,此时该锁的默认过期时间为30000毫秒(30秒),看门狗机制会异步执行一个监听器,每隔internalLockLeaseTime/3之后执行,算下来就是大约10秒钟执行一次,如果当前方法未完成,则会延长锁的过期时间,直至方法完成释放锁。
lock.tryLock(0, 30000, TimeUnit.MICROSECONDS),此时手动设置锁的过期时间为30000毫秒,当30000毫秒后,该锁会自动释放(无论方法是否执行完成),因此会导致线程不安全,引起并发问题。
unlock()
方法释放锁,锁被释放后,看门狗线程会停止工作,锁的续期也随之停止。场景:根据前面的分析解决了多个服务器之间的线程安全问题,防止了超卖和超买等问题,但是上述方案的前提都是有多个服务器,单Redis服务器的情况,此时所有的资源都存在一个主Redis服务器上,如果主服务器发生宕机,依然会导致线程不安全。
针对于上述问题,当Redis是集群架构时,为防止主从服务器的锁不一致性问题,Redisson使用RedLock来实现,核心思想是:有N个Redis实例时,只有当N/2 + 1
个Redis实例成功获得锁时,才表示当前锁获取成功,否则重新获取。
N
个 Redis 实例(推荐使用 3 或 5 个 Redis 实例)。客户端(如应用程序中的某个进程)会尝试依次向所有 Redis 实例获取同一个锁。my-lock
)和相同的过期时间来请求每个 Redis 实例。N
个 Redis 实例中获取锁,只有当客户端能在超过 半数的 Redis 实例(即至少 N/2 + 1
)中成功获得锁时,才认为锁获取成功。N
个 Redis 实例中请求锁,并设置相同的键、值和过期时间。N/2 + 1
个)Redis 实例中获取到锁,并且获取锁的总时间小于设定的超时时间,那么客户端成功获得了锁。