分布式锁,怎么个事?

平时的工作中,由于生产环境中的项目是需要部署在多台服务器中的,所以经常会面临解决分布式场景下数据一致性的问题,那么就需要引入分布式锁来解决这一问题。

本文参考文章:
https://www.cnblogs.com/niceyoo/p/13711149.html
https://cloud.tencent.com/developer/article/1595817
https://www.ghosind.com/2020/06/22/redis-string
https://zhuanlan.zhihu.com/p/77484377
https://github.com/redisson/redisson

一句话:分布式锁就是满足分布式系统或集群模式下多进程可见并且互斥的锁。

针对分布式锁的实现,目前比较常用的就如下几种方案:

  1. 基于数据库实现分布式锁
  2. 基于Redis实现分布式锁
  3. 基于Zookeeper实现分布式锁

听起来B格很高,我们一起静下心来研究一下体会,相信对大家工作中会有所帮助

本文章则已基于Redis实现分布式锁的实现方案、实现细节和实现原理入手,我们一起来看看这是怎么个事?

Redis分布式锁

  1. setNX + Lua脚本
  2. Redisson + RLock可重入锁

文章会带大家详解Redisson + RLock可重入锁这套被青睐的实现方案

setNX + Lua脚本

setNX

完整语法:SET key value [EX seconds|PX milliseconds] [EXAT timestamp|PXAT milliseconds-timestamp] [NX|XX] [KEEPTTL]

SET命令有EX、PX、NX、XX以及KEEPTTL五个可选参数,其中KEEPTTL为6.0版本添加的可选参数,其它为2.6.12版本添加的可选参数。

EX seconds:以秒为单位设置过期时间

PX milliseconds:以毫秒为单位设置过期时间

EXAT timestamp:设置以秒为单位的UNIX时间戳所对应的时间为过期时间

PXAT milliseconds-timestamp:设置以毫秒为单位的UNIX时间戳所对应的时间为过期时间

NX:键不存在的时候设置键值

XX:键存在的时候设置键值

KEEPTTL:保留设置前指定键的生存时间

GET:返回指定键原本的值,若键不存在时返回nil

SET命令使用EX、PX、NX参数,其效果等同于SETEX、PSETEX、SETNX命令。根据官方文档的描述,未来版本中SETEX、PSETEX、SETNX命令可能会被淘汰。

EXAT、PXAT以及GET为Redis 6.2新增的可选参数。

注意:其实我们常说的通过 Redis 的 setnx 命令来实现分布式锁,并不是直接使用 Redis 的 setnx 命令,因为在老版本之前 setnx 命令语法为setnx key value,并不支持同时设置过期时间的操作,那么就需要再执行 expire 过期时间的命令,这样的话加锁就成了两个命令,原子性就得不到保障,所以通常需要配合 Lua 脚本使用,而从 Redis 2.6.12 版本后,set 命令开始整合了 setex 的功能,并且 set 本身就已经包含了设置过期时间,因此常说的 setnx 命令实则只用 set 命令就可以实现了,只是参数上加上了 NX 等参数。

那么较低版本如果想使用setnx+expire完成原子抢锁应该怎么办呢?

借助lua脚本

大致说一下用 setnx 命令实现分布式锁的流程:

在 Redis 2.6.12 版本之后,Redis 支持原子命令加锁,我们可以通过向 Redis 发送 set key value NX 过期时间 命令,实现原子的加锁操作。比如某个客户端想要获取一个 key 为lock 的锁,此时需要执行 set lock random_value NX PX 30000 ,在这我们设置了 30 秒的锁自动过期时间,超过 30 秒自动释放。

如果 setnx 命令返回 ok,说明拿到了锁,此时我们就可以做一些业务逻辑处理,业务处理完之后,需要释放锁,释放锁一般就是执行 Redis 的 del 删除指令,del lock

如果 setnx 命令返回 nil,说明拿锁失败,被其他线程占用,如下:

为什么我们往往要将锁加一个时间呢?

这是因为如果redis宕机,那么再次恢复之后,由于之前的代码还未执行到删除锁的时候,redis就宕机了,那么此时获取的锁无法释放,导致后续的请求获取不到锁,业务因此崩盘。

注意,这里在设置值的时候,value 应该是随机字符串,比如 UUID,而不是随便用一个固定的字符串进去,为什么这样做呢?

为了防止锁的误删

value 的值设置为随机数主要是为了更安全的释放锁,释放锁的时候需要检查 key 是否存在,且 key 对应的 value 值是否和指定的值一样,是一样的才能释放锁。

感觉这样说还是不清晰,举个例子:例如进程 A,通过 setnx 指令获取锁成功(命令中设置了加锁自动过期时间30 秒),既然拿到锁了就开始执行业务吧,但是进程 A 在接下来的执行业务逻辑期间,程序响应时间竟然超过30秒了,不管是线程阻塞还是业务执行时间的原因吧,锁自动释放了,而此时进程 B 进来了,由于进程 A 设置的过期时间一到,让进程 B 拿到锁了,然后进程 B 又开始执行业务逻辑,但是呢,这时候进程 A 执行到了释放锁的逻辑(代码层面),进行删除锁,然后把进程 B 的锁得释放了。

总之,有了随机数的 value 后,可以通过判断 key 对应的 value 值是否和指定的值一样,是一样的才能释放锁。

在使用UUID的情况下,且在删除KEY前对其value进行一致性校验,可以99%避免这个情况的发生

