前言
对多线程有所理解的敌人个别都会相熟一个概念:锁。
在多线程并发场景下,要保障在同一时刻只有一个线程能够操作某个业务、数据或者变量,通常须要应用加锁机制。比方synchronized或Lock等。
而随着架构演进、业务倒退,咱们的利用往往都不是只部署在一台服务器上,而是应用分布式集群架构,同时存在多台雷同的利用。
比方某电商网站在进行商品销售时,因为商品的数量是无限的,每个用户购买一件商品,须要将商品的库存减1;
然而咱们想一下,在“双十一”这种火爆的流动时,可能会有大量的用户购买同一件商品,同时对该商品库存减1,如果不加锁,则极有可能会造成商品超卖状况。
要保障在这种分布式场景下,共享数据的安全性和一致性,则须要应用分布式锁。下面例子中的商品库存就是共享数据。
什么是分布式锁
顾名思义,分布式锁是指在分布式场景下,保障同一时刻对共享数据只能被一个利用的一个线程操作。用来保障共享数据的安全性和一致性。

分布式锁应该满足哪些要求
当初咱们来剖析一下,咱们要实现一个分布式锁的话,须要满足哪些要求呢?

首先最根本的,咱们要保障同一时刻只能有一个利用的一个线程能够执行加锁的办法,或者说获取到锁;(一个利用线程执行)
而后咱们这个分布式锁可能会有很多的服务器来获取,所以咱们肯定要可能高性能的获取和开释;(高性能)
不能因为某一个分布式锁获取的服务不可用,导致所有服务都拿不到或开释锁,所以要满足高可用要求;(高可用)
假如某个利用获取到锁之后,始终没有来开释锁,可能服务自身曾经挂掉了,不能始终不开释,导致其余服务始终获取不到锁;(锁生效机制,避免死锁)
一个利用如果胜利获取到锁之后,再次获取锁也能够胜利;(可重入性)
在某个服务来获取锁时,假如该锁曾经被另一个服务获取,咱们要能间接返回失败,不能始终期待。(非阻塞个性)

以上是所有分布式锁要满足的一些根本要求。
实现形式有哪些
那么咱们能够采取哪种形式来实现分布式锁呢?目前常见的形式次要有以下三种:

基于数据库实现
基于ZooKeeper实现
基于Redis实现

