「Redis」基于Redis的分布式锁实现

SETNX命令简介

  • SETNX key value返回(1:key的值被设置,0:key的值没被设置),将key的值设为value,并且仅当key不存在。
  • 锁的key为目标数据的唯一键,value为锁的期望超时时间点;
  • 基于Redis实现的分布式锁,主要基于redissetnx(set if not exist)命令;

1. jedis实现分布式锁

1
2
3
4
5
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>3.0.1</version>
</dependency>

1.1 实现示例:

1
2
3
4
5
6
7
public static boolean correctGetLock(String lockKey, String requestId, int expireTime) {
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
return true;
}
return false;
}

jedis.set(String key, String value, String nxxx, String expx, int time)
- **key**:保证唯一,用来当锁(redis记录的key
- **value**:redis记录的value,目的是为了标志锁的所有者(竞争锁的客户端),保证解锁时只能解自己加的锁。requestId可以使用UUID.randomUUID().toString()方法生成
- **nxxx**:"NX"意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作,若key已经存在,则不做任何操作
- **expx**:"PX"意思是要给这个key加一个过期的设置(单位毫秒),过期时间由第五个参数决定
- **time**:expx设置为"PX"时,redis key的过期时间

1.2 解锁示例:

1
2
3
4
5
6
7
8
public boolean correctReleaseLock(String lockKey, String requestId) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
if (RELEASE_SUCCESS.equals(result)) {
return true;
}
return false;
}

eval命令执行Lua代码的时候,Lua代码将被当成一个命令去执行,并且直到eval命令执行完成,Redis才会执行其他命令,所以保证了检查和删除操作都是原子的。

1.3 这类琐最大的缺点

加锁时只作用在一个Redis节点上,即使Redis通过sentinel保证高可用,如果这个master节点由于某些原因发生了主从切换,那么就会出现锁丢失的情况:

  1. Redismaster节点上拿到了锁;
  2. 但是这个加锁的key还没有同步到slave节点;
  3. master故障,发生故障转移,slave节点升级为master节点;
  4. 导致锁丢失。

因此,Redis作者antirez基于分布式环境下提出了一种更高级的分布式锁的实现方式:Redlock。基于RedisRedisson实现了Redlock

2. Redisson实现普通分布式锁

普通分布式实现非常简单,无论是那种架构,向Redis通过EVAL命令执行LUA脚本即可。

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

单机模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 构造redisson实现分布式锁必要的Config
Config config = new Config();
config.useSingleServer().setAddress("redis://172.29.1.180:5379")
.setPassword("a123456").setDatabase(0);
// 构造RedissonClient
RedissonClient redissonClient = Redisson.create(config);
// 设置锁定资源名称, 还可以getFairLock(), getReadWriteLock()
RLock lock = redissonClient.getLock("DISLOCK");
boolean isLock;
try {
// 尝试获取分布式锁
// 500ms拿不到锁, 就认为获取锁失败。10000ms即10s是锁失效时间。
isLock = lock.tryLock(500, 10000, TimeUnit.MILLISECONDS);
if (isLock) {
//TODO if get lock success, do something;
}
} catch (Exception e) {
} finally {
// 无论如何, 最后都要解锁
lock.unlock();
}

哨兵模式:
Sentinel模式,实现代码和单机模式几乎一样,唯一的不同就是Config的构造:

1
2
3
4
Config config = new Config();
config.useSentinelServers().addSentinelAddress(
"redis://172.29.3.245:26378","redis://172.29.3.245:26379", "redis://172.29.3.245:26380")
.setMasterName("mymaster").setPassword("a123456").setDatabase(0);

集群模式:
Cluster模式,集群模式构造Config如下:

1
2
3
4
5
Config config = new Config();
config.useClusterServers().addNodeAddress(
"redis://172.29.3.245:6375","redis://172.29.3.245:6376", "redis://172.29.3.245:6377",
"redis://172.29.3.245:6378","redis://172.29.3.245:6379", "redis://172.29.3.245:6380")
.setPassword("a123456").setScanInterval(5000);

3. Redisson实现Redlock分布式锁

