使用 Spring Integration 实现基于 Redis 的分布式锁以及踩坑

发布时间 2023-09-26 11:47:25作者: C_BC

背景

分布式锁的应用场景应该还是蛮多的,这里就不赘述了。
之前在开发中实现分布式锁都是自己基于 Redis 造轮子,虽然也不复杂并且自己实现一次能对分布式锁有更深的了解,但是终归有些麻烦。尤其是新项目需要的时候还得 CV 一次。
然后在查询过程中(毫不意外地)发现 Spring 有现成的组件实现,所以决定拿过来使用一次,顺便了解一下。

使用

引入依赖

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-integration</artifactId>
</dependency>

<dependency>
	<groupId>org.springframework.integration</groupId>
	<artifactId>spring-integration-redis</artifactId>
</dependency>

创建相关的 Bean

@Configuration
public class RedisLockConfig {

    @Bean
    public RedisLockRegistry redisLockRegistry(RedisConnectionFactory redisConnectionFactory) {
        return new RedisLockRegistry(redisConnectionFactory, "lock");
    }
}

相关代码示例

这里省略注入部分代码,仅展示使用部分

// 获取锁对象
Lock lock = redisLockRegistry.obtain("lock");

// 尝试上/获取锁
boolean isLocked = false;
try {
	// 1s 内持续尝试获取锁
	isLocked = lock.tryLock(1, TimeUnit.SECONDS);
} catch (InterruptedException e) {
	// 获取锁异常
	log.error("get lock fail", e);
	// ……
}

if (isLocked) {
// 获取锁成功
	try {
		// 业务逻辑
		log.info("get lock success");
	} catch (Exception e) {
		// 业务逻辑异常
		log.error("do sth fail", e);
	} finally {
	 	// 确保解锁
		lock.unlock();
	}
} else {
// 获取锁失败
	log.info("get lock fail");
	// ……
}

踩坑

问题描述

为了尽可能简单测试加锁效果,我的做法是创建一个获取锁不释放的接口,然后重复调用。这样第一次获取锁成功后,后面的调用应该在过期时间内是无法获取的。
上面都没什么问题,然后我尝试手动删掉 redis 里的锁记录之后发现还是锁着的。然后发现,甚至超时事件过去之后都依然是锁着的。

排查

当然很容易想到除了 redis,应该还会有其他地方保存者锁信息,这个通过打断点很容易发现:
image
很明显,在下面获取 redis 锁之前先尝试获取了 localLock 的锁,虽然 redis 的锁已经没了,但 localLock 仍然还在,所以依然被锁。
只有在 localLock 没被锁,能够获取锁的情况下才回去尝试获取 redis 的锁。然后如果获取 redis 锁失败(返回 false)的情况下又把 localLock 解锁……
emmm,感觉逻辑有点怪,不排除是个 BUG?之后再研究下吧。

解决

解决起来也很方便,随便 Google 一下就能找到一篇文档:
Distributed lock with Redis and Spring Boot

In some situations, you might need to acquire a lock but not release it. For example, you have a @Scheduled task that runs each 15 seconds on each instance and you don’t want it to be run more often than once per 15 seconds.
To do it you can get a lock and exit from a method without releasing. In such cases, I suggest calling lockRegistry.expireUnusedOlderThan(TTL) each time before obtaining a lock (actually it is better to call it for all cases). This method removes old not released locks from the map locks and prevents the situation when one instance has a map with old locks and all threads of this instance (except the one which acquired this lock) cannot acquire it.

按以上的介绍,建议每次获取锁前都调用一次lockRegistry.expireUnusedOlderThan(TTL)来避免问题,按这种方法来进行操作确实解决了该问题。
(不得不吐槽一句,使用中文检索到的相关博文没有看到有提到这一点的……)