Redis实现分布式锁相关注意事项

文章目录
  1. Redis实现分布式锁相关注意事项
    1. I. 背景知识
    2. II. 方案设计
      1. 1. 设计思路
      2. 2. getset的实现方案
      3. 3. expire实现方式
    3. III. 小结说明
      1. 参考链接
    4. V. 其他
      1. 声明
      2. 扫描关注,java分享

Redis实现分布式锁相关注意事项

查看了不少关于redis实现分布式锁的文章,无疑要设计一个靠谱的分布式并不太容易,总会出现各种鬼畜的问题;现在就来小述一下,在设计一个分布式锁的过程中,会遇到一些什么问题

I. 背景知识

借助redis来实现分布式锁(我们先考虑单机redis的模式),首先有必要了解下以下几点:

  • 单线程模式
  • setnx : 当不存在时,设置value,并返回1; 否则返回0
  • getset : 设置并获取原来的值
  • expire : 设置失效时间
  • get : 获取对应的值
  • del : 删除
  • ttl : 获取key对应的剩余时间,若key没有设置过超时时间,或者压根没有这个key则返回负数(可能是-1,-2)
  • watch/unwatch : 事务相关

II. 方案设计

1. 设计思路

获取锁:

  • 调用 setnx 尝试获取锁,如果设置成功,表示获取到了锁
  • 设置失败,此时需要判断锁是否过期
    • 未过期,则表示获取失败;循环等待,并再次尝试获取锁
    • 已过期,getset再次设置锁,判断是否获取了锁(根据返回的值进行判断,后面给出具体的方案)
    • 若失败,则重新进入获取锁的逻辑

释放锁:

  • 一个原则就是确保每个业务方释放的是自己的锁

2. getset的实现方案

网上一种常见的case,主要思路如下

  • setnx 尝试获取锁
  • 失败,则 get 获取锁的value (一般是 uuid_timstamp)
  • 判断是否过期,若没有过期,则表示真的获取失败
  • 若过期,则采用 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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class DistributeLock {

private static final Long OUT_TIME = 30L;

public String tryLock(Jedis jedis, String key) {
while (true) {
String value = UUID.randomUUID().toString() + "_" + System.currentTimeMillis();
Long ans = jedis.setnx(key, value);
if (ans != null && ans == 1) { // 获取锁成功
return value;
}

// 锁获取失败, 判断是否超时
String oldLock = jedis.get(key);
if (oldLock == null) {
continue;
}

long oldTime = Long.parseLong(oldLock.substring(oldLock.lastIndexOf("_") + 1));
long now = System.currentTimeMillis();
if (now - oldTime < OUT_TIME) { // 没有超时
continue;
}

String getsetOldVal = jedis.getSet(key, value);
if (Objects.equals(oldLock, getsetOldVal)) { // 返回的正好是上次的值,表示锁获取成功
return value;
} else { // 表示返回的是其他业务设置的锁,赶紧的设置回去
jedis.set(key, getsetOldVal);
}
}
}

public void tryUnLock(Jedis jedis, String key, String uuid) {
String ov = jedis.get(key);
if (uuid.equals(ov)) { // 只释放自己的锁
jedis.del(key);
}
}
}

观察获取锁的逻辑,特别是获取超时锁的逻辑,很容易想到有一个问题 getSet 方法会不会导致写数据混乱的问题,简单来说就是多个线程同时判断锁超时时,执行 getSet设置锁时,最终获取锁的线程,能否保证和redis中的锁的value相同

上面的实现方式,一个混乱的case如下:

  1. 三个线程a,b,c 都进入到了锁超时的阶段
  2. 线程a, 获取原始值 oldVal, 并设置 t1
  3. 线程b, 获取线程a设置的 t1, 并重设为 t2
  4. 线程c, 获取线程b设置的 t2, 并重设为 t3
  5. 线程a,判断,并正式获取到锁
  6. 线程b,判断失败,恢复原来锁的内容为t1
  7. 线程c, 判断失败,恢复原来锁的内容为t2
  8. 问题出现了,获取锁的线程a,期望所得内容为t1, 但是实际为t2; 导致无法释放锁

