Redis08 - redis分布式锁详解

Redis分布式锁详解

一:如何实现互斥能力?

想要实现分布式锁,必须要求 Redis 有「互斥」的能力

我们可以使用 SETNX 命令,这个命令表示SET if not exists,即如果key不存在,才会设置它的值,否则什么也不做。

两个客户端进程可以执行这个命令,达到互斥,就可以实现一个分布式锁。

客户端 1 申请加锁,加锁成功:

127.0.0.1:6379> SETNX lock 1
(integer) 1     # 客户端1,加锁成功

客户端 2 申请加锁,因为它后到达,加锁失败:

127.0.0.1:6379> SETNX lock 1
(integer) 0     # 客户端2,加锁失败

此时,加锁成功的客户端,就可以去操作「共享资源」

操作完成后,还要及时释放锁,给后来者让出操作共享资源的机会。如何释放锁呢?

也很简单,直接使用 DEL 命令删除这个 key 即可:

127.0.0.1:6379> DEL lock  # 释放锁
(integer) 1

在这里插入图片描述

二:死锁问题(设置过期时间)

  1. 程序处理业务逻辑异常,没及时释放锁
  2. 进程挂了,没机会释放锁

这时,这个客户端就会一直占用这个锁,而其它客户端就「永远」拿不到这把锁了。

为了针对上述的死锁问题:在申请锁时,给这把锁设置一个「租期」。

在 Redis 中实现时,就是给这个 key 设置一个「过期时间」。

这里我们假设,操作共享资源的时间不会超过 10s,那么在加锁时,给这个 key 设置 10s 过期即可:

127.0.0.1:6379> SETNX lock 1    # 加锁
(integer) 1
127.0.0.1:6379> EXPIRE lock 10  # 10s后自动过期
(integer) 1

这样一来,无论客户端是否异常,这个锁都可以在 10s 后被「自动释放」,其它客户端依旧可以拿到锁。

还是有问题。

现在的操作,加锁、设置过期是 2 条命令,有没有可能只执行了第一条,第二条却「来不及」执行的情况发生呢?例如:

  1. SETNX 执行成功,执行 EXPIRE 时由于网络问题,执行失败
  2. SETNX 执行成功,Redis 异常宕机,EXPIRE 没有机会执行
  3. SETNX 执行成功,客户端异常崩溃,EXPIRE 也没有机会执行

总之,这两条命令不能保证是原子操作(一起成功),就有潜在的风险导致过期时间设置失败,依旧发生「死锁」问题

在 Redis 2.6.12 之后,Redis 扩展了 SET 命令的参数,用这一条命令就可以了:

# 一条命令保证原子性执行
127.0.0.1:6379> SET lock 1 EX 10 NX
OK

然而还有严重的问题:

  1. 客户端 1 加锁成功,开始操作共享资源
  2. 客户端 1 操作共享资源的时间,「超过」了锁的过期时间,锁被「自动释放」
  3. 客户端 2 加锁成功,开始操作共享资源
  4. 客户端 1 操作共享资源完成,释放锁(但释放的是客户端 2 的锁)

看到了么,这里存在两个严重的问题:

  1. 锁过期:客户端 1 操作共享资源耗时太久,导致锁被自动释放,之后被客户端 2 持有
  2. 释放别人的锁:客户端 1 操作共享资源完成后,却又释放了客户端 2 的锁

三:锁被别人释放了怎么办(lua -> uuid)

客户端在加锁时,设置一个只有自己知道的「唯一标识」进去。

例如,可以是自己的线程 ID,也可以是一个 UUID(随机且唯一),这里我们以 UUID 举例

# 锁的VALUE设置为UUID
127.0.0.1:6379> SET lock $uuid EX 20 NX
OK

这里假设 20s 操作共享时间完全足够,先不考虑锁自动过期的问题。

之后,在释放锁时,要先判断这把锁是否还归自己持有,伪代码可以这么写:

# 锁是自己的,才释放
if redis.get("lock")  $uuid:
    redis.del("lock")

