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.
那么与此同时的,我们还需要上述三个步骤在执行的过程中保证原子性操作。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
没有机会执行。
那么怎么办?使用Redis
的set
命令也可以做到:
- 使用的时候添加命令参数
nx
,可以实现不存在设置,存在不操作。 -
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.script
。 Redis
在执行 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
机制,大致思路如下:
- 让客户端和多个
Redis
实例依次请求加锁。 - 如果客户端和超过半数的实例成功完成了加锁操作。那么等于这个客户端获得了分布式锁(同时还需要满足另外一个时间条件,下文说)。
加锁大概有三个步骤:
- 客户端获取当前的时间。
- 客户端按照顺序依次向
N
个Redis
实例执行加锁操作。这里也是用set
命令 - 一旦客户端完成了和所有
Redis
实例的加锁操作,就要计算整个加锁过程的总耗时M
。
加锁完成后,只有同时满足两个条件,才认为客户端获得分布式锁成功:
- 客户端从超过半数的
Redis
实例上成功获取到了锁。 - 客户端获取锁的总耗时没有超过锁的有效时间。
因此获取到分布式锁之后,它的实际可操作的有效期时间为最初的有效时间 - 获取锁的总耗时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 总结
使用分布式锁需要注意的是:
- 使用
set key value ex seconds nx
命令保证加锁操作的原子性,同时设置过期时间。 - 锁的过期时间要大于操作共享资源的时间,避免锁被提前释放。
- 每个线程加锁的时候,需要判断释放的锁是否和加锁的设置值一致,避免自己的锁被别的线程释放。可以塞入
uuid
作为value
。 - 释放锁可以使用
Lua
脚本,保证操作的原子性
String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
- 基于多个
Redis
节点的分布式锁,可以使用Redlock
,一般是加锁超过半数的节点,并且加锁耗时不超过锁的有效期就认为操作成功。 -
Redlock
释放锁的时候,要对所有节点都释放。因为加锁时可能发生服务端加锁成功,由于网络问题,给客户端回复网络包失败的情况。 - 使用
Redlock
会有一定的性能影响,成本比较高,一般情况下,使用基于单个Redis
节点的分布式锁即可。效率高。但是可能会出现锁失效的情况。
二. Redis 实现 ACID
首先我们知道,ACID
是事务的4大基本特性:
- 原子性:原子性是指事务是一个不可分割的工作单位,要么全部提交,要么全部失败回滚。
- 一致性:一致性是指事务执行前后,数据从一个合法性状态变换到另外一个 合法性状态
- 隔离性:事务的隔离性是指一个事务的执行 。
- 持久性:持久性是指一个事务一旦被提交,它对数据库中数据的改变就是永久性的。
2.1 原子性
Redis
中的事务,可以通过 multi
和 exec
命令配合使用,首先Redis
中的事务原理大致如下:
- 通过
multi
命令开启事务,在这之后的操作命令会被加入到一个队列中,并不会马上执行。 - 当执行
exec
命令的时候,才会去队列中一个个执行这些操作。 - 遇到错误的命令,就会停止执行,而之前执行过的命令倘若成功,则并不会回滚。
那么对于原子性这个问题,有三种情况:
- 在执行
exec
之前,客户端发送的操作命令本身有错。无论这个错误命令本身的前后是否有其他操作,整个事务都会被拒绝执行。 - 事务操作入队,执行
exec
之前,命令和操作的数据类型不匹配,但是Redis
实例检查不出来。那么执行exec
之后,对于执行成功的语句就不会回滚,原子性无法得到保证。 - 事务执行
exec
命令的时候,Redis
实例发生了宕机,导致事务执行失败。
针对以上三种情况,原子性的实现总结为:
- 命令入队就报错,那么会放弃事务执行,保证原子性。
- 命令入队没报错,但是执行的时候报错了,无法保证原子性。成功的命令无法回滚。虽然
Redis
没有提供回滚机制。但是我们可以通过discard
命令主动放弃事务的执行,将暂存的命令队列清空。 - 执行
exec
命令的时候实例宕机,倘若开启了AOF
日志,可以保证原子性。主要通过redis-check-aof
工具检查AOF
日志文件,这个工具可以把未完成的事务操作从AOF
文件中去除。因此实例恢复的时候,事务操作就不会被执行。
提示:对于事务操作的使用,可以将pipeline
和事务结合在一起使用。即将所有命令一次性打包丢给Redis
。避免一次次命令传输。
2.2 一致性
同样分为三种情况,和原子性一样:
- 命令入队的时候报错:事务本身放弃执行,一致性可以保证。
- 命令入队不报错,执行报错:正确的命令执行了,错误的不会执行。对于对应的数据本身一致性是OK的(这里对于一致的概念是数据符合数据库本身的定义和要求,没有包含非法或者无效的错误数据)。但是业务上的数据逻辑上可能就不保证了。
- 执行
exec
命令时发生了故障:这里就要针对AOF
和RDB
快照分情况讨论。
倘若没有开启RDB
或者AOF
,那么实例故障重启之后,数据已经丢失,但是是一种完整的状态,符合一致性。
倘若使用了RDB
快照:
RDB
快照不会在事务执行时执行,所以,事务命令操作的结果不会被保存到RDB
快照中。- 那么实例重启恢复后,使用
RDB
快照进行恢复时,数据库里的数据也是一致的。
倘若使用了AOF
日志,也是可以保证一致性的。
- 倘若实务操作中的部分操作,已经记录到
AOF
中,可以通过redis-check-aof
工具清除事务中已经完成的操作。 - 倘若事务操作并没有记录到
AOF
中,那么毫无疑问是可以保证一致性的。
2.3 隔离性
假如并发场景下,有不同的事务,去操作同一个key
(执行exec
命令前),那么Redis
中的隔离性通过WATCH
机制来实现。其作用如下:
- 在事务执行前,手动执行
watch
命令:watch key
。监控一个或多个键值对的变化。 - 当事务调用
exec
命令行时,WATCH
机制会先检查监控的键是否被其它客户端修改了。 - 如果修改了,就放弃事务执行,避免事务的隔离性被破坏。
如图:
那如果在exec
命令调用之后呢?上文提到过:即使有多个客户端同时发送请求,那么对于Redis
服务器而言,它也会串行处理他们的请求。 因此每个操作命令之间是互相独立的。隔离性得到保障。
2.4 持久性
持久性这块说白了就是数据能否在Redis
上持久性的保存。
- 关闭
AOF
和RDB
快照:宕机数据就丢失,无法保证持久性。 - 若开启
RDB
:在一个事务执行后,而下一次的RDB
快照还未执行前,如果发生了实例宕机,这种情况下不能保证持久性。 - 若开启
AOF
:无论选择哪一种AOF
写回策略,都会存在一定程度的数据丢失,无法保证持久性。
2.5 总结
-
Redis
中可以保证一致性和隔离性。 - 无法保证原子性操作和持久性操作。