分布式锁

孤街醉人 提交于 2020-01-06 21:05:42

【推荐】2019 Java 开发者跳槽指南.pdf(吐血整理) >>>

1、概念

什么是分布式?

  • 分布式的 CAP 理论告诉我们:任何一个分布式系统都无法同时满足一 致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。
  • 目前很多大型网站及应用都是分布式部署的,分布式场景中的数据一 致性问题一直是一个比较重要的话题。基于 CAP理理论,很多系统在设计之初就要对这三者做出取舍。在互联⽹网领域的绝大多数的场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证最终一致 性,我们为了保证数据的最终一致性,需要很多的技术方案来支持,⽐如分布式事务、分布式锁等。

什么是锁?

  • 在单进程的系统中,当存在多个线程可以同时改变某个变量(可变共享变量)时,就需要对变量或代码块做同步,使其在修改这种变量时能够线性执行消除并发修改变量。
  • 而同步的本质是通过锁来实现的。为了实现多个线程在一个时刻同一 个代码块只能有一个线程可执⾏,那么需要在某个地⽅做个标记,这个标记必须每个线程都能看到,当标记不存在时可以设置该标记,其余后续线程发现已经有标记了则等待拥有标记的线程结束同步代码块取消标记后再去尝试设置标记。这个标记可以理理解为锁。
  • 不同地⽅实现锁的⽅式也不一样,只要能满足所有线程都能看得到标记即可。如 Java 中 synchronize 是在对象头设置标记,Lock 接口的实现类基本上都只是某一个 volitile 修饰的 int 型变量其保证每个线程都能拥有对该 int 的可见性和原子修改,linux 内核中也是利⽤互斥量或信号量等内存数据做标记。
  • 除了利用内存数据做锁其实任何互斥的都能做锁(只考虑互斥情况),如流水表中流水号与时间结合做幂等校验可以看作是一个不会释放的锁,或者使用某个文件是否存在作为锁等。只需要满⾜在对标记进⾏修改能保证原子性和内存可见性即可。

什么是分布式锁?

  • Java线程中的锁,基于Java的内存模型,每个线程有自⼰的内存空间,多线程锁是存在一个JVM之中的,如果操作的数据不在一个JVM 中,多线程中锁就失效了,这种情况下分布式锁就诞⽣了,即多个Java 实例、甚⾄不一定是Java程序、或多个系统需要操作同一个副本数据的时候,需要一个指挥交通的人指定操作的先后顺序,这就是分布式锁的概念。
  • 在传统的基于数据库的架构中,对于数据的抢占问题往往是通过数据库事务(ACID)来保证的。在分布式环境中,出于对性能以及一致性敏感度的要求,使得分布式锁成为了一种比较常见而高效的解决方案。

分布式锁使用目的和场景

  • 效率:使用分布式锁可以避免不同节点重复相同的工作,这些工作会浪费资源。比如用户付了钱之后有可能不同节点会发出多封短信。
  • 正确性:加分布式锁同样可以避免破坏正确性的发⽣,如果两个节点在同一条数据上面操作,比如多个节点机器器对同一个订单操作不同的流程有可能会导致该笔订单最后状态出现错误,造成损失。

分布式锁特点

  • 互斥性:和我们本地锁一样互斥性是最基本,但是分布式锁需要保证在不同节点的不同线程的互斥。
  • 可重入性:同一个节点上的同一个线程如果获取了锁之后那么也可以再次获取这个锁(避免死锁)。
  • 锁超时:和本地锁一样支持锁超时,防止死锁。
  • 高效,⾼可用:加锁和解锁需要高效,同时也需要保证高可⽤防⽌分布式锁失效,可以增加降级。
  • 支持阻塞和非阻塞:和ReentrantLock一样支持lock和trylock以及 tryLock(long timeOut)。
  • ⽀持公平锁和⾮公平锁(可选):公平锁的意思是按照请求加锁的顺序获得锁,非公平锁就相反是无序的。这个一般来说实现的比较少。

2、常见的分布式锁

我们了解了一些特点之后,我们一般实现分布式锁有以下几个方式:

  • MySql
  • zookeeper
  • Redis
  • 自研分布式锁:如谷歌的Chubby。

