分布式锁实现方案到底有哪些

您所在的位置:网站首页 分布式架构的优缺点 分布式锁实现方案到底有哪些

分布式锁实现方案到底有哪些

#分布式锁实现方案到底有哪些| 来源: 网络整理| 查看: 265

前言

前面几篇文章从系统架构方式的演进,谈到了单体架构、分布式架构的优缺点,其中特意说到了分布式架构由于功能的拆分,导致系统出现了不能保证数据一致性的问题,即分布式事务问题,从而引出了几种分布式事务的解决方案。文章的篇幅大都在说分布式事务,其实分布式系统中还有一个重要角色--分布式锁,一直没有提及。今天这篇文章我们就将目光移到分布式锁的身上,来说说分布式锁的那些事吧!

我要开始装逼了 什么是锁

说分布式锁之前,我们先说一说什么是锁、锁有什么作用?一般来说当代码中会有多个线程任务访问同一共享资源时,就会出现冲突或者错误,目前解决这种问题都是采用序列化访问共享资源的解决方案,即将共享资源放在某一代码块中并加锁,某一时刻只能有一个线程访问该代码块。从而我们可以得出结论,锁是一种方案,一种解决多线程访问共享资源不出问题的解决方案。

为什么需要分布式锁(distributed lock)

Java中提供了很多种类丰富的锁,每种锁因其特性的不同,可以在适当的场景下展现出非常高的效率,具体有哪些锁此处暂时也按下不表。Java中提供的锁,例如synchronized、ReentrantLock之类的锁,只能在单机JVM中使用,可以称之为单机锁。可在分布式系统环境下,一个功能可能会涉及到多个服务,不同的服务又部署在不同的服务器不同的JVM中,这样Java提供的单机锁就无能为力了。可在分布式环境下,我们仍然需要有一个类似synchronized类的锁,能够获得到锁之后能够正确的访问共享资源。对比单机模式,能够在分布式环境下提供这种能力的锁我们称之为分布式锁。

锁的种类很丰富,例如乐观锁、悲观锁;公平锁、非公平锁;可重入锁、不可重入锁;共享锁、排他锁;本文只考虑分布式情况下的排他锁。

分布式系统专家Martin在他的文章中写了他对分布式锁的理解,以下便是节选(原文为英文此处翻译成了中文)1:

锁的目的是确保在可能尝试执行同一工作的多个节点中,只有一个节点实际执行了该工作(一次至少只有一个节点)。这项工作可能是将一些数据写入共享存储系统,执行一些计算,调用一些外部API,等等。在较高的层次上,您可能希望在分布式应用程序中使用锁的原因有两个:一是为了效率,二是为了正确性。为了区分这些情况,您可以询问如果锁定失败会发生什么:

效率:使用锁可以避免不必要地重复两次相同的工作(例如,一些昂贵的计算)。如果锁定失败,并且两个节点最终执行相同的工作,结果是成本略有增加(您最终向AWS支付的费用比其他情况下多5美分)或带来轻微不便(例如,用户最终两次收到相同的电子邮件通知)。

正确性:使用锁可以防止并发进程互相攻击,从而破坏系统的状态。如果锁定失败,并且两个节点同时处理同一数据段,则会导致文件损坏、数据丢失、永久性不一致或其他严重问题。

分布式锁有什么要求

前面我们说到了分布式锁的使用场景,从中我们可以得出一些分布式锁的核心诉求,我总结大概有以下三点:

互斥性 在同一时间,锁只能被一个客户端的一个线程获得。

安全性 只有获得锁的线程才能释放锁,并且要有超时机制,客户端崩溃之后要能自动释放锁,不能出现死锁。

可用性 高性能的获得锁和释放锁,且不能有单点故障。

分布式锁的简单实现

根据锁资源本身的安全性,我们可以将分布式锁分为两大类:

基于异步复制的中间件,如MySQL、Redis等等

基于paxos协议的分布式一致性的中间件,如zookeeper、etcd等等

数据库实现分布式锁

首先说下结论,不建议使用基于数据库实现分布式锁。

