在集群模式下,synchronized锁失效,因为synchronized只能够保证单个JVM内部的多个线程之间互斥,而不能使得集群下多个JVM之间线程互斥,所以必须使用分布式锁。
分布式锁
满足分布式系统或集群模式下多线程可见并且互斥的锁。
特性
多线程可见、互斥、高可用、高性能、安全性…
分布式锁的实现
分布式锁的核心是实现多线程之间互斥,而满足这一点的方法很多,常见的有三种:
MySQL | Redis | Zookeeper | |
---|---|---|---|
互斥 | 利用mysql本身的互斥锁机制 | 利用setnx这样的互斥命令 | 利用节点的唯一性和有序性实现互斥 |
高可用 | 好 | 好 | 好 |
高性能 | 一般 | 好 | 一般 |
安全性 | 断开连接,自动释放锁(回滚) | 利用锁超时时间,到期释放 | 临时节点,断开连接自动释放 |
基于Redis的分布式锁
setnx lock thread1(获取锁)
del key(释放锁)
expire lock 10(超时释放)
set lock thread1 nx ex 10(获取锁的同时设置超时时间)
获取锁后,执行业务时,如果发生错误,就没有执行释放锁,就会造成死锁,所以需要设置超时时间。但是在获取锁时,还没执行到设置超时时间时,就发生了错误,仍然会造成死锁,所以需要将获取锁和设置超时时间同时进行。
案例
1.需求:定义一个类,实现下面接口,利用Redis实现分布式锁功能。
public interface ILock {
//尝试获取锁
boolean tryLock(long timeoutSec);
//释放锁
void unlock();
}
import java.util.concurrent.TimeUnit;
//初级版本(A线程获取到锁,因为某种原因阻塞,所以业务时间变长,锁超时释放。此时B线程来了,获取到锁,此时A线程正常了,A线程执行完业务误删了B线程的锁。此时C线程来了,获取到锁,此时B线程正常了,B线程执行完业务误删了C线程的锁......)
public class SimpleRedisLock implements ILock{
private String name;
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name) {
this.name = name;
}
@Override
public boolean tryLock(long timeoutSec) {
//获取线程标识
long id = Thread.currentThread().getId();
//获取锁
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(name, id + "", timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
stringRedisTemplate.delete(name);
}
}
2.改进Redis的分布式锁
需求:修改之前的分布式锁实现,满足:
1>在获取锁时存入线程标示(可以用UUID表示)
2>在释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致
如果一致则释放锁
如果不一致则不释放锁
import java.util.concurrent.TimeUnit;
//线程1判断锁标识后阻塞同理可能造成误删
public class SimpleRedisLock implements ILock{
private String name;
private StringRedisTemplate stringRedisTemplate;
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
public SimpleRedisLock(String name) {
this.name = name;
}
@Override
public boolean tryLock(long timeoutSec) {
//获取线程标识
String id = ID_PREFIX + Thread.currentThread().getId();
//获取锁
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(name, id, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
//获取线程标识
String id = ID_PREFIX + Thread.currentThread().getId();
//获取锁中的标识
String name = stringRedisTemplate.opsForValue().get(name);
if (id.equals(name)) {
//释放锁
stringRedisTemplate.delete(name);
}
}
}
3.Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。
redis.call('命令名称','key','其它参数', ...)
eg:set name jack 的脚本为 redis.call('set','name','jack')
eg:我们要先执行set name Rose,再执行get name,则脚本如下:
#先执行set name jack
redis.call( 'set', 'name', 'jack')
#再执行get name
local name = redis.call('get', 'name ' )
#返回
return name
Lua脚本:
--获取锁中的线程标示get key
local id = redis.call('get' ,KEYS[1])
--比较线程标示与锁中的标示是否一致
if(id == ARGV[1])then
--释放锁del key
return redis.call('del',KEYS[1])
end
return 0
需求:基于Lua脚本实现分布式锁的释放锁逻辑
先新建unlock.lua,将上述脚本写进去,代码如下
import java.util.Collections;
import java.util.concurrent.TimeUnit;
public class SimpleRedisLock implements ILock{
private String name;
private StringRedisTemplate stringRedisTemplate;
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
private static final DefaultRedisScript<Long> UNLOCK;
static {
UNLOCK = new DefaultRedisScript<>();
UNLOCK.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK.setResultType(Long.class);
}
public SimpleRedisLock(String name) {
this.name = name;
}
@Override
public boolean tryLock(long timeoutSec) {
//获取线程标识
String id = ID_PREFIX + Thread.currentThread().getId();
//获取锁
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(name, id, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
/*//获取线程标识
String id = ID_PREFIX + Thread.currentThread().getId();
//获取锁中的标识
String name = stringRedisTemplate.opsForValue().get(name);
if (id.equals(name)) {
//释放锁
stringRedisTemplate.delete(name);
}*/
//调用lua脚本
stringRedisTemplate.execute(UNLOCK, Collections.singletonList(name), ID_PREFIX + Thread.currentThread().getId());
}
}
基于Redis的分布式锁优化
问题
基于setnx实现的分布式锁存在下面的问题:
不可重入:同一个线程无法多次获取同一把锁
不可重试:获取锁只尝试一次就返回false,没有重试机制
超时释放:锁超时释放虽然可以避免死锁,但如果是业务执行耗时较长,也会导致锁释放,存在安全隐患
主从一致性:如果Redis提供了主从集群,主从同步存在延迟,当主宕机时,如果从并同步主中的锁数据,则会出现锁实现
解决方案:Redisson
Redisson是架设在Redis基础上的一个Java驻内存数据网格(In-Memory Data Grid)。
Redisson是一个在Redis的基础上实现的分布式工具的集合。
Redisson入门
1、引入依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
2、配置Redisson客户端
@Configuration
public class RedisConfig {
@Bean
public RedissonClient redissonClient() {
//配置类
Config config = new Config();
//添加redis地址,这里添加了单点的地址,也可以使用config.useClusterServers()添加集群地址
config.useSingleServer ().setAddress("redis://192.168.150.101:6379") . setPassword("123321")
//创建客户端
return Redisson.create(config);
}
}
3、使用Redisson的分布式锁
@Resource
private RedissonClient redissonclient;
@Test
void testRedisson() throws InterruptedException {
//获取锁(可重入),指定锁的名称
RLock lock = redissonclient.getLock("anyLock");
//尝试获取锁,参数分别是:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位
boolean isLock = lock.tryLock(1,10,TimeUnit.SECONDS);
//判断释放获取成功
if(isLock){
try {
System.out.println("“执行业务");
}finally{
//释放锁
lock.unlock();
}
}
}
Redisson可重入锁原理
Redisson的锁重试和WatchDog机制
Redisson提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期,也就是说,如果一个拿到锁的线程一直没有完成逻辑,那么看门狗会帮助线程不断的延长锁超时时间,锁不会因为超时而被释放。
leaseTime:锁有效期,默认值-1
ttl:null代表获取锁成功
Redisson的multiLock原理
如果 Redis 提供了主从集群,主从同步存在延迟,此时某个线程从主节点中获取到了锁,但是尚未同步给从节点,而恰巧主节点在这个时候发生宕机,Redis 的哨兵模式就会从从机中选择出一个节点成为新的主节点,那么其他线程就有可能趁虚而入,从新的主节点中获取到锁,这样就出现多个线程拿到多把锁,在极端情况下,可能会出现安全问题。
那么 Redisson 是如何解决这个问题的呢?
既然主从关系是导致一致性问题发生的原因,那么干脆不要主从关系了,所有的节点都变成了独立的 Redis 节点,相互之间没有主从关系,都可以做读写。这就导致我们获取锁的方式发生了变化,以前获取锁只需要找到主节点,然后从主节点中获取锁即可。但是,现在这种方案,必须依次向多个 Redis 节点去获取锁,向这些 Redis 节点中保存锁的标识,才意味着获取锁成功。那这种情况下,还会发生线程安全问题吗?首先,由于没有主从关系,所以就不会出现主从一致性问题。其次,即使某个节点发生宕机,但是其他节点还是存活着,并且还保存着锁的信息,Redis 还是可用的。此外,我们还可以给上述的每个节点建立其自己的主从关系。在这种主从模式下,假设在获取锁的时候,其中某个主节点发生宕机,从机成为新的主节点且未完成锁的同步,那么也不会出现一致性问题。这是因为,虽然宕机的节点及其从节点没有保存锁的信息,但是其他主节点中保存了,当其他线程尝试获取时,其他节点可是有锁的,从而获取锁失败。
总结
不可重入 Redis 分布式锁:
- 原理:利用 SETNX 的互斥性;利用 ex 避免死锁;释放锁时判断线程标识
- 缺陷:不可重入、无法重试、锁超时失效
可重入的 Redis 分布式锁:
- 原理:利用 hash 结构,记录线程标识和重入次数;利用 watchDog 延续锁时间;利用信号量控制锁重试等待
- 缺陷:Redis 宕机引起锁失效问题
Redisson 的 multiLock:
- 原理:多个独立的 Redis 节点,必须在所有节点都获取重入锁,才算获取成功
- 缺陷:运维成本高、实现复杂
Redisson 分布式锁和同步器
可重入锁
基于Redis的Redisson分布式可重入锁RLock
Java对象实现了java.util.concurrent.locks.Lock
接口。同时还提供了异步执行的相关方法。
RLock lock = redisson.getLock("anyLock");
// 最常见的使用方法
lock.lock();
Redisson通过加锁的方法提供了leaseTime
的参数来指定加锁的时间。超过这个时间后锁便自动解开了。
// 加锁以后10秒钟自动解锁
// 无需调用unlock方法手动解锁
lock.lock(10, TimeUnit.SECONDS);
// 尝试加锁,最多等待100秒,上锁以后10秒自动解锁
boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);
if (res) {
try {
...
} finally {
lock.unlock();
}
}
//异步执行的相关方法
RLock lock = redisson.getLock("anyLock");
lock.lockAsync();
lock.lockAsync(10, TimeUnit.SECONDS);
Future<Boolean> res = lock.tryLockAsync(100, 10, TimeUnit.SECONDS);
公平锁
基于Redis的Redisson分布式可重入公平锁也是实现了java.util.concurrent.locks.Lock
接口的一种RLock
对象。它保证了当多个Redisson客户端线程同时请求加锁时,优先分配给先发出请求的线程。所有请求线程会在一个队列中排队,当某个线程出现宕机时,Redisson会等待5秒后继续下一个线程,也就是说如果前面有5个线程都处于等待状态,那么后面的线程会等待至少25秒。
RLock fairLock = redisson.getFairLock("anyLock");
// 最常见的使用方法
fairLock.lock();
// 10秒钟以后自动解锁
// 无需调用unlock方法手动解锁
fairLock.lock(10, TimeUnit.SECONDS);
// 尝试加锁,最多等待100秒,上锁以后10秒自动解锁
boolean res = fairLock.tryLock(100, 10, TimeUnit.SECONDS);
...
fairLock.unlock();
RLock fairLock = redisson.getFairLock("anyLock");
fairLock.lockAsync();
fairLock.lockAsync(10, TimeUnit.SECONDS);
Future<Boolean> res = fairLock.tryLockAsync(100, 10, TimeUnit.SECONDS);
联锁
基于Redis的Redisson分布式联锁RedissonMultiLock
对象可以将多个RLock
对象关联为一个联锁,每个RLock
对象实例可以来自于不同的Redisson实例。
RLock lock1 = redissonInstance1.getLock("lock1");
RLock lock2 = redissonInstance2.getLock("lock2");
RLock lock3 = redissonInstance3.getLock("lock3");
RedissonMultiLock lock = new RedissonMultiLock(lock1, lock2, lock3);
// 同时加锁:lock1 lock2 lock3
// 所有的锁都上锁成功才算成功。
lock.lock();
...
lock.unlock();
RedissonMultiLock lock = new RedissonMultiLock(lock1, lock2, lock3);
// 给lock1,lock2,lock3加锁,如果没有手动解开的话,10秒钟后将会自动解开
lock.lock(10, TimeUnit.SECONDS);
// 为加锁等待100秒时间,并在加锁成功10秒钟后自动解开
boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);
...
lock.unlock();
红锁
基于Redis的Redisson红锁RedissonRedLock
对象实现了Redlock介绍的加锁算法。该对象也可以用来将多个RLock
对象关联为一个红锁,每个RLock
对象实例可以来自于不同的Redisson实例。
红锁采用主节点过半机制,即获取锁或者释放锁成功的标志为:在过半的节点上操作成功。
RLock lock1 = redissonInstance1.getLock("lock1");
RLock lock2 = redissonInstance2.getLock("lock2");
RLock lock3 = redissonInstance3.getLock("lock3");
RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);
// 同时加锁:lock1 lock2 lock3
// 红锁在大部分节点上加锁成功就算成功。
lock.lock();
...
lock.unlock();
RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);
// 给lock1,lock2,lock3加锁,如果没有手动解开的话,10秒钟后将会自动解开
lock.lock(10, TimeUnit.SECONDS);
// 为加锁等待100秒时间,并在加锁成功10秒钟后自动解开
boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);
...
lock.unlock();
读写锁
基于Redis的Redisson分布式可重入读写锁RReadWriteLock
Java对象实现了java.util.concurrent.locks.ReadWriteLock
接口。其中读锁和写锁都继承了RLock接口。
分布式可重入读写锁允许同时有多个读锁和一个写锁处于加锁状态。
RReadWriteLock rwlock = redisson.getReadWriteLock("anyRWLock");
// 最常见的使用方法
rwlock.readLock().lock();
// 或
rwlock.writeLock().lock();
// 10秒钟以后自动解锁
// 无需调用unlock方法手动解锁
rwlock.readLock().lock(10, TimeUnit.SECONDS);
// 或
rwlock.writeLock().lock(10, TimeUnit.SECONDS);
// 尝试加锁,最多等待100秒,上锁以后10秒自动解锁
boolean res = rwlock.readLock().tryLock(100, 10, TimeUnit.SECONDS);
// 或
boolean res = rwlock.writeLock().tryLock(100, 10, TimeUnit.SECONDS);
...
lock.unlock();
信号量
RSemaphore semaphore = redisson.getSemaphore("semaphore");
semaphore.acquire();
//或
semaphore.acquireAsync();
semaphore.acquire(23);
semaphore.tryAcquire();
//或
semaphore.tryAcquireAsync();
semaphore.tryAcquire(23, TimeUnit.SECONDS);
//或
semaphore.tryAcquireAsync(23, TimeUnit.SECONDS);
semaphore.release(10);
semaphore.release();
//或
semaphore.releaseAsync();
闭锁
Redisson的分布式闭锁(CountDownLatch)Java对象RCountDownLatch采用了与java.util.concurrent.CountDownLatch相似的接口和用法。
RCountDownLatch latch = redisson.getCountDownLatch("anyCountDownLatch");
latch.trySetCount(1);
latch.await();
// 在其他线程或其他JVM里
RCountDownLatch latch = redisson.getCountDownLatch("anyCountDownLatch");
latch.countDown();