个人博客
之前一篇介绍了使用setnx
命令实现分布式锁,但是使用这种方式不是那么严谨,需要我们自行做一些额外操作(setnx + lua
方式)来保证锁的健壮性。
redisson
为此就做了一些封装,使得我们使用分布式锁时应用就可以简单许多。
1、Maven依赖
1 2 3 4 5 6 7 8 9 10 11 12
| <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.1.4.RELEASE</version> </parent> <dependencies> <dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.11.5</version> </dependency> </dependencies>
|
2、redisson配置
2.1、yml配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| server: port: 30800
spring: redis: cluster: nodes: - 148.70.153.63:9426 - 148.70.153.63:9427 - 148.70.153.63:9428 - 148.70.153.63:9429 - 148.70.153.63:9430 - 148.70.153.63:9431 password: password timeout: 2000
|
这里沿用之前使用RedisTemplate
时的配置方式。
2.2、构建RedissonClient
。
集群Cluster
模式下配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| @Configuration public class DisLockConfig { @Autowired private RedisProperties redisProperties;
@Bean public RedissonClient clusterRedissonClient() { Config config = new Config(); ClusterServersConfig clusterServersConfig = config.useClusterServers() .setPassword(redisProperties.getPassword()) .setScanInterval(5000);
for (String node : redisProperties.getCluster().getNodes()) { clusterServersConfig.addNodeAddress("redis://".concat(node)); } RedissonClient redissonClient = Redisson.create(config); return redissonClient; } }
|
在redis的不同模式下,构造config
的方式是有区别的。
单机模式
1 2 3 4 5 6 7 8 9
| @Bean public RedissonClient singleRedissonClient() { Config config = new Config(); config.useSingleServer().setAddress("redis://ip:port") .setPassword("password") .setDatabase(0); RedissonClient redissonClient = Redisson.create(config); return redissonClient; }
|
哨兵模式Sentinel
1 2 3 4 5 6 7 8 9 10 11 12
| @Bean public RedissonClient sentinelRedissonClient() { Config config = new Config(); config.useSentinelServers().addSentinelAddress("redis://ip1:port1", "redis://ip2:port2", "redis://ip3:port3") .setMasterName("mymaster") .setPassword("password") .setDatabase(0); RedissonClient redissonClient = Redisson.create(config); return redissonClient; }
|
3、锁应用
3.1、锁自动过期
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| @RunWith(SpringRunner.class) @SpringBootTest @Slf4j public class RedissonApplicationTests { @Autowired private RedissonClient redissonClient;
@Test public void testDisLock() { IntStream.range(0, 5).parallel().forEach(i -> tryLock()); }
@SneakyThrows private void tryLock() { RLock disLock = redissonClient.getLock("disLock"); boolean tryLock = disLock.tryLock(500, 10000, TimeUnit.MILLISECONDS); if (tryLock) { try { log.info("当前线程:[{}]获得锁", Thread.currentThread().getName()); } finally { disLock.unlock(); } } else { log.info("当前线程:[{}]没有获得锁", Thread.currentThread().getName()); } } }
|
- 获取
RLock
同时指定key。 - 尝试获取锁,同时指定获取锁的最大阻塞时间、锁过期时间。
- 获得锁的线程进行资源操作。
- 最后一定要释放锁。
结果
1 2 3 4 5
| 当前线程:[ForkJoinPool.commonPool-worker-11]获得锁 当前线程:[main]获得锁 当前线程:[ForkJoinPool.commonPool-worker-2]没有获得锁 当前线程:[ForkJoinPool.commonPool-worker-13]没有获得锁 当前线程:[ForkJoinPool.commonPool-worker-9]获得锁
|
多次测试可以看出,至少会有1个线程可以获取到锁,其它线程能否获取到锁取决于之前的锁是否已经被释放了。
查看redis
1 2 3
| 127.0.0.1:9426> hgetall disLock af0cc1b2-7896-4eb4-ba2b-efe5bbcb403a:53 1
|
第一个元素:uuid:线程id。
第二个元素:当前线程持有锁的次数,即重入的次数。
3.2、watch dog看门狗机制
如果使用锁自动过期方式,假设客户端在拿到锁之后执行的业务时间比较长,在此期间锁被释放,其它线程依旧可以获取到锁,redisson
提供一种watch dog
看门狗的机制来解决这个问题。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| @RunWith(SpringRunner.class) @SpringBootTest @Slf4j public class RedissonApplicationTests { @Autowired private RedissonClient redissonClient;
@Test public void testDisLock() { IntStream.range(0, 5).parallel().forEach(i -> lock()); }
@SneakyThrows private void lock() { RLock disLock = redissonClient.getLock("disLock"); boolean tryLock = disLock.tryLock(500, TimeUnit.MILLISECONDS); if (tryLock) { try { log.info("当前线程:[{}]获得锁", Thread.currentThread().getName()); } finally { disLock.unlock(); } } else { log.info("当前线程:[{}]没有获得锁", Thread.currentThread().getName()); } } }
|
看门狗机制如下图所示:
默认情况下,看门狗的过期时间是30s,每隔30/3=10秒,看门狗(守护线程)会去续期锁,重设为30秒。可以通过修改Config.lockWatchdogTimeout
来另行指定看门狗的过期时间。
看门狗机制真的万无一失吗?极端情况下:
- P:
Process Pause
,进程暂停(GC)
客户端获取到锁之后进入GC进而导致看门狗没有及时续期,最后锁过期。本质上还是锁过期时间设短导致的,一般只要远大于通常GC所暂停的时间就可以了,一般不太会发生。 - C:
Clock Drift
,时钟漂移
redis服务端所在的服务器时钟发生较大的向前跳跃,导致锁提前过期被释放。这个一般也不会发生,除非人为的进行暴力运维。
4、锁的重入
redisson
支持锁的可重入,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| @Test @SneakyThrows public void testTryLockAgain() { RLock disLock = redissonClient.getLock("disLock"); boolean tryLock = disLock.tryLock(500, 10000, TimeUnit.MILLISECONDS); if (tryLock) { try { log.info("当前线程:[{}]获得锁,持有锁次数:[{}]", Thread.currentThread().getName(), disLock.getHoldCount());
boolean tryLockAgain = disLock.tryLock(500, 10000, TimeUnit.MILLISECONDS); log.info("当前线程:[{}]是否再次拿到锁:[{}],持有锁次数:[{}]", Thread.currentThread().getName(), tryLockAgain, disLock.getHoldCount()); } finally { disLock.unlock(); log.info("当前线程是否持有锁:[{}],持有锁次数:[{}]", disLock.isHeldByCurrentThread(), disLock.getHoldCount()); } } else { log.info("当前线程:[{}]没有获得锁", Thread.currentThread().getName()); } }
|
结果
1 2 3
| 当前线程:[main]获得锁,持有锁次数:[1] 当前线程:[main]是否再次拿到锁:[true],持有锁次数:[2] 当前线程是否持有锁:[true],持有锁次数:[1]
|
经过测试可以看到,已经拿到锁的线程可以重复拿到锁,并且持有锁的次数会+1;
但是在释放锁的时候,发现只释放了一次,并没有完全释放锁。这会导致其他线程不能及时地获取到锁。
通过查看分析unlock()
源码就可以印证测试的结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| protected RFuture<Boolean> unlockInnerAsync(long threadId) { return commandExecutor.evalWriteAsync(getName(), 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.<Object>asList(getName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId)); }
|
改进
- 每次重入锁后都执行一次释放锁的操作。
- 或者通过
forceUnlock()
函数强制释放当前线程持有的锁,只需要在最后释放一次即可。
1 2 3 4
| finally { disLock.forceUnlock(); log.info("当前线程是否持有锁:[{}],持有锁次数:[{}]", disLock.isHeldByCurrentThread(), disLock.getHoldCount()); }
|
5、总结
之前使用setnx
命令实现分布式锁会有一些问题,比如不可重入、非阻塞、误解别的线程的锁、未执行完锁就失效、主从切换锁丢失;其中一些问题我们可以增加代码来解决,但是同样会增加业务代码的复杂度;
redisson
则支持锁的可重入和等待获取锁,并在解锁时判断是否是当前线程持有的锁,以及有看门狗机制防止锁过期程序还未执行完的问题,对于这些功能redisson
已经做好了封装,简化了业务代码。
但是依旧会有1个问题,主从切换导致的锁丢失,场景如下:
- 在Redis的master节点上拿到了锁;
- 但是这个加锁的key还没有同步到slave节点;
- master故障,发生故障转移,slave节点升级为master节点;
- 导致锁丢失。
对于这个问题就可以使用Redlock
机制来解决,接下来的文章会介绍到Redlock
。
参考链接
代码地址