本文主要参考Redis分布式锁的正确实现方式(Java版)这篇文章。个人觉得这篇文对Redis分布式锁的用法理解挺到位的,所以对这篇文章所提到的技术点进行一个实践、验证,加深自己对分布式锁的认识。
注明:本文重点在于对Redis分布式锁的实践,学习并加深理解。文中涉及到的一些关于概念的解说(措辞有调整),摘抄自其它博文,文末的参考文章会列出来。
1 前言
对于分布式场景的应用,数据一致性一直是一个比较重要的话题。然而分布式的CAP原则告诉我们:任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足其中两项。所以,很多情况下我们需要根据自己的业务场景来对这三项进行取舍。在大多数的场景中,我们不得不牺牲强一致性来换取系统的高可用性,只要在用户可接受的最终时间范围内来保证数据的最终一致性即可。
在分布式应用中,我们通常需要通过分布式事务、分布式锁等技术手段来保证数据的最终一致性。对于分布式锁的实现,目前比较常用的有以下几种方案:
1. 基于数据库实现分布式锁
2. 基于ZooKeeper实现分布式锁
3. 基于缓存(Redis,Memcached、Tair)实现分布式锁
我们对分布式锁的要求:
1. 互斥性——在任意时刻,保证只有一个客户端(或线程)持有锁
2. 不会发生死锁——锁可重入;若当前持有锁的客户端(或线程)崩溃了导致没有解锁,也能保证后续的其他客户端(或线程)能加锁
3. 加锁和解锁的必须为同一个客户端(或线程)——除非加锁的客户端崩溃导致没有解锁,就等待锁超时自动解锁,否则其他客户端不能解别人的锁
4. 具有高可用的加锁、解锁功能——只要大部分的Redis节点正常运行,客户端就可以加锁和解锁
5. 具有高性能的加锁、解锁操作——性能不好会导致分布式应用的响应很慢,甚至垮掉
2 加锁代码实现
本文使用Jedis客户端来实现Java与Redis的交互:如果只想知道正确的加锁、解锁代码可以直接翻到【2.3 加锁代码——正确的姿势】和【3.3 解锁代码——正确的姿势】。1
2
3
4
5<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>2.9.0</version>
</dependency>
2.1 加锁代码——错误示例1
此示例代码基于Redis的SETNX
和EXPIRE
指令的组合来实现加锁操作,代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18/**
* 获取分布式锁——错误的示例1
* @param jedis Jedis实例
* @param key 用key来当锁,因为key是唯一的
* @param clientId 执行加锁操作的客户端(或线程)的ID,必须保证唯一性
* @param expireTime 锁的超时时间,单位秒——超过此时间未解锁则Redis会删除锁
*/
public static boolean lockWithWrongWay1(Jedis jedis, String key, String clientId, int expireTime) {
Long result = jedis.setnx(key, clientId);
if (result == 1) {
LOGGER.info("Client[{}] execute SETNX ok.", clientId);
// 在执行EXPIRE指令之前,延时10秒钟,方便我们手动杀掉加锁的客户端进程(模拟客户端崩溃情况)
sleep(10000);
// 若在执行EXPIRE指令之前,该客户端突然崩溃,则无法设置过期时间,将发生死锁情况
return jedis.expire(key, expireTime) == 1;
}
return false;
}
SETNX
是SET if Not eXists
,即仅在没有其他客户端对key
进行加锁的情况下才可以加锁;EXPIRE
的作用是给锁设置一个过期时间。这种使用方式存在的问题:客户端成功执行了SETNX
指令,但是在执行EXPIRE
指令的时候,客户端突然崩溃,就会导致死锁!下面我们写一个测试程序来验证一下: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
36
37
38
39
40
41
42
43
44
45
46
47
48/**
* 获取分布式锁——错误的示例1的测试方法
* @param jedis Jedis
*/
private static void testLockWithWrongWay1(Jedis jedis) {
String key = "distributed-lock-key"; // 加锁的key
String clientId = UUID.randomUUID().toString(); // 客户端ID
int expireTime = 20; // 锁的超时时间设置为20秒
LOGGER.info("Client[{}] now try to get lock.", clientId);
while (true) {
boolean lockResult = RedisLockHelper.lockWithWrongWay1(jedis, key, clientId, expireTime);
if (lockResult) {
LOGGER.info("Client[{}] get lock success.", clientId);
} else {
LOGGER.warn("Client[{}] get lock failed.", clientId);
}
sleep(1000); // 客户端每隔1秒尝试获取一次锁
}
}
/**
* 程序启动入口方法
* @param args args
*/
public static void main(String[] args) {
GenericObjectPoolConfig poolConfig = new GenericObjectPoolConfig();
JedisPool jedisPool = new JedisPool(poolConfig, "10.200.0.206");
// 测试获取分布式锁——错误的示例1
testLockWithWrongWay1(jedisPool.getResource());
}
/** ---- 测试过程描述 ----
* 同时启动两个测试进程(客户端),当其中一个进程(客户端)打印出"Client[···] execute SETNX ok."日志的时候,手动结束该进程(客户端)。
* 可以观察到,另一个进程(客户端)一直在尝试获取锁,但是始终无法获取到锁,该锁也始终不会被释放。这就是死锁的现象。
* **/
/**---- 输出的日志信息 ----**/
// 成功加锁的客户端输出的日志
2018-07-25 17:42:39.034 INFO Client[09fc0441-c443-461e-8e17-f463691dfef0] now try to get lock.
2018-07-25 17:42:39.045 INFO Client[09fc0441-c443-461e-8e17-f463691dfef0] execute SETNX ok.
// 始终无法加锁的客户端输出的日志
2018-07-25 17:42:43.236 INFO Client[212038bd-e525-4cfe-b155-e8a2b614193d] now try to get lock.
2018-07-25 17:42:43.288 WARN Client[212038bd-e525-4cfe-b155-e8a2b614193d] get lock failed.
2018-07-25 17:42:44.291 WARN Client[212038bd-e525-4cfe-b155-e8a2b614193d] get lock failed.
··· ···
2018-07-25 17:49:43.937 WARN Client[212038bd-e525-4cfe-b155-e8a2b614193d] get lock failed.
2018-07-25 17:49:44.940 WARN Client[212038bd-e525-4cfe-b155-e8a2b614193d] get lock failed.
由于SETNX
和EXPIRE
这两条指令是分别执行的,整个加锁操作不具备原子性,在执行EXPIRE
之前加锁客户端突然崩溃了,导致没有设置锁的超时时间,形成死锁。使用SETNX
和EXPIRE
两条指令组合来实现加锁操作的做法,有可能是所使用的Redis版本过低(< Redis 2.6.12
),SET
指令还不支持多参数(同时设置超时时间)所做的一种方案。
2.2 加锁代码——错误示例2
此示例代码基于Redis的SETNX
、GET
和GETSET
三个指令的组合来实现加锁操作,代码如下: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/**
* 获取分布式锁——错误的示例2
* @param jedis Jedis实例
* @param key 用key来当锁,因为key是唯一的
* @param expireTime 锁的超时时间,单位秒——超过此时间未解锁则Redis会删除锁
* @return true-加锁成功;false-加锁失败
*/
public static boolean lockWithWrongWay2(Jedis jedis, String key, int expireTime) {
long expires = System.currentTimeMillis() + (expireTime * 1000L);
// 若当前锁不存在,则SETNX可以设置成功(成功加锁,将过期时间作为锁的value值),返回1
if (1 == jedis.setnx(key, String.valueOf(expires))) {
return true;
}
// 若当前锁存在,则获取锁的过期时间
String value = jedis.get(key);
if (value != null && Long.parseLong(value) < System.currentTimeMillis()) {
// 锁已经过期,获取上一个锁的过期时间,并设置当前锁的过期时间
String oldValue = jedis.getSet(key, String.valueOf(expires));
if (oldValue != null && oldValue.equals(value)) {
// 考虑多线程并发的情况,只有一个线程的设置值与当前值相同,它才有加锁的权利
return true;
}
}
return false;
}
上面加锁代码的问题所在:
1. 锁的过期时间由客户端生成,并通过获取锁的过期时间来跟客户端本地时间进行比较来判断锁是否已经过期;这就需要分布式中的每个客户端的时间必须同步。
2. 锁不具备加锁者的唯一标识,这意味着任何其他客户端都可以解开这个锁(DEL指令删除锁
)。
3. 锁过期时,若多个客户端同时执行GETSET
指令进行加锁,虽然最终只有一个客户端可以加锁,但是该客户端的锁的过期时间可能被其他客户端覆盖。
2.3 加锁代码——正确的姿势
总结一下上面两种错误的加锁方式:
1. 错误示例1——加锁代码不具备原子性,容易被加锁客户端的异常、崩溃打断加锁过程而出现死锁。
2. 错误示例2——(1) 加锁客户端自己管理锁的过期时间,当多个客户端时间不同步时,容易出现加锁、解锁混乱;(2) 锁不具备加锁者的唯一标识,其他人可以随便解锁;这样的锁没什么意义。
正确的加锁代码应该是这样的(这个要求Redis版本要大于2.6.12
,因为SET指令只有2.6.12
以上的版本才支持多参数):1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19private static final String LOCK_SUCCESS = "OK"; // OK表示加锁成功
private static final String EX = "EX"; // 设置键的过期时间为second秒。SET key value EX second效果等同于SETEX key second value。
private static final String PX = "PX"; // 设置键的过期时间为millisecond毫秒。SET key value PX millisecond效果等同于PSETEX key millisecond value。
private static final String NX = "NX"; // 只在键不存在时,才对键进行设置操作。SET key value NX 效果等同于SETNX key value。
private static final String XX = "XX"; // 只在键已经存在时,才对键进行设置操作。
··· ···
/**
* 获取分布式锁——正确的姿势
* @param jedis Jedis实例
* @param key 用key来当锁,因为key是唯一的
* @param clientId 执行加锁操作的客户端(或线程)的ID,必须保证唯一性
* @param expireTime 锁的超时时间,单位秒——超过此时间未解锁则Redis会删除锁
* @return true-加锁成功;false-加锁失败
*/
public static boolean lockWithCorrectWay(Jedis jedis, String key, String clientId, int expireTime) {
return LOCK_SUCCESS.equals(jedis.set(key, clientId, NX, PX, expireTime*1000L));
}
上面的加锁代码虽然解决了之前的问题,但是我觉得还是有个小小的缺点:不可重入
。同一个客户端不能在锁过期之前重入该锁,当然锁的可重入在某些场景并不是非常必要。虽然不可重入,但是并不会导致死锁,因为锁本身有过期时间。
我们接下来编写一个测试方法来测试该锁的可重入性: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/**
* 获取分布式锁——正确的姿势——测试锁的可重入性
* @param jedis Jedis
*/
private static void testReentrantLockWithCorrectWay(Jedis jedis) {
String key = "distributed-lock-key"; // 加锁的key
String clientId = UUID.randomUUID().toString(); // 客户端ID
int expireTime = 5; // 锁的超时时间设置为5秒
LOGGER.info("Client[{}] now try to get lock.", clientId);
while (true) {
boolean lockResult = RedisLockHelper.lockWithCorrectWay(jedis, key, clientId, expireTime);
if (lockResult) {
LOGGER.info("Client[{}] get lock success.", clientId);
} else {
LOGGER.warn("Client[{}] get lock failed.", clientId);
}
sleep(2000); // 同一个客户端每隔2秒尝试获取一次锁
}
}
/**---- 输出的日志信息 ----**/
2018-07-26 10:36:39.514 INFO Client[5c76a05f-a53e-4568-b03b-bf142269f55e] now try to get lock.
2018-07-26 10:36:39.521 INFO Client[5c76a05f-a53e-4568-b03b-bf142269f55e] get lock success.
2018-07-26 10:36:41.525 WARN Client[5c76a05f-a53e-4568-b03b-bf142269f55e] get lock failed.
2018-07-26 10:36:43.527 WARN Client[5c76a05f-a53e-4568-b03b-bf142269f55e] get lock failed.
2018-07-26 10:36:45.530 INFO Client[5c76a05f-a53e-4568-b03b-bf142269f55e] get lock success.
2018-07-26 10:36:47.532 WARN Client[5c76a05f-a53e-4568-b03b-bf142269f55e] get lock failed.
2018-07-26 10:36:49.535 WARN Client[5c76a05f-a53e-4568-b03b-bf142269f55e] get lock failed.
2018-07-26 10:36:51.539 INFO Client[5c76a05f-a53e-4568-b03b-bf142269f55e] get lock success.
/**
* 可以看到:锁的过期时间为5秒,在锁过期之前,就算是同一个客户端也没法重入该锁,说明该锁不具备可重入性。
* **/
3 解锁代码实现
按照上面的尿性,同样先来两个错误的解锁示例代码,再来正确的解锁代码。
3.1 解锁代码——错误示例1
此示例直接使用Redis的DEL
指令来删解锁:此解锁代码问题在于,它没有判断这把锁是不是它持有的就删除。这样一来,就可以随便解开任意客户端加的锁,这是不允许的。1
2
3
4
5
6
7
8/**
* 解锁——错误的示例1
* @param jedis Jedis
* @param key key
*/
public static void unlockWithWrongWay1(Jedis jedis, String key) {
jedis.del(key);
}
3.2 解锁代码——错误示例2
此示例在错误示例1的基础上增加了一个判断操作,判断该锁是不是自己的:1
2
3
4
5
6
7
8
9
10
11/**
* 解锁——错误的示例2
* @param jedis Jedis
* @param key key
* @param clientId 客户端ID,具备唯一性
*/
public static void unlockWithWrongWay2(Jedis jedis, String key, String clientId) {
if (clientId.equals(jedis.get(key))) {
jedis.del(key);
}
}
这样的解锁代码同样有问题:不具备原子性。解锁代码判断该锁确实属于自己之后,若在执行DEL
指令之前,该锁突然过期并且被别的客户端成功加锁,此时再执行DEL
指令,就会删掉别人的锁。导致当前该锁本应存在,但是却被误删而变得不存在,导致数据一致性失效。
3.3 解锁代码——正确的姿势
1 | private static final Long UNLOCK_SUCCESS = 1L; // 1表示解锁成功 |
上面的解锁代码使用了Redis的EVAL
指令来运行Lua脚本:从Redis 2.6.0版本开始,通过内置的Lua解释器,可以使用EVAL
命令对Lua脚本进行求值。
说明一下(摘抄自脚本的原子性):
Redis 使用单个 Lua 解释器去运行所有脚本,并且, Redis 也保证脚本会以原子性(atomic)的方式执行:当某个脚本正在运行的时候,不会有其他脚本或 Redis 命令被执行。这和使用 MULTI / EXEC 包围的事务很类似。在其他别的客户端看来,脚本的效果(effect)要么是不可见的(not visible),要么就是已完成的(already completed)。
另一方面,这也意味着,执行一个运行缓慢的脚本并不是一个好主意。写一个跑得很快很顺溜的脚本并不难,因为脚本的运行开销(overhead)非常少,但是当你不得不使用一些跑得比较慢的脚本时,请小心,因为当这些蜗牛脚本在慢吞吞地运行的时候,其他客户端会因为服务器正忙而无法执行命令。
这里if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
就是一个Lua脚本代码。在Lua中,可以使用redis.call()
来执行Redis指令。我们将key
赋值给KEYS[1]
,将clientId
赋值给ARGV[1]
。
上面脚本的意思是:先执行Redis的GET
指令并将获取到的结果与clientId
进行对比(判断锁是不是自己的),如果锁是自己的则进一步执行Redis的DEL
指令删除锁并返回删除结果;如果锁不是自己的则直接返回0。这个脚本涉及到了两个Redis指令,两个指令的组合的原子性由Redis保证(上面已经说明)。
在Redis中执行Lua脚本的简单用法可以参考这里:EVAL 或这里:http://www.cnblogs.com/huangxincheng/p/6230129.html
4 结语
上面就说完了Redis分布式锁的实践过程,个人觉得除了锁的可重入性之外,另外一点比较难把握的就是锁的过期时间。使用缓存(Redis、Memcached、Tair)来实现分布式锁,性能会比较好;但是通过给锁设置失效时间来二次保证锁的可用性(保证锁最终一定会被释放)并不是非常靠谱。
自己写的测试代码已上传到GitHub上:redis-distributed-lock
——————–【参考文章】——————–