分布式锁的实现

来源:互联网 发布:淘宝店卖家要什么软件 编辑:程序博客网 时间:2024/06/06 14:05

项目里要让线上环境集群里的多台机器,如果不同的系统或是同一个系统的不同主机之间共享了一个或一组资源,那么访问这些资源的时候,往往需要互斥来防止彼此干扰来保证一致性,目前主流的实现方法有以下三种: 

 

一.基于数据库表

要实现分布式锁,最简单的方式可能就是直接创建一张锁表,然后通过操作该表中的数据来实现了。当我们要锁住某个方法或资源时,我们就在该表中增加一条记录,想要释放锁的时候就删除这条记录。

创建这样一张数据库表:

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'

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

优点:

a.不用借助第三方中间件来实现

缺点:

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

2.这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。

3.这把锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。

4.这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。

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

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

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

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

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

基于数据库排他锁

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

 

我们还用刚刚创建的那张数据库表。可以通过数据库的排他锁来实现分布式锁。

基于MySQLInnoDB引擎,可以使用以下方法来实现加锁操作:

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,数据库会在查询过程中给数据库表增加排他锁。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。

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

public void unlock(){

    connection.commit();

}

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

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

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

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

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

 

二.基于Zookeeper的瞬时顺序节点

 

对于加锁操作,可以让所有客户端都去/lock目录下创建临时顺序节点,如果创建的客户端发现自身创建节点序列号是/lock/目录下最小的节点,则获得锁。否则,监视比自己创建节点的序列号小的节点(比自己创建的节点小的最大节点),进入等待。对于解锁操作,只需要将自身创建的节点删除即可 

这也是ZooKeeper客户端curator的分布式锁实现。

 

1 获取锁

 

public void lock(){

    path = 在父节点下创建临时顺序节点

    while(true){

        children = 获取父节点的所有节点

        if(pathchildren中的最小的){

            代表获取了节点

            return;

        }else{

            添加监控前一个节点是否存在的watcher

            wait();

        }

    }

}

 

watcher中的内容{

    notifyAll();

}

2 释放锁

 

public void release(){

    删除上述创建的节点

}

优点:

a.依靠zk集群强大的watch功能,轻松实现非阻塞锁,也不存在锁无法释放的问题更加稳定,高效

b.高可用、心跳保持锁

c.公平锁

 缺点:

a.需要接入zk,实现较为复杂的客户端逻辑,成本较高 

b.性能上可能并没有缓存服务那么高。因为每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。

 

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

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

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

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

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

 

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

 

三. 基于分布式缓存,如redismemcachetair

 

1)Tair实现分布式锁

说了这么多,在公司内部使用的话,还是觉得用tair实现最为高效、快捷,所以下面就分析下tair的实现。 

2) Tair实现分布式锁前提条件

要实现分布式锁,必须要通过有一个全局唯一的控制点。对于tair而言,在集群里面就只能有一份数据。我们都知道:

tair的存储引擎有三种:mdbrdbldb

每种的集群部署方式如下:

mdb存储引擎适用于双机房单集群单份,双机房独立集群,双机房单集群双份。
rdb存储引擎适用于双机房单集群单份。
ldb存储引擎适用于双机房主备集群,双机房单集群单份。 

所以,如果要用tair实现分布式锁,就不能选择mdb的独立集群,当然,如果应用始终只在一个机房部署也是可以用的。

3)  Tair实现分布式锁的两种方案

Tair没有直接提供分布式锁的api,但是可以借助提供的其他api,有两种方法可以实现分布式锁。

 

a.Incr/decr

核心实现原理:通过计数api的上下限值约束来实现。 

 

实现过程:

线程一调用incr加锁,参数如下:

tairManager.incr(NAME_SPACE, key, value:1, defaultValue:0, EXPIRE_TIME, lowBound:0, upperBound:1); 

加锁后,key的值变成1,而key的上限值为1,其他线程再调用该接口时会报错COUNTER_OUT_OF_RANGE 

待线程一使用完成后,调用decr解锁

tairManager.decr(NAME_SPACE, key, value:1, defaultValue:0, EXPIRE_TIME, lowBound:0, upperBound:1);

此时key已经有值1,返回1-1=0,解锁成功。多次调用会失败,因为范围是0~1 

通过01的来回变化,达到分布式锁的目的,当key1时获取到锁,为0时释放锁。 

b.Get/Put/invalide

核心实现原理: putversion校验实现

 

实现过程:

线程一调用get,如果节点存在,直接返回获取锁失败,如果不存在,调用put加锁,参数如下:

tairManager.put(NAME_SPACE, key, value, version:2, expireTime);

此处version,除了01外的任何数字都可以。因为传入0tair会认为强制覆盖;而传1,第一个client写入会成功,但是新写入时服务端的version0开始计数啊,所以此时version也是1,所以下一个到来的client写入也会成功,这样造成了冲突。 

expireTime一定要设置,根据也需要设置,防止锁永远无法释放。 

在线程一put成功后,其他线程再调用put时,由于此时服务端的version已经变成3,而传入的version是固定值2,所以不会成功。 

线程一使用完成后调用invalid释放锁。

释放后,其他线程重复这个过程,即可完成加锁解锁了。 

通过这种方式,可以在putvalue值中保存当前的服务器和线程信息再加上随机字符如uuid等,然后在invalid的时候进行验证,从而可以解决正在使用中的锁被其他线程误释放的情况。如果要实现可重入锁,在get的时候,传入value,比较value是否一致,一致则继续占有锁,在使用完后先不要解锁,方便重入。 

为了保证线程安全,get/put key都加了前缀的:genValue()方法:hostname + "__" + Thread.currentThread().getName() + "__"+ UUID.randomUUID();

 

优点:

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

缺点:

a.需要理解每种缓存方案的部署、api,及隐晦的一些规定。 

b.不可重入锁。

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

 

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

1、这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在tair中,其他线程无法再获得到锁。

2、这把锁只能是非阻塞的,无论成功还是失败都直接返回。

3、这把锁是非重入的,一个线程获得锁之后,在释放锁之前,无法再次获得该锁,因为使用到的keytair中已经存在。无法再执行put操作。

当然,同样有方式可以解决。

· 没有失效时间?tairput方法支持传入失效时间,到达时间之后数据会自动删除。

· 非阻塞?while重复执行。

· 非可重入?在一个线程获取到锁之后,把当前主机信息和线程信息保存起来,下次再获取之前先检查自己是不是当前锁的拥有者。

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

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

原创粉丝点击