Redis
本身会被多个客户端共享访问,因此需要分布式锁来应对高并发的锁操作场景。
那么再看分布式锁之前,我们可以看下Redis
中的单机锁如何实现。单机上的锁,锁本身可以通过一个变量来标识:
而分布式锁和其本质原理一样,也是用一个变量来实现。只不过,在分布式场景下,需要一个共享存储系统来维护这个锁变量。同时分布式锁需要满足两个要求:
首先我们来说下原子性的实现。该场景下,只有个单个Redis
节点,而Redis
使用单线程处理请求,那么即使有多个客户端同时发送请求,那么对于Redis
服务器而言,它也会串行处理他们的请求。(从队列中一个个取,然后执行) 也就保证了分布式锁的原子性。
另一方面,我们知道,对一个变量进行加锁,需要三个步骤:
那么与此同时的,我们还需要上述三个步骤在执行的过程中保证原子性操作。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
加的锁就有可能被释放掉。首先针对第一个问题,我们只需要加一个过期时间即可。那么第一反应是不是这样?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]
针对第二个问题,我们需要能够区分来自不同客户端的锁操作。 我们可以在设置加锁的时候,给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);
这一块主要靠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();
}
使用分布式锁需要注意的是:
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
节点的分布式锁即可。效率高。但是可能会出现锁失效的情况。首先我们知道,ACID
是事务的4大基本特性:
Redis
中的事务,可以通过 multi
和 exec
命令配合使用,首先Redis
中的事务原理大致如下:
multi
命令开启事务,在这之后的操作命令会被加入到一个队列中,并不会马上执行。exec
命令的时候,才会去队列中一个个执行这些操作。那么对于原子性这个问题,有三种情况:
exec
之前,客户端发送的操作命令本身有错。无论这个错误命令本身的前后是否有其他操作,整个事务都会被拒绝执行。exec
之前,命令和操作的数据类型不匹配,但是Redis
实例检查不出来。那么执行exec
之后,对于执行成功的语句就不会回滚,原子性无法得到保证。exec
命令的时候,Redis
实例发生了宕机,导致事务执行失败。针对以上三种情况,原子性的实现总结为:
Redis
没有提供回滚机制。但是我们可以通过discard
命令主动放弃事务的执行,将暂存的命令队列清空。exec
命令的时候实例宕机,倘若开启了AOF
日志,可以保证原子性。主要通过 redis-check-aof
工具检查 AOF
日志文件,这个工具可以把未完成的事务操作从 AOF
文件中去除。因此实例恢复的时候,事务操作就不会被执行。提示:对于事务操作的使用,可以将pipeline
和事务结合在一起使用。即将所有命令一次性打包丢给Redis
。避免一次次命令传输。
同样分为三种情况,和原子性一样:
exec
命令时发生了故障:这里就要针对AOF
和RDB
快照分情况讨论。倘若没有开启RDB
或者AOF
,那么实例故障重启之后,数据已经丢失,但是是一种完整的状态,符合一致性。
倘若使用了RDB
快照:
RDB
快照不会在事务执行时执行,所以,事务命令操作的结果不会被保存到 RDB
快照中。RDB
快照进行恢复时,数据库里的数据也是一致的。倘若使用了AOF
日志,也是可以保证一致性的。
AOF
中,可以通过 redis-check-aof
工具清除事务中已经完成的操作。AOF
中,那么毫无疑问是可以保证一致性的。假如并发场景下,有不同的事务,去操作同一个key
(执行exec
命令前),那么Redis
中的隔离性通过WATCH
机制来实现。其作用如下:
watch
命令:watch key
。监控一个或多个键值对的变化。exec
命令行时,WATCH
机制会先检查监控的键是否被其它客户端修改了。如图:
那如果在exec
命令调用之后呢?上文提到过:即使有多个客户端同时发送请求,那么对于Redis
服务器而言,它也会串行处理他们的请求。 因此每个操作命令之间是互相独立的。隔离性得到保障。
持久性这块说白了就是数据能否在Redis
上持久性的保存。
AOF
和RDB
快照:宕机数据就丢失,无法保证持久性。RDB
:在一个事务执行后,而下一次的 RDB
快照还未执行前,如果发生了实例宕机,这种情况下不能保证持久性。AOF
:无论选择哪一种AOF
写回策略,都会存在一定程度的数据丢失,无法保证持久性。具体的可以复习Redis - 数据结构和持久化机制。
Redis
中可以保证一致性和隔离性。