分布式锁的实现方式

背景

分布式场景中的数据一致性问题一直是一个比较重要的话题。分布式的CAP理论告诉我们“任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。”所以,很多系统在设计之初就要对这三者做出取舍。在互联网领域的绝大多数的场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证“最终一致性”,只要这个最终时间是在用户可以接受的范围内即可。

在很多场景中,我们为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等。在单机环境中,Java中其实提供了很多并发处理相关的API,但是这些API在分布式场景中就无能为力了。也就是说单纯的Java Api并不能提供分布式锁的能力,所以针对分布式锁的实现目前有多种方案。

针对分布式锁的实现,目前常用的有以下几种方案:

  • 基于数据库实现分布式锁
  • 基于缓存(redis、memcached、tair)实现分布式锁
  • 基于Zookeeper实现分布式锁
  • 自研分布式锁:如Google的Chubby

分布式锁使用场景

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

分布式锁的特点

  • 互斥性:和我们本地锁一样互斥性是最基本,但是分布式锁需要保证在不同节点的不同线程的互斥。
  • 可重入性:同一个节点上的同一个线程如果获取了锁之后那么也可以再次获取这个锁。
  • 锁超时:和本地锁一样支持锁超时,防止死锁。
  • 高效、高可用:加锁和解锁需要高效,同时也需要保证高可用防止分布式锁失效,可以增加降级。
  • 支持阻塞和非阻塞:【最好是一把阻塞锁】和ReentrantLock一样支持lock和trylock以及tryLock(long timeOut)。

常见方案

Mysql分布式锁

1.基于数据库表

要实现分布式锁,最简单的方式可能就是直接创建一张锁表,然后通过操作该表中的数据来实现了。

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

分布式锁的实现方式_第1张图片

  • 当我们想要锁住某个方法时,执行以下SQL:
insert into methodLock(method_name, desc) values ('method_name', 'desc')

由于已对method_name做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。

  • 当方法执行完毕之后,想要释放锁的话,需要执行以下Sql:
delete from methodLock where method_name = 'method_name'

这种实现存在一些问题

  1. 这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
  2. 这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
  3. 这把锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
  4. 这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。

解决办法:

  1. 搞主备两个库,同步数据,做灾备,一旦挂掉快速切换到备库。
  2. 没有失效时间?只要做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍。
  3. .非阻塞的?搞一个while循环,直到insert成功再返回成功。
  4. 非重入的?在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。

2.基于数据库排它锁

除了可以通过增删操作数据表中的记录以外,其实还可以借助数据中自带的锁来实现分布式的锁。

分布式锁的实现方式_第2张图片

查询语句后面增加for update,数据库会在查询过程中给数据库表增加排他锁。可以认为获得排它锁的线程即可获得分布式锁,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,再通过以下方法解锁:

通过connection.commit()操作来释放锁。

这种方法可以有效的解决上面提到的无法释放锁和阻塞锁的问题。

阻塞锁? for update语句会在执行成功后立即返回,在执行失败时一直处于阻塞状态,直到成功。

锁定之后服务宕机,无法释放?使用这种方式,服务宕机之后数据库会自己把锁释放掉。

缺点

数据的建立会消耗资源,效率低下,而且从安全和规范操作上,数据库禁止使用for update 是比较常见的方案。

3.总结

使用数据库来实现分布式锁的方式,这两种方式都是依赖数据库的一张表,一种是通过表中的记录的存在情况确定当前是否有锁存在,另外一种是通过数据库的排它锁来实现分布式锁。

  • 使用数据库实现分布式锁的优点
    • 直接借助数据库,容易理解。
  • 使用数据库实现分布式锁的缺点
    • 会有各种各样的问题,在解决问题的过程中会使整个方案变得越来越复杂。
    • 操作数据库需要一定的开销,性能问题需要考虑。
    • 使用数据库的行级锁并不一定靠谱,尤其是当我们的锁表并不大的时候。

基于分布式缓存实现分布式锁

  • 使用redis的setnx()用于分布式锁。(setNx,直接设置值为当前时间+超时时间,保持操作原子性)
  • 使用memcached的add()方法,用于分布式锁。
  • 使用Tair的put()方法,用于分布式锁。

分布式锁的实现方式_第3张图片

相比较于基于数据库实现分布式锁的方案来说,基于缓存来实现在性能方面会表现的更好一点,而且很多缓存是可以集群部署的,可以解决单点问题

目前有很多成熟的缓存产品,包括Redis,memcached以及我们公司内部的Tair。

