for update什么时候释放锁_【分布式】越不过去的分布式锁

aa616d129ac5b207185fd4eb268ea090.png

各位学习Java的过程中,一定会遇到各种锁的概念,尤其是牵涉到JUC多线程,什么公平锁、非公平锁、乐观锁、悲观锁、自旋锁、偏向锁、读写锁、互斥锁等等。初识の时,一头雾水,甚至工作几年,大多时间还是在做一些增删改查的业务编码,多线程方面的知识在实际的开发中也很少使用。其实大可不必着急,静下心来,补补基础,后续我会针对JUC部分的内容推出系列文章,敬请关注!

而今天,我们来一起聊聊分布式锁,探讨一下高并发场景下的一些互斥机制的实现。


一、为什么要使用分布式锁

我们在开发单体服务的时候,如果需要多线程同步访问某一个共享变量,我们一般会用synchronized、ReentrantLock、Atomic等各种方法同步代码块或者对象,来锁定JVM内部的一块内存空间。

后来垂直架构横向扩容,做负载集群,一个应用可能需要部署到多台服务器上做负载,大致如下图:

47967dac58c8dfba40a90bfd681eb5fd.png

从上图可以看出,变量M在三个JVM内存中各有一份,如果不做控制的话,变量M会在三个服务器中同时分配一块内存,三个请求通过Nginx路由分发,同时针对变量A做操作,而变量M之间不存在共享,也不具有可见性,那么就会出现数据不一致的问题,结果自然也是不对的。

而在分布式架构中,因为涉及到各种服务之间的数据共享,包括各自的多线程处理且分布在各种环境下的不同机器上,使得原始单机部署下的并发控制锁策略彻底失效,单纯的JAVA API并不能提供分布式锁的能力。为了保证一个方法或者属性在分布式高并发情况下在同一时间只能被同一个线程执行,就需要一种跨JVM的互斥机制来控制共享资源的访问,这就是分布式锁要解决的事情。


二、分布式锁应该满足的要求

在分析分布式锁的各种实现方式之前,我们先了解一下分布式锁需要做到哪些要求?

  1. 在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行;
  2. 高可用、高性能的获取锁与释放锁;
  3. 具备可重入特性;
  4. 具备锁失效机制,防止死锁;
  5. 具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。

三、分布式锁的实现方式介绍

牵涉到分布式锁的实现方案,我们先了解一下分布式的CAP理念,即Consistency-一致性、Availability-可用性、Partition tolerance-分区容错性,而在一个分布式系统中,最多只能同时保证三个特性中的两个,三者不可兼得。

现如今,对于多数大型互联网应用的场景,主机众多、部署分散,而且现在的集群规模越来越大,节点只会越来越多,所以节点故障、网络故障是常态,因此分区容错性也就成为了一个分布式系统必然要面对的问题。那么就只能在C和A之间进行取舍。但对于传统的项目就可能有所不同,拿银行的转账系统来说,涉及到金钱的对于数据一致性不能做出一丝的让步,C必须保证,出现网络故障的话,宁可停止服务,可以在A和P之间做取舍。

总而言之,没有最好的策略,好的系统应该是根据业务场景来进行架构设计的,只有适合的才是最好的。

为了保证分布式场景中的数据一致性问题,目前实现分布式锁的主流方案有以下几种方案:

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

基于数据库实现分布式锁

1、基于数据库表

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

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

创建这样一张数据库表:

CREATE TABLE `methodLock` (
     `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
     `method_name` varchar(64) NOT NULL DEFAULT '' COMMENT '锁定的方法名',
     `method_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,method_desc) VALUES ('method_name','desc')

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

当方法执行完毕之后,想要释放锁的话,需要执行以下SQL:

delete from methodLock where method_name ='method_name'

使用基于数据库的这种实现方式很简单,但是对于分布式锁应该具备的条件来说,它有一些问题需要解决及优化:

  • 因为是基于数据库实现的,数据库的可用性和性能将直接影响分布式锁的可用性及性能,所以,数据库需要双机部署、数据同步、主备切换;
  • 不具备可重入的特性,因为同一个线程在释放锁之前,行数据一直存在,无法再次成功插入数据,所以,需要在表中新增一列,用于记录当前获取到锁的机器和线程信息,在再次获取锁的时候,先查询表中机器和线程信息是否和当前机器和线程相同,若相同则直接获取锁;
  • 没有锁失效机制,因为有可能出现成功插入数据后,服务器宕机了,对应的数据没有被删除,当服务恢复后一直获取不到锁,所以,需要在表中新增一列,用于记录失效时间,并且需要有定时任务清除这些失效的数据;
  • 不具备阻塞锁特性,获取不到锁直接返回失败,所以需要优化获取逻辑,循环多次去获取。
  • 在实施的过程中会遇到各种不同的问题,为了解决这些问题,实现方式将会越来越复杂;依赖数据库需要一定的资源开销,性能问题需要考虑。

