Redis - 分布式锁和事务

Redis - 分布式锁和事务

  • 一. 分布式锁
    • 1.1 基于单个Redis节点的分布式锁
      • 1.1.1 解决锁释放不掉的问题
      • 1.1.2 解决锁被其他客户端释放的问题
    • 1.2 基于多个Redis节点的分布式锁
    • 1.3 总结
  • 二. Redis 实现 ACID
    • 2.1 原子性
    • 2.2 一致性
    • 2.3 隔离性
    • 2.4 持久性
    • 2.5 总结

一. 分布式锁

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

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

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

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

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

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

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

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

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

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

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

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

del key

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

# 加锁
setnx key 'lock';
# 业务逻辑
doSomething();
# 释放锁
del key

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

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

1.1.1 解决锁释放不掉的问题

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

setnx key 'lock';
# 单位秒
expire key 10;

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

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

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

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

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

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

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

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

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

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

然后可以执行命令:

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

到这里总的来说,基于单个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();
    } 
}

如果是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 基于多个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>

Java

 RLock lock = redissonClient.getLock("");
 lock.lock();
 try {
     process();
 } finally {
     lock.unlock();
 }

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. 基于多个Redis节点的分布式锁,可以使用Redlock,一般是加锁超过半数的节点,并且加锁耗时不超过锁的有效期就认为操作成功。
  2. Redlock释放锁的时候,要对所有节点都释放。因为加锁时可能发生服务端加锁成功,由于网络问题,给客户端回复网络包失败的情况。
  3. 使Redlock会有一定的性能影响,成本比较高,一般情况下,使用基于单个Redis节点的分布式锁即可。效率高。但是可能会出现锁失效的情况。

二. Redis 实现 ACID

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

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

2.1 原子性

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

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

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

  • 在执行exec之前,客户端发送的操作命令本身有错。无论这个错误命令本身的前后是否有其他操作,整个事务都会被拒绝执行。
    Redis - 分布式锁和事务_第1张图片
  • 事务操作入队,执行exec之前,命令和操作的数据类型不匹配,但是Redis实例检查不出来。那么执行exec之后,对于执行成功的语句就不会回滚,原子性无法得到保证。
    Redis - 分布式锁和事务_第2张图片
  • 事务执行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. 如果修改了,就放弃事务执行,避免事务的隔离性被破坏。

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

2.4 持久性

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

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

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

2.5 总结

  1. Redis中可以保证一致性和隔离性。
  2. 无法保证原子性操作和持久性操作。

你可能感兴趣的:(Redis,redis,分布式,数据库)