分布式锁
1. 什么是分布式锁?
1.1 使用场景
锁,就是为了防止多个线程并发操作共享变量而导致不可预期的结果锁采取的一种串行化的方式,那分布式锁 ,顾名思义,也就是在控制分布式条件下,线程能够串行化的操作共享变量,从而达到不同机器的进程的线程之间的同步或者互斥。
1.2 常见的实现方式
分布式锁常见的实现方式有redis的实现,zk的实现,tair的实现,本篇我们主要讨论下通过redis的实现。
2. 分布式锁redis的实现
2.1 锁的实现
话不多说,我们先上代码
class RedisLock{
public boolean lock(String key, V v, int expireTime){
int retry = 0;
//获取锁失败最多尝试10次
while (retry < failRetryTimes){
//获取锁
Boolean result = redis.setNx(key, v, expireTime);
if (result){
return true;
}
try {
//获取锁失败间隔一段时间重试
TimeUnit.MILLISECONDS.sleep(sleepInterval);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
return false;
}
public boolean unlock(String key){
return redis.delete(key);
}
}
复制代码
这是分布式锁简单的实现,先尝试往redis里面设值,如果成功则返回true,否则睡眠指定的时间重试,直到获取成功。 如果超过重试的次数获取锁还是失败的话,就返回false。
2.2 锁的不足
我们看下上面的实现,会发现以下存在的几个问题:
- 在我们这一步
Boolean result = redis.setNx(key, v, expireTime);
去设置k,v的时候,如果此时返回的false是由于超时导致,而实际redis是执行成功了, 那我们重新再设置就会一直失败,我们就会在这里空等待一个expireTime的时间周期。 - 锁的释放没有检测当前的锁是否是当前线程所加,所以是有可能误释放掉别的线程加的锁
- 可重入性,这个其实可以和第2条放在一起
2.3 锁的改进
根据上面的两点的不足,我们改进下锁的实现代码:
public class RedisLock {
public boolean lock(String key, V v, int expireTime){
int retry = 0;
//获取锁失败最多尝试10次
while (retry < failRetryTimes){
//1.先获取锁,如果是当前线程已经持有,则直接返回
//2.防止后面设置锁超时,其实是设置成功,而网络超时导致客户端返回失败,所以获取锁之前需要查询一下
V value = redis.get(key);
//如果当前锁存在,并且属于当前线程持有,直接返回
if (null != value && value.equals(v)){
return true;
}
//获取锁
Boolean result = redis.setNx(key, v, expireTime);
if (result){
return true;
}
try {
//获取锁失败间隔一段时间重试
TimeUnit.MILLISECONDS.sleep(sleepInterval);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
return false;
}
public boolean unlock(String key, String requestId){
String value = redis.get(key);
//锁应该是已经超时了,其实这里可以加一些监控去看下
if (Strings.isNullOrEmpty(value)){
return true;
}
//判断当前锁的持有者是否是当前线程,如果是的话释放锁,不是的话返回false
if (value.equals(requestId)){
redis.delete(key);
return true;
}
return false;
}
}
复制代码
可以看到,我们针对上面不足的两点已经做了些改进,这部分基本上已经满足了我们业务的需求。 其实,我们还是可以做一些优化的,并非必须,不同的业务可以有不同的实现方式:
- 在最后释放锁的时候,我们先去判断该锁是否存在并且属于当前线程所有,如果是的话再去释放锁,而这两步操作其实并非是原子性的,所以也是会存在 竞汏条件,出现问题,所以我们可以把这两部通过lua脚本实现做成原子性的,具体可以不同的业务去考量
- 锁的睡眠时间和重试次数可以暴露出来,不同的业务可以根据业务特点进行控制
2.4 分布式锁实践遇到的问题
在我们分布式锁的实际使用中,有遇到过一些考虑不足的点,这里简单列一下:
- 大多数业务的分布式锁,k和v是一致的,这种其实在并发量比较小的时候,问题不大,但是在并发量比较大的时候,是会出现当前线程释放掉别的线程的锁的,这点见到的比较多, 所以也单独列出来说明下。
- 过期时间的设置,这个比较重要。我们如果分布式锁之后,进行本地事务的操作,如果我们设置的过期时间比事务的超时时间短,那么可能就存在的问题是分布式锁已经过期了,但是事务还在等待提交,等到下一个 线程获取到锁之后,那就会存在并发提交,这就和我们想通过分布式锁达到的预期结果相违背了,所以在设置过期时间的时候,如果有事务的操作需要格外注意下,包括失败之后的重试,如果有的话 也需要多考量下。
- 锁的监控,对于锁获取时间比较长,以及释放的时候锁已经过期了,对于这部分请求可以监控下来,业务上去排查下,是否存在一些问题,在系统上尽量我们还是做到 由我们主动的去找系统的问题,而不是通过系统被动的曝出问题我们去排查。
2.5 不合理的实现
网上经常还可以看到这种实现方式,就是获取到锁之后要检查下锁的过期时间,如果锁过期了要重新设置下时间,大致代码如下:
public boolean tryLock2(String key, int expireTime){
long expires = System.currentTimeMillis() + expireTime;
//获取锁
Boolean result = redis.setNx(key, expires, expireTime);
if (result){
return true;
}
V value = redis.get(key);
if (value != null && (Long)value < System.currentTimeMillis()){
//锁已经过期
String oldValue = redis.getSet(key, expireTime);
if (oldValue != null && oldValue.equals(value)){
return true;
}
}
return false;
}
复制代码
这种实现存在的问题,过度依赖当前服务器的时间了,如果在大量的并发请求下,都判断出了锁过期,而这个时候再去设置锁的时候,最终是会只有一个线程,但是可能会导致不同服务器根据自身不同的时间覆盖掉最终获取锁的那个线程设置的时间。
3. 其他实现的方式
3.1 zk的实现
网上有关zk实现的代码比较多,这里就不展示代码了,可以大致说下思路:
3.1.1获取锁
- 先有一个锁跟节点,lockRootNode,这可以是一个永久的节点
- 客户端获取锁,先在lockRootNode下创建一个顺序的瞬时节点,节点里面可以存储当前线程的一些信息,比如requestId等可以唯一识别当前线程的信息。瞬时节点可以保证客户端断开连接,节点也自动删除
- 调用lockRootNode父节点的getChildren()方法,获取所有的节点,并从小到大排序,获取最小节点,并且判断最小节点的节点信息是否是当前线程,若是,则返回true,获取锁成功,否则,关注比自己序号小的节点的释放动作(exist watch),这样可以保证每一个客户端只需要关注一个节点,不需要关注所有的节点,避免羊群效应。
- 如果有节点释放操作,重复步骤3
3.1.2释放锁
只需要删除步骤2中创建的节点即可
3.2 tair的实现
通过tair来实现分布式锁和redis的实现核心差不多,不过tair有个很方便的api,感觉是实现分布式锁的最佳配置,就是put api调用的时候需要传入一个version,就和数据库的乐观锁一样,修改数据之后,版本会自动累加,如果传入的版本和当前数据版本不一致,就不允许修改,具体可以看下这篇文章的实现:Tair分布式锁这里就不再多说了
小结
分布式锁的常见实现方式,更多的通过redis和tair比较多一些。当然其使用的过程中存在的问题还有好多我们没有提及到,比如redis集群模式下,master down之后,锁如果还没来得及同步到从,那这个时候也会导致业务出现问题。 这里也是想说明下,具体使用方式还是需要根据不同的业务的需求进行考量,毕竟我们使用这个是基于业务,需要保证业务的稳定运行。