这里释放锁使用的是 GET + DEL 两条命令,这时,又会遇到我们前面讲的原子性问题了。

  1. 客户端 1 执行 GET,判断锁是自己的
  2. 客户端 2 执行了 SET 命令,强制获取到锁(虽然发生概率比较低,但我们需要严谨地考虑锁的安全性模型)
  3. 客户端 1 执行 DEL,却释放了客户端 2 的锁

由此可见,这两个命令还是必须要原子执行才行。

怎样原子执行呢?Lua 脚本。我们可以把这个逻辑,写成 Lua 脚本,让 Redis 来执行。

因为 Redis 处理每一个请求是「单线程」执行的,在执行一个 Lua 脚本时,其它请求必须等待,直到这个 Lua 脚本处理完成

在这里插入图片描述
安全释放锁的 Lua 脚本如下:

// 判断锁是自己的,才释放
if redis.call("GET",KEYS[1])  ARGV[1]
then
    return redis.call("DEL",KEYS[1])
else
    return 0
end

好了,这样一路优化,整个的加锁、解锁的流程就更「严谨」了。

这里我们先小结一下,基于 Redis 实现的分布式锁,一个严谨的的流程如下:

  1. 加锁:SET lock_key $unique_id EX $expire_time NX
  2. 操作共享资源
  3. 释放锁:Lua 脚本,先 GET 判断锁是否归属自己,再 DEL 释放锁

在这里插入图片描述

四:锁的过期时间不好评估怎么办?(Redission)

前面我们提到,锁的过期时间如果评估不好,这个锁就会有「提前」过期的风险。

当时给的妥协方案是,尽量「冗余」过期时间,降低锁提前过期的概率。

加锁时,先设置一个过期时间,然后我们开启一个「守护线程」,定时去检测这个锁的失效时间,如果锁快要过期了,操作共享资源还未完成,那么就自动对锁进行「续期」,重新设置过期时间

如果你是 Java 技术栈,幸运的是,已经有一个库把这些工作都封装好了:Redisson。

Redisson采用了「自动续期」的方案来避免锁过期,这个守护线程我们一般也把它叫做「看门狗」线程。

在这里插入图片描述
这个 SDK 提供的 API 非常友好,它可以像操作本地锁的方式,操作分布式锁。如果你是 Java 技术栈,可以直接把它用起来。

五:集群中的分布式锁(RedLock)

之前分析的场景都是,锁在「单个」Redis 实例中可能产生的问题,并没有涉及到 Redis 的部署架构细节。

而我们在使用 Redis 时,一般会采用主从集群 + 哨兵的模式部署

这样做的好处在于,当主库异常宕机时,哨兵可以实现「故障自动切换」,把从库提升为主库,继续提供服务,以此保证可用性。

那当「主从发生切换」时,这个分布锁会依旧安全吗?

试想这样的场景:

  1. 客户端 1 在主库上执行 SET 命令,加锁成功
  2. 此时,主库异常宕机,SET 命令还未同步到从库上(主从复制是异步的)
  3. 从库被哨兵提升为新主库,这个锁在新的主库上,丢失了!

在这里插入图片描述
为此,Redis 的作者提出一种解决方案,就是我们经常听到的 Redlock(红锁)。

Redlock 的方案基于 2 个前提:

  1. 不再需要部署从库和哨兵实例,只部署主库
  2. 但主库要部署多个,官方推荐至少 5 个实例

也就是说,想用使用 Redlock,你至少要部署 5 个 Redis 实例,而且都是主库,它们之间没有任何关系,都是一个个孤立的实例。

不是部署 Redis Cluster,就是部署 5 个简单的 Redis 实例

