前言对多线程有所了解的朋友一般都熟悉一个概念:锁。在多线程并发场景下,要保证同一时刻只有一个线程可以操作某个业务、数据或变量,通常需要使用锁机制。比如synchronized或者Lock等。随着架构的演进和业务的发展,我们的应用往往不仅仅部署在一台服务器上,而是采用分布式集群架构,同时存在多个相同的应用。例如,电商网站销售商品时,由于商品数量有限,每个用户在购买商品时需要将商品库存减1;活动期间,可能会有大量用户购买同款商品,同时商品库存会减1,如果不锁定,商品超卖的可能性很大。为了保证这种分布式场景下共享数据的安全性和一致性,就需要使用分布式锁。上例中的商品库存是共享数据。什么是分布式锁?顾名思义,分布式锁就是在分布式场景下,共享数据在同一时刻只能被一个应用的一个线程操作。用于保证共享数据的安全性和一致性。分布式锁应该满足哪些需求下面我们来分析一下,如果要实现分布式锁,需要满足哪些需求呢?首先,最基本的是,我们要保证只有一个应用的一个线程可以同时执行lock方法,或者获取到锁;(一个应用程序线程执行)然后我们的分布式锁可能有很多服务器。采集,所以我们要能够高性能的采集和释放;(高性能)不能因为分布式锁获取到的一个服务不可用而导致所有服务无法获取或释放锁,所以必须满足高可用性要求;(高可用)假设一个应用程序获取到锁后一直没有释放锁,服务本身可能已经挂了,一直无法释放,导致其他服务无法获取到锁;(防止死锁的锁失败机制)1如果应用程序成功获取锁,也可以再次成功获取锁;(reentrancy)当一个服务获取到锁的时候,假设锁已经被另一个服务获取到,我们必须能够直接返回失败,不能永远等待。(非阻塞特性)以上是所有分布式锁必须满足的一些基本要求。实现方法有哪些?那么我们可以采用什么方法来实现分布式锁呢?目前常见的主要有三种方式:基于数据库实现、基于ZooKeeper实现、基于Redis实现。接下来我们看看这三个方法是如何实现分布式锁的。使用数据库实现基于数据库的分布式锁有两种方式。第一种是基于数据库表实现。例如,我们有下表存储分布式锁记录:CREATETABLEmethodLock(`id`int(11)NOTNULLAUTO_INCREMENTCOMMENT'主键',`method_name`varchar(64)NOTNULLCOMMENT'锁定的方法名',`desc`varchar(1024)NOTNULLDEFAULT'remarks',`update_time`timestampNOTNULLDEFAULTnow()ONUPDATEnow()COMMENT'savetime',PRIMARYKEY(`id`),UNIQUEKEY`uidx_method_name`(`method_name`))ENGINE=InnoDBCOMMENT='分布式锁定方法';复制代码当我们的一个服务要执行某个需要分布式锁的方法时,会执行insert语句在表中插入一条记录。insertintomethodLock(method_name,desc)values('saleProduct','saleproductminusinventory');copycode因为我们在定义表的时候method_name添加了一个唯一约束,如果我们插入记录的时候有多个服务执行这个操作之后,数据库可以保证只有一个服务可以成功。我们认为只有插入成功的服务获取了锁,方法才能继续执行。当执行完该方法需要释放锁时,会执行一条delete语句,删除插入表中的记录。从methodLock中删除method_name='saleProduct';复制代码另一种是基于数据库独占锁。分布式锁除了上面的插入删除方式外,还借助排他锁来实现。我们可以利用上面方法中的表,为分布式方法预先插入一条记录。insertintomethodLock(method_name,desc)values('saleProduct','saleproductminusinventory');copycode当应用线程需要对方法加锁时,使用如下语句:selectmethod_namefrommethodLockwheremethod_name='saleProduct'forupdatecopycodeusesforupdate查询语句后,数据库在查询时给记录加独占锁,并且其他线程无法向记录添加锁。我们可以在查询数据的时候获取分布式锁,然后执行方法中的逻辑。方法执行完成后,提交事务,锁会自动释放。当然,它可以更简单。不是建表插入数据,而是直接锁住需要分发的数据。比如我们要对商品库存进行操作,数据库中通常会有一个商品库存记录表,如:t_product_quantity。在我们减少一个商品的库存之前,我们先通过如下SQL查询记录:selectproduct_no,quantitywhereproduct_no=xxxforupdatecopycode同样的查询会对商品的库存记录加排他锁,之后no其他线程可以锁定记录。接下来我们对库存数据进行操作后,提交交易,锁会自动释放;如果运行过程中出现异常,事务会回滚,锁也会自动释放。基于数据库分布式锁使用上面的方法还是比较简单的。但是我们回过头来看一下,这种方式能不能满足我们上面列举的分布式锁应该满足的要求呢?一个应用一个线程执行高性能&高可用因为是基于数据库实现的,所以高性能高可用依赖于数据库,需要多机部署,主从同步,主备切换等。失效机制需要手动删除,没有失效机制。如果要支持失败机制,需要单独添加定时任务,根据记录的更新时间定时清除。不可重入,因为线程获取成功后,锁记录会一直存在,无法再次获取。通过增加字段,记录占用锁的应用节点信息和线程信息,再次获取锁时判断当前线程获取的锁是否可重入。具备非阻塞特性,当获取锁失败时,直接返回失败。但是不能满足超时获取的场景,比如5秒内获取不到锁,然后失败。我们可以发现,这种方式虽然可以满足最基本的分布式锁能力,但是在实际使用中还需要针对一些问题进行优化。这些优化会变得越来越复杂,并且会出现一定的性能问题。所以一般不建议基于数据库做分布式锁。基于ZooKeeper,也可以基于ZooKeeper实现分布式锁。这里需要先铺垫一些ZK的基础知识。在ZK中,数据存储在数据节点中,数据节点称为Znode,ZK会将所有数据存储在内存中,所有数据的数据模型是一个树结构(ZNodeTree),不同层次的节点是以斜杠“/”分隔,如/zoo/cat,类似于文件系统结构。ZK中ZNode的数据节点分为以下四种:持久化节点持久化节点是ZK默认的节点类型。节点创建后,无论客户端是否与服务器断开连接,节点都会一直存在。临时节点不同于持久节点。客户端与服务器断开连接后,临时节点将被删除。顺序节点顾名思义,顺序节点具有顺序。在创建节点时,ZK会根据创建时间为每个节点分配一个序号。临时时序节点临时时序节点是临时节点和时序节点的组合。每个节点在创建时都会分配一个序号,当客户端与ZK服务器断开连接时,节点会被删除。ZK分布式锁实现原理在ZK中,没有类似Lock或Synchronized的API。它实现了分布式锁,依靠临时的时序节点来完成。要获取锁,首先需要在ZK中创建一个持久化节点ParentLock,代表一个分布式锁节点。当第一个client获取到锁时,会在ParentLock节点下创建一个时序临时节点001-Node,然后检查ParentLock下的所有临时时序节点,判断当前创建的节点是否在第一个。如果是,则表示锁定成功;当第二个client来获取锁的时候,同样会在ParentLock节点下创建一个时序临时节点002-Node,然后判断是否在第一位,因为第一位是001-Node,所以这是会向自己前面的001-Node注册一个Watcher来监控001-Node节点。此时客户端加锁失败,进入等待状态;当第三个client来的时候,同理因为新创建的003-Node不在第一位,所以在它前面的002-Node注册一个Watcher,以此类推。大家有没有注意到这里形成了一个链式结构,有点类似于JUC中的AQS结构。释放锁有两种场景。一种是业务处理完毕,锁正常释放时;另一种是客户端与服务器断开连接时。首先,正常释放时,客户端会显式删除ZK中的数据节点;例如,客户端1在业务处理完成后删除了001-Node。客户端与服务端断开连接可能发生在客户端获取锁成功后,执行过程中出现异常,或者应用程序崩溃,或者网络异常等原因。此时ZK会自动将相应的Node节点删除。由于Client2一直在监听001-Node节点,当001-Node节点被删除时,Client2会立即收到通知。这时Client2会再次查看节点列表,判断是否在前面。如果是,则占用Lock,说明锁成功;Client2释放锁后,Client3以同样的方式处理。以上就是使用ZooKeeper实现分布式锁的基本原理和过程。整个流程可以简化如下图所示。如果你想在Java中使用ZK,官方提供了API包zkClient。使用时可以引入zookeeper-3.4.6.jar和zkclient-0.1.jar;也可以使用第三方打包的工具包,比如Curator、Menagerie等。从上面我们可以看出,使用ZooKeeper实现分布式锁基本可以满足我们对分布式锁的要求。需要注意的一件事是,您必须使用顺序临时节点而不是临时节点。使用临时节点会造成羊群效应问题。使用Redis作为基于Redis的分布式锁也是一个很常见的选择。并且有多种实现。接下来,我们将一一解释。第一种方法:SETNX+EXPIRE这种方法可能是大多数朋友的第一反应。先通过SETNX获取锁,然后通过EXPIRE命令加上超时时间。这种方式有个很大的问题,就是这两条命令的操作不是原子操作,需要和Redis交互两次。客户端可能会在执行完第一条命令后挂掉,导致没有超时设置,那么锁就一直存在。为了解决这个问题,第二种方案诞生了。第二种:SETNX+VALUE该方法中的VALUE值保存了客户端计算的过期时间,通过SETNX命令一次性放入Redis;publicbooleangetLock(Stringkey,LongexpireTime){longexpireTime=System.currentTimeMills()+expireTime;Stringvalue=String.valueOf(expireTime);//锁成功if(jedis.setnx(key,value)==1){returntrue;}//获取锁valueStringcurrentValueStr=jedis.get(key);//如果过期时间小于系统时间,说明已经过期if(currentValueStr!=null&&Long.parseLong(currentValueStr)
