一、背景
在分布式架构下,CAP不能同时满足,一般互联网应用都是通过最终一致甚至弱一致(不保证系统中的数据的一致性)这种方式来牺牲一致性,换取高可用性:完全不访问:(提高系统的SLA),访问很卡(提高qps)。
导致一致性问题可两种原因:
- 一个请求需要更新两份数据且存在不同系统中,A系统数据更新成功,B系统数据更新失败,导致AB系统不一致。这种需要通过分布式事务来解决。
- 一份共享数据,A服务器(进程)获取到进行修改,B服务器(进程)获取到进行修改,最终修改结果和预想的不一样,这种分布式锁来解决。
二、概念
很显然,分布式锁和单机锁(synchronized)的概念很一致,前者是针对线程并发,后者是针对进程并发。
关于单机锁,有以下设计要点,
- 控制并发,同一时间只有一个线程能获取执行权力。
- 可重入
- 可选择阻塞或非阻塞。
- 可自动释放(synchronized和Lock都有各自的自动释放机制)
此外除了需要继承单机锁的所有要素之外,分布式锁独有的设计
- 高可用(因为单机锁用的是操作系统底层的监视器能力或CAS能力,只要单机不宕机就没有高可用的问题)
- 高性能(同上逻辑,没有性能问题)
分布式锁存在的问题?
在获取分布式锁之后,调用解锁锁之前的这段时间,如果服务器挂了,一般有如下机制来保证不会死锁:
- redis提供了超时时间机制
- zk提供了断连删除节点机制
考虑这种情况,如果通过redis加锁后,执行了某种耗时的操作(FullGC、IO、复杂计算),导致执行时间大于设置的超时时间,这时候锁会自动释放,所以另外一个进程获取到锁,此时,原线程刚好执行完执行删锁操作,那么就会导致删除掉后面一个进程的锁。怎么解决:
- 设置评估锁范围内的操作耗时情况,设置适当的超时时间。
- 当前线程在获取锁的时候通过setNx指令同时设置value,解锁时判断value是否是自己设定的,如果是则继续,如果不是则认为锁已经释放切被其他线程占用,无需操作。
- 使用zk分布式锁,因为zk方式只要没有主动释放锁或服务器没有挂掉,那么就不会出现这种情况。
三、方案
分布式锁设计的本质是,要有一个中间件可以存储一个共享标记,可以被不同进程获取和设置,有如下方式:
- mysql
- redis(memcache)
- zk | | mysql | redis | zookeeper | | —- | —- | —- | —- | | 并发控制 | √ | √ | √ | | 可重入 | √ | √ | √ | | 非阻塞 | √ | √ | √ | | 可自动释放 | × | √ | √ | | 高可用 | √ | √ | √ | | 高性能 | × | √ | √ |
3.1 redis实现
public interface DistributeLock{
/**
* 阻塞锁
**/
void lock(String uniqueKey);
/**
* 非阻塞锁
**/
boolean tryLock(String uniqueKey, int expireTime);
/**
* 阻塞锁
**/
void unlock(String uniqueKey);
}
public class RedisDistributeLock{
private RedisTemplate redisTemplate;
@Override
public void lock(String uniqueKey) {
int cnt = 0;
while(cnt++ < 100){
if(redisTemplate.setNX(uniqueKey, "1") == 1){
break;
}
Thread.sleep(100 * cnt);
}
}
@Override
public boolean tryLock(String uniqueKey, int expireTime) {
String expireDate = DateUtil.add(now(), expireTime);
while(now().before(expireDate)){
if(redisTemplate.setNX(uniqueKey, "1") == 1){
return true;
}
}
return false
}
@Override
public void unlock(String uniqueKey) {
redisTemplate.del(uniqueKey);
}
}
3.2 zk实现
有两种实现方式:
- zk原理1:节点下的子节点命名有唯一性,比如创建/lock节点后,其他节点无法创建同路径节点。
- 其他节点在无法创建之后,会添加一个监听器在该节点上,节点删除之后,客户端会收到通知,会再次竞争锁。如果有大量客户端处于这种监听状态,一旦锁释放,那么就会导致惊群现象,是一个计算机普遍的概念,不止zk,包括linux、nginx等层面也有惊群现象,惊群现象带来的问题就是会瞬间耗费大量cpu资源、影响系统稳定性。
- zk原理2:临时有序类型的节点,临时可以会话丢失自动删除节点,有序可以用于判断是否最小,从而判断是否获取到锁,所以总结一下,包括redis.setNX方式,是否获取到锁的方式都判断是否获取某个唯一性资源。
- 线程1创建/lock/node_001,线程2创建/lock/node_002,并给前一个节点添加监听器(防止惊群)…
- 每个线程在创建完node之后,会判断自己是否最小节点(zk并没有提供获取最小节点的能力,但是提供了获取父节点下所有子节点的接口,那么就可以对所有子节点进行排序,其实就是节点路径(string)的字典序排序,排序后判断自己是否是首节点即可),如果是就拿到锁,如果不是就等待通知。