Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

关于分布式锁? #22

Closed
bfchengnuo opened this Issue Sep 12, 2018 · 5 comments

Comments

1 participant
@bfchengnuo
Copy link
Owner

bfchengnuo commented Sep 12, 2018

经过上一主题,感觉这部分内容也不少,单独拆分出来吧,应该会很有意思吧~

大纲:

  • 介绍
  • 基于缓存(Redis 为例)实现分布式锁
  • 基于数据库实现分布式锁
  • 基于Zookeeper实现分布式锁

@bfchengnuo bfchengnuo referenced this issue Sep 12, 2018

Closed

分布式中如何保证数据一致性? #21

9 of 9 tasks complete

@bfchengnuo bfchengnuo added this to 待办 in 基础巩固 via automation Sep 12, 2018

@bfchengnuo

This comment has been minimized.

Copy link
Owner Author

bfchengnuo commented Sep 24, 2018

介绍

分布式锁,是控制分布式系统之间同步访问共享资源的一种方式
在分布式系统中,常常需要协调他们的动作。如果不同的系统或是同一个系统的不同主机之间共享了一个或一组资源,那么访问这些资源的时候,往往需要互斥来防止彼此干扰来保证一致性,在这种情况下,便需要使用到分布式锁。

传统实现分布式锁的方案一般是利用持久化数据库(如利用 InnoDB 行锁,或事务、或 version 乐观锁),当然大部分时候可以满足大部分人的需求。
而如今互联网应用的量级已经几何级别的爆发,利用诸如 zookeeper、redis 等更高效的分布式组件来实现分布式锁,可以提供高可用的更强壮的锁特性,并且支持丰富化的使用场景。

开源实现已有不少比如 Redis 作者基于 Redis 设计的 Redlock、Redission 等。

分布式锁的实现:

  1. Memcached分布式锁
    利用 Memcached 的 add 命令。此命令是原子性操作,只有在 key 不存在的情况下,才能 add 成功,也就意味着线程得到了锁。

  2. Redis分布式锁
    和 Memcached 的方式类似,利用 Redis 的 setnx 命令。此命令同样是原子性操作,只有在 key 不存在的情况下,才能 set 成功。(setnx 命令并不完善,后续可能会介绍替代方案)

  3. Zookeeper分布式锁
    利用 Zookeeper 的顺序临时节点,来实现分布式锁和等待队列。Zookeeper 设计的初衷,就是为了实现分布式锁服务的。

  4. Chubby
    Google 公司实现的粗粒度分布式锁服务,底层利用 Paxos 一致性算法。

@bfchengnuo

This comment has been minimized.

Copy link
Owner Author

bfchengnuo commented Sep 24, 2018

使用Redis实现分布式锁

使用 Redis 实现分布式锁首先要先知道几个 Redis 的命令,分布式锁就是通过这几个命令来实现的

  • setnx
    只有不存在的时候,setnx 才会设置值成功;
    可以理解为是否存在和设置值这两条命令的集合版,不过是原子性的。
  • getset
    先 get 再 set,也是两条命令的整合,具有原子性。
  • expire
    设置有效期
  • del
    删除

简单流程

首先使用 setnx 存入一个值,key 为锁名,val 为当前的时间戳加一个超时时间,这是为了防止死锁。

image

仔细看这个架构好像有点问题,因为我们设置的 val 根本没用,也没有任何的防死锁措施,只是实现比较简单而已,更完善的第二版在这:
image

这样基本就不会出现死锁的情况了,具体的代码稍后上传,可以参考一下。
尤其是这第二张图,可以仔细想想,无论在哪一步宕掉,都是可以防止死锁的。

@bfchengnuo

This comment has been minimized.

Copy link
Owner Author

bfchengnuo commented Sep 26, 2018

基于数据库实现分布式锁

常见的实现方式又分两种:

  • 完全基于数据库表的
  • 基于数据库排它锁

参见:http://www.hollischuang.com/archives/1716

基于数据库表

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

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

创建这样一张数据库表:

CREATE TABLE `methodLock` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `method_name` varchar(64) NOT NULL DEFAULT '' COMMENT '锁定的方法名',
  `desc` varchar(1024) NOT NULL DEFAULT '备注信息',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存数据时间,自动生成',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uidx_method_name` (`method_name `) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';

当我们想要锁住某个方法时,执行以下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. 这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。

当然,我们也可以有其他方式解决上面的问题。

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

基于数据库排他锁

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

我们还用刚刚创建的那张数据库表。可以通过数据库的排他锁来实现分布式锁。 基于 MySql 的 InnoDB 引擎,可以使用以下方法来实现加锁操作:

public boolean lock(){
  connection.setAutoCommit(false)
    while(true){
      try{
        result = "select * from methodLock where method_name=xxx for update";
        if(result==null){
          return true;
        }
      }catch(Exception e){}
      sleep(1000);
    }
  return false;
}

在查询语句后面增加for update,数据库会在查询过程中给数据库表增加排他锁。

这里再多提一句,InnoDB 引擎在加锁的时候,只有通过索引进行检索的时候才会使用行级锁否则会使用表级锁

这里我们希望使用行级锁,就要给 method_name 添加索引,值得注意的是,这个索引一定要创建成唯一索引,否则会出现多个重载方法之间无法同时被访问的问题。重载方法的话建议把参数类型也加上。

当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。

我们可以认为获得排它锁的线程即可获得分布式锁,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,再通过以下方法解锁:

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

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

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

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

但是还是无法直接解决数据库单点和可重入问题。

这里还可能存在另外一个问题,虽然我们对 method_name 使用了唯一索引,并且显示使用 for update 来使用行级锁。

但是,MySql 会对查询进行优化,即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的,如果 MySQL 认为全表扫效率更高,比如对一些很小的表,它就不会使用索引,这种情况下 InnoDB 将使用表锁,而不是行锁。如果发生这种情况就悲剧了。。。


还有一个问题,就是我们要使用排他锁来进行分布式锁的 lock,那么一个排他锁长时间不提交,就会占用数据库连接。一旦类似的连接变得多了,就可能把数据库连接池撑爆

总结

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

  • 优点
    直接借助数据库,容易理解。

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

@bfchengnuo

This comment has been minimized.

Copy link
Owner Author

bfchengnuo commented Sep 26, 2018

基于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 客户端支持多种重试策略。多次重试之后还不行的话才会删除临时节点。

所以,选择一个合适的重试策略也比较重要,要在锁的粒度和并发之间找一个平衡

总结

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

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

@bfchengnuo

This comment has been minimized.

Copy link
Owner Author

bfchengnuo commented Sep 26, 2018

三种方案的比较

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

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

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

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

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

@bfchengnuo bfchengnuo closed this Sep 26, 2018

基础巩固 automation moved this from 待办 to 已完成 Sep 26, 2018

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.