Mysql分布式锁

  • 创建一张 distribution_lock表
  • mysql悲观锁的实现方式: 事务+ for update的⽅式
  • mysql乐观锁实现⽅式:我们可以对我们的表加⼀个版本号字段,那么我们查询出来一个版本号之后,update或者delete的时候需要依赖我们查询出来的版本号,判断当前数据库和查询出来的版本号是否相等,如果相等那么就可以执行,如果不等那么就不能执行。这样的一个策略略很像我们的CAS(Compare And Swap),⽐较并交换是一个原子操作。这样我们就能避免加select * for update行锁的开销。

Mysql释放锁超时

我们有可能会遇到我们的机器节点挂了,那么这个锁就不会得到释放,我们可以启动一个定时任务,通过计算一般我们处理任务的⼀般的时间,比如是10ms,那么我们可以稍微扩⼤一点,当这个锁超过100ms没有被释放我们就可以认定是节点挂了然后将其直接释放。

Mysql小结

优点:理解起来简单,不需要维护额外的第三⽅中间件(比如 Redis,Zk)。

缺点:虽然容易理解但是实现起来较为繁琐,需要自己考虑锁超时,加事务等等。性能局限于数据库,一般对比缓存来说性能较低。对于高并发的场景并不是很适合。

ZooKeeper分布式锁

ZooKeeper也是我们常见的实现分布式锁方法,ZooKeeper是以Paxos算法为基础分布式应⽤程序协调服务。Zk的数据节点和文件目录类似,所以我们可以用此特性实现分布式锁。我们以某个资源为目录,然后这个目录下面的节点就是我们需要获取锁的客户端,未获取到锁的客户端注册需要注册Watcher到上一个客户端,可以用下图表示。

/lock是我们⽤用于加锁的⽬目录,/resource_name是我们锁定的资源,其下⾯的节点按照我们加锁的顺序排列。

锁超时失效时间

Zookeeper不需要配置锁超时失效时间,由于我们设置节点是临时节点,我们的每个机器维护着一个zookeeper的session,通过这个session,ZK可以判断机器器是否宕机。如果我们的机器挂掉的话,那么这个临时节点对应的就会被删除,所以我们不需要关心锁超时失效时间。

zookeeper小结

优点:ZK可以不需要关心锁超时失效时间,实现起来有现成的第三方包,⽐较⽅便,并且支持读写锁,ZK获取锁会按照加锁的顺序,所以其是公平锁。对于⾼可用利用ZK集群进⾏保证。

缺点:ZK需要额外维护,增加维护成本,性能和Mysql相差不大,依然⽐较差。并且需要开发⼈员了解ZK是什么。

Redis分布锁

大家在网上搜索分布式锁,恐怕最多的实现就是Redis了了,Redis因为其性能好,实现起来简单所以让很多人都对其十分青睐。

Redis分布式锁简单实现

熟悉Redis的同学那么肯定对setNx(set if not exist)方法不陌⽣,如果不存在则更新,其可以很好的用来实现我们的分布式锁。对于某个资源加锁我们只需要 setNx resourceName value这里有个问题,加锁了之后如果机器宕机那么这个锁就不会得到释放所以会加入过期时间,加⼊过期时间需要和setNx同一个原子操作,在Redis2.8之前我们需要使用Lua脚本达到我们的目的,但是redis2.8之后redis⽀持nx和ex操作是同 一原子操作。set resourceName value ex 5 nx

Redis小结

优点:对于Redis实现简单,性能对比ZK和Mysql较好。如果不需要特别复杂的要求,那么自⼰就可以利⽤setNx进⾏实现,如果自⼰需要复杂的需求的话那么可以利用或者借鉴Redission。对于一些要求比较严格的场景来说的话可以使用RedLock。

缺点:需要维护Redis集群,如果要实现RedLock那么需要维护更多的集群。

3、常见的分布式锁框架

curator框架实现了zookeeper版的分布式锁:Curator实现了可重入锁 (InterProcessMutex),也实现了不可重入锁(InterProcessSemaphoreMutex)。在可重入锁中还实现了读写锁。

  • 加锁的流程具体如下:

1. 首先进⾏可重入的判定:这里的可重入锁记录在ConcurrentMap<Thread, LockData> threadData这个Map里面,如果threadData.get(currentThread) 是有值的那么就证明是可重入锁,然后记录就会加1。我们之前的Mysql其实也可以通过这种⽅法去优化,可以不需要count字段的值,将这个维护在本地可以提高性能。

2. 然后在我们的资源目录下创建一个节点:⽐如这里创建一个/0000000002这个节点,这个节点需要设置为EPHEMERAL_SEQUENTIAL也就是临时节点并且有序。

3. 获取当前目录下所有子节点,判断⾃己的节点是否位于子节点第一个。

4. 如果是第一个,则获取到锁,那么可以返回。

5. 如果不是第一个,则证明前面已经有人获取到锁了,那么需要获取⾃⼰节点的前一个节点。/0000000002的前一个节点是/0000000001,我们获取到这个节点之后,再上面注册Watcher(这里的watcher其实调用的是 object.notifyAll(),⽤来解除阻塞)。

6. object.wait(timeout)或object.wait():进行阻塞等待这⾥和我们第5步的 watcher相对应。

  • 解锁的具体流程:

1. ⾸先进⾏可重⼊锁的判定:如果有可重入锁只需要次数减1即可,减1之后加锁次数为0的话继续下面步骤,不为0直接返回。

2. 删除当前节点。

3. 删除threadDataMap⾥面的可重入锁的数据。

Redission框架实现了redis版的分布式锁:可重入、阻塞、读写、红锁、连锁等。 Redission封装了锁的实现,其继承了了java.util.concurrent.locks.Lock的接⼝,Redission不仅提供了Java自带的一些方法(lock,tryLock),还提供了异步加锁,对于异步编程更加方便。

1. 尝试加锁:⾸先会尝试进行加锁,由于保证操作是原⼦性,那么就只能使用lua脚本。可以看见他并没有使用我们的sexNx来进⾏操作,而是使用的hash结构,我们的每一个需要锁定的资源都可以看做是一个HashMap,锁定资源的节点信息是Key,锁定次数是value。通过这种方式可以很好的实现可重入的效果,只需要对value进行加1操作,就能进⾏可重 锁。当然这⾥也可以⽤之前我们说的本地计数进⾏优化。

2. 如果尝试加锁失败,判断是否超时,如果超时则返回false。

3. 如果加锁失败之后,没有超时,那么需要在名字为 redisson_lock__channel+lockName的channel上进行订阅,用于订阅解锁消息,然后一直阻塞直到超时,或者有解锁消息。

4. 重试步骤1,2,3,直到最后获取到锁,或者某一步获取锁超时。

5. redission的unlock⽅方法比较简单也是通过lua脚本进⾏解锁,如果是可重⼊入锁,只是减1。如果是非加锁线程解锁,那么解锁失败。

4、分布式锁的安全问题

1. 我们想象⼀个这样的场景当机器A申请到⼀把锁之后,如果Redis主宕机了了,这个时候从机并没有同步到这一把锁,那么机器B再次申请的时候就会再次申请到这把锁,

2. 长时间的GC pause,在GC的时候会发生STW(stop-the-world),例如 CMS垃圾回收器,他会有两个阶段进行STW防止引用继续进⾏变化。 (redis、zk、mysql)

3. 时钟发生跳跃:对于Redis服务器如果其时间发⽣了向跳跃,那么肯定会影响我们锁的过期时间,那么我们的锁过期时间就不是我们预期的了,也会出现client1和client2获取到同一把锁,那么也会出现不安全,这个对于Mysql也会出现。但是ZK由于没有设置过期时间,那么发生跳跃也不会受影响。

4. ⻓时间的网络I/O:这个问题和我们的GC的STW很像,也就是我们这个获取了锁之后我们进⾏网络调用,其调用时间由可能比我们锁的过期时间都还长,那么也会出现不安全的问题,这个Mysql也会有,ZK也不会出现这个问题。

5、⼩结

我们主要讲了多种分布式锁的实现⽅法,以及他们的一些优缺点。最后也说了一下有关于分布式锁的安全的问题,对于不同的业务需要的安全程度完全不同,我们需要根据⾃己的业务场景,通过不同的维度分析,选取最适合自己的方案。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!