美文网首页
分布式锁的应用与实现原理

分布式锁的应用与实现原理

作者: 千淘萬漉 | 来源:发表于2018-03-16 20:33 被阅读483次

    在很多场景中,我们为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等。有的时候,我们需要保证一个方法在同一时间内只能被同一个线程执行。在单机环境中,Java中其实提供了很多并发环境下的线程安全方法,在JVM层面以通过Synchronized关键字或者Lock接口,可协调资源像原子性一样操作。但是在分布式环境下,跨JVM无法这些都无法发挥作用,只能寻求公证人(第三方服务)进行协调,典型的场景如在进行Quartz调度的时候,如果碰到集群就可能会用类似的应用场景,见下图:

    Quartz如何保证多个节点的应用只进行一次调度(即某一时刻的调度任务只由其中一台服务器执行)?针对这种场景,目前比较常用的有以下几种方案:

    • 基于数据库实现分布式锁
    • 基于缓存(redis,memcached)实现分布式锁
    • 基于Zookeeper实现分布式锁

    在分析这几种实现方案之前我们先来想一下,我们需要的分布式锁应该是怎么样的?(这里以方法锁为例,资源锁同理)
    可以保证在分布式部署的应用集群中,同一个方法在同一时间只能被一台机器上的一个线程执行。

    • 这把锁要是一把可重入锁(避免死锁)
    • 这把锁最好是一把阻塞锁(根据业务需求考虑要不要这条)
    • 有高可用的获取锁和释放锁功能
    • 获取锁和释放锁的性能要好

    source: 《分布式锁的几种实现方式》

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


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

    虽然直接借助数据库,容易理解。但是会有各种各样的问题,在解决问题的过程中会使整个方案变得越来越复杂,而且操作数据库需要一定的开销,性能问题需要考虑。

    二、基于Redis实现分布式锁(推荐)

    相比较于基于数据库实现分布式锁的方案来说,基于缓存来实现在性能方面会表现的更好一点。而且很多缓存是可以集群部署的,可以解决单点问题。目前有很多成熟的缓存产品,包括Redis,memcached以及Tair。在这里就以常见的Redis来讲讲其实现:

    Quartz定时器到时间被触发,程序开始先争取一个redis锁来执行任务。redis的命令可以采用如下:

    SET key value [EX seconds] [PX milliseconds] [NX|XX]
    
       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;
        }
    
    • 第一个为key,使用key来当锁,因为key是唯一的。
    • 第二个为value,可以传的是requestId,有key作为锁不就够了吗为什么还需要value呢?原因就是在上面讲到可靠性时,分布式锁需要特定的客户端来解锁,通过给value赋值为requestId就知道这把锁是哪个请求加的了,在解锁的时候就可以有依据。requestId可以使用UUID.randomUUID().toString()方法生成。
    • 第三个为nx,这个参数我们填的是NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作;
    • 第四个为expx,这个参数我们传的是PX,意思是我们要给这个key加一个过期的设置,具体时间由第五个参数决定。
    • 第五个为timev,与第四个参数相呼应,代表key的过期时间,1000等价于1s。

    总的来说,执行上面的set()方法就只会导致两种结果:

    1. 当前没有锁(key不存在),那么就进行加锁操作,并对锁设置个有效期,同时value表示加锁的客户端
    2. 已有锁存在,不做任何操作。

    加锁代码满足我们可靠性里描述的三个条件。首先,set()加入了NX参数,可以保证如果已有key存在,则函数不会调用成功,也就是只有一个客户端能持有锁,满足互斥性。其次,由于我们对锁设置了过期时间,即使锁的持有者后续发生崩溃而没有解锁,锁也会因为到了过期时间而自动解锁(即key被删除),不会发生死锁。最后,因为我们将value赋值为requestId,代表加锁的客户端请求标识,那么在客户端在解锁的时候就可以进行校验是否是同一个客户端。

    当然除了setnx(),还有基于Redlock做分布式锁、基于Redisson做分布式锁,Redis做分布式锁是性能最好的(优于Zookeeper锁),但是可靠性方面逊于ZK,且通过超时时间来控制锁的失效时间并不是十分的靠谱。

    source:Redis分布式锁的正确实现方式

    三、基于Zookeeper实现分布式锁(推荐)

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

    ZK锁可分为排他锁和共享读锁两种情况:

    • 排他锁,又称写锁或独占锁。如果事务T1对数据对象O1加上了排他锁,那么在整个加锁期间,只允许事务T1对O1进行读取或更新操作,其他任务事务都不能对这个数据对象进行任何操作,直到T1释放了排他锁。排他锁核心是保证当前有且仅有一个事务获得锁,并且锁释放之后,所有正在等待获取锁的事务都能够被通知到。

    • 共享锁,又称读锁。如果事务T1对数据对象O1加上了共享锁,那么当前事务只能对O1进行读取操作,其他事务也只能对这个数据对象加共享锁,直到该数据对象上的所有共享锁都被释放。共享锁与排他锁的区别在于,加了排他锁之后,数据对象只对当前事务可见,而加了共享锁之后,数据对象对所有事务都可见。

    排他锁流程

    以上两种锁方式在加锁解锁操作上都类似的,获取锁上都可以通过客户端通过调用create方法创建表示锁的临时节点,可以认为创建成功的客户端获得了锁,同时可以让没有获得锁的节点在该节点上注册Watcher监听,以便实时监听到lock节点的变更情况。释放锁,两种情况都可以让锁释放:1、当前获得锁的客户端发生宕机或异常,那么Zookeeper上这个临时节点就会被删除;2、正常执行完业务逻辑,客户端主动删除自己创建的临时节点。

    注意:当集群规模比较大时,系统中将有大量的 “Watcher通知” 和 “子节点列表获取” 这个操作重复执行,这些 操作会对Zookeeper造成巨大的性能影响和网络冲击,引发“羊群效应”,获取锁的方式可以优化。

    总结:在可靠性方面,ZK锁是优于Redis,释放锁上无需失效时间来保证更为稳定靠谱,但是牺牲了一定的性能,且实现相对复杂。

    source: 《利用Zookeeper实现 - 分布式锁》

    相关文章

      网友评论

          本文标题:分布式锁的应用与实现原理

          本文链接:https://www.haomeiwen.com/subject/upxkqftx.html