实际验证

在上面的代码中,配合测试case,加上一些日志输出

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
49
50
51
52
53
54
55
56
public static String tryLock(Jedis jedis, String key) throws InterruptedException {
String threadName = Thread.currentThread().getName();
while (true) {
String value = threadName + "_" + UUID.randomUUID().toString() + "_" + System.currentTimeMillis();
Long ans = jedis.setnx(key, value);
if (ans != null && ans == 1) { // 获取锁成功
return value;
}

// 锁获取失败, 判断是否超时
String oldLock = jedis.get(key);
if (oldLock == null) {
continue;
}

long oldTime = Long.parseLong(oldLock.substring(oldLock.lastIndexOf("_") + 1));
long now = System.currentTimeMillis();
if (now - oldTime < OUT_TIME) { // 没有超时
continue;
}


// 强制使所有的线程都可以到这一步
Thread.sleep(50);
System.out.println(threadName + " in getSet!");


// 人工接入,确保t1 获取到锁, t2 获取的是t1设置的内容, t3获取的是t2设置的内容
if ("t2".equalsIgnoreCase(threadName)) {
Thread.sleep(20);
} else if ("t3".equalsIgnoreCase(threadName)) {
Thread.sleep(40);
}

String getsetOldVal = jedis.getSet(key, value);
System.out.println(threadName + " set redis value: " + value);

if (Objects.equals(oldLock, getsetOldVal)) { // 返回的正好是上次的值,表示锁获取成功
System.out.println(threadName + " get lock!");
if ("t1".equalsIgnoreCase(threadName)) {
// t1获取到锁,强制sleep40ms, 确保线t2,t3也进入了 getSet逻辑
Thread.sleep(40);
}
return value;
} else { // 表示返回的是其他业务设置的锁,赶紧的设置回去
// 人肉介入,确保t2优先执行,并设置回t1设置的值, t3后执行设置的是t2设置的值
if ("t3".equalsIgnoreCase(threadName)) {
Thread.sleep(40);
} else if ("t2".equalsIgnoreCase(threadName)){
Thread.sleep(20);
}
jedis.set(key, getsetOldVal);
System.out.println(threadName + " recover redis value: " + getsetOldVal);
}
}
}

