分布式锁的一点理解

分布式锁的一点理解

 

什么是锁

  • 在单进程的系统中,当存在多个线程可以同时改变某个变量(可变共享变量)时,就需要对变量或代码块做同步,使其在修改这种变量时能够线性执行消除并发修改变量。

  • 而同步的本质是通过锁来实现的。为了实现多个线程在一个时刻同一个代码块只能有一个线程可执行,那么需要在某个地方做个标记,这个标记必须每个线程都能看到,当标记不存在时可以设置该标记,其余后续线程发现已经有标记了则等待拥有标记的线程结束同步代码块取消标记后再去尝试设置标记。这个标记可以理解为锁。

  • 不同地方实现锁的方式也不一样,只要能满足所有线程都能看得到标记即可。如java中synchronize是在对象头设置标记,Lock接口的实现类基本上都只是某一个volitile修饰的int型变量其保证灭个线程都能拥有对该int的可见性和原子修改,linux内核中也是利用互斥量或信号量等内存数据做标记。
  • 除了利用内存数据做锁其实任何互斥的都能做锁(只考虑互斥情况),如流水表中流水号与时间结合做幂等校验可以看作是一个不会释放的锁,或者使用某个文件是否存在作为锁等。只需要满足在对标记进行修改能保证原子性和内存可见性即可。


分布式

分布式情况

此处主要指集群模式下,多个相同服务同时开启.

  • 分布式与单机情况下最大的不同在于其不是多线程而是多进程。
  • 多线程由于可以共享堆内存,因此可以简单的采取内存作为标记存储位置。而进程之间甚至可能都不在同一台物理机上,因此需要将标记存储在一个所有进程都能看到的地方。

分布式锁

  • 当在分布式模型下,数据只有一份(或有限制),此时需要利用锁的技术控制某一时刻修改数据的进程数。
  • 与单机模式下的锁不仅需要保证进程可见,还需要考虑进程与锁之间的网络问题。(我觉得分布式情况下之所以问题变得复杂,主要就是需要考虑到网络的延时和不可靠。。。一个大坑)

  • 分布式锁还是可以将标记存在内存,只是该内存不是某个进程分配的内存而是公共内存如Redis、Memcache。至于利用数据库、文件等做锁与单机的实现是一样的,只要保证标记能互斥就行。


单机Redis锁

基本锁

  • 原理:利用Redis的setnx如果不存在某个key则设置值,设置成功则表示取得锁成功。
  • 缺点:如果获取锁后的进程,在还没执行完的时候挂调了,则锁永远不会释放。

改进型

  • 改进:在基本型是锁上的setnx后设置expire,保证即使获取锁的进程不主动释放锁,过一段时间后也能自动释放。
  • 缺点:
    1. setnx与expire不是一个原子操作,可能执行完setnx该进程就挂了。
    2. 当锁过期后,该进程还没执行完,可能造成同时多个进程取得锁。(貌似这个问题目前还没有很优雅的解决方案)

再改进 redission 

  • 改进:利用Lua脚本,将setnx与expire变成一个原子操作,可解决一部分问题。

 

 总结

一般情况下直接用setnx加expire就够了,但从安全性的角度看还是存在一下几个问题:

  1. 单点问题。单机Redis只在单机上,如果单机down了,那么所有需要用分布式锁的地方均获取不到锁,全部阻塞。需要做好降级的处理。
  2. 可能出现多进程同时拥有锁。

Redlock

Redlock 是一种分布式锁算法,旨在解决 Redis 单节点故障时的数据一致性问题。它是由 Redis 的作者 Antirez 在 Redis 官方文档中提出的。

Redlock 算法的原理如下:

1. **获取锁**:当一个客户端想要获取分布式锁时,它会尝试在多个 Redis 节点上执行相同的操作。这些 Redis 节点可以是不同的物理节点或 Redis Sentinel(哨兵)监控的主从节点。

2. **竞争锁**:客户端会向这些 Redis 节点发送 SETNX(SET if Not eXists)命令,尝试在指定的键上设置一个值。只有在大多数(大于一半)的 Redis 节点上成功设置了值,客户端才认为自己成功获取到锁。

