关于后端:分布式锁的多种实现方式详解

2次阅读

共计 7801 个字符,预计需要花费 20 分钟才能阅读完成。

前言
对多线程有所理解的敌人个别都会相熟一个概念:锁。
在多线程并发场景下,要保障在同一时刻只有一个线程能够操作某个业务、数据或者变量,通常须要应用加锁机制。比方 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;}
// 获取锁的 value
String 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 三种抉择,并且不同的抉择存在不同的一些特色和它的问题,心愿通过本文能让你对分布式锁有一个比拟全面的意识,在理论开发过程中可能做到“心中有谱,办事儿不慌”。

正文完
 0