点击查看maven依赖
<dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- Gson -->
        <dependency>
            <groupId>com.google.code.gson</groupId>
            <artifactId>gson</artifactId>
            <version>2.8.6</version>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
@Slf4j
@RestController
@RequestMapping("/test")
public class TestController {

    @Resource
    private RedisTemplate<String,Object> redisTemplate;

    @PostMapping(value = "/addUser")
    public String createOrder(@RequestBody User user) {

        String key = user.getName();
        // 如下为使用UUID、固定字符串,固定字符串容易出现线程不安全
        String value = UUID.randomUUID().toString().replace("-","");
        // String value = "123";
        /*
         * setIfAbsent <=> SET key value [NX] [XX] [EX <seconds>] [PX [millseconds]]
         * set expire time 5 mins
         */
        Boolean flag = redisTemplate.opsForValue().setIfAbsent(key, value, 20000, TimeUnit.MILLISECONDS);
        if (flag) {
            log.info("{} 锁定成功,开始处理业务", key);
            try {
                /** 模拟处理业务逻辑 **/
                Thread.sleep(10000 );
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            /** 判断是否是key对应的value **/
            String lockValue = (String) redisTemplate.opsForValue().get(key);
            if (lockValue != null && lockValue.equals(value)) {
                redisTemplate.delete(key);
                log.info("{} 解锁成功,结束处理业务", key);
            }
            return "SUCCESS";
        } else {
            log.info("{} 获取锁失败", key);
            return "请稍后再试...";
        }
    }

}

我们模拟三个并发新增用户数据,发现:

只有一个会抢到锁并进行删除。

但是如果是固定的字符串的话:

可以看到刚拿到锁就被删除,我们这里演示了如何解决分布式锁使用场景中锁的误删

但随机字符串就真的安全了吗?

。。哈哈相信看到这里有点无语,但是我们还是需要不断深入,前人栽树就是这么伟大啊!

由于无法保证 redisTemplate.delete(key); 的原子操作,在多进程下还是会有进程安全问题。

举个例子,比如进程 A 执行完业务逻辑,在 redisTemplate.opsForValue().get(key); 获得 key 这一步执行没问题,同时也进入了 if 判断中,但是恰好这时候进程 A 的锁自动过期时间到了(别问为啥,就是这么巧,就是会发生这种情况),而另一个进程 B 获得锁成功,然后还没来得及执行,进程 A 就执行了 delete(key) ,释放了进程 B 的锁,那么进程B锁了个寂寞。。。

那么我们接下来的目标就是将删除前的判断(防止误删),和删除key的操作合并,让它成为一个原子性的操作,那我就需要使用到lua脚本

lua脚本

又是个听着很有B格的词汇,听哥们给你逐步深入

先简单介绍一下 Lua 脚本:

Lua 是一种轻量小巧的脚本语言,用标准 C 语言编写并以源代码形式开放, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。

Lua有哪些优势?

  1. 减少网络开销:原先多次请求的逻辑放在 redis 服务器上完成。使用脚本,减少了网络往返时延
  2. 原子操作:Redis会将整个脚本作为一个整体执行,中间不会被其他命令插入(想象为事务)
  3. 复用:客户端发送的脚本会永久存储在Redis中,意味着其他客户端可以复用这一脚本而不需要使用代码完成同样的逻辑

那么我们使用它最为重要和核心的就是原子操作,Redis会将整个脚本作为一个整体执行,这个就为我们使用redis实现分布式锁的时候带来的极大的便捷,我们可以将删除前的判断(防止误删),和删除key的操作合并到lua脚本中去,实现原子操作,同时,如果我们使用的是低版本的redis,那么其实setnx是不能设置过期时间的,还需要一个expire命令来设置,那这两步宏观上来看也不是原子的,我们仍然可以借助lua脚本来实现

这里就不在赘述lua脚本的使用以及编写,大家看可以看一下 https://zhuanlan.zhihu.com/p/77484377 相关文章,我帮大家找的这篇文章若简单看完eval命令即可编写简单的lua脚本,相信大家一眼就能看懂

如下是Lua脚本,通过 Redis 的 eval/evalsha 命令来运行:

-- lua删除锁:
-- KEYS和ARGV分别是以集合方式传入的参数,对应上文的key和uuid。
-- 如果对应的value等于传入的uuid。
if redis.call('get', KEYS[1]) == ARGV[1] 
    then 
 -- 执行删除操作
        return redis.call('del', KEYS[1]) 
    else 
 -- 不成功,返回0
        return 0 
end

那么项目中如何使用lua脚本呢?

配置如下:

if redis.call('get', KEYS[1]) == ARGV[1]
    then
        return redis.call('del', KEYS[1])
    else
        return 0
end

完整的controller如下:

点击查看代码
package com.itvayne.distributedlock.controller;

import com.itvayne.distributedlock.pojo.User;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.concurrent.TimeUnit;

@Slf4j
@RestController
@RequestMapping("/test")
public class TestController {

    @Resource
    private RedisTemplate<String, Object> redisTemplate;


    private DefaultRedisScript<Long> script;

    @PostConstruct
    void init() {
        script = new DefaultRedisScript<>();
        script.setResultType(Long.class);
        script.setScriptSource(new ResourceScriptSource(new ClassPathResource("unlock.lua")));
    }