2、基于数据库资源表做乐观锁

乐观锁大多数是基于数据版本(version)的记录机制实现的。何谓数据版本号?即为数据增加一个版本标识,在基于数据库表的版本解决方案中,一般是通过为数据库表添加一个 “version”字段来实现读取出数据时,将此版本号一同读出,之后更新时,对此版本号加1。

在更新过程中,会对版本号进行比较,如果是一致的,没有发生改变,则会成功执行本次操作;如果版本号不一致,则会更新失败。

(1). 假设我们有一张资源表,如下图所示: t_resource , 其中有6个字段id, resoource, state, add_time, update_time, version,分别表示表主键、资源、分配状态(1未分配 2已分配)、资源创建时间、资源更新时间、资源数据版本号。

76d6f5f4a33cab0fc6142b3b5ff4a118.png

a. 先执行select操作查询当前数据的数据版本号,比如当前数据版本号是26:

select id, resource, state,version from t_resource  where state=1 and id=5780;

b. 执行更新操作:

update t_resoure set state=2, version=27, update_time=now() where resource=xxxxxx and state=1 and version=26

c. 如果上述update语句真正更新影响到了一行数据,那就说明占位成功。如果没有更新影响到一行数据,则说明这个资源已经被别人占位了。

基于数据库表做乐观锁的一些缺点:

  • 这种操作方式,使原本一次的update操作,必须变为2次操作: select版本号一次;update一次。增加了数据库操作的次数。
  • 如果业务场景中的一次业务流程中,多个资源都需要保证数据一致性,那么如果全部使用基于数据库资源表的乐观锁,就要让每个资源都有一张资源表,这个在实际使用场景中肯定是无法满足的。而且这些都基于数据库操作,在高并发的要求下,对数据库连接的开销一定是无法忍受的。
  • 乐观锁机制往往基于系统中的数据存储逻辑,因此可能会造成脏数据被更新到数据库中。在系统设计阶段,我们应该充分考虑到这些情况出现的可能性,并进行相应调整,如将乐观锁策略在数据库存储过程中实现,对外只开放基于此存储过程的数据更新途径,而不是将数据库表直接对外公开。

3、基于数据库表的悲观锁

通过数据库的排他锁来实现分布式锁。 基于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){
             e.printStackTrace();
        }
        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,那么一个排他锁长时间不提交,就会占用数据库连接。一旦类似的连接变得多了,就可能把数据库连接池撑爆!

2b4999105552b9278310f1624aebda82.png

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

  • 会有各种各样的问题,在解决问题的过程中会使整个方案变得越来越复杂。
  • 操作数据库需要一定的开销,性能问题需要考虑。在高并发的场景下开销常常是不能容忍的,容易出现数据库死锁等情况。
  • 乐观锁只能对一张表的数据进行加锁,如果是需要对多张表的数据操作加分布式锁,基于版本号的乐观锁是办不到的。

基于缓存

1、基于Redis

Redis提供了setNx原子操作。基于redis的分布式锁也是基于这个操作实现的,SETNX是指如果有这个key就set失败,如果没有这个key则set成功,但是SETNX不能设置超时时间。

命令介绍:

(1)SETNX

SETNX key val:当且仅当key不存在时,set一个key为val的字符串,返回1;若key存在,则什么都不做,返回0。

(2)expire

expire key timeout:为key设置一个超时时间,单位为second,超过这个时间锁会自动释放,避免死锁。

(3)delete

delete key:删除key

基于redis组成的分布式锁解决方案为:

  1. SETNX一个锁key,相应的value为当前时间加上过期时间的时钟;
  2. 如果SETNX成功,或者当前时钟大于此时key对应的时钟则加锁成功,否则加锁失败退出;
  3. 加锁成功执行相应的业务操作(处理共享数据源);
  4. 释放锁时判断当前时钟是否小于锁key的value,如果当前时钟小于锁key对应的value则执行删除锁key的操作。