数据库如何实现分布式锁

使用MySQL的唯一索引加上行锁机制,由线程名threadName、类名className、方法名methodName三个字段创建唯一索引。

在要获得锁的时候向DB中写一条记录,这条记录主要包含主键、当前占用锁的线程唯一 threadId、类名className、方法名methodName、锁的有效截止时间deadline和创建时间createTime等,并且以类名和方法名创建唯一索引,唯一threadId作为解锁的凭证,防止误解锁。如果插入成功表示当前线程获取到了锁,如果插入失败那么证明锁被其他客户端的线程获取。释放分布式锁的时候,客户端比对threadId直接删除对应的记录即可。如果客户端崩溃了,不能自行删除对应的记录,则需要一个定时任务补偿,查询当前时间已经大于锁的有效截止时间deadline的记录,删除即可。

CREATE TABLE `distributed_lock` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `class_name` varchar(64) NOT NULL COMMENT '类名', `method_name` varchar(64) NOT NULL COMMENT '方法名', `thread_id` varchar(64) NOT NULL COMMENT '获得锁的线程唯一ID', `deadline` datetime NOT NULL COMMENT '锁有效期截止时间', `create_time` datetime NOT NULL COMMENT '创建时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_lock_index` (`class_name`,`method_name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

我们将MySQL实现的分布式锁进行步骤拆解,具体如下:

加锁

客户端在表distributed_lock 插入记录,返回影响行数值为1,则代表该客户端获得了锁,反之没有获得锁。

解锁

客户端在执行完自己的业务代码之后,删除distributed_lock表中自己插入的数据,注意删除的时候要比对 thread_id ,这样才能避免客户端端误解锁。

锁超时

客户端如果发生异常,导致没有释放自己持有的锁时,我们此时需要引入一个定时任务进行补偿。即查询当前时间已经大于锁的有效截止时间deadline的记录进行删除。

数据库实现分布式锁优缺点 优点 使用MySQL实现简单,不用引入额外的中间件。MySQL的唯一索引加上行锁机制,可以保证在同一时刻只会有一个客户端的一个线程获得锁。 缺点

MySQL存在单点故障,并发量大的时候吞吐量急剧下降。如果要实现高可用的话,则需要搭建MySQL集群,如果采用异步复制的方式同步数据到slave,存在数据延迟或者数据丢失的风险,这样就会出现客户端已经在master数据库上获得到了锁,但数据未同步到slave时master宕机,集群高可用重新选举出新的master之后,客户端又可以获得同一把锁,这样的情况就不满足分布式锁的互斥性要求了。但是,如果MySQL集群采用半同步复制或者全同步复制到slave,则锁的性能、吞吐量又降低了很多。

锁失效机制实现的不够优雅,而且补偿任务删除数据会存在一定时间的延迟,延迟这段时间锁不可用。

Redis实现分布式锁

Redis是开发中常用的中间件,后端的开发也基本上都使用过。Redis基本上都是内存操作,性能好、速度快。接下来我们来用Redis实现一个简版分布式锁。

Redis如何实现分布式锁

我们用Redis的set命令实现加锁,用lua脚本来释放锁,用key的过期机制来保障分布式锁不会死锁。

可能有些头铁的老哥直接使用set加锁;使用expire命令设置key的有效期;使用del命令释放锁。别不相信,我还看过相关的文章,文章作者对不正确使用Redis实现分布式锁,导致出现商品出现超卖进行复盘。

我们将Redis实现的分布式锁进行步骤拆解,具体如下:

加锁 实现加锁方式一

单独使用set和expire命令来实现因为命令之间不是原子的关系,导致锁失效。所以我们首先想到用lua脚本来执行这两个命令,让他满足原子性,从而成功实现功能。具体的lua脚本如下:

if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then return redis.call('expire',KEYS[1],ARGV[2]) else return 0 end

KEYS[1] 代表 键名,ARGV[1] 代表键名对应的value,ARGV[2] 代表锁的时间

实现加锁方式二

从 Redis 2.6.12 版本开始,Redis对set命令进行了一系列的升级。