在这里插入图片描述
Redlock 具体如何使用的流程是这样的,一共分为 5 步:

  1. 客户端先获取「当前时间戳T1」
  2. 客户端依次向这 5 个 Redis 实例发起加锁请求(用前面讲到的 SET 命令),且每个请求会设置超时时间(毫秒级,要远小于锁的有效时间),如果某一个实例加锁失败(包括网络超时、锁被其它人持有等各种异常情况),就立即向下一个 Redis 实例申请加锁
  3. 如果客户端从 >=3 个(超过半数)以上 Redis 实例加锁成功,则再次获取「当前时间戳T2」,如果 T2 - T1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败
  4. 加锁成功,去操作共享资源(例如修改 MySQL 某一行,或发起一个 API 请求)
  5. 加锁失败,向「全部节点」发起释放锁请求(前面讲到的 Lua 脚本释放锁)

有 4 个重点:

  1. 客户端在多个 Redis 实例上申请加锁a
  2. 必须保证大多数节点加锁成功
  3. 大多数节点加锁的总耗时,要小于锁设置的过期时间
  4. 释放锁,要向全部节点发起释放锁请求

❓ 疑问:为什么要在多个实例上加锁?

本质上是为了「容错」,部分实例异常宕机,剩余的实例加锁成功,整个锁服务依旧可用。

❓ 疑问:为什么大多数加锁成功,才算成功?

多个 Redis 实例一起来用,其实就组成了一个「分布式系统」。

在分布式系统中,总会出现「异常节点」,所以,需要考虑异常节点达到多少个,也依旧不会影响整个系统的「正确性」。

如果只存在「故障」节点,只要大多数节点正常,那么整个系统依旧是可以提供正确服务的。

这个问题的模型,就是我们经常听到的「拜占庭将军」问题

拜占庭将军问题是一个协议问题,拜占庭帝国军队的将军们必须全体一致的决定是否攻击某一支敌军。问题是这些将军在地理上是分隔开来的,并且将军中存在叛徒。叛徒可以任意行动以达到以下目标:欺骗某些将军采取进攻行动;促成一个不是所有将军都同意的决定,如当将军们不希望进攻时促成进攻行动;或者迷惑某些将军,使他们无法做出决定。如果叛徒达到了这些目的之一,则任何攻击行动的结果都是注定要失败的,只有完全达成一致的努力才能获得胜利 。

拜占庭假设是对现实世界的模型化,由于硬件错误、网络拥塞或断开以及遭到恶意攻击,计算机和网络可能出现不可预料的行为

❓ 疑问:为什么步骤 3 加锁成功后,还要计算加锁的累计耗时?

因为操作的是多个节点,所以耗时肯定会比操作单个实例耗时更久,而且,因为是网络请求,网络情况是复杂的,有可能存在延迟、丢包、超时等情况发生,网络请求越多,异常发生的概率就越大。

所以,即使大多数节点加锁成功,但如果加锁的累计耗时已经「超过」了锁的过期时间,那此时有些实例上的锁可能已经失效了,这个锁就没有意义了。

❓ 疑问:为什么释放锁,要操作所有节点?

在某一个 Redis 节点加锁时,可能因为「网络原因」导致加锁失败。

例如,客户端在一个 Redis 实例上加锁成功,但在读取响应结果时,网络问题导致读取失败,那这把锁其实已经在 Redis 上加锁成功了。

所以,释放锁时,不管之前有没有加锁成功,需要释放「所有节点」的锁,以保证清理节点上「残留」的锁。

六:实现示例(redission)

1:引入依赖

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter</artifactId>
    </dependency>
    <!-- redission --> 
    <dependency>
        <groupId>org.redisson</groupId>
        <artifactId>redisson-spring-boot-starter</artifactId>
        <version>3.16.7</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>junit</groupId>
        <artifactId>junit</artifactId>
        <version>4.13.1</version>
        <scope>compile</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-test</artifactId>
        <version>2.6.2</version>
        <scope>compile</scope>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>RELEASE</version>
        <scope>compile</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-test</artifactId>
        <version>5.3.14</version>
        <scope>compile</scope>
    </dependency>
</dependencies>

2:配置文件设置

第一种方式:创建redission.yml文件

application.yml

spring:
  	redis:
    	redisson:
      		file: classpath:redisson.yaml

创建redisson.yaml

