一文带你精通分布式锁

发布于:2022-11-28 ⋅ 阅读:(376) ⋅ 点赞:(0)

在单机环境下,由于使用环境简单和通信可靠,锁的可见性和原子性很容易可以保证,可以简单和可靠地实现锁功能。到了分布式的环境下,由于公共资源和使用方之间的分离,以及使用方和使用方之间的分离,相互之间的通信由线程间的内存通信变为网络通信。网络通信的时延和不可靠,加上分布式环境中各种故障的常态化发生,导致实现一个可靠的分布式锁服务需要考虑更多更复杂的问题。

锁,核心是协调各个使用方对公共资源使用的一种机制。当存在多个使用方互斥地使用某一个公共资源时,为了避免并行使用导致的修改结果不可控,需要在某个地方记录一个标记,这个标记能够被所有使用方看到,当标记不存在时,可以设置标记并且获得公共资源的使用权,其余使用者发现标记已经存在时,只能等待标记拥有方释放后,再去尝试设置标记。这个标记即可以理解为锁。

在单机多线程的环境下,由于使用环境简单和通信可靠,锁的可见性和原子性很容易可以保证,所以使用系统提供的互斥锁等方案,可以简单和可靠地实现锁功能。到了分布式的环境下,由于公共资源和使用方之间的分离,以及使用方和使用方之间的分离,相互之间的通信由线程间的内存通信变为网络通信。网络通信的时延和不可靠,加上分布式环境中各种故障的常态化发生,导致实现一个可靠的分布式锁服务需要考虑更多更复杂的问题。

目前常见的分布式锁服务,可以分为以下三大类:

  • 基于数据库实现的锁服务:典型代表是 mysql
  • 基于分布式缓存实现的锁服务及其变种:典型代表是使用 Redis 实现的锁服务和基于 Redis 实现的 RedLock 方案;
  • 基于分布式一致性算法实现的锁服务:典型代表为 Zookeeperetcd 和 Chubby 等。

本文从上述三大类常见的分布式锁服务实现方案入手,从分布式锁服务的各个核心问题(核心架构、锁数据一致性、锁服务可用性、死锁预防机制、易用性、性能)展开,尝试对比分析各个实现方案的优劣和特点。

基于数据库实现的锁服务

基于数据库的实现方式的核心思想是:在数据库中创建一个表,表中包含方法名等字段,并在方法名字段上创建唯一索引,想要执行某个方法,就使用这个方法名向表中插入数据,成功插入则获取锁,执行完成后删除对应的行数据释放锁。

加解锁流程

(1)创建一个表:

DROP TABLE IF EXISTS `method_lock`;
CREATE TABLE `method_lock` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
  `method_name` varchar(64) NOT NULL COMMENT '锁定的方法名',
  `desc` varchar(255) NOT NULL COMMENT '备注信息',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uidx_method_name` (`method_name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';

(2)想要执行某个方法,就使用这个方法名向表中插入数据:

INSERT INTO method_lock (method_name, desc) VALUES ('methodName', '测试的methodName');

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

(3)成功插入则获取锁,执行完成后删除对应的行数据释放锁:

delete from method_lock where method_name ='methodName';

注意: 这只是使用基于数据库的一种方法,使用数据库实现分布式锁还有很多其他的方法。

锁安全性分析

1、数据库的可用性和性能将直接影响分布式锁的可用性及性能,当数据库发生故障时,锁就会失效,当然也可以通过数据库双机部署、数据同步、主备切换来提高可用性。

2、锁没有失效机制,如果客户端1获取锁后,服务器宕机了,对应的锁没有释放,当服务恢复后一直获取不到锁,可以在表中新增一列,用于记录失效时间,并且需要有定时任务清除这些失效的数据。

总结

1、 锁服务性能
由于锁数据基于数据库,且实现一个安全的锁机制需要应用层编写大量的代码。在并发度不高,且不想引入其他组件的情况下可以使用这种方法。

