分布式锁解决并发的三种实现方式

转载自:分布式锁简单入门以及三种实现方式介绍java

分布式锁解决并发的三种实现方式

  • 在不少场景中,咱们为了保证数据的最终一致性,须要不少的技术方案来支持,好比分布式事务、分布式锁等。有的时候,咱们须要保证一个方法在同 一时间内只能被同一个线程执行。在单机环境中,Java中其实提供了不少并发处理相关的API,可是这些API在分布式场景中就无能为力了。也就是说单纯的Java Api并不能提供分布式锁的能力。因此针对分布式锁的实现目前有多种方案:面试

    分布式锁通常有三种实现方式:redis

    • 一、 数据库锁
    • 二、基于Redis的分布式锁
    • 三、基于ZooKeeper的分布式锁

分布式锁应该是怎么样的数据库

  • 互斥性 能够保证在分布式部署的应用集群中,同一个方法在同一时间只能被一台机器上的一个线程执行。缓存

  • 这把锁要是一把可重入锁(避免死锁)服务器

  • 不会发生死锁:有一个客户端在持有锁的过程当中崩溃而没有解锁,也能保证其余客户端可以加锁微信

  • 这把锁最好是一把阻塞锁(根据业务需求考虑要不要这条)多线程

  • 有高可用的获取锁和释放锁功能架构

  • 获取锁和释放锁的性能要好并发

一、数据库锁

  • 基于数据库表

    • 要实现分布式锁,最简单的方式可能就是直接建立一张锁表,而后经过操做该表中的数据来实现了。

    • 当咱们要锁住某个方法或资源时,咱们就在该表中增长一条记录,想要释放锁的时候就删除这条记录。

      image

      当咱们想要锁住某个方法时,执行如下SQL:

      image

      由于咱们对method_name作了惟一性约束,这里若是有多个请求同时提交到数据库的话,数据库会保证只有一个操做能够成功,那么咱们就能够认为

      操做成功的那个线程得到了该方法的锁,能够执行方法体内容。

      当方法执行完毕以后,想要释放锁的话,须要执行如下Sql:

      image
    • 上面这种简单的实现有如下几个问题:

      • 一、这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会致使业务系统不可用。

      • 二、这把锁没有失效时间,一旦解锁操做失败,就会致使锁记录一直在数据库中,其余线程没法再得到到锁。

      • 三、这把锁只能是非阻塞的,由于数据的insert操做,一旦插入失败就会直接报错。没有得到锁的线程并不会进入排队队列,要想再次得到锁就要再次触发得到锁操做。

      • 四、这把锁是非重入的,同一个线程在没有释放锁以前没法再次得到该锁。由于数据中数据已经存在了。

    • 固然,咱们也能够有其余方式解决上面的问题。

      • 数据库是单点?搞两个数据库,数据以前双向同步。一旦挂掉快速切换到备库上。

      • 没有失效时间?只要作一个定时任务,每隔必定时间把数据库中的超时数据清理一遍。

      • 非阻塞的?搞一个while循环,直到insert成功再返回成功。

      • 非重入的?在数据库表中加个字段,记录当前得到锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,若是当前机器的主机信息和线程信息在数据库能够查到的话,直接把锁分配给他就能够了。

  • 基于数据库的排它锁

    除了能够经过增删操做数据表中的记录之外,其实还能够借助数据库中自带的锁来实现分布式的锁。

    咱们还用刚刚建立的那张数据库表。能够经过数据库的排他锁来实现分布式锁。

    在查询语句后面增长for update,数据库会在查询过程当中给数据库表增长排他锁。当某条记录被加上排他锁以后,其余线程没法再在该行记录上增长排他锁。

    咱们能够认为得到排它锁的线程便可得到分布式锁,当获取到锁以后,能够执行方法的业务逻辑,执行完方法以后,再经过如下方法解锁:

    咱们能够认为得到排它锁的线程便可得到分布式锁,当获取到锁以后,能够执行方法的业务逻辑,执行完方法以后,再经过如下方法解锁:

    public void unlock(){
          connection.commit();
    }

    经过connection.commit()操做来释放锁。

    这种方法能够有效的解决上面提到的没法释放锁和阻塞锁的问题。

    • 阻塞锁? for update语句会在执行成功后当即返回,在执行失败时一直处于阻塞状态,直到成功。
    • 锁定以后服务宕机,没法释放?使用这种方式,服务宕机以后数据库会本身把锁释放掉。

    可是仍是没法直接解决数据库单点和可重入问题。

  • 总结:

    • 总结一下使用数据库来实现分布式锁的方式,这两种方式都是依赖数据库的一张表,一种是经过表中的记录的存在状况肯定当前是否有锁存在,另一种是经过数据库的排他锁来实现分布式锁。

    • 数据库实现分布式锁的优势: 直接借助数据库,容易理解。

    • 数据库实现分布式锁的缺点: 会有各类各样的问题,在解决问题的过程当中会使整个方案变得愈来愈复杂。

    • 操做数据库须要必定的开销,性能问题须要考虑。

