分布式锁的最佳实践之Redisson
本文接从库存超卖问题分析锁和分布式锁的应用(二)讲解Redisson在分布式锁的应用实践。
官网文档地址:https://github.com/redisson/redisson/wiki
Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务。其中包括(BitSet, Set, Multimap, SortedSet, Map, List, Queue, BlockingQueue, Deque, BlockingDeque, Semaphore, Lock, AtomicLong, CountDownLatch, Publish / Subscribe, Bloom filter, Remote service, Spring cache, Executor service, Live Object service, Scheduler service) Redisson提供了使用Redis的最简单和最便捷的方法。Redisson的宗旨是促进使用者对Redis的关注分离(Separation of Concern),从而让使用者能够将精力更集中地放在处理业务逻辑上。
pom依赖:
org.redisson redisson 3.17.1
【1】可重入锁(Reentrant Lock)
基于Redis的分布式可重入锁对象用于Java,并实现了Lock接口。它使用发布/订阅(pub/sub)通道来通知所有等待获取锁的Redisson实例中的其他线程。
如果获得锁的Redisson实例崩溃,那么该锁可能会永远处于已获取状态,导致死锁。为了避免这种情况,Redisson维护了一个锁看门狗(lock watchdog),只要持有锁的Redisson实例还活着,它就会延长锁的过期时间。默认情况下,锁看门狗的超时时间为30秒,并且可以通过Config.lockWatchdogTimeout设置进行更改。
在获取锁时可以定义leaseTime参数。在指定的时间间隔后,被锁定的锁将自动释放。
RLock对象根据Java的Lock规范行为。这意味着只有锁的所有者线程才能解锁,否则会抛出IllegalMonitorStateException异常。如果需要多个线程可以同时拥有锁,则应考虑使用RSemaphore对象。
看门狗、leaseTime以及只有锁的所有者线程才能解锁对本文所有锁章节都适用。
简而言之,Redisson的RLock提供了一种机制,使得在分布式环境中多个节点能够安全地共享和控制对资源的访问,而不会出现死锁或竞争条件。通过使用Redis作为协调服务,它确保了锁的一致性和可用性,即使在某个节点失败的情况下也能维持系统运行的稳定性。
RLock lock = redisson.getLock("anyLock"); // 最常见的使用方法 lock.lock(); // 加锁以后10秒钟自动解锁// 无需调用unlock方法手动解锁 lock.lock(10, TimeUnit.SECONDS); // 尝试加锁,最多等待100秒,上锁以后10秒自动解锁 boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);
① 配置
官网文档配置章节详细说明了单节点模式、哨兵模式、集群模式等情况下关于Redisson的配置。
单节点模式配置如下:
@Configuration public class RedissonConfig { @Bean public RedissonClient redissonClient(){ Config config = new Config(); // 可以用"rediss://"来启用SSL连接 config.useSingleServer().setAddress("redis://127.0.0.1:6379"); //... 还可以设置其他参数比如用户名、密码、连接数等 return Redisson.create(config); } }
② 锁实践
修改我们的代码如下:
@Autowired private RedissonClient redissonClient; public void deduct() { // 加锁,获取锁失败重试 RLock lock = this.redissonClient.getLock("lock"); lock.lock(); try { // 1. 查询库存信息 String stock = redisTemplate.opsForValue().get("stock"); // 2. 判断库存是否充足 if (stock != null && stock.length() != 0) { int st = Integer.parseInt(stock); if (st > 0) { // 3.扣减库存 redisTemplate.opsForValue().set("stock", String.valueOf(--st)); } } } finally { // 释放锁 lock.unlock(); } }
这里获取的是RedissonLock实例,其实现了可重入(底层是lua脚本)和定期续约(底层是时间轮定时器+lua脚本)功能。其解锁的核心代码如下所示,在解锁成功后采用了发布/订阅的机制来通知其他阻塞该锁的线程来获取锁。
protected RFuture unlockInnerAsync(long threadId) { return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN, "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " + "return nil;" + "end; " + "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " + "if (counter > 0) then " + "redis.call('pexpire', KEYS[1], ARGV[2]); " + "return 0; " + "else " + "redis.call('del', KEYS[1]); " + "redis.call('publish', KEYS[2], ARGV[1]); " + "return 1; " + "end; " + "return nil;", Arrays.asList(getRawName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId)); }
③ 看门狗如何解决死锁问题
当一个Redisson实例获得了锁并突然崩溃,其内部的锁看门狗机制会防止锁永久持有,从而避免潜在的死锁情况。看门狗的工作原理如下:
-
初始设置锁过期时间:
当一个Redisson实例成功获取锁时,它会在Redis中为这个锁设置一个过期时间(TTL)。这个过期时间是锁的租约时间,即leaseTime参数,加上一个额外的安全缓冲时间,通常由lockWatchdogTimeout配置项决定,默认是30秒。
-
看门狗续租:
成功获取锁的Redisson实例会启动一个后台线程,即看门狗,它会周期性地检查锁的持有者是否仍然活跃。如果Redisson实例正常运行,看门狗会在锁的过期时间到达之前,自动更新锁的过期时间,以保持锁的有效性。这个操作会持续进行,直到锁的持有者主动释放锁或Redisson实例本身停止运行。
-
检测实例崩溃:
如果持有锁的Redisson实例崩溃,它的看门狗也将随之停止工作,不再能更新锁的过期时间。因此,一旦超过锁的总过期时间(leaseTime + lockWatchdogTimeout),锁将在Redis中自动过期并被释放。
-
其他实例尝试获取锁:
锁一旦释放,其他正在等待的Redisson实例可以尝试获取这个锁。由于锁的过期,它们将有机会成为新的锁持有者,从而继续执行其任务,避免了死锁的发生。
通过这种方式,即使某个Redisson实例崩溃,系统也能够自动恢复并允许其他实例继续正常工作,确保分布式系统的健壮性和一致性。
【2】公平锁
基于Redis的Redisson分布式可重入公平锁也是实现了 java.util.concurrent.locks.Lock 接口的一种 RLock 对象。同时还提供了异步(Async)、反射式(Reactive)和RxJava2标准的接口。
它保证了当多个Redisson客户端线程同时请求加锁时,优先分配给先发出请求的线程。所有请求线程会在一个队列中排队,当某个线程出现宕机时,Redisson会等待5秒后继续下一个线程,也就是说如果前面有5个线程都处于等待状态,那么后面的线程会等待至少25秒。
其同样可以指定过期时间,在获取锁时,可以定义leaseTime参数。在指定的时间间隔后,被锁定的锁将自动释放。
RLock lock = redisson.getFairLock("myLock"); // traditional lock method lock.lock(); // or acquire lock and automatically unlock it after 10 seconds lock.lock(10, TimeUnit.SECONDS); // 尝试加锁,最多等待100秒,上锁以后10秒自动解锁 boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS); if (res) { try { ... } finally { lock.unlock(); } }
【3】红锁
基于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();
目前已经过期,被 RLock or RFencedLock 替代。
【4】读写锁
基于Redis的分布式可重入读写锁(ReadWriteLock)对象为Java实现了ReadWriteLock接口。其中,读锁(ReadLock)和写锁(WriteLock)都实现了RLock接口。
该读写锁允许多个读锁持有者和仅一个写锁持有者存在。
如果获得锁的Redisson实例崩溃,那么该锁可能永远保持在获取状态,导致死锁。为了避免这种情况,Redisson维护了一个锁看门狗,只要持有锁的Redisson实例还活着,它就会延长锁的过期时间。默认情况下,锁看门狗的超时时间是30秒,这个值可以通过Config.lockWatchdogTimeout设置来更改。
此外,Redisson允许在获取锁时指定leaseTime参数。在指定的时间间隔后,被锁定的锁将自动释放。
RLock对象遵循Java的Lock规范。这意味着只有锁的所有者线程才能解锁,否则会抛出IllegalMonitorStateException异常。如果需要多个线程可以同时拥有锁,那么应该考虑使用RSemaphore对象。
总结来说,Redisson提供的分布式读写锁确保了读操作可以并发进行,而写操作具有排他性。通过锁看门狗和租约时间的机制,它不仅提供了锁的自动释放功能,还确保了即使在持有锁的实例发生故障时,系统也能避免死锁,保持一致性和可用性。
RReadWriteLock rwlock = redisson.getReadWriteLock("myLock"); // RedissonReadLock RLock lock = rwlock.readLock(); // RedissonWriteLock RLock lock = rwlock.writeLock(); // traditional lock method lock.lock(); // or acquire lock and automatically unlock it after 10 seconds lock.lock(10, TimeUnit.SECONDS); // or wait for lock aquisition up to 100 seconds // and automatically unlock it after 10 seconds boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS); if (res) { try { ... } finally { lock.unlock(); } }
比如我们有两个请求,一个读,一个写,分别对应不同的方法逻辑:
public String testRead() { RReadWriteLock rwLock = this.redissonClient.getReadWriteLock("rwLock"); rwLock.readLock().lock(10, TimeUnit.SECONDS); System.out.println("测试读锁。。。。"); // rwLock.readLock().unlock(); return null; } public String testWrite() { RReadWriteLock rwLock = this.redissonClient.getReadWriteLock("rwLock"); rwLock.writeLock().lock(10, TimeUnit.SECONDS); System.out.println("测试写锁。。。。"); // rwLock.writeLock().unlock(); return null; }
打开两个浏览器窗口测试:
- 同时访问写:一个写完之后,等待一会儿(约10s),另一个写开始
- 同时访问读:不用等待
- 先写后读:读要等待(约10s)写完成
- 先读后写:写要等待(约10s)读完成
【5】信号量
基于Redis的Redisson的分布式信号量(Semaphore)Java对象 RSemaphore 采用了与java.util.concurrent.Semaphore 相似的接口和用法。同时还提供了异步(Async)、反射式(Reactive)和RxJava2标准的接口。
JUC的Semaphore可以解决服务实例内部/方法内部的资源竞争问题,但是无法解决分布式场景下的资源竞争问题。Redisson的分布式信号量RSemaphore 可以解决分布式场景下资源竞争问题。
示例如下:
RSemaphore semaphore = redisson.getSemaphore("mySemaphore"); //尝试设置许可数量,如果设置成功返回true,如果已经被设置过,返回false。 boolean res = trySetPermits(3); // acquire single permit 获取一个许可 semaphore.acquire(); // or acquire 10 permits 获取10个许可 semaphore.acquire(10); // or try to acquire permit 尝试获取一个许可 boolean res = semaphore.tryAcquire(); // or try to acquire permit or wait up to 15 seconds 尝试获取许可,15秒后自动放弃 boolean res = semaphore.tryAcquire(15, TimeUnit.SECONDS); // or try to acquire 10 permit 尝试获取10个许可 boolean res = semaphore.tryAcquire(10); // or try to acquire 10 permits or wait up to 15 seconds //尝试获取10个许可,等待15秒后自动放弃 boolean res = semaphore.tryAcquire(10, 15, TimeUnit.SECONDS); if (res) { try { ... } finally { // 释放资源 semaphore.release(); } }
【6】闭锁(CountDownLatch)
基于Redisson的Redisson分布式闭锁(CountDownLatch)Java对象RCountDownLatch采用了与java.util.concurrent.CountDownLatch相似的接口和用法。
在使用之前应通过trySetCount(count)方法进行初始化。
代码使用示例如下:
RCountDownLatch latch = redisson.getCountDownLatch("myCountDownLatch"); latch.trySetCount(1); // await for count down -阻塞等待计数减减 latch.await(); // in other thread or JVM RCountDownLatch latch = redisson.getCountDownLatch("myCountDownLatch"); // 计数-1 ,为0 时会唤醒阻塞线程 latch.countDown();
闭锁又称倒计时器,适用于某个线程等待一组子线程完成(或者是发生、处理中)任务的场景。