    @PostMapping(value = "/addUser")
    public String createOrder(@RequestBody User user) {

        String key = user.getName();
        // 如下为使用UUID、固定字符串,固定字符串容易出现线程不安全
//        String value = UUID.randomUUID().toString().replace("-","");
        String value = "123";
        /*
         * setIfAbsent <=> SET key value [NX] [XX] [EX <seconds>] [PX [millseconds]]
         * set expire time 5 mins
         */
        Boolean flag = redisTemplate.opsForValue().setIfAbsent(key, value, 10000, TimeUnit.MILLISECONDS);
        if (flag) {
            log.info("{} 锁定成功,开始处理业务", key);
            try {
                /** 模拟处理业务逻辑 **/
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
             finally {
                ArrayList<String> arrayList = new ArrayList<>();
                arrayList.add(key);
                /** 判断是否是key对应的value **/
                Long execute = redisTemplate.execute(script, arrayList, value);
                System.out.println("execute执行结果,1表示执行del,0表示未执行 ===== " + execute);
                log.info("{} 解锁成功,结束处理业务", key);
            }


            return "SUCCESS";
        } else {
            log.info("{} 获取锁失败", key);
            return "请稍后再试...";
        }
    }

}

可以看到lua脚本已经执行成功

我们先来对setnx+lua脚本做简单的总结,再继续向下学习:

  1. 所谓的 setnx 命令来实现分布式锁,其实不是直接使用 Redis 的 setnx 命令,因为 setnx 不支持设置自动释放锁的时间(至于为什么要设置自动释放锁,是因为防止被某个进程不释放锁而造成死锁的情况),不支持设置过期时间,就得分两步命令进行操作,一步是 setnx key value,一步是设置过期时间,这种情况的弊端很显然,无原子性操作。

  2. Redis 2.6.12 版本后,set 命令开始整合了 setex 的功能,并且 set 本身就已经包含了设置过期时间,因此常说的 setnx 命令实则只用 set 命令就可以实现了,只是参数上加上了 NX 等参数。