3.1 Redlock算法大概原理:

  • Redis的分布式环境中,我们假设有NRedis master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。我们确保将在N个实例上使用与在Redis单实例下相同方法获取和释放锁。
  • 为了取到锁,客户端应该执行以下操作:
    • 获取当前Unix时间,以毫秒为单位。
    • 依次尝试从N个实例,使用相同的key和具有唯一性的value(例如UUID)获取锁。
    • 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。
    • 当且仅当(N/2+1)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功,例如3个节点至少需要3/2+1=22个。
    • 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
    • 若获取锁失败,客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功)。

3.2 使用Redlock

单机模式Redis为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
Config config = new Config();
config.useClusterServers().addNodeAddress(
"redis://127.0.0.1:6379","redis://127.0.0.1:6369", "redis://127.0.0.1:6359",
"redis://127.0.0.1:6349","redis://127.0.0.1:6339")
.setPassword("******");
// 节点1
Config config1 = new Config();
config1.useSingleServer().setAddress("redis://127.0.0.1:6379");
RedissonClient redissonClient1 = Redisson.create(config1);
// 节点2
Config config2 = new Config();
config2.useSingleServer().setAddress("redis://127.0.0.1:6378");
RedissonClient redissonClient2 = Redisson.create(config2);
// 节点3
Config config3 = new Config();
config3.useSingleServer().setAddress("redis://127.0.0.1:6377");
RedissonClient redissonClient3 = Redisson.create(config3);
// 设置锁定资源名称
String resourceName = "REDLOCK";
RLock lock1 = redissonClient1.getLock(resourceName);
RLock lock2 = redissonClient2.getLock(resourceName);
RLock lock3 = redissonClient3.getLock(resourceName);
// 实例化RedissonRedLock
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
try {
boolean isLock = redLock.tryLock(500, 30000, TimeUnit.MILLISECONDS);
if (isLock) {
//TODO if get lock success, do something;
Thread.sleep(30000);
}
} catch (Exception e) {
} finally {
//解锁
redLock.unlock();
}

最核心的变化就是 RedissonRedLock redLock=**new RedissonRedLock(lock1,lock2,lock3);**,因为我这里是以三个节点为例。

  • 如果是主从Redis架构、哨兵Redis架构、集群Redis架构实现Redlock,只需要改变上述config1config2config3为主从模式、哨兵模式、集群模式配置即可,但相应需要3个独立的Redis主从集群、3Redis独立的哨兵集群、3个独立的Cluster集群。
  • sentinel模式架构为例,3sentinel模式集群,如果要获取分布式锁,那么需要向这3sentinel集群通过EVAL命令执行LUA脚本,需要3/2+1=2,即至少2个sentinel集群响应成功,才算成功的以Redlock算法获取到分布式锁。

4. Redlock问题合集

4.1 N个节点的理解

假设我们用N(>=3)个节点实现Redlock算法的分布式锁。不是一个有N个主节点的cluster集群;而是要么是N个redis单实例,要么是N个sentinel集群,要么是N个cluster集群

4.2 失效时间如何设置

这个问题的场景是,假设设置失效时间10秒,如果由于某些原因导致10秒还没执行完任务,这时候锁自动失效,导致其他线程也会拿到分布式锁。
这确实是Redis分布式最大的问题,不管是普通分布式锁,还是Redlock算法分布式锁,都没有解决这个问题。也有一些文章提出了对失效时间续租,即延长失效时间,很明显这又提升了分布式锁的复杂度(没有现成的框架有实现)。

4.3 redis分布式锁的高可用

关于Redis分布式锁的安全性问题,在分布式系统专家Martin Kleppmann和Redis的作者Antirez之间已经发生过一场争论。有兴趣的同学,搜索”基于Redis的分布式锁到底安全吗”就能得到你想要的答案,需要注意的是,有上下两篇(这应该就是传说中的神仙打架吧)。

4.4 使用Zookeeper还是Redis实现分布式锁

没有绝对的好坏,只有更适合自己的业务。
性能而言,Redis很明显优于Zookeeper;就分布式锁实现的健壮性(高可用)而言,Zookeeper很明显优于Redis。至于如何选择,还要看具体业务场景。

参考:https://mp.weixin.qq.com/s/8uhYult2h_YUHT7q7YCKYQ