以上实现方式同样存在几个问题:

  1. 这把锁锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在tair中,其他线程无法再获得到锁。
  2. 这把锁只能是非阻塞的,无论成功还是失败都直接返回。
  3. 这把锁是非重入的,一个线程获得锁之后,在释放锁之前,无法再次获得该锁,因为使用到的key在tair中已经存在。无法再执行put操作。

当然有方式可以解决

  1. 没有失效时间?tair的put方法支持传入失效时间,到达时间之后数据会自动删除。
  2. 非阻塞?while重复执行。
  3. 非可重入?在一个线程获取到锁之后,把当前主机信息和线程信息保存起来,下次再获取之前先检查自己是不是当前锁的拥有者。

但是,失效时间我设置多长时间为好?如何设置的失效时间太短,方法没等执行完,锁就自动释放了,那么就会产生并发问题。如果设置的时间太长,其他获取锁的线程就可能要平白的多等一段时间。这个问题使用数据库实现分布式锁同样存在。

总结

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

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

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

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

通过超时时间来控制锁的失效时间并不是十分的靠谱。

【可以重点看下Redis实现分布式锁的方式】

基于Zookeeper实现分布式锁

chubby

大家搜索ZK的时候,会发现他们都写了ZK是Chubby的开源实现,Chubby内部工作原理和ZK类似。但是Chubby的定位是分布式锁和ZK有点不同。Chubby也是使用上面自增序列的方案用来解决分布式不安全的问题,但是他提供了多种校验方法。

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

分布式锁的实现方式_第4张图片

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

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

背景

分布式场景中的数据一致性问题一直是一个比较重要的话题。分布式的CAP理论告诉我们“任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。”所以,很多系统在设计之初就要对这三者做出取舍。在互联网领域的绝大多数的场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证“最终一致性”,只要这个最终时间是在用户可以接受的范围内即可。

在很多场景中,我们为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等。在单机环境中,Java中其实提供了很多并发处理相关的API,但是这些API在分布式场景中就无能为力了。也就是说单纯的Java Api并不能提供分布式锁的能力,所以针对分布式锁的实现目前有多种方案。

针对分布式锁的实现,目前常用的有以下几种方案:

  • 基于数据库实现分布式锁
  • 基于缓存(redis、memcached、tair)实现分布式锁
  • 基于Zookeeper实现分布式锁
  • 自研分布式锁:如Google的Chubby

分布式锁使用场景

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

分布式锁的特点

  • 互斥性:和我们本地锁一样互斥性是最基本,但是分布式锁需要保证在不同节点的不同线程的互斥。
  • 可重入性:同一个节点上的同一个线程如果获取了锁之后那么也可以再次获取这个锁。
  • 锁超时:和本地锁一样支持锁超时,防止死锁。
  • 高效、高可用:加锁和解锁需要高效,同时也需要保证高可用防止分布式锁失效,可以增加降级。
  • 支持阻塞和非阻塞:【最好是一把阻塞锁】和ReentrantLock一样支持lock和trylock以及tryLock(long timeOut)。

常见方案

Mysql分布式锁

1.基于数据库表

要实现分布式锁,最简单的方式可能就是直接创建一张锁表,然后通过操作该表中的数据来实现了。

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

分布式锁的实现方式_第5张图片

  • 当我们想要锁住某个方法时,执行以下SQL:
insert into methodLock(method_name, desc) values ('method_name', 'desc')

由于已对method_name做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。

  • 当方法执行完毕之后,想要释放锁的话,需要执行以下Sql:
delete from methodLock where method_name = 'method_name'

这种实现存在一些问题

  1. 这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
  2. 这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
  3. 这把锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
  4. 这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。

解决办法:

  1. 搞主备两个库,同步数据,做灾备,一旦挂掉快速切换到备库。
  2. 没有失效时间?只要做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍。
  3. .非阻塞的?搞一个while循环,直到insert成功再返回成功。
  4. 非重入的?在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。

2.基于数据库排它锁

除了可以通过增删操作数据表中的记录以外,其实还可以借助数据中自带的锁来实现分布式的锁。

分布式锁的实现方式_第6张图片

查询语句后面增加for update,数据库会在查询过程中给数据库表增加排他锁。可以认为获得排它锁的线程即可获得分布式锁,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,再通过以下方法解锁:

通过connection.commit()操作来释放锁。

这种方法可以有效的解决上面提到的无法释放锁和阻塞锁的问题。

阻塞锁? for update语句会在执行成功后立即返回,在执行失败时一直处于阻塞状态,直到成功。

锁定之后服务宕机,无法释放?使用这种方式,服务宕机之后数据库会自己把锁释放掉。

缺点

数据的建立会消耗资源,效率低下,而且从安全和规范操作上,数据库禁止使用for update 是比较常见的方案。

3.总结

