本文分析,在分布式系统中,使用redis实现分布式锁,会遇到什么问题。关于分布式锁概念和redis分布式锁的具体实现,可参考前面的2篇文章。本文重点在于,对分布式锁技术选型的分析。
1.redis锁单节点实现
常规的,使用redis做分布式锁,主要实现如下:
1.1加锁
代码语言:javascript复制 /**
* 加锁
*
* @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解锁
代码语言:javascript复制 /**
* 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从阻塞中恢复过来,释放掉了客户端2持有的锁。
此时,如果客户端2访问共享资源,就没有锁来提供资源保护了。
3.互斥性
一个时刻只能有一个客户端可以得到锁,这个由redis自身命令setnx即可得到保证。
4.解锁
释放锁,包含3个操作,“get”、“判断”、“del”,我们必须保证释放锁时,这三个操作是原子性的。有2种方式来保证这一批操作的原子性。(这里本质是想说,这三个操作期间,这个key不能被更改,基于watch机制的乐观锁)
- 1.我们可以执行lua脚本,来保证删除操作的原子性。
- 2.使用redis提供的watch机制,如前文的实现,来保证。
如果三个操作的原子性得不到保证,下面的场景,就会出问题:
- 客户端1获取锁成功。
- 客户端1访问共享资源。
- 客户端1为了释放锁,先执行’GET’操作获取随机字符串的值。
- 客户端1判断随机字符串的值,与预期的值相等。
- 客户端1由于某个原因阻塞住了很长时间。
- 过期时间到了,锁自动释放了。
- 客户端2获取到了对应同一个资源的锁。
- 客户端1从阻塞中恢复过来,执行DEL操纵,释放掉了客户端2持有的锁。
前面的4个问题,如果我们自己单节点实现时,可以考虑到,代码层面都是可以正确处理的。那么,还有一个问题,如何做到高可用呢?
5.高可用
单节点,存在挂机的风险,为了达到高可用,我们可以做redis集群。一个redis节点作为master,master挂一个slave,当master挂掉后,自动切到slave节点。
看上去,前面5个问题都得到了解决。
但是,在集群模式下,考虑一个场景:
- 客户端1从Master获取了锁。
- Master宕机了,存储锁的key还没有来得及同步到Slave上。
- Slave升级为Master。
- 客户端2从新的Master获取到了对应同一个资源的锁。
由于redis的主从复制是异步的,这可能导致,在failover的过程中,资源丧失了锁带来的安全性。
这是我们使用redis实现锁遇到的第一个问题。
6.参数的选择
在前文的算法中,我们给锁设置了有效期,这个值,究竟多少合适呢?
如果太短,锁可能在客户端还未完成对资源的操作之前就过期,从而失去了保护;
如果太长,一个客户端如果主动释放锁失败了,那么,需要等到过期时间才会被动释放,那么,在漫长的有效期内,其他客户端,都无法获得这个资源的锁。