使用 Redis 和 Spring Boot 的分布式锁

684 阅读3分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 11 天,点击查看活动详情

锁的目的是提供对资源的互斥访问。通常,锁用于更改共享资源的状态,而不是用于读取它。分布式锁的一个常见用例是当应用程序在多个实例中运行时使用共享资源并且需要更新其状态但有限制:只有其中一个应该能够更新它。

每个锁都有一个名称,实例通过其名称获得一个锁并解锁它。其中一个实例(设法获取了锁)执行共享资源的更新,然后释放锁。

使用 Spring Integration Redis 和 Lettuce 的分布式锁

Lettuce 是一个基于 netty 和 Reactor 的可扩展的线程安全的 Redis 客户端。Lettuce 提供同步、异步和响应式 API 来与 Redis 交互。

我不会在这里提供 RedisConnectionFactory for Lettuce 的配置,因为有很多这样的例子。

“Spring Integration”框架提供了 RedisLockRegistry,您可以使用它来获取和释放锁。因此,首先您应该创建 RedisLockRegistry 的 bean,指定“生存时间”(TTL) 和密钥的名称。

import java.time.Duration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.integration.redis.util.RedisLockRegistry;
import org.springframework.integration.support.locks.ExpirableLockRegistry;

@Configuration
public class RedisDistributedLockConfiguration {
    private static final String LOCK_REGISTRY_REDIS_KEY = "MY_REDIS_KEY";
    private static final Duration RELEASE_TIME_DURATION = Duration.ofSeconds(30);

    @Bean(LOCK_REGISTRY_BEAN)
    public ExpirableLockRegistry lockRegistry(RedisConnectionFactory redisConnectionFactory) {
        RedisLockRegistry redisLockRegistry =
            new RedisLockRegistry(redisConnectionFactory, LOCK_REGISTRY_REDIS_KEY,
                RELEASE_TIME_DURATION.toMillis());
        return redisLockRegistry;
    }
}

现在我们来实现获取和释放锁的逻辑:

import java.util.concurrent.locks.Lock;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.integration.support.locks.ExpirableLockRegistry;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class LockService implements ILockService {

    @Qualifier(LOCK_REGISTRY_BEAN)
    @Autowired
    private ExpirableLockRegistry lockRegistry;

    @Override
    public boolean update(UpdateRequest request) {
        Lock lock = lockRegistry.obtain(request.getId());
        boolean success = lock.tryLock();

        if (!success) {
            return false;
        }
        
        // ...
        // update a shared resource  
        // ... 
        
        lock.unlock();
        return true;
    }
}

它是如何工作的?

正如文档所说,可以配置两种类型的锁(都是可重入的)

  • RedisLockType.SPIN_LOCK- 通过周期性循环(100ms)检查是否可以获取锁来获取锁。默认。
  • RedisLockType.PUB_SUB_LOCK- 通过redis pub-sub订阅获取锁。

pub-sub 是首选模式——客户端 Redis 服务器之间的网络通信较少,性能更高——当订阅在另一个进程中收到解锁通知时,会立即获取锁。但是,Redis 不支持主/副本连接中的发布-订阅(例如在 AWS ElastiCache 环境中),因此默认选择忙碌自旋模式以使注册表在任何环境中工作。

我这里说一下默认的锁类型SPIN_LOCK。

每个对象 LockRegistry 都有一个 UUID 类型的随机 id,并包含一个称为的映射(锁名称/锁对象) ,由当前实例持有。当我们执行 lockRegistry.obtain(lockKey) 时,lockRegistry 首先检查 map 是否包含这个锁(换句话说,当前实例是否有此时获取的同名锁),如果是则返回这个锁。否则,lockRegistry 检查 Redis 是否包含名称为“registry key:lock name”的键,以及该值是否等于 lockRegistry 对象的 ID。如果是,它会返回这个锁,否则它会尝试在 Redis 中创建键“registry key:lock name”。当您获得锁并调用 lock.tryLock() 时,基本上执行与 lockRegistry.obtain(lockKey) 中类似的步骤,首先映射带锁被选中,然后是 Redis。

在某些情况下,您可能需要获取锁而不是释放它。例如,您有一个 @Scheduled 任务,它在每个实例上每 15 秒运行一次,并且您不希望它运行的频率超过每 15 秒一次。
为此,您可以在不释放的情况下获得锁定并退出方法。在这种情况下,我建议每次在获取锁之前调用lockRegistry.expireUnusedOlderThan(TTL) (实际上最好在所有情况下都调用它)。 此方法从映射中删除旧的未释放锁,并防止当一个实例具有带有旧锁的映射并且该实例的所有线程(获取此锁的线程除外)无法获取它的情况。