3. **锁的有效性**:获取锁后,客户端会计算一个锁的有效时间,并在 Redis 节点上设置该锁的过期时间。客户端会定期续约锁的过期时间,以确保在持有锁的期间不会过期。

4. **释放锁**:当客户端执行完任务或不再需要锁时,它会向所有的 Redis 节点发送 DEL(删除)命令,释放锁。

Redlock 算法的核心思想是通过在多个 Redis 节点上进行竞争,以确保在 Redis 节点故障的情况下仍然能够保持数据的一致性。通过要求大多数节点都成功设置了锁,可以容忍一部分节点的故障或网络延迟。

然而,需要注意的是,Redlock 算法并不是完美的,它仍然存在一些问题和限制。例如,在网络分区(网络拆分)的情况下,可能会导致不同的客户端同时获取到锁,从而破坏了互斥性。因此,在使用 Redlock 算法时,需要仔细考虑应用的具体情况和要求,并进行适当的调优和测试。


Zookeeper锁

zookeeper锁相关基础知识

  • zk一般由多个节点构成(单数),采用zab一致性协议。因此可以将zk看成一个单点结构,对其修改数据其内部自动将所有节点数据进行修改而后才提供查询服务。
  • zk的数据以目录树的形式,每个目录称为 znode, znode中可存储数据(一般不超过1M),还可以在其中增加子节点。
  • 子节点有三种类型。序列化节点,每在该节点下增加一个节点自动给该节点的名称上自增。临时节点,一旦创建这个 znode 的客户端与服务器失去联系,这个 znode 也将自动删除。最后就是普通节点。
  • Watch机制,client可以监控每个节点的变化,当产生变化会给client产生一个事件。

zk基本锁

  • 原理:利用临时节点与watch机制。每个锁占用一个普通节点/lock,当需要获取锁时在/lock下创建一个临时节点,创建成功则表示获取锁成功,失败则watch/lock节点,有删除操作后再去争锁。临时节点好处在于当进程挂掉后能自动上锁的节点自动删除即取消锁。

  • 缺点:所有取锁失败的进程都监听父节点,很容易发生羊群效应,即当释放锁后所有等待进程一起来创建节点,并发量很大。

zk锁 优化

  • 原理:上锁改为创建临时有序节点,每个上锁的节点均能创建节点成功,知识其序号不同。只有序号最小的可以拥有锁,当需要不是最小的则watch序号排在前面的一个节点(公平锁)。

  • 步骤:

1. 在/lock节点下创建一个有序临时节点(EPHEMERAL_SEQUENTIAL)。
2. 判断创建的节点序号是否最小,如果是最小则获取锁成功。不是则取锁失败,然后watch序号比本身小的前一个节点。
3. 当取锁失败,设置watch后则等待watch事件到来后,再次判断是否序号最小。
4. 取锁成功则执行代码,最后删除本身节点,释放了锁。

分布式锁总结

分布式锁存在的问题

  1. 均可能存在多进程拥有锁的情况。redis锁主要是expire时间与代码执行时间的问题,zk锁的问题在于zk是通过心跳监控进程存活状态,如果进程进行GC pause或者因为网络原因导致很长时间没与zk联系,则将导致zk认为进程已挂,而后锁自动释放,而此时进程并未挂任然在执行。
  2. Redlock锁的时间问题。由于redis的expire的实现是通过pexpireat,如果某个节点发生时钟跳跃,则该节点可能过早释放锁导致一系列问题。

解决方案

  1. 获取锁时提供一个fencing token(两种说法,一种说需要有序,一种说随机值就可以,我觉得随机值就可以),在进程获取锁后对数据进行操作时,数据所在的资源服务器需要去锁中查看当前token,如果token对的才执行,不对则放弃执行。
  2. 我觉得对于放弃执行的应该在我们的代码块中增加类似事物的rollback的操作。因此如果资源服务器拒绝了我们的操作则表明此时起码已经存在了另外一个进程拥有锁了,为了保证数据安全性不能继续执行,因此需要回滚到执行代码块之前而继续去竞争锁。
  3. 至于Redis锁的时间问题,Antirez说在运维层面是可以控制时钟跳跃的区间的,只要能控制跳跃区间与expire的比例就没问题,详细可看《基于Redis的分布式锁真的安全吗?》

 

 