2、 数据一致性和可用性
如果是单点的数据库,当数据库挂掉之后锁就不可以了。

基于分布式缓存实现的锁服务

基于单 Redis 节点的分布式锁

基于分布式缓存实现的锁服务,思路最为简单和直观。和单机环境的锁一样,我们把锁数据存放在分布式环境中的一个唯一结点,所有需要获取锁的调用方,都去此结点访问,从而实现对调用方的互斥,而存放锁数据的结点,使用各类分布式缓存产品充当。

加解锁流程

加锁操作:

 SET resource_name my_random_value NX PX 30000

  • my_random_value 是由客户端生成的一个随机字符串,它要保证在足够长的一段时间内在所有客户端的所有获取锁的请求中都是唯一的,用于唯一标识锁持有方。
  • NX 表示只有当 resource_name 对应的 key 值不存在的时候才能 SET 成功。这保证了只有第一个请求的客户端才能获得锁,而其它客户端在锁被释放之前都无法获得锁。
  • PX 30000 表示这个锁结点有一个30秒的自动过期时间。(自动过期时间,目的是为了防止持有锁的客户端故障后,锁无法被释放导致死锁而设置,从而要求锁拥有者必须在过期时间之内执行完相关操作并释放锁)。

解锁操作:

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

释放锁的操作必须使用Lua脚本来实现,释放锁其实包含三步操作:'GET'、判断和'DEL',用Lua脚本来实现能保证这三步的原子性。

锁安全性分析

1、Redis 结点故障后,由于 Redis 的主从复制(replication)是异步的,这可能导致在 failover 过程中没有备份到锁数据,从而破坏锁的安全性。 2、程序执行耗时大于锁过期时间。可以考虑以下情景:

  • 客户端1获取锁成功
  • 客户端1在某个操作上执行了很长时间
  • 过期时间到,锁自动释放
  • 客户端2获取到了对应同一个资源的锁
  • 客户端1从阻塞中恢复过来,认为自己依旧持有锁,继续操作同一个资源,导致互斥性失效

     

这种就需要客户端实现锁续期的机制。在程序执行的过程定时检查锁是否快过期,如果快过期就延长锁的过期时间。

在上例中客户端1如果阻塞了很长时间(例如 Java 执行了长时间的 GC)导致客户端假死无法进行锁续期,还是会破坏锁的安全性。

image.png

总结

1、 锁服务性能
由于锁数据基于 Redis 等分布式缓存保存,基于内存的数据操作特性使得这类锁服务拥有着非常好的性能表现。同时锁服务调用方和锁服务本身只有一次RTT就可以完成交互,使得加锁延迟也很低。所以,高性能、低延迟是基于分布式缓存实现锁服务的一大优势。因此,在对性能要求较高,但是可以容忍极端情况下丢失锁数据安全性的场景下,非常适用。

2、 数据一致性和可用性
锁数据一致性基于上述的分析,基于分布式缓存的锁服务受限于通用分布式缓存的定位,无法完全保证锁数据的安全性,核心的问题为:

  • 锁数据写入的时候,没有保证同时写成功多份:任何事后的同步在机制上都是不够安全的,因此在故障时,锁数据存在丢失的可能。

基于多 Redis 节点的分布式锁

基于分布式缓存实现锁服务,在业界还存在各类变种的方案,其核心是利用不同分布式缓存产品的额外特性,来改善基础方案的各类缺点,各类变种方案能提供的安全性和可用性也不尽相同。此处介绍一种业界最出名,同时也是引起过最大争论的一个锁服务变种方案- RedLock。它基于 N 个完全独立的 Redis 节点(通常情况下 N 可以设置成5)

加解锁流程