测试case

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
49
50
@Test
public void testLock() throws InterruptedException {
// 先无视获取jedis的方式
JedisPool jedisPool = cacheWrapper.getJedisPool(0);
Jedis jedis = jedisPool.getResource();
String lockKey = "lock_test";

String old = DistributeLock.tryLock(jedis, lockKey);
System.out.println("old lock: " + old);

// 确保锁超时
Thread.sleep(40);

// 创建三个线程
Thread t1 = new Thread(() -> {
try {
Jedis j =jedisPool.getResource();
DistributeLock.tryLock(j, lockKey);
System.out.println("t1 >>>> " + j.get(lockKey));
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "t1");
Thread t2 = new Thread(() -> {
try {
Jedis j =jedisPool.getResource();
DistributeLock.tryLock(j, lockKey);
System.out.println("t2 >>>>> " + j.get(lockKey));
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "t2");
Thread t3 = new Thread(() -> {
try {
Jedis j =jedisPool.getResource();
DistributeLock.tryLock(j, lockKey);
System.out.println("t3 >>>>> " + j.get(lockKey));
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "t3");


t1.start();
t2.start();
t3.start();


Thread.sleep(10000);
};

部分输出结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
main in getSet!
main set redis value: main_d4cc5d69-5027-4550-abe1-10126f057779_1515643763130
main get lock!
old lock: main_d4cc5d69-5027-4550-abe1-10126f057779_1515643763130
t1 in getSet!
t2 in getSet!
t1 set redis value: t1_105974db-7d89-48bf-9669-6f122a3f9fb6_1515643763341
t1 get lock!
t3 in getSet!
t2 set redis value: t2_be06f80a-9b70-4a0e-a86d-44337abe8642_1515643763341
t1 >>>> t2_be06f80a-9b70-4a0e-a86d-44337abe8642_1515643763341
t3 set redis value: t3_9aa5d755-43b2-43bd-9a0b-2bad13fa31f6_1515643763345
t2 recover redis value: t1_105974db-7d89-48bf-9669-6f122a3f9fb6_1515643763341
t3 recover redis value: t2_be06f80a-9b70-4a0e-a86d-44337abe8642_1515643763341

重点关注 t1 >>>> t2_be06f80a-9b70-4a0e-a86d-44337abe8642_1515643763341,表示t1线程过去了锁,但是锁的内容不是其value,即便t2去恢复,也会被t3给覆盖


如何解决上面这个问题呢?

上面是典型的并发导致的问题,当然可以考虑从解决并发问题的角度出发来考虑,一个常见的方式就是加锁了,思路如下:(不详细展开了)

  • 在判断超时之后,加锁
  • 再次获取对应的值,判断是否超时,是则执行上面的操作
  • 否则退出逻辑,继续循环

这种实现方式,会有以下的问题:

  • getset 这个方法执行,可能导致写入脏数据
  • 基于服务器时钟进行超时判断,要求所有服务器始终一致,否则有坑

3. expire实现方式

相比于前面一种直接将value设置为时间戳,然后来比对的方法,这里则直接借助redis本身的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
public class DistributeExpireLock {

private static final Integer OUT_TIME = 3;

public static String tryLock(Jedis jedis, String key) {
String value = UUID.randomUUID().toString();

while(true) {
Long ans = jedis.setnx(key, value);
if (ans != null && ans == 1) { // 获取锁成功
jedis.expire(key, OUT_TIME); // 主动设置超时时间为3s
return value;
}

// 获取失败,先确认下是否有设置国超是时间
// 防止锁的超时时间设置失效,导致一直竞争不到
if(jedis.ttl(key) < 0) {
jedis.expire(key, OUT_TIME);
}
}
}


public static void tryUnLock(Jedis jedis, String key, String uuid) {
String ov = jedis.get(key);
if (uuid.equals(ov)) { // 只释放自己的锁
jedis.del(key);
System.out.println(Thread.currentThread() +" del lock success!");
} else {
System.out.println(Thread.currentThread() +" del lock fail!");
}
}
}

获取锁的逻辑相比之前的,就简单很多了,接下来则需要简单的分析下,上面这种实现方式,会不会有坑呢?我们主要看一下获取锁失败的场景

  • 如果获取锁失败
  • 表示有其他的业务方已经获取到了锁
  • 此时,只能等持有锁的业务方主动释放锁
  • 判断锁是否设置了超时时间,若没有则加一个(防止设置超时时间失败导致问题)

从上面这个逻辑来看问题不大,但是有个问题,case :

  • 如某个业务方setnx获取到了锁,但是因为网络问题,过了很久才获取到返回,此时锁已经失效并被其他业务方获取到了,就会出现多个业务方同时持有锁的场景

III. 小结说明

想基于redis实现一个相对靠谱的分布式锁,需要考虑的东西还是比较多的,而且这种锁并不太适用于业务要求特别严格的地方,如

  • 一个线程持有锁时,如果发生gc,导致锁超时失效,但是自己又不知道,此时就会出现多个业务方同时持有锁的场景
  • 对于锁超时的场景,需要仔细考虑,是否会出现并发问题
  • 确保只能释放自己的锁(以防止释放了别人的锁,出现问题)

参考链接

V. 其他

声明

尽信书则不如,已上内容,纯属一家之言,因本人能力一般,见解不全,如有问题,欢迎批评指正

扫描关注,java分享

QrCode

# Redis

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×