set key value [EX seconds] [PX milliseconds] [NX|XX]

SET 命令的行为可以通过一系列参数来修改:

EX seconds:将键的过期时间设置为 seconds 秒。执行 SET key value EX seconds 的效果等同于执行 SETEX key seconds value 。

PX milliseconds:将键的过期时间设置为 milliseconds 毫秒。 执行 SET key value PX milliseconds 的效果等同于执行 PSETEX key milliseconds value 。

NX:只在键不存在时,才对键进行设置操作。执行 SET key value NX 的效果等同于执行 SETNX key value 。

XX:只在键已经存在时,才对键进行设置操作。

对于返回值的升级,从 Redis 2.6.12 版本开始, SET 命令只在设置操作成功完成时才返回 OK;如果命令使用了 NX 或者 XX 选项;但是因为条件没达到而造成设置操作未执行;那么命令将返回空(nil)。

我们来验证一下,执行两次以下命令:

set bizLock randomValue EX 20 NX

randomValue是由客户端生成的一个随机字符串(可以使用UUID生成),相当于是客户端持有锁的标志。 NX 表示只有当 bizLock 对应的key值不存在的时候才能SET成功,相当于只有第一个请求的客户端才能获得锁。 EX 20 表示这个锁有效期20秒。

验证set命令

确实如Redis的文档所说,SET 命令只在设置操作成功完成时才返回 OK;如果命令使用了 NX 或者 XX 选项;但是因为条件没达到而造成设置操作未执行;那么命令将返回空(nil)。这样一个命令就可以保证分布式锁的互斥性和安全性两个要求。

解锁

前面说了有人直接使用del命令删除对应的key来实现解锁,这个是有问题的,很有可能误解锁。下面是一个Redis分布式锁服务的流程图:

分布式锁服务流程图 由于客户端A在成功获得分布式锁之后,自身发生GC导致出现STW(stop the world)现象或者是发生了网络问题,客户端的业务操作还没有完成,分布式锁的有效期已经到了,锁已经被释放,可是这个情况客户端A并不知晓。然后客户端B向分布式锁服务请求锁,获得锁成功之后,处理自己的业务逻辑。客户端B业务处理完成释放分布式锁,客户端A从GC停顿或者网络问题中恢复过来,继续自己的业务处理。这个过程存在两个问题:

假设客户端A在步骤5和6中间恢复过来,处理完业务使用del命令释放分布式锁,很明显他释放的是客户端B的锁,这个不符合分布式锁的安全性要求。

假设客户端A按照图中的流程一样,这样就意味着在同一时刻分布式系统中有两把一样的锁,导致业务处理重复出现错误数据。

问题1,那是因为判断是否拥有锁和解锁是两个步骤,不具有原子性。对于这个问题,我们可以使用lua脚本来解锁。Redis 2.6 版本通过内嵌支持 Lua 环境,会单线程原子性执行 Lua 脚本,保证 Lua 脚本在处理的过程中不会被任意其它请求打断。

lua解锁脚本如下:

if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end

问题2,客户端发生GC导致停顿过久获得的分布式锁失效,且造成系统中同一时间拥有两把分布式锁。对于这种问题,建议在最终提交业务逻辑之前,再检查一下自身是否还拥有锁,拥有的话提交不拥有的业务回滚。当然,这种方案只是尽可能的减少出错的可能性,并不能完全避免问题发生。(因为在你判断的那一刻可能锁还是你持有,但是判断结束这个锁就已经失效,被其他的线程获取,当然这种情况很极端,但是也不能说没有可能) 另外就是建议,保证业务处理接口的幂等性。例如在数据库层面添加唯一索引,使用数据库进行兜底。

Redis实现分布式锁优缺点 优点 代码实现起来简单,加锁解锁效率高。 客户端崩溃或者网络延迟,Redis的key过期策略和内存淘汰机制保证锁一定会被释放,保证不会阻塞所有流程。 Redis的优异的性能,全内存操作,可以支持很高的并发。 缺点