1.一、乐观锁

  • 乐观锁假设认为数据通常状况下不会形成冲突,只有在进行数据的提交更新时,才会检测数据的冲突状况,若是发现冲突了,则返回错误信息

  • 实现方式:

    • 时间戳(timestamp)记录机制实现:给数据库表增长一个时间戳字段类型的字段,当读取数据时,将timestamp字段的值一同读出,数据每更新一次,timestamp也同步更新。当对数据作提交更新操做时,检查当前数据库中数据的时间戳和本身更新前取到的时间戳进行对比,若相等,则更新,不然认为是失效数据。

    • 若出现更新冲突,则须要上层逻辑修改,启动重试机制

    • 一样也可使用version的方式。

  • 性能对比

    • 一、悲观锁实现方式是独占数据,其它线程须要等待,不会出现修改的冲突,可以保证数据的一致性,可是依赖数据库的实现,且在线程较多时出现等待形成效率下降的问题。通常状况下,对于数据很敏感且读取频率较低的场景,能够采用悲观锁的方式

    • 二、 乐观锁能够多线程同时读取数据,若出现冲突,也能够依赖上层逻辑修改,可以保证高并发下的读取,适用于读取频率很高而修改频率较少的场景

    • 三、 因为库存回写数据属于敏感数据且读取频率适中,因此建议使用悲观锁优化

二、基于redis的分布式锁

  • 相比较于基于数据库实现分布式锁的方案来讲,基于缓存来实如今性能方面会表现的更好一点。并且不少缓存是能够集群部署的,能够解决单点问题。

-首先,为了确保分布式锁可用,咱们至少要确保锁的实现同时知足如下四个条件:

  • 互斥性。在任意时刻,只有一个客户端能持有锁。

  • 不会发生死锁。即便有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其余客户端能加锁。

  • 具备容错性。只要大部分的Redis节点正常运行,客户端就能够加锁和解锁。

  • 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端本身不能把别人加的锁给解了。

能够看到,咱们加锁就一行代码:==jedis.set(String key, String value, String nxxx, String expx, int time)==,这个set()方法一共有五个形参:

  • 第一个为key,咱们使用key来当锁,由于key是惟一的。

  • 第二个为value,咱们传的是requestId,不少童鞋可能不明白,有key做为锁不就够了吗,为何还要用到value?缘由就是咱们在上面讲到可靠性时,分布式锁要知足第四个条件解铃还须系铃人,经过给value赋值为requestId,咱们就知道这把锁是哪一个请求加的了,在解锁的时候就能够有依据。requestId可使用UUID.randomUUID().toString()方法生成。

  • 第三个为nxxx,这个参数咱们填的是NX,意思是SET IF NOT EXIST,即当key不存在时,咱们进行set操做;若key已经存在,则不作任何操做;

  • 第四个为expx,这个参数咱们传的是PX,意思是咱们要给这个key加一个过时的设置,具体时间由第五个参数决定。

  • 第五个为time,与第四个参数相呼应,表明key的过时时间。

总的来讲,执行上面的set()方法就只会致使两种结果:1. 当前没有锁(key不存在),那么就进行加锁操做,并对锁设置个有效期,同时value表示加锁的客户端。2. 已有锁存在,不作任何操做。

加锁代码知足咱们可靠性里描述的三个条件。首先,set()加入了NX参数,能够保证若是已有key存在,则函数不会调用成功,也就是只有一个客户端能持有锁,知足互斥性。其次,因为咱们对锁设置了过时时间,即便锁的持有者后续发生崩溃而没有解锁,锁也会由于到了过时时间而自动解锁(即key被删除),不会发生死锁。最后,由于咱们将value赋值为requestId,表明加锁的客户端请求标识,那么在客户端在解锁的时候就能够进行校验是不是同一个客户端。因为咱们只考虑Redis单机部署的场景,因此容错性咱们暂不考虑。

