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中可以保证一致性和隔离性。