基于Redis、Zookeeper、Etcd的分布式锁实现方案总结

什么是锁,为什么需要锁我们不多说。一张图足矣。

我们拿锁来对限制有限的资源,进行分配配调度。我们可以总结一下一把锁最基本的特点:

互斥,当有人拿到了这把锁,可以对资源进行上锁。阻止其他人获取锁。 安全,在使用完成后这把锁必须被释放掉,让出共享资源

单机锁

最常见的有两种锁,一种是Synchronized锁,一种是CAS锁。

java 复制代码
private static final Object lock = new Object();
Synchronized(lock){
	//一系列操作
}
java 复制代码
private static final ReentrantLock casLock = new ReentrantLock();
try {
	casLock.lock();
	//一系列操作
} finally {
	casLock.unlock();
}

这两种锁依赖于JVM,因此在单体项目下是可以使用的。因为只有一个JVM。

分布式锁

而到了分布式环境下,每一个服务都有自己的JVM,对于共享资源的上锁便不能单独的依赖自己的JVM,而是需要去依赖一个大家都知道的第三方服务。那么实现的思路可以是继续依赖某一个服务,利用他来实现一个互斥的效果。不过,目前已经存在很多成熟的中间件,例如Redis、Zookeeper、Etcd分布式存储等等。

基于Redis实现的分布式锁

利用Redis中间件简单实现锁的功能。主要利用的就是下面这几条命令。 setnx:set if not exits