错误实例

  • 使用jedis.setnx()和jedis.expire()组合实现加锁
image

setnx()方法做用就是SET IF NOT EXIST,expire()方法就是给锁加一个过时时间。乍一看好像和前面的set()方法结果同样,然而因为这是两条Redis命令,不具备原子性,若是程序在执行完setnx()以后忽然崩溃,致使锁没有设置过时时间。那么将会发生死锁。网上之因此有人这样实现,是由于低版本的jedis并不支持多参数的set()方法。

  • 解锁:

    • 首先获取锁对应的value值,检查是否与requestId相等,若是相等则删除锁(解锁)

    • 使用缓存实现分布式锁的优势

      • 性能好,实现起来较为方便。

      • 使用缓存实现分布式锁的缺点

      • 经过超时时间来控制锁的失效时间并非十分的靠谱。

  • 总结:

    • 可使用缓存来代替数据库来实现分布式锁,这个能够提供更好的性能,同时,不少缓存服务都是集群部署的,能够避免单点问题。而且不少缓存服务都提供了能够用来实现分布式锁的方法,好比redis的setnx方法等。而且,这些缓存服务也都提供了对数据的过时自动删除的支持,能够直接设置超时时间来控制锁的释放。

三、基于Zookeeper实现分布式锁

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

  • 完成业务流程后,删除对应的子节点释放锁。

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

    • 锁没法释放?使用Zookeeper能够有效的解决锁没法释放的问题,由于在建立锁的时候,客户端会在ZK中建立一个临时节点,一旦客户端获取到锁以后忽然挂掉(Session链接断开),那么这个临时节点就会自动删除掉。其余客户端就能够再次得到锁。
    • 非阻塞锁?使用Zookeeper能够实现阻塞的锁,客户端能够经过在ZK中建立顺序节点,而且在节点上绑定监听器,一旦节点有变化,Zookeeper会通知客户端,客户端能够检查本身建立的节点是否是当前全部节点中序号最小的,若是是,那么本身就获取到锁,即可以执行业务逻辑了。
    • 不可重入?使用Zookeeper也能够有效的解决不可重入的问题,客户端在建立节点的时候,把当前客户端的主机信息和线程信息直接写入到节点中,下次想要获取锁的时候和当前最小的节点中的数据比对一下就能够了。若是和本身的信息同样,那么本身直接获取到锁,若是不同就再建立一个临时的顺序节点,参与排队。
    • 单点问题?使用Zookeeper能够有效的解决单点问题,ZK是集群部署的,只要集群中有半数以上的机器存活,就能够对外提供服务。
  • 能够直接使用zookeeper第三方库Curator客户端,这个客户端中封装了一个可重入的锁服务。

  • Zookeeper实现的分布式锁其实存在一个缺点,那就是性能上可能并无缓存服务那么高。

    • 由于每次在建立锁和释放锁的过程当中,都要动态建立、销毁瞬时节点来实现锁功能。ZK中建立和删除节点只能经过Leader服务器来执行,而后将数据同不到全部的Follower机器上。
  • 使用Zookeeper实现分布式锁的优势: 有效的解决单点问题,不可重入问题,非阻塞问题以及锁没法释放的问题。实现起来较为简单。

  • 使用Zookeeper实现分布式锁的缺点 : 性能上不如使用缓存实现分布式锁。 须要对ZK的原理有所了解。

    image

四、三种方案的比较

  • 从理解的难易程度角度(从低到高): 数据库 > 缓存 > Zookeeper

  • 从实现的复杂性角度(从低到高): Zookeeper >= 缓存 > 数据库

  • 从性能角度(从高到低): 缓存 > Zookeeper >= 数据库

  • 从可靠性角度(从高到低): Zookeeper > 缓存 > 数据库

在实践中,固然是从以可靠性为主。因此首推Zookeeper。

145天以来,Java架构更新了 428个主题,已经有91位同窗加入。微信扫码关注java架构,获取Java面试题和架构师相关题目和视频。上述相关面试题答案,尽在Java架构中。

相关文章
相关标签/搜索