• Redis - 分布式锁和事务


    一. 分布式锁

    Redis本身会被多个客户端共享访问,因此需要分布式锁来应对高并发的锁操作场景。

    那么再看分布式锁之前,我们可以看下Redis中的单机锁如何实现。单机上的锁,锁本身可以通过一个变量来标识:

    • 变量值为0:表示没有线程获取到这个锁。
    • 变量值为1:表示已经有线程获取到这个锁。

    而分布式锁和其本质原理一样,也是用一个变量来实现。只不过,在分布式场景下,需要一个共享存储系统来维护这个锁变量。同时分布式锁需要满足两个要求:

    • 原子性:分布式锁的加锁和释放锁的过程,往往涉及多个操作,我们需要保证原子性。
    • 锁的可靠性:共享存储系统保存了锁变量,那就应该避免其发生宕机时,锁变量不可用导致死锁的发生。

    1.1 基于单个Redis节点的分布式锁

    首先我们来说下原子性的实现。该场景下,只有个单个Redis节点,Redis使用单线程处理请求,那么即使有多个客户端同时发送请求,那么对于Redis服务器而言,它也会串行处理他们的请求。(从队列中一个个取,然后执行) 也就保证了分布式锁的原子性。

    另一方面,我们知道,对一个变量进行加锁,需要三个步骤:

    1. 读取锁变量。
    2. 判断锁变量值。(如果已经有锁了,就不能再获取了)
    3. 将锁变量值设置为1.

    那么与此同时的,我们还需要上述三个步骤在执行的过程中保证原子性操作。Redis中有这么一个命令setnx,用于设置键值对的值,分为两种情况:

    • 该命令执行的时候会判断这个键值对是否存在,如果存在,不做任何操作。
    • 如果不存在,那么设置键值对的值。
    # 该操作有两个返回值,返回1代表成功。0代表失败
    SETNX key value
    
    • 1
    • 2

    反之,如果要对一个变量进行锁释放,就比较简单了,直接调用del命令删除键值对即可。

    del key
    
    • 1

    那么对于Redis来说,整个加锁释放锁的流程就是:

    # 加锁
    setnx key 'lock';
    # 业务逻辑
    doSomething();
    # 释放锁
    del key
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    不过这样简单的操作,还具备着两个问题:

    • 如果客户端在处理业务逻辑的时候发生了宕机或者异常,导致del命令没有被执行,即锁一直没有被释放掉。那么其他客户端就无法获取锁
    • 高并发下,客户端A执行了setnx命令加锁后,并给锁设置了10s的超时时间。客户端B开始执行业务逻辑,倘若客户端A发生了网络波动,导致超时(但是程序并没有停止),此时自动释放锁,那么客户端B就能够成功加锁并设置超时时间。倘若客户端B还没有执行完毕,此时客户端A的业务逻辑执行完毕,并del掉了这个锁,那么客户端B加的锁就有可能被释放掉。

    1.1.1 解决锁释放不掉的问题

    首先针对第一个问题,我们只需要加一个过期时间即可。那么第一反应是不是这样?setnx + expire 完成加锁操作。

    setnx key 'lock';
    # 单位秒
    expire key 10;
    
    • 1
    • 2
    • 3

    但是很遗憾,这样是不行的,因为使用2个命令是无法保证操作的原子性的。在异常的情况下,加锁的结果依旧得不到预期的效果:

    • setnx 执行成功,执行 expire 时由于网络问题设置过期失败。

    • setnx 执行成功,此时 Redis 实例宕机或者客户端异常, expire 没有机会执行。

    那么怎么办?使用Redisset命令也可以做到:

    1. 使用的时候添加命令参数nx,可以实现不存在设置,存在不操作。
    2. set命令自带过期时间的设置。
    set key value [ex seconds | px milliseconds] [nx]
    
    • 1

    1.1.2 解决锁被其他客户端释放的问题

    针对第二个问题,我们需要能够区分来自不同客户端的锁操作。 我们可以在设置加锁的时候,value设置一个能够区分客户端表示的ID信息。 例如:

    # 设置一个100秒过期时间的锁
    set lock_key client_unique_value  nx ex 100
    
    • 1
    • 2

    反观,在释放锁的时候,我们需要判断锁变量的值,是否是当前执行释放锁操作的客户端的唯一标识,避免当前客户端还没有执行完业务逻辑,其占到的锁就被其他客户端给错误地释放掉。

    Lua脚本为例,命名为unlock.scriptRedis 在执行 Lua 脚本时,可以以原子性的方式执行,从而保证了锁释放操作的原子性。

    // 释放锁 比较unique_value是否相等,避免误释放
    // KEYS[1]和 ARGV[1]都是调用脚本的时候传参进来的,前者代表代表锁的key,后者代表客户端唯一标识
    if redis.call("get",KEYS[1]) == ARGV[1] then
        return redis.call("del",KEYS[1])
    else
        return 0
    end
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    然后可以执行命令:

    redis-cli  --eval  unlock.script lock_key , unique_value 
    
    • 1

    到这里总的来说,基于单个Redis节点的分布式锁,我们可以通过set命令 + Lua脚本执行的方式来实现。

    Java实现:

    @Test
    public void testScriptLoad() {
        String lua = "if redis.call(\"get\",KEYS[1]) == ARGV[1] then\n" +
                "return redis.call(\"del\",KEYS[1])\n" +
                "\telse\n" +
                "\treturn 0\n" +
                "end\n";
        String scriptLoad = jedis.scriptLoad(lua);
        System.out.println(scriptLoad);
    }
    @Test
    public void testEvalsha() {
        try {
        	// 上面的方法打印出来的
            String scriptLoad = "635d6aa00850b7bac01c8591bb9bdfe85e5515de";  //来自上面的 testScriptLoad()的值
            Object result = jedis.evalsha(scriptLoad, Arrays.asList("localhost"), Arrays.asList("10000", "2"));
            System.out.println("result:" + result);
        } catch (Exception e) {
            e.printStackTrace();
        } 
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    如果是RedisTemplate

    String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
    // 原子删锁
    Long lock1 = redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class), Arrays.asList("lock"), token);
    
    • 1
    • 2
    • 3

    1.2 基于多个Redis节点的分布式锁

    这一块主要靠Redis中的Redlock机制,大致思路如下:

    1. 让客户端和多个Redis实例依次请求加锁。
    2. 如果客户端和超过半数的实例成功完成了加锁操作。那么等于这个客户端获得了分布式锁(同时还需要满足另外一个时间条件,下文说)。

    加锁大概有三个步骤:

    1. 客户端获取当前的时间。
    2. 客户端按照顺序依次向 NRedis实例执行加锁操作。这里也是用set命令
    3. 一旦客户端完成了和所有 Redis 实例的加锁操作,就要计算整个加锁过程的总耗时M

    加锁完成后,只有同时满足两个条件,才认为客户端获得分布式锁成功:

    1. 客户端从超过半数的Redis实例上成功获取到了锁。
    2. 客户端获取锁的总耗时没有超过锁的有效时间。

    因此获取到分布式锁之后,它的实际可操作的有效期时间为最初的有效时间 - 获取锁的总耗时M

    这一块不打算深入讲,其实使用RedLock也是非常简单的:pom依赖:

    <dependency>
        <groupId>org.redisson</groupId>
        <artifactId>redisson</artifactId>
        <version>3.12.0</version>
    </dependency>
    
    • 1
    • 2
    • 3
    • 4
    • 5

    Java

     RLock lock = redissonClient.getLock("");
     lock.lock();
     try {
         process();
     } finally {
         lock.unlock();
     }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    1.3 总结

    使用分布式锁需要注意的是:

    1. 使用set key value ex seconds nx 命令保证加锁操作的原子性,同时设置过期时间。
    2. 锁的过期时间要大于操作共享资源的时间,避免锁被提前释放。
    3. 每个线程加锁的时候,需要判断释放的锁是否和加锁的设置值一致,避免自己的锁被别的线程释放。可以塞入uuid作为value
    4. 释放锁可以使用Lua脚本,保证操作的原子性
    String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
    
    • 1
    1. 基于多个Redis节点的分布式锁,可以使用Redlock,一般是加锁超过半数的节点,并且加锁耗时不超过锁的有效期就认为操作成功。
    2. Redlock释放锁的时候,要对所有节点都释放。因为加锁时可能发生服务端加锁成功,由于网络问题,给客户端回复网络包失败的情况。
    3. 使Redlock会有一定的性能影响,成本比较高,一般情况下,使用基于单个Redis节点的分布式锁即可。效率高。但是可能会出现锁失效的情况。

    二. Redis 实现 ACID

    首先我们知道,ACID是事务的4大基本特性:

    • 原子性:原子性是指事务是一个不可分割的工作单位,要么全部提交,要么全部失败回滚。
    • 一致性:一致性是指事务执行前后,数据从一个合法性状态变换到另外一个 合法性状态
    • 隔离性:事务的隔离性是指一个事务的执行 。
    • 持久性:持久性是指一个事务一旦被提交,它对数据库中数据的改变就是永久性的。

    2.1 原子性

    Redis中的事务,可以通过 multiexec 命令配合使用,首先Redis中的事务原理大致如下:

    1. 通过multi命令开启事务,在这之后的操作命令会被加入到一个队列中,并不会马上执行。
    2. 当执行exec命令的时候,才会去队列中一个个执行这些操作。
    3. 遇到错误的命令,就会停止执行,而之前执行过的命令倘若成功,则并不会回滚。

    那么对于原子性这个问题,有三种情况:

    • 在执行exec之前,客户端发送的操作命令本身有错。无论这个错误命令本身的前后是否有其他操作,整个事务都会被拒绝执行。
      在这里插入图片描述
    • 事务操作入队,执行exec之前,命令和操作的数据类型不匹配,但是Redis实例检查不出来。那么执行exec之后,对于执行成功的语句就不会回滚,原子性无法得到保证。
      在这里插入图片描述
    • 事务执行exec命令的时候,Redis实例发生了宕机,导致事务执行失败。

    针对以上三种情况,原子性的实现总结为:

    • 命令入队就报错,那么会放弃事务执行,保证原子性。
    • 命令入队没报错,但是执行的时候报错了,无法保证原子性。成功的命令无法回滚。虽然Redis没有提供回滚机制。但是我们可以通过discard命令主动放弃事务的执行,将暂存的命令队列清空。
    • 执行exec命令的时候实例宕机,倘若开启了AOF日志,可以保证原子性。主要通过 redis-check-aof 工具检查 AOF 日志文件,这个工具可以把未完成的事务操作从 AOF 文件中去除。因此实例恢复的时候,事务操作就不会被执行。

    提示:对于事务操作的使用,可以将pipeline和事务结合在一起使用。即将所有命令一次性打包丢给Redis。避免一次次命令传输。

    2.2 一致性

    同样分为三种情况,和原子性一样:

    • 命令入队的时候报错:事务本身放弃执行,一致性可以保证。
    • 命令入队不报错,执行报错:正确的命令执行了,错误的不会执行。对于对应的数据本身一致性是OK的(这里对于一致的概念是数据符合数据库本身的定义和要求,没有包含非法或者无效的错误数据)。但是业务上的数据逻辑上可能就不保证了。
    • 执行exec命令时发生了故障:这里就要针对AOFRDB快照分情况讨论。

    倘若没有开启RDB或者AOF,那么实例故障重启之后,数据已经丢失,但是是一种完整的状态,符合一致性。


    倘若使用了RDB快照:

    1. RDB 快照不会在事务执行时执行,所以,事务命令操作的结果不会被保存到 RDB 快照中。
    2. 那么实例重启恢复后,使用 RDB 快照进行恢复时,数据库里的数据也是一致的。

    倘若使用了AOF日志,也是可以保证一致性的。

    • 倘若实务操作中的部分操作,已经记录到AOF中,可以通过 redis-check-aof 工具清除事务中已经完成的操作。
    • 倘若事务操作并没有记录到AOF中,那么毫无疑问是可以保证一致性的。

    2.3 隔离性

    假如并发场景下,有不同的事务,去操作同一个key(执行exec命令前),那么Redis中的隔离性通过WATCH机制来实现。其作用如下:

    1. 在事务执行前,手动执行watch命令:watch key。监控一个或多个键值对的变化。
    2. 当事务调用 exec 命令行时,WATCH 机制会先检查监控的键是否被其它客户端修改了。
    3. 如果修改了,就放弃事务执行,避免事务的隔离性被破坏。

    如图:
    在这里插入图片描述
    那如果在exec命令调用之后呢?上文提到过:即使有多个客户端同时发送请求,那么对于Redis服务器而言,它也会串行处理他们的请求。 因此每个操作命令之间是互相独立的。隔离性得到保障。

    2.4 持久性

    持久性这块说白了就是数据能否在Redis上持久性的保存。

    • 关闭AOFRDB快照:宕机数据就丢失,无法保证持久性。
    • 若开启RDB:在一个事务执行后,而下一次的 RDB 快照还未执行前,如果发生了实例宕机,这种情况下不能保证持久性。
    • 若开启AOF:无论选择哪一种AOF写回策略,都会存在一定程度的数据丢失,无法保证持久性。

    具体的可以复习Redis - 数据结构和持久化机制

    2.5 总结

    1. Redis中可以保证一致性和隔离性。
    2. 无法保证原子性操作和持久性操作。
  • 相关阅读:
    CAS:1620475-28-6_AF647 NHS ester_AF647-活性酯
    【webrtc】sigslot : 继承has_slot 及相关流程和逻辑
    i.MX 6ULL 驱动开发 一:搭建开发环境
    v-model实现父子互传数据
    Web大学生网页作业成品 基于HTML+CSS+JavaScript---个人介绍5页 带视频 带报告
    golang docker client通过ssh调用远程主机的接口
    MySQL安装
    SpringMVC基础:配置视图解析器和控制器
    环境搭建和项目初始化
    fdisk分区以及格式化磁盘简要步骤
  • 原文地址:https://blog.csdn.net/Zong_0915/article/details/126318511