go 复制代码
获取锁
```redis
setnx key value

释放锁

redis 复制代码
del key

分布式死锁问题

如果一个服务线程拿到了分布式锁,但是这个服务线程宕机了导致这把锁永远无法释放,就造成了死锁。

新增锁超时释放

设置超时时间

redis 复制代码
expire key seconds
建立锁与设置超时时间不是原子的

在Redis 2.6.12版本之前,需要使用lua脚本来确保一组命令执行的原子性。在之后,Redis新增了这个功能,解决了上述问题。

如此以来,该命令则变为如下:

redis 复制代码
set lockKey value ex 5 nx

ex:过期时间,单位秒(s) 与其对应的则是px单位毫秒(ms) nx:not exit,不存在则创建

锁超时带来的问题

假设当前分布式锁的超时时间为30s

  • 服务一拿到锁后,执行业务时间较长,为35s,锁超时被释放掉了
  • 服务二成功拿到了这把锁,然后去执行逻辑。
  • 服务一执行完毕后,将服务二建立的锁给释放掉了
如何避免误删锁

在建立锁的时候,需要保存该锁持有者的唯一标识,在释放的时候,就可以先行判断该锁是否是自己的,才进行释放。如此便不会造成误删。

redis 复制代码
set key thread:uuid

我们可以看到此时的释放操作有两条命令。因此我们需要使用lua脚本来确保原子性。

lua 复制代码
if redis.call("get",KEY[1]) == ARGB[1] then
   redis.call("del",KEY[1])
end

简单Redis实现分布式锁

由上述几点总结一下设计一个简单的Redis分布式锁

加锁

redis 复制代码
set lockKey thread:uuid ex 30 nx

释放锁(两步)

redis 复制代码
if get(lockKey) == thread:uuid :
	del(lockKey)

RedLock

自己设计一个分布式锁,有以下的缺点

  • 锁存放在Redis单节点中,存在单点故障问题
  • 必须自己严格控制业务执行时间,一旦超时这个业务操作就需要回滚否则会有并发问题

在Redssion中,Redis之父设计了红锁。它主要是利用了两点去解决上述两个问题。

  • 在分片集群中,通过向集群中的半数节点进行加锁,只有半数节点都加锁成功,此时的锁才算拿到。
  • 在Redission中,有一个看门狗机制(Watch Dog),会不断的去给锁续期,直到该业务完成或者是该服务宕机,确保这把锁一定被释放。

上述仅仅简单介绍了RedLock的解决方式。

ZooKeeper的分布式锁实现方案

ZooKeeper的数据存储结构

Zookeeper的使用,我们需要了解它的数据模型。

  • Zookeeper其采用的存储方式是树形结构,就像我们电脑中的文件夹一样,一层一层的。
  • 在Zookeeper中,所有的节点被称为znode,而znode有临时节点永久节点顺序节点三种。

注意:Zookeeper并不能进行相对路径查找,而只能对绝对路径进行查找。 原因是:

ZooKeeper 大多是应用场景是定位数据模型上的节点,并在相关节点上进行操作。像这种查找与给定值相等的记录问题最适合用散列来解决。因此 ZooKeeper 在底层实现的时候,使用了一个 hashtable,即 hashtableConcurrentHashMap nodes ,用节点的完整路径来作为 key 存储节点数据。这样就大大提高了 ZooKeeper 的性能。

Zookeeper的Watch机制

上面列举了Zookeeper客户端在不同的状态下所能监控的事件,例如在客户端节点被新增、删除、修改时,会触发对应的事件。

Zookeeper的分布式锁简单实现

我们从Redis中的方案中吸取经验,可以想想在Zookeeper中怎么实现一个分布式锁。如何保证分布式锁一定被释放?

Zookeeper中采用的是临时节点 + 顺序节点实现的分布式锁。

临时节点:当创建该节点的客户端挂掉之后,该节点会被自动删除。利用这个特性,我们可以确保不会出现锁无法被释放的问题。 顺序节点:确保让其顺序获得锁

获取锁

先在Zookeeper中创建一个持久节点/locks

等待客户端过来抢夺锁,znode节点会确保客户端创建的节点是按照顺序的。当创建了节点之后,会判断自己是不是当前最小的顺序。如果是则获取锁成功,如果不是则获取锁失败。

如果获取锁失败,则说明有其他的客户端已经成功获取锁。客户端 并不会不停地循环去尝试加锁,而是在前一个节点比如/locks/lock0上注册一个事件监听器。这个监听器的作用是当前一个节点释放锁之后通知客户端(避免无效自旋),这样客户端就加锁成功了。

释放锁

当客户端使用完锁之后,将会对创建的节点进行删除操作。这时候,我们可以利用Watch机制,来监听上一个节点是否被删除,如果删除了,说明上一个节点已经释放掉锁了。有没有觉得这一幕很熟悉(像是公平锁的实现机制以及Java中的wait与notify机制)。

为什么监听上一个节点是否删除

主要还是为了性能问题。倘若给每一个未获取到锁的客户端都注册一次监听,会影响性能的。

成熟的方案

实际项目中,推荐使用 Curator 来实现 ZooKeeper 分布式锁。Curator 是 Netflix 公司开源的一套 ZooKeeper Java 客户端框架,相比于 ZooKeeper 自带的客户端 zookeeper 来说,Curator 的封装更加完善,各种 API 都可以比较方便地使用。Curator主要实现了下面四种锁:

  • InterProcessMutex:分布式可重入排它锁
  • InterProcessSemaphoreMutex:分布式不可重入排它锁
  • InterProcessReadWriteLock:分布式读写锁
  • InterProcessMultiLock:将多个锁作为单个实体管理的容器,获取锁的时候获取所有锁,释放锁也会释放所有锁资源(忽略释放失败的锁)。
java 复制代码
CuratorFramework client = ZKUtils.getClient();
client.start();
// 分布式可重入排它锁
InterProcessLock lock1 = new InterProcessMutex(client, lockPath1);
// 分布式不可重入排它锁
InterProcessLock lock2 = new InterProcessSemaphoreMutex(client, lockPath2);
// 将多个锁作为一个整体
InterProcessMultiLock lock = new InterProcessMultiLock(Arrays.asList(lock1, lock2));

if (!lock.acquire(10, TimeUnit.SECONDS)) {
   throw new IllegalStateException("不能获取多锁");
}
System.out.println("已获取多锁");
System.out.println("是否有第一个锁: " + lock1.isAcquiredInThisProcess());
System.out.println("是否有第二个锁: " + lock2.isAcquiredInThisProcess());
try {
    // 资源操作
    resource.use();
} finally {
    System.out.println("释放多个锁");
    lock.release();
}
System.out.println("是否有第一个锁: " + lock1.isAcquiredInThisProcess());
System.out.println("是否有第二个锁: " + lock2.isAcquiredInThisProcess());
client.close();

etcd的分布式锁实现方案

什么是etcd

大型分布式存储,基于Raft共识算法、强一致性。

etcd的数据存储结构

在v2版本时,其采用的类似于Zookeeper一样的树形结构,但是在v3版本,优化了一些结构。v3版本采用的是前缀模式,可以某一个前缀获取一批数据加快查询速度。

etcd的租约机制

在etcd中,存在着租约机制。类似于你租房子与房东约定的租的时间。到期后,会自动删除数据。有了租约机制,我们可以用它来确保不会发生类似于拿到锁的服务宕机后造成死锁的问题。

etcd的监听机制

etcd也和Zookeeper类似的有一套监听机制。利用监听机制,我们同样可以做到监听前一个节点是否删除,来实现锁释放唤醒其他下一个客户端来争抢锁。

etcd节点的顺序更新

etcd 节点都可接收读写请求,但变更的请求会在集群内转给 leader 执行,来所有客户端的变更请求将按照 leader 接收的顺序被处理,采用 revision 机制来管理请求的顺序,使用一个全局单调递增计数器,每当有数据变更,revision 就会加一(具有全局唯一性),而每个 revision 都关联对应修改的数据,所以我们可以通过 revision 的大小推断数据变更的顺序,利用这个特性可以实现高级协调服务。

ectd的实现方案

  • 租约机制:用于支撑异常情况下的锁自动释放能力。
  • 前缀和 Revision 机制:用于支撑公平获取锁和排队等待的能力。
  • 监听机制:用于支撑抢锁能力,类似于zk的后一个节点监听前一个节点的能力。

总结

从上述的几个方案,我们可以总结出一个分布式锁应当具备的几点

  • 互斥性:在多个客户端争抢锁时,确保只有一个客户端可以拿到锁。
  • 可用性:加锁服务不能单纯的放在一个服务中,避免出现单点故障。
  • 安全性:需要有一套机制来确保一个客户端拿到锁后宕机导致死锁问题的发生。

参考资料:

《分布式锁中-偶遇 etcd 后就想抛弃 Redis ?》

《Java Guide》

相关推荐
_.Switch3 小时前
Python机器学习:自然语言处理、计算机视觉与强化学习
python·机器学习·计算机视觉·自然语言处理·架构·tensorflow·scikit-learn
feng_xiaoshi8 小时前
【云原生】云原生架构的反模式
云原生·架构
架构师吕师傅9 小时前
性能优化实战(三):缓存为王-面向缓存的设计
后端·微服务·架构
团儿.11 小时前
解锁MySQL高可用新境界:深入探索MHA架构的无限魅力与实战部署
数据库·mysql·架构·mysql之mha架构
艾伦~耶格尔20 小时前
Spring Boot 三层架构开发模式入门
java·spring boot·后端·架构·三层架构
_.Switch1 天前
Python机器学习框架介绍和入门案例:Scikit-learn、TensorFlow与Keras、PyTorch
python·机器学习·架构·tensorflow·keras·scikit-learn
神一样的老师1 天前
构建5G-TSN测试平台:架构与挑战
5g·架构
huaqianzkh1 天前
付费计量系统通用功能(13)
网络·安全·架构
2402_857583491 天前
新闻推荐系统:Spring Boot的架构优势
数据库·spring boot·架构
bylander1 天前
【AI学习】Mamba学习(一):总体架构
人工智能·深度学习·学习·架构