singleServerConfig:
      idleConnectionTimeout: 10000
      connectTimeout: 10000
      timeout: 3000
      retryAttempts: 3
      retryInterval: 1500
      password: null
      subscriptionsPerConnection: 5
      clientName: null
      address: "redis://127.0.0.1:6379"
      subscriptionConnectionMinimumIdleSize: 1
      subscriptionConnectionPoolSize: 50
      connectionMinimumIdleSize: 24
      connectionPoolSize: 64
      database: 0
      dnsMonitoringInterval: 5000
threads: 16
nettyThreads: 32
codec: !<org.redisson.codec.MarshallingCodec> {}
transportMode: "NIO"

第二种:直接配置

spring:
  	redis:
   		redisson: 
      		config: 
        		clusterServersConfig:
                      idleConnectionTimeout: 10000
                      connectTimeout: 10000
                      timeout: 3000
                      retryAttempts: 3
                      retryInterval: 1500
                      failedSlaveReconnectionInterval: 3000
                      failedSlaveCheckInterval: 60000
                      password: null
                      subscriptionsPerConnection: 5
                      clientName: null
                      loadBalancer: !<org.redisson.connection.balancer.RoundRobinLoadBalancer> {}
                      subscriptionConnectionMinimumIdleSize: 1
                      subscriptionConnectionPoolSize: 50
                      slaveConnectionMinimumIdleSize: 24
                      slaveConnectionPoolSize: 64
                      masterConnectionMinimumIdleSize: 24
                      masterConnectionPoolSize: 64
                      readMode: "SLAVE"
                      subscriptionMode: "SLAVE"
                      nodeAddresses:
                      - "redis://127.0.0.1:7004"
                      - "redis://127.0.0.1:7001"
                      - "redis://127.0.0.1:7000"
                      scanInterval: 1000
                      pingConnectionInterval: 0
                      keepAlive: false
                      tcpNoDelay: false
                threads: 16
                nettyThreads: 32
                codec: !<org.redisson.codec.MarshallingCodec> {}
                transportMode: "NIO"

可供使用的Spring Bean:

  • RedissonClient
  • RedissonRxClient
  • RedissonReactiveClient
  • RedisTemplate
  • ReactiveRedisTemplate

3:代码测试实例

@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest
public class RedissonSpringBootStarterApplicationTests {
    @Resource
    private RedissonClient redissonClient;

    @Test
    public void start() {
        RLock lock = redissonClient.getLock("test");
        try {
            // 等待时间
            long waitTime = 3;
            // 锁有效时间
            long leaseTime = 10;
            // 获取锁
            boolean tryLock = lock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS);
            log.info("tryLock:{}", tryLock);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            if(lock.isLocked()) {
                if(lock.isHeldByCurrentThread()){
                    lock.unlock();
                }
            }
        }
    }
}

4:多节点实现红锁

// 红锁
Config config1 = new Config();
config1.useSingleServer().setAddress("redis://172.0.0.1:5378").setPassword("a123456").setDatabase(0);
RedissonClient redissonClient1 = Redisson.create(config1);

Config config2 = new Config();
config2.useSingleServer().setAddress("redis://172.0.0.1:5379").setPassword("a123456").setDatabase(0);
RedissonClient redissonClient2 = Redisson.create(config2);

Config config3 = new Config();
config3.useSingleServer().setAddress("redis://172.0.0.1:5380").setPassword("a123456").setDatabase(0);
RedissonClient redissonClient3 = Redisson.create(config3);

/**
* 获取多个 RLock 对象
*/
RLock lock1 = redissonClient1.getLock(lockKey);
RLock lock2 = redissonClient2.getLock(lockKey);
RLock lock3 = redissonClient3.getLock(lockKey);
/**
* 根据多个 RLock 对象构建 RedissonRedLock (最核心的差别就在这里)
*/
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
try {
    //有看门狗,业务没执行完可以不停的延长锁的过期时间
    boolean res = redLock.lock();
    if (res) {
        //成功获得锁,在这里处理业务
    }
} catch (Exception e) {
    throw new RuntimeException("aquire lock fail");
}finally{
    //无论如何, 最后都要解锁
    redLock.unlock();
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值