异步复制的缺点导致不满足互斥性的要求。使用Redis实现分布式锁自然是不能使用单个Redis,这样必然有单点故障,起码要使用集群的Redis,例如主从架构的Redis集群。Redis是使用异步复制的方式同步数据到slave,存在数据延迟或者数据丢失的风险,这样就会出现客户端已经在master上获得到了锁,但数据未同步到slave时master宕机,集群高可用重新选举出新的master之后,客户端又可以获得同一把锁,打破分布式锁的互斥性要求。

Redis服务器时钟漂移问题。简单的说,就是Redis服务器上的时间比正常的时钟快,导致锁提前过期释放。但客户端仍然认为自己持有锁,打破分布式锁的互斥性要求。

RedLock算法实现分布式锁

假设我们有N个master节点,官方文档里将N设置成5,其实大等于3就行。加锁大概流程如下:

获取当前服务器时间。 轮流用相同的key和随机值在N个节点上请求锁,在这一步里,客户端在每个master上请求锁时,会有一个和总的锁释放时间相比小的多的超时时间。比如如果锁自动释放时间是10秒钟,那每个节点锁请求的超时时间可能是5-50毫秒的范围,这个可以防止一个客户端在某个宕掉的master节点上阻塞过长时间,如果一个master节点不可用了,我们应该尽快尝试下一个master节点。 客户端计算第二步中获取锁所花的时间,只有当客户端在大多数master节点上成功获取了锁(在这里是3个),而且总共消耗的时间不超过锁释放时间,这个锁就认为是获取成功了。 如果锁获取成功了,那现在锁自动释放时间就是最初的锁释放时间减去之前获取锁所消耗的时间。 如果锁获取失败了,不管是因为获取成功的锁不超过一半(N/2+1)还是因为总消耗时间超过了锁释放时间,客户端都会到每个master节点上释放锁,即便是那些他认为没有获取成功的锁。

这个地方说说关于集群相关知识点,节点发生宕机,剩余多少节点集群仍然可以正确使用? 计算公式: 当前剩余节点数量 > n/2

注意公式里大于 > 号,不带等于 = 号,这个主要是为了最大程度上防止选举投票时出现票数相同导致投票失败,从而提高投票效率。

问题1: 多少个节点可以搭建成集群

1个节点能否搭建集群 1-1 ?>0.5 (1是最少宕机1个节点,条件不成立),1个节点不能搭建集群

2个节点能否搭建集群 2-1 ?>1 (条件不成立),2个节点不能搭建集群

3个节点能否搭建集群 3-1 ?>1.5 (条件成立),3个节点是搭建集群的最小单位

4个节点能否搭建集群 4-1 ?>2 (条件成立),4个节点能搭建集群

问题2: 为什么集群节点都是奇数,而不是偶数?

3个节点允许宕机的最大的数量是几个节点? 3-2 >1.5条件不成立,3-1 >1.5条件成立,因此3个节点最多允许宕机1个节点.

4个节点允许宕机的最大的数量是几个节点? 4-2 >2条件不成立,4-1 >2条件成立,因此4个节点最多允许宕机1个节点。

结论: 5个节点与6个节点搭建的集群最大都是容忍2个节点宕机,所以从成本的角度考虑,5个节点更节省资源。

Zookeeper实现分布式锁

ZooKeeper是开放源码的分布式应用程序协调服务,是Google的Chubby一个开源的实现。基于它可以实现诸如数据发布/订阅、命名服务、分布式协调/通知、分布式锁等功能。下面我们先来介绍一下zookeeper相关的知识点。

我们都知道Zookeeper有4类节点类型,分别是:

持久化目录节点(PERSISTENT )

客户端与zookeeper断开连接之后,zookeeper中该节点仍然存在。

创建节点语法:

create /path data

示例:创建一个test节点,数据是123

create /test 123

持久化顺序编号目录节点(PERSISTENT_SEQUENTIAL)

Zookeeper给该节点名称进行顺序编号,客户端与zookeeper断开连接后,该节点依旧存在。

创建持久化顺序编号目录节点语法:

create -s /path data

示例:创建一个顺序编号目录test节点,数据是123

