本文分析,在分布式系统中,使用redis实现分布式锁,会遇到什么问题。关于分布式锁概念redis分布式锁的具体实现,可参考前面的2篇文章。本文重点在于,对分布式锁技术选型的分析。

1.redis锁单节点实现

常规的,使用redis做分布式锁,主要实现如下:

1.1加锁

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
/**
* 加锁
*
* @param lockName 锁名,对应被争用的共享资源
* @param randomValue 随机值,需要保持全局唯一,便于释放时校验锁的持有者
* @param expireTime 过期时间,到期后自动释放,防止出现问题时死锁,资源无法释放
* @return
*/
public static boolean acquireLock(String lockName,String randomValue,int expireTime){
Jedis jedis = jedisPool.getResource();
try {
while (true){
String result = jedis
.set(lockName, randomValue, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
if(LOCK_SUCCESS.equals(result)){
logger.info("【Redis lock】success to acquire lock for [ "+lockName+" ],expire time:"+expireTime+"ms");
return true;
}
}
}catch (Exception ex){
ex.printStackTrace();
}finally {
if(null != jedis){
jedis.close();
}
}
logger.info("【Redis lock】failed to acquire lock for [ "+lockName+" ]");
return false;
}

1.2解锁

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
/**
* redis释放锁
* watch和muti命令保证释放时的对等性,防止误解锁
*
* @param lockName 锁名,对应被争用的共享资源
* @param randomValue 随机值,需要保持全局唯一,以检验锁的持有者
* @return 是否释放成功
*/
public static boolean releaseLock(String lockName,String randomValue){
Jedis jedis = jedisPool.getResource();
try{
jedis.watch(lockName);//watch监控
if(randomValue.equals(jedis.get(lockName))){
Transaction multi = jedis.multi();//开启事务
multi.del(lockName);//添加操作到事务
List<Object> exec = multi.exec();//执行事务
if(RELEASE_SUCCESS.equals(exec.size())){
logger.info("【Redis lock】success to release lock for [ "+lockName+" ]");
return true;
}
}
}catch (Exception ex){
logger.info("【Redis lock】failed to release lock for [ "+lockName+" ]");
ex.printStackTrace();
}finally {
if(null != jedis){
jedis.unwatch();
jedis.close();
}
}
return false;
}

2.redis锁单节点分析

基于单点redis的锁实现,上述这种实现,基本达到了单节点的安全限度,解决了如下几个问题:

1.防止死锁

设置过期时间后,即使客户端挂了,加锁后未解锁,这个锁也是会到期释放的,不存在死锁的可能。

典型的死锁场景:

一个客户端获取锁成功,但是在释放锁之前崩溃了,此时该客户端实际上已经失去了对公共资源的操作权,但却没有办法请求解锁(删除 key-value 键值对),那么,它就会一直持有这个锁,而其它客户端永远无法获得锁。

2.对称性

加锁的时候,我们给锁设置了个随机值,保证了即使在如下情况,解锁也是只会释放自己加的锁,而不会误删。
典型的误删场景:

假如获取锁时SET的不是一个随机字符串,而是一个固定值,那么可能会发生下面的执行序列:

  1. 客户端1获取锁成功。
  2. 客户端1在某个操作上阻塞了很长时间。
  3. 过期时间到了,锁自动释放了。
  4. 客户端2获取到了对应同一个资源的锁。
  5. 客户端1从阻塞中恢复过来,释放掉了客户端2持有的锁。

此时,如果客户端2访问共享资源,就没有锁来提供资源保护了。

3.互斥性

一个时刻只能有一个客户端可以得到锁,这个由redis自身命令setnx即可得到保证。

4.解锁

释放锁,包含3个操作,”get”、”判断”、”del”,我们必须保证释放锁时,这三个操作是原子性的。有2种方式来保证这一批操作的原子性。

  • 1.我们可以执行lua脚本,来保证删除操作的原子性。
  • 2.使用redis提供的watch机制,如前文的实现,来保证。

    如果三个操作的原子性得不到保证,下面的场景,就会出问题:

  1. 客户端1获取锁成功。
  2. 客户端1访问共享资源。
  3. 客户端1为了释放锁,先执行’GET’操作获取随机字符串的值。
  4. 客户端1判断随机字符串的值,与预期的值相等。
  5. 客户端1由于某个原因阻塞住了很长时间。
  6. 过期时间到了,锁自动释放了。
  7. 客户端2获取到了对应同一个资源的锁。
  8. 客户端1从阻塞中恢复过来,执行DEL操纵,释放掉了客户端2持有的锁。

前面的4个问题,如果我们自己单节点实现时,可以考虑到,代码层面都是可以正确处理的。那么,还有一个问题,如何做到高可用呢?

5.高可用

单节点,存在挂机的风险,为了达到高可用,我们可以做redis集群。一个redis节点作为master,master挂一个slave,当master挂掉后,自动切到slave节点。

看上去,前面5个问题都得到了解决。

但是,在集群模式下,考虑一个场景:

  1. 客户端1从Master获取了锁。
  2. Master宕机了,存储锁的key还没有来得及同步到Slave上。
  3. Slave升级为Master。
  4. 客户端2从新的Master获取到了对应同一个资源的锁。

由于redis的主从复制是异步的,这可能导致,在failover的过程中,资源丧失了锁带来的安全性。

这是我们使用redis实现锁遇到的第一个问题。

6.参数的选择

在前文的算法中,我们给锁设置了有效期,这个值,究竟多少合适呢?

如果太短,锁可能在客户端还未完成对资源的操作之前就过期,从而失去了保护;

如果太长,一个客户端如果主动释放锁失败了,那么,需要等到过期时间才会被动释放,那么,在漫长的有效期内,其他客户端,都无法获得这个资源的锁。

这是我们使用redis实现锁遇到的第二个问题。