几种分布式锁的实现方式

目前几乎不少大型网站及应用都是分布式部署的,分布式场景中的数据一致性问题一直是一个比较重要的话题。分布式的CAP理论告诉咱们“任何一个分布式系统都没法同时知足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时知足两项。”因此,不少系统在设计之初就要对这三者作出取舍。在互联网领域的绝大多数的场景中,都须要牺牲强一致性来换取系统的高可用性,系统每每只须要保证“最终一致性”,只要这个最终时间是在用户能够接受的范围内便可。

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

针对分布式锁的实现,目前比较经常使用的有如下几种方案:算法

1.基于数据库实现分布式锁
2.基于缓存(redis,memcached,tair)实现分布式锁
3.基于Zookeeper实现分布式锁数据库

在分析这几种实现方案以前咱们先来想一下,咱们须要的分布式锁应该是怎么样的?(这里以方法锁为例,资源锁同理)apache

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

这把锁要是一把可重入锁(避免死锁)缓存

这把锁最好是一把阻塞锁(根据业务需求考虑要不要这条)服务器

有高可用的获取锁和释放锁功能网络

获取锁和释放锁的性能要好多线程

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

  1. 互斥性。在任意时刻,只有一个客户端能持有锁。
  2. 不会发生死锁。即便有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其余客户端能加锁。
  3. 具备容错性。只要大部分的Redis节点正常运行,客户端就能够加锁和解锁。
  4. 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端本身不能把别人加的锁给解了。

一、基于数据库实现分布式锁

1)基于数据库表

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

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

建立这样一张数据库表:

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'

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

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

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

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

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

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

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

2)基于数据库排他锁

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

咱们还用刚刚建立的那张数据库表。能够经过数据库的排他锁来实现分布式锁。
基于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,数据库会在查询过程当中给数据库表增长排他锁。当某条记录被加上排他锁以后,其余线程没法再在该行记录上增长排他锁。

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

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

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

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

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

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


总结

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

数据库实现分布式锁的优势:

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

数据库实现分布式锁的缺点:

会有各类各样的问题,在解决问题的过程当中会使整个方案变得愈来愈复杂。

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


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

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

目前有不少成熟的缓存产品,包括Redis,memcached以及Tair。

1)基于Redis实现分布式锁

组件依赖

首先咱们要经过Maven引入Jedis开源组件,在pom.xml文件加入下面的代码:

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>2.9.0</version>
</dependency>

加锁代码

正确姿式

Talk is cheap, show me the code。先展现代码,再带你们慢慢解释为何这样实现:

public class RedisTool {
    private static final String LOCK_SUCCESS = "OK";
    private static final String SET_IF_NOT_EXIST = "NX";
    private static final String SET_WITH_EXPIRE_TIME = "PX";

    /**
     * 尝试获取分布式锁
     * @param jedis Redis客户端
     * @param lockKey 锁
     * @param requestId 请求标识
     * @param expireTime 超期时间
     * @return 是否获取成功
     */
    public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
        String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);

        if (LOCK_SUCCESS.equals(result)) {
            return true;
        }
        return false;
    }
}

能够看到,咱们加锁就一行代码: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单机部署的场景,因此容错性咱们暂不考虑。

 

错误示例1:

比较常见的错误示例就是使用jedis.setnx()jedis.expire()组合实现加锁,代码以下:

public static void wrongGetLock1(Jedis jedis, String lockKey, String requestId, int expireTime) {
    Long result = jedis.setnx(lockKey, requestId);
    if (result == 1) {
        // 若在这里程序忽然崩溃,则没法设置过时时间,将发生死锁
        jedis.expire(lockKey, expireTime);
    }
}

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

错误示例2:

public static boolean wrongGetLock2(Jedis jedis, String lockKey, int expireTime) {
    long expires = System.currentTimeMillis() + expireTime;
    String expiresStr = String.valueOf(expires);
    // 若是当前锁不存在,返回加锁成功
    if (jedis.setnx(lockKey, expiresStr) == 1) {
        return true;
    }
    // 若是锁存在,获取锁的过时时间
    String currentValueStr = jedis.get(lockKey);
    if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
        // 锁已过时,获取上一个锁的过时时间,并设置如今锁的过时时间
        String oldValueStr = jedis.getSet(lockKey, expiresStr);
        if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
            // 考虑多线程并发的状况,只有一个线程的设置值和当前值相同,它才有权利加锁
            return true;
        }
    }
    // 其余状况,一概返回加锁失败
    return false;
}

这一种错误示例就比较难以发现问题,并且实现也比较复杂。实现思路:使用jedis.setnx()命令实现加锁,其中key是锁,value是锁的过时时间。执行过程:1. 经过setnx()方法尝试加锁,若是当前锁不存在,返回加锁成功。2. 若是锁已经存在则获取锁的过时时间,和当前时间比较,若是锁已通过期,则设置新的过时时间,返回加锁成功。代码以下:

 

