Spring 基于 Lettuce Reactive API 实现 Redis 分布式锁
前言
通常都是基于 Redis
的 setnx
操作来实现分布式锁,思想不难理解:
- 获取锁资源,在一定时间内试图获取锁资源,即试图基于
setnx
设置锁标识,若设置失败说明锁资源已被其他对象持有。锁资源一定要有过期时间,否则持有锁资源的对象如果出于各种原因没有及时释放,会造成其他对象获取不到锁资源 - 释放锁资源,释放锁资源时要确认当前对象确实持有锁资源,可以通过锁资源的值进行匹配判断
如果基于 Redisson
整合 Redis
,有现成的 API
可以直接调用,因为个人习惯使用 Lettuce
客户端,且整个 分布式锁
的实现思想较简单,因此基于 Lettuce
实现 分布式锁
,同时也方便进行一定程度上的抽象
实现细节
Lock
/**
* 资源锁
*/
public interface Lock {
/**
* 获取一个 10s 过期的锁资源
* @param lockName
* @return
*/
default String acquire(String lockName) throws InterruptedException {
return acquire(lockName, 10);
}
/**
* 获取超时时长 expireTime(单位:s)的锁资源
* 默认 1s 内未获取到则返回 null
* @param lockName
* @param expireTime
* @return
*/
default String acquire(String lockName, int expireTime) throws InterruptedException {
return acquireWithWait(lockName, expireTime, 1);
}
/**
* 获取超时时长 expireTime(单位:s)的锁资源
* waitTime 秒内若未获取到则返回 null
* @param lockName
* @param expireTime
* @param waitTime
* @return
*/
String acquireWithWait(String lockName, int expireTime, int waitTime) throws InterruptedException;
/**
* 释放 lockName 的锁资源,以 lock 值匹配确保
* 释放锁的当前应用持有该锁资源
* @param lockName
* @param lock
* @return
*/
boolean release(String lockName, String lock);
}
定义顶层接口 Lock
,提供以下方法:
String acquire(String lockName)
,获取lockName
的锁资源,默认过期时长10s
,即10s
后无论释放锁资源都会过期,获取过程默认持续1s
String acquire(String lockName, int expireTime)
,可以指定锁资源的过期时长,获取过程默认持续1s
String acquireWithWait(String lockName, int expireTime, int waitTime)
,可以指定锁资源的过期时长,可以指定获取等待时长boolean release(String lockName, String lock)
,释放锁资源,必须对锁资源的值lock
进行匹配,以判断当前对象是否持有锁资源(而不是锁资源过期而导致释放掉其他对象持有的锁)
AbstractLock
public abstract class AbstractLock implements Lock {
@Override
public String acquireWithWait(String lockName, int expireTime, int waitTime) throws InterruptedException {
String lock = null;
/**
* 试图在规定时间内获取锁资源
*/
long endTime = System.currentTimeMillis() + waitTime * 1000;
while (System.currentTimeMillis() < endTime) {
if ((lock = doAcquire(lockName, expireTime)) != null) {
break;
}
TimeUnit.MILLISECONDS.sleep(100);
}
return lock;
}
protected abstract String doAcquire(String lockName, int expireTime);
}
方法 acquireWithWait
的实现基调:
- 在规定等待时长中多次尝试获取,每次尝试间隔
100ms
- 核心逻辑委托给
doAcquire
方法,交给子类来实现锁资源的获取,比如:基于Lettuce
整合Redis
实现
LettuceConfig
@Configuration
@EnableConfigurationProperties(RedisProperties.class)
// @ConditionalOnClass(RedisClient.class)
public class LettuceConfig {
@Bean
public RedisURI redisURI(RedisProperties redisProperties) {
RedisURI.Builder builder = RedisURI.builder()
.withHost(redisProperties.getHost())
.withPort(redisProperties.getPort())
.withDatabase(redisProperties.getDatabase())
.withSsl(redisProperties.isSsl());
Optional.ofNullable(redisProperties.getClientName())
.ifPresent(clientName -> builder.withClientName(clientName));
Optional.ofNullable(redisProperties.getTimeout())
.ifPresent(timeout -> builder.withTimeout(timeout));
if (StringUtils.hasText(redisProperties.getUsername())
&& StringUtils.hasText(redisProperties.getPassword())) {
builder.withAuthentication(
redisProperties.getUsername()
, redisProperties.getPassword()
);
}
return builder.build();
}
@Bean
public RedisClient redisClient(RedisURI redisURI) {
return RedisClient.create(redisURI);
}
// ...其他 Lettuce 组件声明
@Bean
public RedisReactiveCommands<String, String> redisReactiveCommands(
RedisClient redisClient
) {
return redisClient.connect().reactive();
}
}
Lettuce
配置类,本文打算基于 Reactive API
来实现,因此注册的 Bean
组件为 RedisReactiveCommands<String, String>
RedisLock
public abstract class RedisLock extends AbstractLock {
}
中间类,主要是方便拓展不同客户端的实现,比如 Redssion
Jedis
Lettuce
等
LettuceRedisLock
@Component
public class LettuceRedisLock extends RedisLock {
@Autowired
RedisReactiveCommands<String, String> redisClient;
AlternativeJdkIdGenerator idGenerator = new AlternativeJdkIdGenerator();
Logger logger = LoggerFactory.getLogger(this.getClass());
@Override
public String doAcquire(String lockName, int expireTime) {
// 生成锁资源值
String lock = idGenerator.generateId().toString();
// 基于 setnx 设置锁资源
Boolean block = redisClient.setnx(lockName, lock)
.block(Duration.ofSeconds(3));
/**
* 获取锁资源成功,则指定超时时间并返回
* 获取失败则说明锁已被其他对象持有,此时如果该锁资源并未
* 指定超时时间,则此处为了确保锁资源保证释放,未其
* 指定超时时间
*/
if (block) {
doExpire(lockName, expireTime);
return lock;
} else {
redisClient.ttl(lockName)
.subscribe(time -> {
if (time == -1) {
doExpire(lockName, expireTime);
}
});
}
return null;
}
/**
* 基于 expire 命令指定锁的超时时间
* @param lockName
* @param expireTime
*/
private void doExpire(String lockName, int expireTime) {
redisClient.expire(lockName, expireTime)
.doOnError(e -> logger.error(
"error occurred when set expire time for lock: {}", lockName
))
.subscribe();
}
/**
* 释放锁资源
* @param lockName
* @param lock
* @return
*/
@Override
public boolean release(String lockName, String lock) {
redisClient.get(lockName)
.subscribe(l -> {
if (lock.equals(l)) {
redisClient.del(lockName)
.doOnError(e -> release(lockName, lock))
.subscribe();
}
});
return true;
}
}
String doAcquire(String lockName, int expireTime)
,基于AlternativeJdkIdGenerator
生成UUID
唯一资源,基于setnx
命令获取锁资源,无论获取成功失败都要指定expire
超时时间,防止锁资源得不到正确释放doExpire(String lockName, int expireTime)
,基于expire
命令指定超时时间boolean release(String lockName, String lock)
,锁资源的释放,释放之前会把锁资源与当前对象持有的锁对象进行对比,以避免释放到其他对象持有的锁
测试
@Component
public class LettuceRedisLockTest implements CommandLineRunner {
@Autowired
LettuceRedisLock lettuceRedisLock;
Logger logger = LoggerFactory.getLogger(this.getClass());
@Override
public void run(String... args) throws Exception {
for (int i = 0; i < 5; i++) {
new Thread(new TestRunner(i)).start();
}
Thread.currentThread().join();
}
private void handle(int i) throws InterruptedException {
String test = lettuceRedisLock.acquireWithWait("test", 10, 3);
if (test != null) {
logger.info("start:" + i);
TimeUnit.SECONDS.sleep(1);
logger.info("end:" + i);
lettuceRedisLock.release("test", test);
}
}
class TestRunner implements Runnable {
int i;
TestRunner(int i) {
this.i = i;
}
@Override
public void run() {
try {
handle(i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
此处以 5
个线程模拟五个分布式应用,每个应用处理逻辑都需要 1s
,而锁的等待时长指定为 3s
,因此最后只有 3
个应用可以获取到锁执行代码,可以自己尝试下
总结
整体实现相对简单,也忽略了部分细节,比如参数的鉴定等,但大体上实现了分布式锁的思想,在个人或小团队内的开发使用问题应该不大