04_Redisson分布式锁
redis集群状态下的问题:
-
-
在master将锁同步到slave之前,master宕掉了。
-
slave节点被晋级为master节点
-
客户端B取得了同一个资源被客户端A已经获取到的另外一个锁。
安全失效!
解决集群下锁失效,参照redis官方网站针对redlock文档:https://redis.io/topics/distlock
在算法的分布式版本中,我们假设有N个Redis服务器。这些节点是完全独立的,因此我们不使用复制或任何其他隐式协调系统。前几节已经描述了如何在单个实例中安全地获取和释放锁,在分布式锁算法中,将使用相同的方法在单个实例中获取和释放锁。将N设置为5是一个合理的值,因此需要在不同的计算机或虚拟机上运行5个Redis主服务器,确保它们以独立的方式发生故障。
为了获取锁,客户端执行以下操作:
-
客户端以毫秒为单位获取当前时间的时间戳,作为起始时间。
-
客户端尝试在所有N个实例中顺序使用相同的键名、相同的随机值来获取锁定。每个实例尝试获取锁都需要时间,客户端应该设置一个远小于总锁定时间的超时时间。例如,如果自动释放时间为10秒,则尝试获取锁的超时时间可能在5到50毫秒之间。这样可以防止客户端长时间与处于故障状态的Redis节点进行通信:如果某个实例不可用,尽快尝试与下一个实例进行通信。
-
客户端获取当前时间 减去在步骤1中获得的起始时间,来计算获取锁所花费的时间。当且仅当客户端能够在大多数实例(至少3个)中获取锁时,并且获取锁所花费的总时间小于锁有效时间,则认为已获取锁。
-
如果获取了锁,则将锁有效时间减去 获取锁所花费的时间,如步骤3中所计算。
-
如果客户端由于某种原因(无法锁定N / 2 + 1个实例或有效时间为负)而未能获得该锁,它将尝试解锁所有实例(即使没有锁定成功的实例)。
每台计算机都有一个本地时钟,我们通常可以依靠不同的计算机来产生很小的时钟漂移。只有在拥有锁的客户端将在锁有效时间内(如步骤3中获得的)减去一段时间(仅几毫秒)的情况下终止工作,才能保证这一点。以补偿进程之间的时钟漂移
当客户端无法获取锁时,它应该在随机延迟后重试,以避免同时获取同一资源的多个客户端之间不同步(这可能会导致脑裂的情况:没人胜)。同样,客户端在大多数Redis实例中尝试获取锁的速度越快,出现裂脑情况(以及需要重试)的窗口就越小,因此理想情况下,客户端应尝试将SET命令发送到N个实例同时使用多路复用。
基于Redis的Redisson分布式可重入锁RLock
Java对象实现了java.util.concurrent.locks.Lock
接口。
大家都知道,如果负责储存这个分布式锁的Redisson节点宕机以后,而且这个锁正好处于锁住的状态时,这个锁会出现锁死的状态。为了避免这种情况的发生,Redisson内部提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期。默认情况下,看门狗检查锁的超时时间是30秒钟,也可以通过修改Config.lockWatchdogTimeout
来另行指定。
RLock
对象完全符合Java的Lock规范。也就是说只有拥有锁的进程才能解锁,其他进程解锁则会抛出IllegalMonitorStateException
错误。
另外Redisson还通过加锁的方法提供了leaseTime
的参数来指定加锁的时间。超过这个时间后锁便自动解开了。
RLock lock = redisson.getLock("anyLock"); // 最常见的使用方法 lock.lock(); // 加锁以后10秒钟自动解锁 // 无需调用unlock方法手动解锁 lock.lock(10, TimeUnit.SECONDS); // 尝试加锁,最多等待100秒,上锁以后10秒自动解锁 boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS); if (res) { try { ... } finally { lock.unlock(); } }
引入依赖
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.11.2</version> </dependency>
添加配置
@Configuration public class RedissonConfig { @Bean public RedissonClient redissonClient(){ Config config = new Config(); // 可以用"rediss://"来启用SSL连接 config.useSingleServer().setAddress("redis://172.16.116.100:6379"); return Redisson.create(config); } }
代码中使用
@Autowired private RedissonClient redissonClient; public void checkAndLock() { // 加锁,获取锁失败重试 RLock lock = this.redissonClient.getLock("lock"); lock.lock(); // 先查询库存是否充足 Stock stock = this.stockMapper.selectById(1L); // 再减库存 if (stock != null && stock.getCount() > 0){ stock.setCount(stock.getCount() - 1); this.stockMapper.updateById(stock); } // 释放锁 lock.unlock(); }
使用jemeter进行压力测试:
性能非常的不错,库存扣减情况正常。
看门狗程序的自动续期也正常:
TimeUnit.SECONDS.sleep(100);
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();
公平锁测试:
@GetMapping("/fair/Lock/Test/{id}") public String fairLockTest(@PathVariable(name = "id") Integer id) { this.stockService.fairLockTest(id); return "fairLockTest!"; }
service层:
public void fairLockTest(Integer id) { RLock fairLock = redissonClient.getFairLock("fairLock"); fairLock.lock(); System.out.println("===公平锁===id=>" + id); try { TimeUnit.SECONDS.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } fairLock.unlock(); }
测试结果:按照访问顺序获取锁
原理:在redis中增加了一个queue来记录访问顺序。
换成普通锁进行测试:
public void fairLockTest(Integer id) { RLock fairLock = redissonClient.getLock("fairLock"); fairLock.lock(); try { TimeUnit.SECONDS.sleep(10); System.out.println("===公平锁===id=>" + id); } catch (InterruptedException e) { e.printStackTrace(); } fairLock.unlock(); }
测试结果,并不会按照访问顺序获取锁:
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();
单点故障隐患,不推荐使用。
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();
读写锁(ReadWriteLock)
基于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();
添加StockService方法:
public String testRead() { RReadWriteLock rwLock = this.redissonClient.getReadWriteLock("rwLock"); rwLock.readLock().lock(10, TimeUnit.SECONDS); System.out.println("测试读锁。。。。"); // rwLock.readLock().unlock(); return "测试读锁"; } public String testWrite() { RReadWriteLock rwLock = this.redissonClient.getReadWriteLock("rwLock"); rwLock.writeLock().lock(10, TimeUnit.SECONDS); System.out.println("测试写锁。。。。"); // rwLock.writeLock().unlock(); return "测试写锁"; }
打开开两个浏览器窗口测试:
http://127.0.0.1:10010/test/read
http://127.0.0.1:10010/test/write
-
同时访问写:一个写完之后,等待一会儿(约10s),另一个写开始
-
同时访问读:不用等待
-
-
先读后写:写要等待(约10s)读完成
RSemaphore semaphore = redisson.getSemaphore("semaphore"); semaphore.trySetPermits(3); semaphore.acquire(); semaphore.release();
在StockController添加方法:
@GetMapping("test/semaphore") public String testSemaphore(){ this.stockService.testSemaphore(); return "测试信号量"; }
在StockService添加方法:
public void testSemaphore() { RSemaphore semaphore = this.redissonClient.getSemaphore("semaphore"); semaphore.trySetPermits(3); try { semaphore.acquire(); System.out.println("---获取资源---"+Thread.currentThread().getName()); TimeUnit.SECONDS.sleep(5); System.out.println(System.currentTimeMillis()); System.out.println("---释放资源---"+Thread.currentThread().getName()); semaphore.release(); } catch (InterruptedException e) { e.printStackTrace(); } }
测试结果,初始化可以获取三个资源之后必须释放一个才能获取一个:
RCountDownLatch latch = redisson.getCountDownLatch("anyCountDownLatch"); latch.trySetCount(1); latch.await(); // 在其他线程或其他JVM里 RCountDownLatch latch = redisson.getCountDownLatch("anyCountDownLatch"); latch.countDown();
需要两个方法:一个等待,一个计数countDown
给StockController添加测试方法:
@GetMapping("test/latch") public String testLatch(){ this.stockService.testLatch(); return "班长锁门。。。"; } @GetMapping("test/countdown") public String testCountDown(){ this.stockService.testCountDown(); return "出来了一位同学"; }
给StockService添加测试方法:
public void testLatch() { RCountDownLatch latch = this.redissonClient.getCountDownLatch("latch"); latch.trySetCount(6); try { latch.await(); } catch (InterruptedException e) { e.printStackTrace(); } } public void testCountDown() { RCountDownLatch latch = this.redissonClient.getCountDownLatch("latch"); latch.trySetCount(6); latch.countDown(); }
重启测试,打开两个页面:当第二个请求执行6次之后,第一个请求才会执行。