接下来咱们看看这三种形式具体都是怎么实现分布式锁的。
基于数据库
应用数据库实现分布式锁,有两种形式。
第一种是基于数据库表实现。
比方咱们有如下表来保留分布式锁记录:
CREATE TABLE methodLock (

`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',  `method_name` varchar(64) NOT NULL COMMENT '锁定的办法名',`desc` varchar(1024) NOT NULL DEFAULT '备注信息',`update_time` timestamp NOT NULL DEFAULT now() ON UPDATE now() COMMENT '保留工夫',  PRIMARY KEY (`id`),UNIQUE KEY `uidx_method_name` (`method_name `)

) ENGINE=InnoDB COMMENT='分布式锁定的办法';
复制代码
当咱们的某个服务要执行某段须要分布式锁定的办法时,则执行插入语句,在该表中插入一条记录。
insert into methodLock(method_name,desc) values ('saleProduct','发售产品减库存');
复制代码
因为咱们在定义表时,method_name增加了惟一束缚,如果在咱们插入记录时,有多个服务都要执行这个操作,那么数据库能够保障只能有一个服务胜利,咱们认为只有插入胜利的那个服务获取到了锁,能够继续执行该办法。
当办法执行结束后,须要开释锁,则执行一条删除语句,将插入表中的记录删除。
delete from methodLock where method_name = 'saleProduct';
复制代码
另一种是基于数据库排他锁。
除了下面的通过插入删除的形式外,借助排它锁实现分布式锁。咱们能够应用下面办法中的表,对于要应用分布式的办法提前插入一条记录。
insert into methodLock(method_name,desc) values ('saleProduct','发售产品减库存');
复制代码
在某个利用线程须要对该办法加锁时,则应用如下语句:
select method_name from methodLock where method_name = 'saleProduct' for update
复制代码
在查问语句后应用for update,数据库在查问时给该条记录增加上排他锁,其余线程则无奈给该条记录增加锁。
咱们能够当做查问到数据时,则获取到分布式锁,接下来执行办法中的逻辑。在办法执行结束后,提交事务,锁会主动开释。
当然,还能够更简略一点,不必这么麻烦建一张表插入数据,而是对要进行分布式锁定的数据间接加锁。
比方咱们要操作商品库存,在数据库中个别会有商品库存记录的表,比方叫:t_product_quantity,咱们在对某商品减库存之前,先通过以下SQL查问出记录:
select product_no,quantity where product_no = xxx for update
复制代码
同样该查问会对该产品的库存记录增加排它锁,之后其余线程都不能够对该条记录加锁。
接下来咱们再对库存数据操作后,提交事务,锁会主动开释;如果操作过程中产生异样,事务回滚,也会主动开释锁。
应用以上基于数据库分布式锁的形式还是挺简略的。然而咱们来回头看一下,这种形式是否可能满足咱们下面列出来的分布式锁应该满足的要求呢?

一个利用一个线程执行
高性能&高可用

因为是基于数据库实现的,所以高性能和高可用依赖于数据库,须要多机部署,主从同步、主备切换等。

生效机制

须要手动删除,不具备生效机制。如果要反对生效机制,须要独自减少定时工作,依照记录的更新工夫定时革除。

可重入性

不具备,因为某线程在获取胜利后,锁记录会始终存在,无奈再次获取。
可通过减少字段,记录占有锁的利用节点信息和线程信息,再次获取锁时判断是否是以后线程获取的锁达到可重入的个性。

非阻塞个性

具备,在获取锁失败时,会间接返回失败。
然而无奈满足超时获取的场景,比方5秒内获取不到锁再失败等。

咱们能够发现,这种形式尽管能满足最根本的分布式锁能力,然而在理论应用时,还是要针对一些问题做出优化,这些优化将会越来越简单,并且存在肯定的性能问题。所以个别不倡议基于数据库做分布式锁。
基于ZooKeeper
基于ZooKeeper同样也能实现分布式锁,这里须要先铺垫一些ZK的基本知识。在ZK中,数据都是寄存在数据节点中,数据节点称为Znode,ZK会将所有的数据都寄存在内存中,所有的数据形成的数据模型是一个树状构造(ZNode Tree),不同层级的节点通过斜杠"/"宰割,如/zoo/cat,和文件系统构造相似。

ZNode
在ZK中的数据节点分为以下四种:
长久节点
长久节点是ZK默认的节点类型,创立节点后,不论客户端与服务端是否断开连接,该节点会始终存在。
长期节点
可长久节点不同,长期节点在客户端与服务端断开连接后,长期节点会被删除。

程序节点
顾名思义,程序节点具备程序,在创立节点时,ZK会依据创立工夫给每个节点指定程序编号。

长期程序节点
长期程序节点是长期节点和程序节点的结合体,每个节点创立时会指定程序编号,并且在客户端与ZK服务端断开时,节点会被删除。

ZK分布式锁实现原理
在ZK中并没有相似于Lock或Synchronized的API,它实现分布式锁依赖于长期程序节点来实现。
获取锁

首先须要在ZK中先创立一个长久节点ParentLock示意一个分布式锁节点。
第一个客户端来获取锁时,就在这个ParentLock节点下创立一个程序长期节点001-Node,而后查看ParentLock下所有长期程序节点,判断以后创立节点是否在第一位,如果是,示意加锁胜利;

之后第二个客户端来获取锁时,同样在ParentLock节点下创立一个程序长期节点002-Node,而后判断本人是否在第一位,因为这是第一位是001-Node,所以这是会向排在本人后面的001-Node注册一个Watcher,用来监听001-Node节点,此时该客户端加锁失败,进入期待状态;

当有第三个客户端来时,同理因为新创建的003-Node不在第一位,于是向排在本人后面的002-Node注册一个Watcher,以此类推。

有没有发现,这里是造成了一个链式构造,和JUC中的AQS构造有点类似。
开释锁
开释锁的场景分两种,一种是业务处理完毕,失常开释锁;还有一种是客户端与服务端断开连接。
首先失常开释时,客户端会显式地将ZK中的数据节点删除;比方Client 1在业务解决实现时,将001-Node删除。
而客户端与服务器断开连接的状况,可能产生在客户端获取锁胜利后,执行过程中产生异样,或利用解体,或网络异样等各种起因导致,这时ZK会主动将对应的Node节点删除。
因为Client 2始终在监听着001-Node节点,当001-Node节点删除后,Client 2会立即收到告诉,这时Client 2会再次查看节点列表,判断本人是否在最后面,如果是,则占有锁,示意加锁胜利;
当Client 2开释锁之后,Client 3采纳同样的形式解决。
以上就是应用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中;
public boolean getLock(String key,Long expireTime){

long expireTime = System.currentTimeMills()+expireTime;String value = String.valueOf(expireTime);// 加锁胜利if(jedis.setnx(key,value)==1){    return true;}// 获取锁的valueString currentValueStr = jedis.get(key);// 如果过期工夫小于零碎工夫,则示意已过期if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {    // 锁已过期,获取上一个锁的过期工夫,并设置当初锁的过期工夫    String oldValueStr = jedis.getSet(key_resource_id, expiresStr);    if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {        // 思考多线程并发的状况,只有一个线程的设置值和以后值雷同,它才能够加锁        return true;    }}//其余状况,均返回加锁失败return false;

}
复制代码
这种形式通过value将超时工夫赋值,解决了第一种计划的两次操作不原子性的问题。然而这种形式也有问题:

在锁过期时,如果多个线程同时来加锁,可能会导致多个线程都加锁胜利;
当多个线程都加锁胜利后,因为锁中没有加锁线程的标识,会导致多个线程都能够解锁;
超时工夫是在客户端计算的,不同的客户端的时钟可能会存在差别,导致在加锁客户端没有超时的锁,在另一个客户端曾经超时。

第三种:应用Lua脚本
同样是为了解决第一种计划中的原子性问题,咱们能够采纳Lua脚本,来保障SETNX+EXPIRE操作的原子性。
if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then

redis.call('expire',KEYS[1],ARGV[2])

else

return 0

end;
复制代码
在Java代码中,应用jedis.eval()执行加锁。
public boolean getLock(String key,String value){

String lua_scripts = "if redis.call('setnx',KEYS[1],ARGV[1]) == 1 "    + "then redis.call('expire',KEYS[1],ARGV[2]) return 1 else return 0 end";   Object result = jedis.eval(lua_scripts,Collections.singletonList(key),Collections.singletonList(value));return result.equals(1L);

}
复制代码
这种形式能够完全避免在加锁后中断设置不上超时工夫的问题。也不会存在有时钟不统一的问题,和高并发状况下多个线程都加上锁的问题。然而这种形式就肯定没有问题了吗?答案是否定的,看下图。

当服务A加锁胜利后,正在执行业务的过程中,锁过期啦,这时服务A是没有感知的;
接着服务B这时来获取锁,胜利获取到了;
紧接着,服务A解决完业务了,来开释锁,胜利开释掉了,而服务B这时还认为它的锁还在,在执行代码。
全乱套了有没有?认为本人加锁了,其实你没加;
认为本人解锁胜利了,其实解的是他人的锁;

这种计划的问题次要是因为两点:锁过期开释,业务没解决完;锁没有惟一身份标识。
第四种:SET NX PX EX + 惟一标识
对于误删锁的问题,咱们能够在加锁时,由客户端生成一个惟一ID作为value设置在锁中,在删除锁时先进行身份判断,再删除;加锁逻辑如下:
public boolean getLock(String key,String uniId,Long expireTime){

//加锁return jedis.set(key, uniId, "NX", "EX", expireTime) == 1;

}

// 解锁
public boolean releaseLock(String key,String uniId){

// 因为get和del操作并不是原子的,所以应用lua脚本String lua_script = "if redis.call('get',KEYS[1]) == ARGV[1] then  return redis.call('del',KEYS[1])     +"else return 0  end;";Object result = jedis.eval(lua_scripts,Collections.singletonList(key),Collections.singletonList(uniId));return result.equals(1L);

}
复制代码
这种形式解决了锁被误删的问题,然而同样存在锁超时生效,然而业务还未解决完的问题。
第五种:Redission框架
那么对于锁过期生效,业务未处理完毕的问题,该如何解决呢?
咱们能够在加锁胜利后,启动一个守护线程,在守护线程中隔一段时间就对锁的超时工夫再续长一点,直到业务解决实现后,开释锁,避免锁在业务处理完毕之前提前开释。
而Redission框架就是应用的这种机制,来解决的这个问题。

当一个线程去获取锁,在加锁胜利的状况下,那么它曾经同Lua脚本将数据保留在了redis中;
而后在加锁胜利的同时,启动watch Dog看门狗,每隔10秒查看是否还持有锁,如果是则将锁超时工夫缩短。
如果一开始就获取锁失败,则会始终循环获取。
这种计划的Redis锁总该没有问题了吧?格局小了呀我滴敌人,还有问题呢。

以上的这些计划,都只是在Redis单机模式下探讨的计划,如果Redis是采纳集群模式,还会存在一些问题,不过问题不是很离谱,咱们来简略解说一下。
在集群模式下,个别Master节点会将数据同步到Salve节点,如果咱们当初Master节点上加锁胜利,在同步到Salve节点之前,这个Master节点挂了,而后另一台Salve节点降级为Master节点,这时这个节点上并没有咱们的加锁数据;
此时另一个客户端线程来获取雷同的锁,它就会获取胜利,这时在咱们的利用中将会有两个线程同时获取到这个锁,这个锁也就不平安了。
为了解决这个问题,Redis的作者亲自出马了,提出了一种高级的分布式锁算法,很牛批,叫:RedLock。
第六种:RedLock+Redission
首先这个RedLock的意思并不是“红色的锁”,和红色没啥关系,而是Redis Distributed Lock,Redis分布式锁,看见没,这才是正主,官网出品。
RedLock的外围原理是这样的:

在Redis集群中选出多个Master节点,保障这些Master节点不会同时宕机;
并且各个Master节点之间互相独立,数据不同步;
应用与Redis单实例雷同的办法来加锁和解锁。

那么RedLock到底是如何来保障在有节点宕机的状况下,还能平安的呢?

假如集群中有N台Master节点,首先,获取以后工夫戳;
客户端依照程序应用雷同的key,value顺次获取锁,并且获取工夫要比锁超时工夫足够小;比方超时工夫5s,那么获取锁工夫最多1s,超过1s则放弃,持续获取下一个;
客户端通过获取所有能获取的锁之后减去第一步的工夫戳,这个时间差要小于锁超时工夫,并且要至多有N/2 + 1台节点获取胜利,才示意锁获取胜利,否则算获取失败;
如果胜利获取锁,则锁的无效工夫是本来超时工夫减去第三不得时间差;
如果获取锁失败,则要解锁所有的节点,不论该节点加锁时是否胜利,避免有漏网之鱼。

Redssion库对RedLock计划曾经做了实现,如果你的Redis是集群部署,能够看看应用办法。

参考文档:redis.io/topics/dist…

通过以上6种基于Redis的形式,咱们该如何抉择呢?能够按以下准则:

如果Redis是单机部署,应用计划五,Redission框架,加锁时可按场景开启watch dog机制;解锁时应用Lua脚本原子删除;
如果是集群部署,倡议采纳RedLock计划实现。

总结
本期内容次要跟大家解说了分布式锁的原理和不同的实现计划,有基于数据库,ZooKeeper和Redis三种抉择,并且不同的抉择存在不同的一些特色和它的问题,心愿通过本文能让你对分布式锁有一个比拟全面的意识,在理论开发过程中可能做到“心中有谱,办事儿不慌”。