这对于单点的redis能很好地实现分布式锁,如果redis集群,会出现master宕机的情况。如果master宕机,此时锁key还没有同步到slave节点上,会出现机器B从新的master上获取到了一个重复的锁。

设想以下执行序列:

  1. 机器A SETNX 了一个锁key,value为当前时间加上过期时间,master更新了锁key的值;
  2. 此时master宕机,选举出新的master,新的master正同步数据;
  3. 新的master不含锁key,机器B SETNX 了一个锁key,value为当前时间加上过期时间;

这样机器A和机器B都获得了一个相同的锁;解决这个问题的办法可以在第3步进行优化,内存中存储了锁key的value,在执行访问共享数据源前再判断内存存储的锁key的value与此时redis中锁key的value是否相等如果相等则说明获得了锁,如果不相等则说明在之前有其他的机器修改了锁key,加锁失败。同时在第4步不仅仅判断当前时钟是否小于锁key的value,也可以进一步判断存储的value值与此时的value值是否相等,如果相等再进行删除。

此时的执行序列:

  1. 机器A SETNX 了一个锁key,value为当前时间加上过期时间,master更新了锁key的值;
  2. 此时,master宕机,选举出新的master,新的master正同步数据;
  3. 机器B SETNX 了一个锁key,value为此时的时间加上过期时间;
  4. 当机器A再次判断内存存储的锁与此时的锁key的值不一样时,机器A加锁失败;
  5. 当机器B再次判断内存存储的锁与此时的锁key的值一样,机器B加锁成功。

如果是为了效率而使用分布式锁,例如:部署多台定时作业的机器,在同一时间只希望一台机器执行一个定时作业,在这种场景下是允许偶尔的失败的,可以使用单点的redis分布式锁;如果是为了正确性而使用分布式锁,最好使用再次检查的redis分布式锁,再次检查的redis分布式锁虽然性能下降了,但是正确率更高。

7de93c342cf616cc168f66799a2a8ca0.png

以上是Redis分布式锁的普通实现,也是大部分人基于Redis能够想到的。

Redis 官方站这篇文章提出了一种权威的基于 Redis 实现分布式锁的方式名叫 Redlock,此种方式比原先的单节点的方法更安全。它可以保证以下特性:

  • 安全特性:互斥访问,即永远只有一个 client 能拿到锁
  • 避免死锁:最终 client 都可能拿到锁,不会出现死锁的情况,即使原本锁住某资源的 client crash 了或者出现了网络分区
  • 容错性:只要大部分 Redis 节点存活就可以正常提供服务

关于redlock算法会在后续章节做着重介绍和验证,这里暂时不做过多介绍。

2、基于Memcached

Memcached 可以使用 add 命令,该命令只有KEY不存在时,才进行添加,或者不会处理。

Memcached 所有命令都是原子性的,并发下add 同一个KEY ,只会一个会成功。

利用这个原理,可以先定义一个 锁 LockKEY ,add 成功的认为是得到锁。并且设置[过期超时] 时间,保证宕机后,也不会死锁。

在具体操作完后,判断是否此次操作已超时。如果超时则不删除锁,如果不超时则删除锁。

伪代码:

if (mc.Add("LockKey", "Value", expiredtime)) {
                //得到锁
                try {
                    //do business  function

                    //检查超时
                    if (!CheckedTimeOut()){
                        mc.Delete("LockKey");
                    }

                } catch (Exception e){
                    mc.Delete("LockKey");
                }
            }

需要注意的是:过期时间一定要长于业务操作的执行时间。

基于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方法用于释放锁。

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

但是性能上可能并没有缓存服务那么高。因为每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。ZK中创建和删除节点只能通过Leader服务器来执行,然后将数据同不到所有的Follower机器上。

参考文章:

  • Distributed locks with Redis:https://redis.io/topics/distlock
  • Apache Curator:https://curator.apache.org/
  • 分布式锁简单入门以及三种实现方式介绍:https://blog.csdn.net/xlgen157387/article/details/79036337
  • 分布式锁的几种实现方式:https://www.cnblogs.com/garfieldcgf/p/6380816.html

结束语:针对分布式锁的各种实现方式,没有在所有场合都是完美的,所以,应根据不同的应用场景选择最适合的实现方式。顺手帮转哟。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值