运行Redlock算法的客户端依次执行下面各个步骤,来完成获取锁的操作:

  • 获取当前时间(毫秒数)
  • 按顺序依次向 N 个 Redis 节点执行获取锁的操作:这个获取操作跟前面基于单 Redis 节点的获取锁的过程相同,包含随机字符串 my_random_value,也包含过期时间(比如 PX 30000,即锁的有效时间)。为了保证在某个 Redis 节点不可用的时候算法能够继续运行,这个获取锁的操作还有一个超时时间(time out),它要远小于锁的有效时间(几十毫秒量级)。客户端在向某个 Redis 节点获取锁失败以后,应该立即尝试下一个 Redis 节点。这里的失败,应该包含任何类型的失败,比如该 Redis 节点不可用,或者该 Redis 节点上的锁已经被其它客户端持有。
  • 计算整个获取锁的过程总共消耗了多长时间:如果客户端从大多数 Redis 节点(>= N/2+1)成功获取到了锁,并且获取锁总共消耗的时间没有超过锁的有效时间(lock validity time),那么这时客户端才认为最终获取锁成功;否则,认为最终获取锁失败。
  • 如果最终获取锁成功了,那么这个锁的有效时间应该重新计算,它等于最初的锁的有效时间减去第3步计算出来的获取锁消耗的时间。
  • 如果最终获取锁失败了(可能由于获取到锁的 Redis 节点个数少于N/2+1,或者整个获取锁的过程消耗的时间超过了锁的最初有效时间),那么客户端应该立即向所有 Redis 节点发起释放锁的操作。

而释放锁的过程比较简单:客户端向所有 Redis 节点发起释放锁的操作,不管这些节点当时在获取锁的时候成功与否。

锁安全性分析

1、RedLock 的安全性依旧强依赖于系统时间,如果发生时钟跳跃就会出现问题,假设一共有5个 Redis 节点:A, B, C, D, E:

  • 客户端1成功锁住了 A, B, C,获取锁成功(但 D 和 E 没有锁住)。
  • 节点C时间异常,导致C上的锁数据提前到期,而被释放。
  • 客户端2此时尝试获取同一把锁:锁住了 C, D, E,获取锁成功。

2、缺乏锁数据丢失的识别机制和恢复机制,假设一共有5个 Redis 节点:A, B, C, D, E:

  • 客户端1成功锁住了 A, B, C,获取锁成功(但 D 和 E 没有锁住)。
  • 节点 C 崩溃重启了,但客户端1在 C 上加的锁没有持久化下来,丢失了。
  • 节点 C 重启后,客户端2锁住了 C, D, E,获取锁成功。

官方给出的解决方案是延迟重启,一个节点崩溃后,先不立即重启它,而是等待一段时间再重启,这段时间应该大于锁的有效时间。这样的话,这个节点在重启前所参与的锁都会过期,它在重启后就不会对现有的锁造成影响。这个方案,是在缺乏丢失数据识别的能力下,实现的较“悲观”的一个替代方案,首先其方案依旧依赖于时间,其次如何确定最大过期时间,也是一个麻烦的事情,因为最大过期时间很可能也一起丢失了(未持久化),再有延迟重启使得故障结点恢复的时间延长,增加了集群服务可用性的隐患。怎么来看,都不算一个优雅的方案。

3、仍未解决程序执行耗时大于锁过期时间的问题。

总结

1、锁服务性能

由于RedLock锁数据仍然基于Redis保存,所以和基于单点的Redis锁一样,具有高性能和低延迟的特性,不过由于引入多数派的思想,加锁和解锁时的并发写,所以在流量消耗来说,比基于单点的Redis锁消耗要大。从资源角度来说,是用流量换取了比单点Redis稍高的数据一致性和服务可用性。

2、数据一致性和可用性

RedLock的核心价值,在于多数派思想。不过根据上面的分析,它依然不是一个工程上可以完全保证锁数据一致性的锁服务。相比于基于单点Redis的锁服务,RedLock解决了锁数据写入时多份的问题,从而可以克服单点故障下的数据一致性问题,但是还是受限于通用存储的定位,其锁服务整体机制上的不完备,使得无法完全保证锁数据的安全性。在继承自基于单点的Redis锁服务缺陷(解锁不具备原子性;锁服务、调用方、资源方缺乏确认机制)的基础上,其核心的问题为:缺乏锁数据丢失的识别机制。