[zk: localhost:2181(CONNECTED) 35] create -s /test 123 Created /test0000000006 [zk: localhost:2181(CONNECTED) 36]

临时目录节点(EPHEMERAL )

客户端与zookeeper断开连接之后,该节点则被删除。

创建临时目录节点目录节点语法:

create -e /path data

示例:创建一个临时目录test节点,数据是123

[zk: localhost:2181(CONNECTED) 36] create -e /test 123 Created /test [zk: localhost:2181(CONNECTED) 37]

临时顺序编号目录节点(EPHEMERAL_SEQUENTIAL )

Zookeeper给该节点名称进行顺序编号,客户端与zookeeper断开连接后,该节点则被删除。

创建临时顺序编号目录节点语法:

create -e -s /path data

示例:创建一个临时顺序编号目录test节点,数据是123

[zk: localhost:2181(CONNECTED) 38] create -s -e /test 123 Created /test0000000008 [zk: localhost:2181(CONNECTED) 39]

最终创建的节点数据如下图:

[zk: localhost:2181(CONNECTED) 43] ls -R / / /test /test0000000006 /test0000000008 /zookeeper /zookeeper/config /zookeeper/quota [zk: localhost:2181(CONNECTED) 44]

前面说到临时目录节点当客户端断开之后就会被删除,那么试着将客户端重启,剩余的数据如下图:

[zk: localhost:2181(CONNECTED) 4] ls -R / / /test0000000006 /zookeeper /zookeeper/config /zookeeper/quota [zk: localhost:2181(CONNECTED) 5]

果然,除了zookeeper自己的、持久化的节点之外,临时目录节点都已经被删除了。

其实zookeeper版本更新之后,又增加了3种类型的节点,但这里没有介绍。

这里在提一下Zookeeper的Watcher(事件监听器),他是ZooKeeper中的一个很重要的特性。ZooKeeper允许用户在指定节点上注册Watcher,并且在一些特定事件触发的时候,ZooKeeper 服务端会将事件通知客户端。这个特性在Zookeeper实现分布式锁的时候,可以用它实现高级一点的功能。这里用一张图来展示一下他的用途:

zookeeper的watcher

创建watcher的命令: get /path watch

localhost:2181(CONNECTED) 2] get /test0000000006 watch 'get path [watch]' has been deprecated. Please use 'get [-s] [-w] path' instead. 213213131 [zk: localhost:2181(CONNECTED) 3]

如果出现上述的提示,说明你的zookeeper版本比较新,上述的命令已经被废弃,替代的命令是 get [-s] [-w] /path,开启两个终端,一个终端创建watcher,一个终端修改对应节点内数据,就会触发watcher。

[zk: localhost:2181(CONNECTED) 3] get -w /test0000000006 213213131 [zk: localhost:2181(CONNECTED) 4] WATCHER:: WatchedEvent state:SyncConnected type:NodeDataChanged path:/test0000000006

那这几种节点还有watcher和实现分布式锁有什么关系呢?那就请接着往下看吧!

Zookeeper如何实现分布式锁

说了这么多到底如何用Zookeeper实现分布式锁呢?因为Zookeeper节点名称都是唯一的,我们可以这个特性加上临时目录节点就可以实现了。

简版

假设名为lock的节点,节点中存放获得锁的客户端的信息,这样的节点存在就代表某个客户端获得了锁,反之就是没有获得锁。这个简版分布式锁的原理是不是感觉和Redis实现原理一样,其实就是一样的!

代表锁的节点存在就说明已有客户端获得锁。 客户端断开连接,节点自动删除,即客户端释放锁,不依赖于过期时间。

在此申明一下Zookeeper实现分布式锁,也是需要搭建集群的,不能用单机,单机就会有单点故障,就不满足分布式锁高可用的要求了。

豪华版