那么这段代码问题在哪里?1. 因为是客户端本身生成过时时间,因此须要强制要求分布式下每一个客户端的时间必须同步。 2. 当锁过时的时候,若是多个客户端同时执行jedis.getSet()方法,那么虽然最终只有一个客户端能够加锁,可是这个客户端的锁的过时时间可能被其余客户端覆盖。3. 锁不具有拥有者标识,即任何客户端均可以解锁。

解锁代码

正确代码:

仍是先展现代码,再带你们慢慢解释为何这样实现:

public class RedisTool {
    private static final Long RELEASE_SUCCESS = 1L;
    /**
     * 释放分布式锁
     * @param jedis Redis客户端
     * @param lockKey 锁
     * @param requestId 请求标识
     * @return 是否释放成功
     */
    public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {

        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));

        if (RELEASE_SUCCESS.equals(result)) {
            return true;
        }
        return false;
    }
}

能够看到,咱们解锁只须要两行代码就搞定了!第一行代码,咱们写了一个简单的Lua脚本代码,上一次见到这个编程语言仍是在《黑客与画家》里,没想到此次竟然用上了。第二行代码,咱们将Lua代码传到jedis.eval()方法里,并使参数KEYS[1]赋值为lockKey,ARGV[1]赋值为requestId。eval()方法是将Lua代码交给Redis服务端执行。

那么这段Lua代码的功能是什么呢?其实很简单,首先获取锁对应的value值,检查是否与requestId相等,若是相等则删除锁(解锁)。那么为何要使用Lua语言来实现呢?由于要确保上述操做是原子性的。关于非原子性会带来什么问题,能够阅读【解锁代码-错误示例2】 。那么为何执行eval()方法能够确保原子性,源于Redis的特性,下面是官网对eval命令的部分解释:

简单来讲,就是在eval命令执行Lua代码的时候,Lua代码将被当成一个命令去执行,而且直到eval命令执行完成,Redis才会执行其余命令。

错误示例1:

最多见的解锁代码就是直接使用jedis.del()方法删除锁,这种不先判断锁的拥有者而直接解锁的方式,会致使任何客户端均可以随时进行解锁,即便这把锁不是它的。

public static void wrongReleaseLock1(Jedis jedis, String lockKey) {
    jedis.del(lockKey);
}

 

错误示例2:

这种解锁代码乍一看也是没问题,甚至我以前也差点这样实现,与正确姿式差很少,惟一区别的是分红两条命令去执行,代码以下:

public static void wrongReleaseLock2(Jedis jedis, String lockKey, String requestId) {
        
    // 判断加锁与解锁是否是同一个客户端
    if (requestId.equals(jedis.get(lockKey))) {
        // 若在此时,这把锁忽然不是这个客户端的,则会误解锁
        jedis.del(lockKey);
    }

}

如代码注释,问题在于若是调用jedis.del()方法的时候,这把锁已经不属于当前客户端的时候会解除他人加的锁。那么是否真的有这种场景?答案是确定的,好比客户端A加锁,一段时间以后客户端A解锁,在执行jedis.del()以前,锁忽然过时了,此时客户端B尝试加锁成功,而后客户端A再执行del()方法,则将客户端B的锁给解除了。

2)基于Tair实现分布式锁

关于Redis和memcached在网络上有不少相关的文章,而且也有一些成熟的框架及算法能够直接使用。

基于Tair的实现分布式锁其实和Redis相似,其中主要的实现方式是使用TairManager.put方法来实现。

public boolean trylock(String key) { ResultCode code = ldbTairManager.put(NAMESPACE, key, "This is a Lock.", 2, 0); if (ResultCode.SUCCESS.equals(code)) return true; else return false; } public boolean unlock(String key) { ldbTairManager.invalid(NAMESPACE, key); }

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

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

二、这把锁只能是非阻塞的,不管成功仍是失败都直接返回。

三、这把锁是非重入的,一个线程得到锁以后,在释放锁以前,没法再次得到该锁,由于使用到的key在tair中已经存在。没法再执行put操做。

固然,一样有方式能够解决。

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

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


总结

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

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

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

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

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

三、基于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实现分布式锁的优势:

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

使用Zookeeper实现分布式锁的缺点:

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


三种方案的比较

从理解的难易程度角度(从低到高)

       数据库 > 缓存 > Zookeeper

从实现的复杂性角度(从低到高)

       Zookeeper >= 缓存 > 数据库

从性能角度(从高到低)

       缓存 > Zookeeper >= 数据库

从可靠性角度(从高到低)

       Zookeeper > 缓存 > 数据库

相关文章
相关标签/搜索