使用数据库来实现分布式锁的方式,这两种方式都是依赖数据库的一张表,一种是通过表中的记录的存在情况确定当前是否有锁存在,另外一种是通过数据库的排它锁来实现分布式锁。

  • 使用数据库实现分布式锁的优点
    • 直接借助数据库,容易理解。
  • 使用数据库实现分布式锁的缺点
    • 会有各种各样的问题,在解决问题的过程中会使整个方案变得越来越复杂。
    • 操作数据库需要一定的开销,性能问题需要考虑。
    • 使用数据库的行级锁并不一定靠谱,尤其是当我们的锁表并不大的时候。

基于分布式缓存实现分布式锁

  • 使用redis的setnx()用于分布式锁。(setNx,直接设置值为当前时间+超时时间,保持操作原子性)
  • 使用memcached的add()方法,用于分布式锁。
  • 使用Tair的put()方法,用于分布式锁。

分布式锁的实现方式_第7张图片

相比较于基于数据库实现分布式锁的方案来说,基于缓存来实现在性能方面会表现的更好一点,而且很多缓存是可以集群部署的,可以解决单点问题

目前有很多成熟的缓存产品,包括Redis,memcached以及我们公司内部的Tair。

以上实现方式同样存在几个问题:

  1. 这把锁锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在tair中,其他线程无法再获得到锁。
  2. 这把锁只能是非阻塞的,无论成功还是失败都直接返回。
  3. 这把锁是非重入的,一个线程获得锁之后,在释放锁之前,无法再次获得该锁,因为使用到的key在tair中已经存在。无法再执行put操作。

当然有方式可以解决

  1. 没有失效时间?tair的put方法支持传入失效时间,到达时间之后数据会自动删除。
  2. 非阻塞?while重复执行。
  3. 非可重入?在一个线程获取到锁之后,把当前主机信息和线程信息保存起来,下次再获取之前先检查自己是不是当前锁的拥有者。

但是,失效时间我设置多长时间为好?如何设置的失效时间太短,方法没等执行完,锁就自动释放了,那么就会产生并发问题。如果设置的时间太长,其他获取锁的线程就可能要平白的多等一段时间。这个问题使用数据库实现分布式锁同样存在。

总结

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

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

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

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

通过超时时间来控制锁的失效时间并不是十分的靠谱。

【可以重点看下Redis实现分布式锁的方式】

基于Zookeeper实现分布式锁

chubby

大家搜索ZK的时候,会发现他们都写了ZK是Chubby的开源实现,Chubby内部工作原理和ZK类似。但是Chubby的定位是分布式锁和ZK有点不同。Chubby也是使用上面自增序列的方案用来解决分布式不安全的问题,但是他提供了多种校验方法。

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

分布式锁的实现方式_第8张图片

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

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

分布式锁的实现方式_第9张图片

总结

  • 使用zookeeper实现分布式锁优点
    • zookeeper可以不需要关心锁超时时间
    • 实现起来有现成的第三方包,比较方便
    • 支持读写锁
    • 公平锁,zookeeper获取锁会按照加锁的顺序
    • 高可用,通过zookeeper集群进行保证
  • 使用zookeeper实现分布式锁缺点
    • zookeeper需要额外维护,增加维护成本
    • 性能较差,和Mysql相差不大
    • 学习成本高,需要开发人员了解zookeeper是什么

方案的比较

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

  • 从理解的难易程度角度(从低到高)
    • 数据库 > 缓存 > Zookeeper
  • 从实现的复杂性角度(从低到高)
    • Zookeeper >= 缓存 > 数据库
  • 从性能角度(从高到低)
    • 缓存 > Zookeeper >= 数据库
  • 从可靠性角度(从高到低)
    • Zookeeper > 缓存 > 数据库

总结

  • 使用zookeeper实现分布式锁优点
    • zookeeper可以不需要关心锁超时时间
    • 实现起来有现成的第三方包,比较方便
    • 支持读写锁
    • 公平锁,zookeeper获取锁会按照加锁的顺序
    • 高可用,通过zookeeper集群进行保证
  • 使用zookeeper实现分布式锁缺点
    • zookeeper需要额外维护,增加维护成本
    • 性能较差,和Mysql相差不大
    • 学习成本高,需要开发人员了解zookeeper是什么

方案的比较

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

  • 从理解的难易程度角度(从低到高)
    • 数据库 > 缓存 > Zookeeper
  • 从实现的复杂性角度(从低到高)
    • Zookeeper >= 缓存 > 数据库
  • 从性能角度(从高到低)
    • 缓存 > Zookeeper >= 数据库
  • 从可靠性角度(从高到低)
    • Zookeeper > 缓存 > 数据库

你可能感兴趣的:(技术文案,分布式,java,数据库)