RedLock中的每台Redis,充当的仍旧只是存储锁数据的功能,每台Redis之间各自独立,单台Redis缺乏全局的信息,自然也不知道自己的锁数据是否是完整的。在单台Redis数据的不完整的前提下,没有识别机制,使得在各种分布式环境的典型场景下(结点故障、网络丢包、网络乱序),没有完整数据但参与决策,从而破坏数据一致性。

关于Redis分布式锁的安全性问题,在分布式系统专家Martin Kleppmann和Redis的作者antirez之间就发生过一场争论,感兴趣的可以看一下这篇文章

基于分布式一致性算法实现的锁服务

加解锁流程

获取锁

客户端尝试创建一个 znode 节点,比如 /lock 。那么第一个客户端就创建成功了,相当于拿到了锁;而其它的 客户端会创建失败(znode 已存在),获取锁失败。znode 应该被创建成 ephemeral 的。这是znode的一个特性,它保证如果创建 znode 的那个客户端崩溃了,那么相应的 znode 会被自动删除。这保证了锁一定会被释放。这个特性避免了设置锁的过期时间。

释放锁

持有锁的客户端访问共享资源完成后,将 znode 删掉,这样其它客户端接下来就能来获取锁了。如上所述的基于ZooKeeper 的分布式锁的实现,并不是最优的,它会引发 “herd effect”(羊群效应),降低获取锁的性能。可以设置锁节点为顺序临时节点,后面的节点 watch 前面的节点,当前面的节点删除时唤醒后面的节点从而避免羊群效应。

锁安全性分析

看起来这个锁相当完美,没有 Redlock 过期时间的问题,而且能在需要的时候让锁自动释放。但是他还是有阻塞了很长时间导致客户端假死的情况,可以考虑这一种情况:

  • 客户端1创建了 znode 节点 /lock,获得了锁。
  • 客户端1进入了长时间的 GC pause
  • 客户端1连接到 ZooKeeper 的 Session 过期了。znode 节点/lock被自动删除。
  • 客户端2创建了 znode 节点 /lock,从而获得了锁。
  • 客户端1从 GC pause 中恢复过来,它仍然认为自己持有锁。

看起来,用 ZooKeeper 实现的分布式锁也不一定就是安全的。该有的问题它还是有。但是,ZooKeeper 作为一个专门为分布式应用提供方案的框架,它提供了一些非常好的特性,是 Redis 之类的方案所没有的。像前面提到的 ephemeral 类型的 znode 自动删除的功能就是一个例子。

总结

本文通过分析三类分布式锁服务,基本涵盖了所有分布式锁服务中涉及到的关键技术,以及对应具体的工程实现方案。

基于分布式存储实现的锁服务,由于其内存数据存储的特性,所以具有结构简单,高性能和低延迟的优点。但是受限于通用存储的定位,其在锁数据一致性上缺乏严格保证,同时其在解锁验证、故障切换、死锁处理等方面,存在各种问题。所以其适用于在对性能要求较高,但是可以容忍极端情况下丢失锁数据安全性的场景下。

基于分布式一致性算法实现的锁服务,其使用类 Paxos 协议保证了锁数据的严格一致性,同时又具备高可用性。在要求锁数据严格一致的场景下,此类锁服务几乎是唯一的选择。但是由于其结构和分布式一致性协议的复杂性,其在性能和加锁延迟上,比基于分布式存储实现的锁服务要逊色。

所以实际应用场景下,需要根据具体需求出发,权衡各种考虑因素,选择合适的锁服务实现模型。无论选择哪一种模型,需要我们清楚地知道它在安全性上有哪些不足,以及它会带来什么后果。更特别的,如果是对于锁数据安全性要求十分严格的应用场景,那么需要更加慎之又慎。

本文含有隐藏内容,请 开通VIP 后查看

网站公告

今日签到

点亮在社区的每一天
去签到