前面实现的锁其实功能都比较简陋,如果一个客户端没有获取到锁,就直接没有他啥事情了,假设资源比较紧张,客户端阻塞在那,过一段时间来试一下能不能获得锁,这种轮训是比较耗费资源的,最好是虽然没有获取到锁,但是一旦锁空闲了,能来通知一下客户端“现在锁空出来了,你赶紧过来拿呀”。要实现这种效果就可以用上面提到的Watcher(事件监听器)了。想获得锁的客户端都来创建lock节点,如果创建失败就认为没有获得锁,然后去监听lock节点,貌似完美的实现了通知的功能。

都来抢夺锁

Redis 有一个键过期事件通知,也能实现类似的效果,但是他属于fire and forget,不保证可靠性的,所以可以忽略。之前见有人用这玩意延期相关的实现,这个方案可不行。

但是如果这个节点有太多的客户端监听,就会有些问题。一个客户端的释放锁,删除节点,所有watch这个lock节点的客户端都来尝试一下自己能否创建节点,这岂不是要命了!这其中占用的内存和网络带宽,不知道服务器能不能顶得住!

能不能顶得住

这个现象称之为“羊群效应”或者是“惊群效应”。那这种问题该如何解决呢?

要解决这种问题,我们就不能使用临时目录节点了,要使用临时顺序编号目录节点,且客户端不能监听一个节点,而是每个客户端创建的节点监听他之前的节点。假设锁空间是/lock,那么调整之后的分布式锁工作流程如下:

客户端在/lock 下创建临时顺序编号目录节点,第一个客户端创建的节点是/lock/lock0000000000,则第二个节点就是/lock/lock0000000001,其他的以此类推。 某个客户端获取/lock下的子节点列表,判断自己创建的节点是否为/lock的子节点列表中顺序编号最小的子节点,如果是最小的子节点,则认为成功获得锁;反之监听自己前面那个子节点的删除事件。后期监听到事件,重复此步骤直至获得到锁。 客户端执行自己的业务代码。 客户端执行完业务代码,删除自己创建的子节点。

我们将Zookeeper实现的分布式锁进行步骤拆解,具体如下:

加锁

客户端在锁空间/lock下创建临时顺序编号目录节点,子节点创建成功,遍历锁空间/lock下所有的子节点,若自己是最小的节点,则认为客户端获得了锁,反之认为没有获得锁,并监听自己前面那个子节点的删除事件。

解锁

客户端执行完自己的业务代码,直接删除客户端自己创建的节点即可。

锁超时

客户端如果发生异常,Zookeeper通过心跳机制认为客户端已经断开连接,会自动删除创建临时顺序编号目录节点。

Zookeeper实现分布式锁优缺点

我们都知道 Zookeeper 是典型的CP系统,他是基于Zab协议实现的,不会有采用异步主从复制 出现数据不一致的问题,可靠性非常之高。

优点 没有时钟跳跃的问题。解锁不依赖机器时钟,客户端断连,锁自动被释放。 实现简单。有良好的客户端Curator来实现。 可靠性强。Zookeeper 是典型的CP系统。 缺点

性能不够好。较多的客户端的申请、释放锁,zk集群的压力会比较大。

要维护一个Zookeeper集群,增大了运维难度。

网络故障或者GC停顿导致临时节点提前被删除。

image-20210826164521458

如果这个图不够形象,也可以看看前面Redis分布式锁服务的流程图,可能会更加形象。

如何抉择

本文重点关注的是分布式锁各种实现方案的基础理论,而且是最简单的实现理论,尤其是RedLock也只是说了他的简单加锁流程,都没有给出具体的代码,有兴趣的同学可以自己去实现一下。对于各种方案的分布式锁如何去抉择,这个要看我们系统状况和业务的要求。一般情况下的系统,都会使用到Redis,所以推荐优先使用Redis实现的分布式锁,或者直接使用Redisson,他提供了多种锁的实现,可以让你像使用JVM中的synchronized、ReentrantLock那样简单;如果系统中有用到zookeeper,那在一些重要的业务场景可以考虑使用zookeeper,毕竟它比Redis更可靠。当然有些时候,加锁也许不是最好的选择,有时换个思路,分而治之可能会有不一样的效果。

[1] 分布式系统专家Martin的《How to do distributed locking》



【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3