  3. 经过分析,在使用 set key value nx px xxx 命令时,value 最好是随机字符串,这样可以防止业务代码执行时间超过设置的锁自动过期时间,而导致再次释放锁时出现释放其他进程锁的情况(锁的误删

  4. 尽管使用随机字符串的 value,但是在释放锁时(delete方法),还是无法做到原子操作,比如进程 A 执行完业务逻辑,在准备释放锁时,恰好这时候进程 A 的锁自动过期时间到了,而另一个进程 B 获得锁成功,然后 B 还没来得及执行,进程 A 就执行了 delete(key) ,释放了进程 B 的锁.... ,因此需要配合 Lua 脚本释放锁,文章也给出了 SpringBoot 的使用示例。

setnx 琐最大的缺点就是它加锁时只作用在一个 Redis 节点上,即使 Redis 通过 Sentinel(哨岗、哨兵机制) 保证高可用,如果这个 master 节点由于某些原因发生了主从切换,那么就会出现锁丢失的情况,下面是个例子:

  1. 在 Redis 的 master 节点上拿到了锁;

  2. 但是这个加锁的 key 还没有同步到 slave 节点;

  3. master 故障,发生故障转移,slave 节点升级为 master节点;

  4. 上边 master 节点上的锁丢失。

有的时候不单单是锁丢失,新选出来的 master 节点可以重新获取同样的锁,出现一把锁被拿两次的场景。

锁被拿两次,也就不能满足安全性了...

缺陷看完了,怎么解决嘛~

哈哈哈我们继续深入。。。

Redisson + RLock可重入锁

本人评价:白银老兄用set key value 钻石老哥用setnx+lua脚本 那么王者就用Redisson

Redisson

先来看看什么是Redisson

Redisson 是架设在 Redis 基础上的一个 Java 驻内存数据网格(In-Memory Data Grid)。充分 的利用了 Redis 键值数据库提供的一系列优势,基于 Java 实用工具包中常用接口,为使用者 提供了一系列具有分布式特性的常用工具类。使得原本作为协调单机多线程并发程序的工具包获得了协调分布式多机多线程并发系统的能力,大大降低了设计和研发大规模分布式 系统的难度。同时结合各富特色的分布式服务,更进一步简化了分布式环境中程序相互之间 的协作。

龟龟听听这b格~

来看看redisson框架有哪些东西:

  1. Netty 框架:Redisson 采用了基于 NIO 的Netty框架,不仅能作为 Redis 底层驱动客户端,具备提供对 Redis 各种组态形式的连接功能,对 Redis 命令能以同步发送、异步形式发送、异步流形式发送或管道形式发送的功能,LUA脚本执行处理,以及处理返回结果的功能

  2. 基础数据结构:将原生的 Redis Hash,List,Set,String,Geo,HyperLogLog等数据结构封装为 Java 里大家最熟悉的映射(Map),列表(List),集(Set),通用对象桶(Object Bucket),地理空间对象桶(Geospatial Bucket),基数估计算法(HyperLogLog)等结构,

  3. 分布式数据结构:这基础上还提供了分布式的多值映射(Multimap),本地缓存映射(LocalCachedMap),有序集(SortedSet),计分排序集(ScoredSortedSet),字典排序集(LexSortedSet),列队(Queue),阻塞队列(Blocking Queue),有界阻塞列队(Bounded Blocking Queue),双端队列(Deque),阻塞双端列队(Blocking Deque),阻塞公平列队(Blocking Fair Queue),延迟列队(Delayed Queue),布隆过滤器(Bloom Filter),原子整长形(AtomicLong),原子双精度浮点数(AtomicDouble),BitSet等 Redis 原本没有的分布式数据结构。

  4. 分布式锁:Redisson 还实现了 Redis文档中提到像分布式锁Lock这样的更高阶应用场景。事实上 Redisson 并没有不止步于此,在分布式锁的基础上还提供了联锁(MultiLock),读写锁(ReadWriteLock),公平锁(Fair Lock),红锁(RedLock),信号量(Semaphore),可过期性信号量(PermitExpirableSemaphore)和闭锁(CountDownLatch)这些实际当中对多线程高并发应用至关重要的基本部件。正是通过实现基于 Redis 的高阶应用方案,使 Redisson 成为构建分布式系统的重要工具。

  5. 节点:Redisson 作为独立节点可以用于独立执行其他节点发布到分布式执行服务和分布式调度服务里的远程任务。

下文中 Redisson 分布式锁的实现是基于 RLock 接口,而 RLock 锁接口实现源码主要是 RedissonLock 这个类,而源码中加锁、释放锁等操作都是使用 Lua 脚本来完成的,并且封装的非常完善,开箱即用。

整合 Redisson 框架+RLock可重入锁

引入依赖

<!-- https://mvnrepository.com/artifact/org.redisson/redisson -->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.15.5</version>
</dependency>

编写配置类

@Configuration
public class MyRedissonConfig {
    /**
     * 对 Redisson 的使用都是通过 RedissonClient 对象
     * @return
     * @throws IOException
     */
    @Bean(destroyMethod="shutdown") // 服务停止后调用 shutdown 方法。
    public RedissonClient redisson() throws IOException {
        // 1.创建配置
        Config config = new Config();
        // 集群模式
        // config.useClusterServers().addNodeAddress("127.0.0.1:7004", "127.0.0.1:7001");
        // 2.根据 Config 创建出 RedissonClient 示例。
        config.useSingleServer().setAddress("redis://127.0.0.1:6379");
        return Redisson.create(config);
    }
}

如果是集群模式下,还需要更改配置文件:

redis:
    redis:
      cluster:
        nodes: 10.211.55.4:6379, 10.211.55.4:6380, 10.211.55.4:6381
      lettuce:
        pool:
          min-idle: 0
          max-idle: 8
          max-active: 20

修改之前的案例Controller代码,实现简单的使用:

点击查看代码
package com.itvayne.distributedlock.controller;

import com.itvayne.distributedlock.pojo.User;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.PostConstruct;
import javax.annotation.Resource;

@Slf4j
@RestController
@RequestMapping("/test")
public class Controller {

    @Resource
    private RedisTemplate<String, Object> redisTemplate;

    @Autowired
    private RedissonClient redissonClient;


    private DefaultRedisScript<Long> script;

    @PostConstruct
    void init() {
        script = new DefaultRedisScript<>();
        script.setResultType(Long.class);
        script.setScriptSource(new ResourceScriptSource(new ClassPathResource("unlock.lua")));
    }

    @PostMapping(value = "/addUser2")
    public String createOrder(@RequestBody User user) {

        String key = user.getName();
        RLock lock = redissonClient.getLock(key);
        // 如下为使用UUID、固定字符串,固定字符串容易出现线程不安全
//        String value = UUID.randomUUID().toString().replace("-","");
        String value = "123";
        /*
         * setIfAbsent <=> SET key value [NX] [XX] [EX <seconds>] [PX [millseconds]]
         * set expire time 5 mins
         */
        //Boolean flag = redisTemplate.opsForValue().setIfAbsent(key, value, 10000, TimeUnit.MILLISECONDS);
        lock.lock();
        if (lock.isLocked()) {
            log.info("{} 锁定成功,开始处理业务", key);
            try {
                /** 模拟处理业务逻辑 **/
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
             finally {
//                ArrayList<String> arrayList = new ArrayList<>();
//                arrayList.add(key);
//                /** 判断是否是key对应的value **/
//                Long execute = redisTemplate.execute(script, arrayList, value);
//                System.out.println("execute执行结果,1表示执行del,0表示未执行 ===== " + execute);
                lock.unlock();
                log.info("{} 解锁成功,结束处理业务", key);
            }
            return "SUCCESS";
        } else {
            log.info("{} 获取锁失败", key);
            return "请稍后再试...";
        }
    }

}

那么去除业务代码,我们的简单使用公式是:

RLock lock = redissonClient.getLock("xxx");

lock.lock();

try {
    ...
} finally {
    lock.unlock();
}

其他的简单方法说明:

/**
     * 加锁 锁的有效期默认30秒
     */
    void lock();
    
    /**
     * tryLock()方法是有返回值的,它表示用来尝试获取锁,如果获取成功,则返回true,如果获取失败(即锁已被其他线程获取),则返回false .
     */
    boolean tryLock();
    
    /**
     * tryLock(long time, TimeUnit unit)方法和tryLock()方法是类似的,只不过区别在于这个方法在拿不到锁时会等待一定的时间,
     * 在时间期限之内如果还拿不到锁,就返回false。如果如果一开始拿到锁或者在等待期间内拿到了锁,则返回true。
     *
     * @param time 等待时间
     * @param unit 时间单位 小时、分、秒、毫秒等
     */
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
    
    /**
     * 解锁
     */
    void unlock();
    
    /**
     * 中断锁 表示该锁可以被中断 假如A和B同时调这个方法,A获取锁,B为获取锁,那么B线程可以通过
     * Thread.currentThread().interrupt(); 方法真正中断该线程
     */
    void lockInterruptibly();
/**
     * 加锁 上面是默认30秒这里可以手动设置锁的有效时间
     *
     * @param leaseTime 锁有效时间
     * @param unit      时间单位 小时、分、秒、毫秒等
     */
    void lock(long leaseTime, TimeUnit unit);
    
    /**
     * 这里比上面多一个参数,多添加一个锁的有效时间
     *
     * @param waitTime  等待时间
     * @param leaseTime 锁有效时间
     * @param unit      时间单位 小时、分、秒、毫秒等
     */
    boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException;
    
    /**
     * 检验该锁是否被线程使用,如果被使用返回True
     */
    boolean isLocked();
    
    /**
     * 检查当前线程是否获得此锁(这个和上面的区别就是该方法可以判断是否当前线程获得此锁,而不是此锁是否被线程占有)
     * 这个比上面那个实用
     */
    boolean isHeldByCurrentThread();
    
    /**
     * 中断锁 和上面中断锁差不多,只是这里如果获得锁成功,添加锁的有效时间
     * @param leaseTime  锁有效时间
     * @param unit       时间单位 小时、分、秒、毫秒等
     */
    void lockInterruptibly(long leaseTime, TimeUnit unit);  
}

我们介绍完简单Redisson+Rlock的简单使用之后,我们来看看他巧在哪些地方,而他怎么就解决了我们的钻石方案的问题呢(redis集群部署时发生的master宕机,会造成锁丢失或持有两把锁,丧失安全性)?

image

我们看到返回的锁类型大部分为RLock类型,我们跟进观察类图发现Lock是顶级接口,而Redisson的实现都基于RLock

image

RLock详解

我们先来看看lock方法的写了哪些东西

image

我们发现刚开始就使用tryAcquire尝试异步请求获取资源,且在死循环中也出现了这个方法,那我们看看这个方法到底要干嘛?

image

image

然后我们又看到了tryLockInnerAsync

<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
        return this.evalWriteAsync(this.getRawName(), LongCodec.INSTANCE, command,
		"if (redis.call('exists', KEYS[1]) == 0) 
			then redis.call('hincrby', KEYS[1], ARGV[2], 1);
			redis.call('pexpire', KEYS[1], ARGV[1]); 
		return nil;
		end;
		if (redis.call('hexists', KEYS[1], ARGV[2]) == 1)
			then redis.call('hincrby', KEYS[1], ARGV[2], 1);
			redis.call('pexpire', KEYS[1], ARGV[1]);
		return nil; 
		end;
		return redis.call('pttl', KEYS[1]);"
		, Collections.singletonList(this.getRawName()), new Object[]{unit.toMillis(leaseTime),this.getLockName(threadId)});
    }

我们注意到这里使用到了lua脚本,我们浅浅解读一下它的含义:
首先根据我们前面的知识,

KEYS[1]应该指代的是下面Collections.singletonList(this.getRawName())中的this.getRawName(),其实方法中是获取我们定义锁是传入的name,那么如果我们传入的是一个lock,那么此时KEYS[1]就是lock

ARGV[2]则指代的是下面的this.getLockName(threadId),底层是 this.id + ":" + threadId

image

我们根据底层代码看到了id是UUID,那么ARGV[1]是一个UUID+threadId, ARGV[1]是存活时间

我们来解读上面的lua脚本:

  1. 如果存在key为lock的键不存在,调用Redis数据库中的hincrby命令,将给定的键名(lock)对应的哈希表中的UUID+threadId的值加1(加完就成了1),调用Redis数据库中的pexpire命令,设置给定的键名(KEYS[1])的过期时间为ARGV[1]秒。执行结束

  2. 通过redis.call()函数调用Redis数据库中的hexists命令,检查给定的键名(lock)对应的哈希表中是否存在指定的字段(UUID+threadId)。如果返回值为1,则表示字段存在,调用hincrby命令将哈希表中对应的值加1,并设置键的过期时间为leaseTime秒,执行结束

  3. 如果都不满足上述条件,则调用pttl命令返回键的剩余生存时间。

为什么下面存在key要加1?RLock是一把可重入锁,阻塞的可重入锁

看完加锁在来看看解锁的大致流程

我们一样的流程找到了对应的lua脚本:

protected RFuture<Boolean> unlockInnerAsync(long threadId) {
        return this.evalWriteAsync(this.getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
		"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) 
			then return nil;
		end;
		local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
		if (counter > 0) then redis.call('pexpire', KEYS[1], ARGV[2]);
			return 0; 
		else
			redis.call('del', KEYS[1]); 
			redis.call('publish', KEYS[2], ARGV[1]); 
			return 1; 
		end;
		return nil;", Arrays.asList(this.getRawName(), this.getChannelName()), new Object[]{LockPubSub.UNLOCK_MESSAGE, this.internalLockLeaseTime, this.getLockName(threadId)});
    }

我就直接翻译了,兄弟们

KEYS[1]就是getRawName()->我们定义的名称比如lock

ARGV[2]就是剩余存活时间

ARGV[3]就是this.getLockName(threadId)就是UUID+threadId

  1. 如果哈希表中不存在lock,字段为UUID+thread,则返回nil,结束

  2. 如果存在,则对lock,字段为UUID+thread对应的值-1,如果-1之后的值仍然大于0,说明还没解锁完,锁重入了。那么设置存活为剩余存活时间,返回0

  3. 如果存在,则对lock,字段为UUID+thread对应的值-1,如果-1之后的值等于或小于0,说明锁解完了,就可以删除了,并将消息发布给信号通道,传输给对应订阅者,返回1,表示删除完成

那么看到这里,我们可以结合加锁和解锁,了解到的流程是:

image

image

回想我们之前的setnx+lua脚本,我们哪怕我们使用了UUID作为value也有可能出现value相等的情况,且同时线程一如果发生阻塞,锁失效,线程二抢锁进入,线程一执行释放锁,虽然加了校验,value相等就会将线程二的锁释放掉,相当的不优雅,那么在redisson中如何解决这个问题呢?

看门狗

image

我们先来看一组流程描述,再来看看门狗的具体实现

这是很经典的分布式锁流程图

image

我们之前在setnx+lua脚本的时候需要对锁设置过期时间,但是没有优雅的办法对锁续命,那么在redisson中则直接利用看门狗就可以优雅实现

watchDog 只有在未显示指定加锁时间(leaseTime)时才会生效。(这点很重要)

其实之前我们看源码是发现,如果设置过期时间,租期时间就是我们设置的存活时间,可以通过修改Config.lockWatchdogTimeout来另行指定

image

但是如果我们不设置就会走到scheduleExpirationRenewal方法中,而他的底层就是如下的代码,是一个定时任务,一个internalLockLeaseTime/3,一个internalLockLeaseTime执行一个watchdog的时间

image

image

而定时执行的则是一段lua脚本:

if (redis.call('hexists', KEYS[1], ARGV[2]) == 1)
	then redis.call('pexpire', KEYS[1], ARGV[1]); 
	return 1;
end; 
return 0;

Collections.singletonList(this.getRawName()), this.internalLockLeaseTime, this.getLockName(threadId)

如果存在键为lock,值为UUID+threadId的键值对,延长时间internalLockLeaseTime,返回1,
如果不存在了,则返回0

直到找不到值,才会从map中删除对应的lock,才会退出循环,优雅的解决的业务加锁的持续时间

谨记:我们不加过期时间,Redisson就会帮我们实现看门狗机制,但是有几点我们要注意

watchdog的注意事项

  1. watch dog 在当前节点存活时每10s给分布式锁的key续期 30s;
  2. watch dog 机制启动,且代码中没有释放锁操作时,watch dog 会不断的给锁续期;
  3. 如果程序释放锁操作时因为异常没有被执行,那么锁无法被释放,所以释放锁操作一定要放到 finally {} 中;
  4. 要使 watchLog机制生效 ,lock时 不要设置 过期时间
  5. watchlog的延时时间 可以由 lockWatchdogTimeout指定默认延时时间,但是不要设置太小。如100
  6. watchdog 会每 lockWatchdogTimeout/3时间,去延时。
  7. watchdog 通过 类似netty的 Future功能来实现异步延时
  8. watchdog 最终还是通过 lua脚本来进行延时

Redisson的其他锁介绍,读写锁,以及信号量的使用

分布式读写锁

基于 Redis 的 Redisson 分布式可重入读写锁RReadWriteLock Java 对象实现了java.util.concurrent.locks.ReadWriteLock接口。其中读锁和写锁都继承了 RLock接口。

写锁是一个拍他锁(互斥锁),读锁是一个共享锁。

读锁 + 读锁:相当于没加锁,可以并发读。

读锁 + 写锁:写锁需要等待读锁释放锁。

写锁 + 写锁:互斥,需要等待对方的锁释放。

写锁 + 读锁:读锁需要等待写锁释放。

RReadWriteLock rwlock = redisson.getReadWriteLock("anyRWLock");
// 最常见的使用方法
rwlock.readLock().lock();
// 或
rwlock.writeLock().lock();

// 10秒钟以后自动解锁
// 无需调用unlock方法手动解锁
rwlock.readLock().lock(10, TimeUnit.SECONDS);
// 或
rwlock.writeLock().lock(10, TimeUnit.SECONDS);

// 尝试加锁,最多等待100秒,上锁以后10秒自动解锁
boolean res = rwlock.readLock().tryLock(100, 10, TimeUnit.SECONDS);
// 或
boolean res = rwlock.writeLock().tryLock(100, 10, TimeUnit.SECONDS);
...
lock.unlock();

互斥关系

分布式信号量

基于 Redis 的 Redisson 的分布式信号量(Semaphore)Java 对象RSemaphore采用了与java.util.concurrent.Semaphore相似的接口和用法。同时还提供了异步(Async)、反射式(Reactive)和RxJava2标准的接口。

关于信号量的使用大家可以想象一下这个场景,有三个停车位,当三个停车位满了后,其他车就不停了。可以把车位比作信号,现在有三个信号,停一次车,用掉一个信号,车离开就是释放一个信号。

我们用 Redisson 来演示上述停车位的场景。
先定义一个占用停车位的方法:

/**
* 停车,占用停车位
* 总共 3 个车位
*/
@ResponseBody
@RequestMapping("park")
public String park() throws InterruptedException {
  // 获取信号量(停车场)
  RSemaphore park = redisson.getSemaphore("park");
  // 获取一个信号(停车位)
  park.acquire();

  return "OK";
}

再定义一个离开车位的方法:

/**
 * 释放车位
 * 总共 3 个车位
 */
@ResponseBody
@RequestMapping("leave")
public String leave() throws InterruptedException {
    // 获取信号量(停车场)
    RSemaphore park = redisson.getSemaphore("park");
    // 释放一个信号(停车位)
    park.release();

    return "OK";
}

用 Redis 客户端添加了一个 key:“park”,值等于 3,代表信号量为 park,总共有三个值。

调用接口

然后在 redis 客户端查看 park 的值,发现已经改为 2 了。继续调用两次,发现 park 的等于 0,当调用第四次的时候,会发现请求一直处于等待中,说明车位不够了。如果想要不阻塞,可以用 tryAcquire 或 tryAcquireAsync。

我们再调用离开车位的方法,park 的值变为了 1,代表车位剩余 1 个。

注意点:多次执行释放信号量操作,剩余信号量会一直增加,而不是到 3 后就封顶了。

Redlock的简介

RedLock是基于redis实现的分布式锁,它能够保证以下特性:

  1. 互斥性:在任何时候,只能有一个客户端能够持有锁;避免死锁:
    当客户端拿到锁后,即使发生了网络分区或者客户端宕机,也不会发生死锁;(利用key的存活时间)
  2. 容错性:只要多数节点的redis实例正常运行,就能够对外提供服务,加锁或者释放锁;

RedLock算法思想,意思是不能只在一个redis实例上创建锁,应该是在多个redis实例上创建锁,n / 2 + 1,必须在大多数redis节点上都成功创建锁,才能算这个整体的RedLock加锁成功,避免说仅仅在一个redis实例上加锁而带来的问题。

Redisson实现原理

Redisson中有一个MultiLock 连琐的概念,可以将多个锁合并为一个大锁,对一个大锁进行统一的申请加锁以及释放锁

而Redisson中实现RedLock就是基于MultiLock 去做的,接下来就具体看看对应的实现吧

public class RedissonRedLock extends RedissonMultiLock {
    public RedissonRedLock(RLock... locks) {
        super(locks);
    }

    /**
     * 锁可以失败的次数,锁的数量-锁成功客户端最小的数量
     */
    @Override
    protected int failedLocksLimit() {
        return locks.size() - minLocksAmount(locks);
    }
    
    /**
     * 锁的数量 / 2 + 1,例如有3个客户端加锁,那么最少需要2个客户端加锁成功
     */
    protected int minLocksAmount(final List<RLock> locks) {
        return locks.size()/2 + 1;
    }

    /** 
     * 计算多个客户端一起加锁的超时时间,每个客户端的等待时间
     * remainTime默认为4.5s
     */
    @Override
    protected long calcLockWaitTime(long remainTime) {
        return Math.max(remainTime / locks.size(), 1);
    }
    
    @Override
    public void unlock() {
        unlockInner(locks);
    }

}

那么我们再来看看具体的实现原理:

public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
    long newLeaseTime = -1L; // 初始化newLeaseTime为-1
    
    if (leaseTime != -1L) { // 检查leaseTime是否不等于-1
        if (waitTime == -1L) { // 检查waitTime是否等于-1
            newLeaseTime = unit.toMillis(leaseTime); // 将leaseTime转换为毫秒
        } else {
            newLeaseTime = unit.toMillis(waitTime) * 2L; // // 如果等待时间设置了,那么将等待时间 * 2
        }
    }
    
    long time = System.currentTimeMillis(); // 获取当前系统时间
    long remainTime = -1L; // 初始化remainTime为-1
    
    if (waitTime != -1L) { // 检查waitTime是否不等于-1
        remainTime = unit.toMillis(waitTime); // 将waitTime转换为毫秒
    }
    
    long lockWaitTime = this.calcLockWaitTime(remainTime); // 使用calcLockWaitTime方法计算lockWaitTime
    int failedLocksLimit = this.failedLocksLimit(); // 使用failedLocksLimit方法获取failedLocksLimit, RedLock中failedLocksLimit即为n/2 + 1
    List<RLock> acquiredLocks = new ArrayList(this.locks.size()); // 创建一个空的acquiredLocks列表
    ListIterator<RLock> iterator = this.locks.listIterator(); // 获取locks列表的迭代器
    
    while(iterator.hasNext()) { // 遍历locks列表中的每个锁
        RLock lock = (RLock)iterator.next(); // 获取下一个锁
        
        boolean lockAcquired;
        try {
            if (waitTime == -1L && leaseTime == -1L) { // 检查waitTime和leaseTime是否都等于-1
                lockAcquired = lock.tryLock(); // 尝试获取锁
            } else {
                long awaitTime = Math.min(lockWaitTime, remainTime); // 获取lockWaitTime和remainTime的最小值
                lockAcquired = lock.tryLock(awaitTime, newLeaseTime, TimeUnit.MILLISECONDS); // 使用awaitTime和newLeaseTime尝试获取锁
            }
        } catch (RedisResponseTimeoutException var21) {
            this.unlockInner(Arrays.asList(lock)); // 解锁已获取的锁
            lockAcquired = false;
        } catch (Exception var22) {
            lockAcquired = false;
        }
        // 如果获取锁成功,将锁加入到list集合中
        if (lockAcquired) {
            acquiredLocks.add(lock); // 将已获取的锁添加到acquiredLocks列表中
        } else {
                // 如果获取锁失败,判断失败次数是否等于失败的限制次数
                // 比如,3个redis客户端,最多只能失败1次
                // 这里locks.size = 3, 3-x=1,说明只要成功了2次就可以直接break掉循环
            if (this.locks.size() - acquiredLocks.size() == this.failedLocksLimit()) { // 检查失败的锁的数量是否等于failedLocksLimit
                break; // 达到限制时跳出循环
            }
            
            if (failedLocksLimit == 0) { // 检查failedLocksLimit是否为0
                
                this.unlockInner(acquiredLocks); // 解锁已获取的锁
                if (waitTime == -1L) {
                    return false; // 如果waitTime为-1,则返回false
                }
                
                failedLocksLimit = this.failedLocksLimit(); // 再次获取failedLocksLimit
                acquiredLocks.clear(); // 清空acquiredLocks列表

                while(iterator.hasPrevious()) {
                    iterator.previous(); // 将迭代器移动到列表的开头
                }
            } else {
                --failedLocksLimit; // 减少failedLocksLimit计数
            }
        }
        
        if (remainTime != -1L) { // 检查remainTime是否不等于-1
            remainTime -= System.currentTimeMillis() - time; // 更新remainTime的值
            time = System.currentTimeMillis(); // 更新time的值
            
            if (remainTime <= 0L) { // 检查remainTime是否小于等于0
                this.unlockInner(acquiredLocks); // 解锁已获取的锁
                return false; // 返回false
            }
        }
    }
    
    return !acquiredLocks.isEmpty(); // 返回acquiredLocks列表是否为空
}

在上一节中我们提到了 「setNX+Lua脚本」实现分布式锁在集群模式下的缺陷,我们再来回顾一下,通常我们为了实现 Redis 的高可用,一般都会搭建 Redis 的集群模式,比如给 Redis 节点挂载一个或多个 slave 从节点,然后采用哨兵模式进行主从切换。但由于 Redis 的主从模式是异步的,所以可能会在数据同步过程中,master 主节点宕机,slave 从节点来不及数据同步就被选举为 master 主节点,从而导致数据丢失,大致过程如下:

  1. 用户在 Redis 的 master 主节点上获取了锁;
  2. master 主节点宕机了,存储锁的 key 还没有来得及同步到 slave 从节点上;
  3. slave 从节点升级为 master 主节点;
  4. 用户从新的 master 主节点获取到了对应同一个资源的锁,同把锁获取两次。

ok,然后为了解决这个问题,Redis 作者提出了 RedLock 算法,步骤如下(五步):

在下面的示例中,我们假设有 5 个完全独立的 Redis Master 节点,他们分别运行在 5 台服务器中,可以保证他们不会同时宕机。

获取当前 Unix 时间,以毫秒为单位。

依次尝试从 N 个实例,使用相同的 key 和随机值获取锁。在步骤 2,当向 Redis 设置锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为 10 秒,则超时时间应该在 5-50 毫秒之间。这样可以避免服务器端 Redis 已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试另外一个 Redis 实例。

客户端使用当前时间减去开始获取锁时间(步骤 1 记录的时间)就得到获取锁使用的时间。当且仅当从大多数(这里是 3 个节点)的 Redis 节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。

如果取到了锁,key 的真正有效时间等于有效时间减去获取锁所使用的时间(步骤 3 计算的结果)。

如果因为某些原因,获取锁失败(没有在至少 N/2+1 个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的 Redis 实例上进行解锁(即便某些 Redis 实例根本就没有加锁成功)。

到这,基本看出来,只要是大多数的 Redis 节点可以正常工作,就可以保证 Redlock 的正常工作。这样就可以解决前面单点 Redis 的情况下我们讨论的节点挂掉,由于异步通信,导致锁失效的问题。

但是细想后, Redlock 还是存在如下问题:

假设一共有5个Redis节点:A, B, C, D, E。设想发生了如下的事件序列:

客户端1 成功锁住了 A, B, C,获取锁成功(但 D 和 E 没有锁住)。
节点 C 崩溃重启了,但客户端1在 C 上加的锁没有持久化下来,丢失了。
节点 C 重启后,客户端2 锁住了 C, D, E,获取锁成功。
这样,客户端1 和 客户端2 同时获得了锁(针对同一资源)。
哎,还是不能解决故障重启后带来的锁的安全性问题...

针对节点重后引发的锁失效问题,Redis 作者又提出了 延迟重启 的概念,大致就是说,一个节点崩溃后,不要立刻重启他,而是等到一定的时间后再重启,等待的时间应该大于锁的过期时间,采用这种方式,就可以保证这个节点在重启前所参与的锁都过期,听上去感觉 延迟重启 解决了这个问题...

但是,还是有个问题,节点重启后,在等待的时间内,这个节点对外是不工作的。那么如果大多数节点都挂了,进入了等待,就会导致系统的不可用,因为系统在过期时间内任何锁都无法加锁成功...

那么,首先我们要明确使用分布式锁的目的是什么?

无外乎就是保证同一时间内只有一个客户端可以对共享资源进行操作,也就是共享资源的原子性操作。

总之,在 Redis 分布式锁的实现上还有很多问题等待解决,我们需要认识到这些问题并清楚如何正确实现一个 Redis 分布式锁,然后在工作中合理的选择和正确的使用分布式锁。

目前我们项目中也有在用分布式锁,也有用到 Redis 实现分布式锁的场景,然后有的兄弟就可能问,啊,你们就不怕出现上边提到的那种问题吗~

其实实现分布式锁,从中间件上来选,也有 Zookeeper 可选,并且 Zookeeper 可靠性比 Redis 强太多,但是效率是低了点,如果并发量不是特别大,追求可靠性,那么肯定首选 Zookeeper。

如果是为了效率,就首选 Redis 实现。

我们讲完了Redis分布式锁的应用以及一些实现,辛苦大家能够指出不足以及没有讲到的篇幅~

posted @ 2023-11-08 22:21  VayneBeSelf  阅读(20)  评论(0编辑  收藏  举报