基于Zookeeper实现分布式锁

基于zookeeper临时有序节点可以实现的分布式锁。

大致思想即为:每个客户端对某个方法加锁时,在zookeeper上的与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点。 判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。 当释放锁的时候,只需将这个瞬时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。

来看下Zookeeper能不能解决前面提到的问题。

  • 锁无法释放?使用Zookeeper可以有效的解决锁无法释放的问题,因为在创建锁的时候,客户端会在ZK中创建一个临时节点,一旦客户端获取到锁之后突然挂掉(Session连接断开),那么这个临时节点就会自动删除掉。其他客户端就可以再次获得锁。

  • 非阻塞锁?使用Zookeeper可以实现阻塞的锁,客户端可以通过在ZK中创建顺序节点,并且在节点上绑定监听器,一旦节点有变化,Zookeeper会通知客户端,客户端可以检查自己创建的节点是不是当前所有节点中序号最小的,如果是,那么自己就获取到锁,便可以执行业务逻辑了。

  • 不可重入?使用Zookeeper也可以有效的解决不可重入的问题,客户端在创建节点的时候,把当前客户端的主机信息和线程信息直接写入到节点中,下次想要获取锁的时候和当前最小的节点中的数据比对一下就可以了。如果和自己的信息一样,那么自己直接获取到锁,如果不一样就再创建一个临时的顺序节点,参与排队。

  • 单点问题?使用Zookeeper可以有效的解决单点问题,ZK是集群部署的,只要集群中有半数以上的机器存活,就可以对外提供服务。

可以直接使用zookeeper第三方库Curator客户端,这个客户端中封装了一个可重入的锁服务。

public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException {
    try {
        return interProcessMutex.acquire(timeout, unit);
    } catch (Exception e) {
        e.printStackTrace();
    }
    return true;
}
public boolean unlock() {
    try {
        interProcessMutex.release();
    } catch (Throwable e) {
        log.error(e.getMessage(), e);
    } finally {
        executorService.schedule(new Cleaner(client, path), delayTimeForClean, TimeUnit.MILLISECONDS);
    }
    return true;
}

Curator提供的InterProcessMutex是分布式锁的实现。acquire方法用户获取锁,release方法用于释放锁。

使用ZK实现的分布式锁好像完全符合了本文开头我们对一个分布式锁的所有期望。但是,其实并不是,Zookeeper实现的分布式锁其实存在一个缺点,那就是性能上可能并没有缓存服务那么高。因为每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。ZK中创建和删除节点只能通过Leader服务器来执行,然后将数据同不到所有的Follower机器上。

其实,使用Zookeeper也有可能带来并发问题,只是并不常见而已。考虑这样的情况,由于网络抖动,客户端可ZK集群的session连接断了,那么zk以为客户端挂了,就会删除临时节点,这时候其他客户端就可以获取到分布式锁了。就可能产生并发问题。这个问题不常见是因为zk有重试机制,一旦zk集群检测不到客户端的心跳,就会重试,Curator客户端支持多种重试策略。多次重试之后还不行的话才会删除临时节点。(所以,选择一个合适的重试策略也比较重要,要在锁的粒度和并发之间找一个平衡。)


总结

使用Zookeeper实现分布式锁的优点

有效的解决单点问题,不可重入问题,非阻塞问题以及锁无法释放的问题。实现起来较为简单。

使用Zookeeper实现分布式锁的缺点

性能上不如使用缓存实现分布式锁。 需要对ZK的原理有所了解。


三种方案的比较

上面几种方式,哪种方式都无法做到完美。就像CAP一样,在复杂性、可靠性、性能等方面无法同时满足,所以,根据不同的应用场景选择最适合自己的才是王道。

从理解的难易程度角度(从低到高)

数据库 > 缓存 > Zookeeper

从实现的复杂性角度(从低到高)

Zookeeper >= 缓存 > 数据库

从性能角度(从高到低)

缓存 > Zookeeper >= 数据库

从可靠性角度(从高到低)

Zookeeper > 缓存 > 数据库

posted @ 2018-01-04 16:27  daniel456  阅读(251)  评论(0编辑  收藏  举报