分布式的CAP理论告诉我们:
分布式锁是控制分布式系统之间同步访问共享资源的一种方式。如果一个分布式系统最多只能同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance)这三项中的两项。
CA without P:如果不要求P(不允许分区),则C(强一致性)和A(可用性)是可以保证的。但其实分区不是你想不想的问题,而是始终会存在,因此CA的系统更多的是允许分区后各子系统依然保持CA。
CP without A:如果不要求A(可用),相当于每个请求都需要在Server之间强一致,而P(分区)会导致同步时间无限延长,如此CP也是可以保证的。很多传统的数据库分布式事务都属于这种模式。
AP wihtout C:要高可用并允许分区,则需放弃一致性。一旦分区发生,节点之间可能会失去联系,为了高可用,每个节点只能用本地数据提供服务,而这样会导致全局数据的不一致性。现在众多的NoSQL都属于此类。目前很多大型网站及应用都是分布式部署的,分布式场景中的数据一致性问题一直是一个比较重要的话题。基于CAP理论,很多系统在设计之初就要对这三者做出取舍。
在互联网领域的绝大多数的场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证最终一致性;对于金融的场景,C必须保证。网络发生故障宁可停止服务,这是保证CP,舍弃A。
孰优孰略,没有定论,只能根据场景定夺,适合的才是最好的。
什么是分布式锁?
不同的系统或是同一个系统的不同主机之间共享了一个或一组资源,那么访问这些资源的时候,往往需要通过一些互斥的手段来防止彼此之间的干扰,以保持一致性。
与单机模式下的锁不同,分布式锁不仅需要保证进程可见,还需要考虑进程与锁之间的网络问题。
分布式锁需要标记在公共内存,如Redis、Memcache、Tair。至于利用数据库、文件等做锁与单机的实现是一样的,只要保证标记能互斥就行,但通常来说主流的实现方式偏向存放于KV集群中。
分布式锁需要满足的条件
-
互斥性:同一时刻只能有一个线程持有锁
-
可重入性:同一节点上的同一个线程如果获取了锁之后能够再次获取锁
-
锁超时:支持锁超时,防止死锁
-
高性能和高可用:加锁和解锁需要高效,同时也需要保证高可用,防止分布式锁失效
-
具备阻塞和非阻塞性:能够及时从阻塞状态中被唤醒
Redis实现分布式锁—通过setExNx(互斥锁)实现
1.利用setNXEX命令
PX milliseconds:设定过期时间,单位为毫秒
public boolean lock() {
//设置超时时间
long waitMillis = timeoutMillis;
value = UUID.randomUUID().toString();
while (waitMillis >= 0) {
long startNanoTime = System.nanoTime();
//尝试持有redis分布式锁
//redisCommands:Redis高性能客户端lettuce
String lockResult = redisCommands.set(lockKey, value, SetArgs.Builder.nx().px(expireMillis));
locked = OK.equals(lockResult);
if (locked || waitMillis == 0) {
//locked为true,返回加锁成功;waitMillis == 0超时,返回失败;
return locked;
}
int sleepMillis = new Random().nextInt(100);
sleep(sleepMillis);
//随机休眠0-100ms重试
long escapedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanoTime);
waitMillis = waitMillis - escapedMillis;
//锁自旋
}
//超时,返回失败
return false;
}
value必须要具有唯一性,我们可以用UUID来做,设置随机字符串保证唯一性,至于为什么要保证唯一性?假如value不是随机字符串,而是一个固定值,那么就可能存在下面的问题:
- 客户端1获取锁成功
- 客户端1在某个操作上阻塞了太长时间
- 设置的key过期了,锁自动释放了
- 客户端2获取到了对应同一个资源的锁
- 客户端1从阻塞中恢复过来,因为value值一样,所以执行释放锁操作时就会释放掉客户端2持有的锁,这样就会造成问题
所以对应来说,在释放锁时,我们需要对value进行uuid的验证
2.释放锁的实现
释放锁时需要验证value值,我们在获取锁的时候需要设置的UUID,不能直接用del key这种粗暴的方式,因为直接del key任何客户端都可以进行解锁了,所以解锁时,我们需要判断锁是否是自己的,基于value值来判断。
public void unlock() {
if (!locked) {
return;
}
//释放锁,调用lua脚本,任务执行完毕,释放redis中的锁
Object result = redisCommands.eval(UNLOCK_SCRIPT, ScriptOutputType.INTEGER, new String[]{lockKey}, value);
if (CastUtil.castInt(result) < 0) {
log.info("redis lock {} unlock failure, result is {}", lockKey, result);
}
locked = false;
}
Lua脚本代码
local value = redis.call('get',KEYS[1])
if value then
if value == ARGV[1]
then
redis.call('del',KEYS[1])
return 1
else
return -1
end
else
return -2
end
基于 REDISSON 做分布式锁
首先,为什么要使用Redisson 做分布式锁做分布式锁算法(RedLock)?实现Redis分布式锁的方法在上一章节已经描述,就是在Redis中创建一个key,这个key有一个失效时间(TTL),以保证锁最终会被自动释放掉。当客户端释放资源(解锁)的时候,会删除掉这个key。
上一小节的方案在这种场景(主从结构)中存在明显的问题:
- 客户端A从master获取到锁
- 在master将锁同步到slave之前,master宕掉了。
- slave节点被晋级为master节点
- 客户端B从新的master获取到锁
- 这个锁对应的资源之前已经被客户端A已经获取到了。安全失效!
解决方案
Redis中针对此种情况,引入了红锁的概念。红锁采用主节点过半机制,即获取锁或者释放锁成功的标志为:在过半的节点上操作成功。
原理
假设有5个完全独立的redis主服务器
- 获取当前时间戳。
- client尝试按照顺序使用相同的key,value获取所有redis服务的锁,在获取锁的过程中的获取时间比锁过期时间短很多,这是为了不要过长时间等待已经关闭的redis服务。并且试着获取下一个redis实例。
- client通过获取所有能获取的锁后的时间减去第一步的时间,这个时间差要小于TTL时间并且至少有3个redis实例成功获取锁,才算真正的获取锁成功。
- 如果成功获取锁,则锁的真正有效时间是 TTL减去第三步的时间差 的时间;比如:TTL 是5s,获取所有锁用了2s,则真正锁有效时间为3s(其实应该再减去时钟漂移);
- 如果客户端由于某些原因获取锁失败,便会开始解锁所有redis实例;因为可能已经获取了小于3个锁,必须释放,否则影响其他client获取锁。
基于Redis的Redisson分布式实现可重入锁(Reentrant Lock)
基于Redis的Redisson分布式可重入锁RLock Java对象实现了java.util.concurrent.locks.Lock接口。同时还提供了异步(Async)、反射式(Reactive)和RxJava2标准的接口。
RLock lock = redisson.getLock("anyLock");
// 最常见的使用方法
lock.lock();
大家都知道,如果负责储存这个分布式锁的Redisson节点宕机以后,而且这个锁正好处于锁住的状态时,这个锁会出现锁死的状态。为了避免这种情况的发生,Redisson内部提供了一个监控锁的看门狗,
它的作用是在Redisson实例被关闭前,不断的延长锁的有效期。默认情况下,看门狗的检查锁的超时时间是30秒钟,也可以通过修改Config.lockWatchdogTimeout来另行指定。
另外Redisson还通过加锁的方法提供了leaseTime的参数来指定加锁的时间。超过这个时间后锁便自动解开了。
// 加锁以后10秒钟自动解锁
// 无需调用unlock方法手动解锁
lock.lock(10, TimeUnit.SECONDS);
// 尝试加锁,最多等待100秒,上锁以后10秒自动解锁
boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);
if (res) {
try {
...
} finally {
lock.unlock();
}
}
Redisson同时还为分布式锁提供了异步执行的相关方法:
RLock lock = redisson.getLock("anyLock");
lock.lockAsync();
lock.lockAsync(10, TimeUnit.SECONDS);
Future<Boolean> res = lock.tryLockAsync(100, 10, TimeUnit.SECONDS);
RLock对象完全符合Java的Lock规范。也就是说只有拥有锁的进程才能解锁,其他进程解锁则会抛出IllegalMonitorStateException错误。但是如果遇到需要其他进程也能解锁的情况,请使用分布式信号量Semaphore对象.
引用自-github.com/redisson/redisson/wiki/8.-分布式锁和同步器
缺点
崩溃策略:
如果Redis的主服务器重启或者宕掉了,导致半数选举的时候,发现服务器没有超过半数,那么本来持有的锁就会释放掉。因此,Redission仍然不是一个严格的公平锁。
基于 ZooKeeper 做分布式锁
每个客户端对某个方法加锁时,在 Zookeeper 上与该方法对应的指定节点的目录下,生成一个唯一的临时有序节点。
判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。 当释放锁的时候,只需将这个临时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。
排它锁
排他锁,又称写锁或独占锁。如果事务T1对数据对象O1加上了排他锁,那么在整个加锁期间,只允许事务T1对O1进行读取或更新操作,其他任务事务都不能对这个数据对象进行任何操作,直到T1释放了排他锁。
排他锁核心是保证当前有且仅有一个事务获得锁,并且锁释放之后,所有正在等待获取锁的事务都能够被通知到。
Zookeeper 的强一致性特性,能够很好地保证在分布式高并发情况下节点的创建一定能够保证全局唯一性,即Zookeeper将会保证客户端无法重复创建一个已经存在的数据节点。可以利用Zookeeper这个特性,实现排他锁。
定义锁
通过Zookeeper上的数据节点来表示一个锁
获取锁
客户端通过调用 create 方法创建表示锁的临时节点,可以认为创建成功的客户端获得了锁,同时可以让没有获得锁的节点在该节点上注册Watcher监听,以便实时监听到lock节点的变更情况
释放锁
以下两种情况都可以让锁释放
- 当前获得锁的客户端发生宕机或异常,那么Zookeeper上这个临时节点就会被删除
- 正常执行完业务逻辑,客户端主动删除自己创建的临时节点
共享锁
共享锁,又称读锁。如果事务T1对数据对象O1加上了共享锁,那么当前事务只能对O1进行读取操作,其他事务也只能对这个数据对象加共享锁,直到该数据对象上的所有共享锁都被释放。
共享锁与排他锁的区别在于,加了排他锁之后,数据对象只对当前事务可见,而加了共享锁之后,数据对象对所有事务都可见。
定义锁
通过Zookeeper上的数据节点来表示一个锁,是一个类似于 /shared_lock/[hostname]-请求类型-序号 的临时顺序节点
获取锁
客户端通过调用 create 方法创建表示锁的临时顺序节点,如果是读请求,则创建 /shared_lock/[hostname]-R-序号 节点,如果是写请求则创建 /shared_lock/[hostname]-W-序号节点
判断读写顺序
大概分为4个步骤:
- 创建完节点后,获取 /shared_lock 节点下的所有子节点,并对该节点注册子节点变更的Watcher监听
- 确定自己的节点序号在所有子节点中的顺序
- 对于读请求:如果没有比自己序号更小的子节点,或者比自己序号小的子节点都是读请求,那么表明自己已经成功获取到了共享锁,同时开始执行读取逻辑 ;如果有比自己序号小的子节点有写请求,那么等待。对于写请求,如果自己不是序号最小的节点,那么等待
- 接收到Watcher通知后,重复步骤1
释放锁
与排他锁逻辑一致
缺点:羊群效应
在实现共享锁的 “判断读写顺序” 的第1个步骤是:创建完节点后,获取 /lockpath 节点下的所有子节点,并对该节点注册子节点变更的Watcher监听。这样的话,任何一次客户端移除共享锁之后,
Zookeeper将会发送子节点变更的Watcher通知给所有机器,系统中将有大量的 “Watcher通知” 和 “子节点列表获取” 这个操作重复执行,然后所有节点再判断自己是否是序号最小的节点(写请求)或者判断比自己序号小的子节点是否都是读请求(读请求),
从而继续等待下一次通知。然而,这些重复操作很多都是 “无用的”,实际上每个锁竞争者只需要关注序号比自己小的那个节点是否存在即可。
当集群规模比较大时,这些 “无用的” 操作不仅会对Zookeeper造成巨大的性能影响和网络冲击,更为严重的是,如果同一时间有多个客户端释放了共享锁,Zookeeper服务器就会在短时间内向其余客户端发送大量的事件通知–这就是所谓的 “羊群效应”。
改进分布锁的实现
1.客户端调用 create 方法创建一个类似于 /lockpath/[hostname]-请求类型-序号 的临时顺序节点。
2.客户端调用 getChildren 方法获取所有已经创建的子节点列表(这里不注册任何Watcher)。
3.如果无法获取任何共享锁,那么调用 exist 来对比自己小的那个节点注册Watcher
读请求:向比自己序号小的最后一个写请求节点注册Watcher监听
写请求:向比自己序号小的最后一个节点注册Watcher监听
4.等待Watcher监听,继续进入步骤2
Zookeeper羊群效应改进前后Watcher监听图:
改进后的分布式锁锁流程图:
引用-《从Paxos到Zookeeper 分布式一致性原理与实践》6.1.7分布式锁
总结
ZK是保证强一致性的分布式锁,但同时他也牺牲了一定的可用性,适用于对数据一致性要求较高的业务。
Redisson分布式锁提高了高可用性,但也牺牲了部分一致性,适用于对一致性要求不高的业务。
此外ETCD也是目前一个可以替代zookeeper的良好方案,使用的算法是Raft算法,感兴趣的可以了解一下
孰优孰略,没有定论,只能根据场景定夺,适合的才是最好的。