Spring 基于 Lettuce Reactive API 实现 Redis 分布式锁

  • 前言
  • 实现细节
  • Lock
  • AbstractLock
  • LettuceConfig
  • RedisLock
  • LettuceRedisLock
  • 测试
  • 总结


前言

通常都是基于 Redissetnx 操作来实现分布式锁,思想不难理解:

  • 获取锁资源,在一定时间内试图获取锁资源,即试图基于 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 个应用可以获取到锁执行代码,可以自己尝试下

总结

整体实现相对简单,也忽略了部分细节,比如参数的鉴定等,但大体上实现了分布式锁的思想,在个人或小团队内的开发使用问题应该不大