分布式锁知识

分布式锁的使用场景 在并发场景中,为了保证临界资源的数据一致性,我们会经常使用到“锁”这个工具对临界资源进行保护,让混乱的并发访问行为退化为秩序的串行访问行为. 分布式锁应该有以下性质: 独占性:同一把锁,同一时刻只能被一个取锁方占有 健壮性:不能产生死锁 对称性:加锁和解锁使用方必须用同一身份 高

分布式锁的使用场景

在并发场景中,为了保证临界资源的数据一致性,我们会经常使用到“锁”这个工具对临界资源进行保护,让混乱的并发访问行为退化为秩序的串行访问行为.

分布式锁应该有以下性质:

  • 独占性:同一把锁,同一时刻只能被一个取锁方占有

  • 健壮性:不能产生死锁

  • 对称性:加锁和解锁使用方必须用同一身份

  • 高可用:当分布式锁服务的基础组件中少量节点发声故障时,不应该影响分布式服务的稳定性

可以分为两大类型:

  • 主动轮询型:该模型类似于单机锁中的主动轮询 + cas(compare and swap) 乐观锁(认为锁被占有只是临时的,很快就可以被自己获取)模型,取锁方会持续对分布式锁发出尝试获取动作,如果锁已被占用则会不断发起重试,直到取锁成功为止

  • watch 回调型:在取锁方发现锁已被他人占用时,会创建 watcher 监视器订阅锁的释放事件,随后不再发起主动取锁的尝试;当锁被释放后,取锁方能通过之前创建的 watcher 感知到这一变化,然后再重新发起取锁的尝试动作

在分布式场景中,我个人觉得优势的天平在略微朝着 watch 回调型的实现策略倾斜. 这是因为分布式场景中”轮询“这一动作的成本相比于单机锁而言要高很多,背后存在的行为可能是一次甚至多次网络 IO 请求. 这种情况下,取锁方基于 watch 回调的方式,在确保锁被释放、自身有机会取锁的情况下,才会重新发出尝试取锁的请求,这样能在很大程度上避免无意义的轮询损耗.

当然,主动轮询型的分布式锁能够保证使用方始终占据流程的主动权,整个流程可以更加轻便灵活;此外,watch 机制在实现过程中需要建立长连接完成 watch 监听动作,也会存在一定的资源损耗. 因此这个问题没有标准答案,应该结合实际的需求背景采取不同的应对策略:在并发激烈程度较高时倾向于 watch 回调型分布式锁;反之,主动轮询型分布式锁可能会是更好的选择.

除此之外,基于 watch 回调模型实现的分布式锁背后可能还存在其他的问题,比如:当有多个尝试取锁的使用方 watch 监听同一把锁时,一次锁的释放事件可能会引发“惊群效应”. 这个问题以及对应的解决方案将会在本文第 4 章中进行探讨.

主动轮询式分布式锁

主动轮询型分布式锁的实现思路为:

  • 针对于同一把分布式锁,使用同一条数据进行标识(以 redis 为例,则为同一个 key 对应的 kv 数据记录

  • 假如在存储介质成功插入了该条数据(要求之前该 key 对应的数据不存在),则被认定为加锁成功

  • 把从存储介质中删除该条数据这一行为理解为释放锁操作

  • 倘若在插入该条数据时,发现数据已经存在(锁已被他人持有),则持续轮询,直到数据被他人删除(他人释放锁),并由自身完成数据插入动作为止(取锁成功)

  • 由于是并发场景,需要保证【 (1)检查数据是否已被插入(2)数据不存在则插入数据 】这两个步骤之间是原子化不可拆分的(在 redis 中是 set only if not exist —— SETNX 操作

(1)redis实现,用setnx操作,redis 还支持使用 lua 脚本自定义组装同一个 redis 节点下的多笔操作形成一个具备原子性的事务,这个lua脚本用于解锁

(2)mysql实现

  • 建立一张用于存储分布式锁记录的数据表

  • 以分布式锁的标识键作为表中的唯一键(类比于 redis 中的 key)

  • 基于唯一键的特性,同一把锁只能被插入一条数据,因此也就只能由一个使用方持有锁

  • 当锁被占有时,其他取锁方尝试插入数据时,会被 mysql 表的唯一键所拦截报错,进而感知到锁已被占用这一情报

  • 在表中可以新增一个字段标识使用方的身份. 完整的解锁动作可以基于 mysql 事务(使用 innodb 引擎)保证原子性:【(1)检查释放锁动作执行者的身份;(2)身份合法时才进行解锁】. 基于此,分布式锁的对称性性质能够得到保证.

如何应对死锁?

使用 redis 时,我们可以通过过期时间 expire time 机制得以保证,即便使用方因为异常原因导致无法正常解锁,锁对应的数据项也会在达到过期时间阈值后被自动删除,实现释放分布式锁的效果

因为锁的持有者并不能精确预判到自己持锁后处理业务逻辑的实际耗时,因此此处设置的过期时间只能是一个偏向于保守的经验值,假如因为一些异常情况导致占有锁的使用方在业务处理流程中的耗时超过了设置的过期时间阈值,就会导致锁被提前释放,其他取锁方可能取锁成功,最终引起数据不一致的并发问题.

针对于这个问题,在分布式锁工具 redisson 中给出了解决方案——看门狗策略(watch dog strategy):在锁的持有方未完成业务逻辑的处理时,会持续对分布式锁的过期阈值进行延期操作

但是对于redis的主从复制是弱一致性的,如果master节点发生故障,锁信息还没同步给别人,那就回出现多个人占有一把锁的情况。因此redis有提出redlock红锁的方案。

Redis Red clock

在红锁的实现中:

  • 我们假定集群中有 2N+1个 redis 节点(通常将节点总数设置为奇数,有利于多数派原则的执行效率)

  • 这些 redis 节点彼此间是相互独立的,不存在从属关系

  • 每次客户端尝试进行加锁操作时,会同时对2N+1个节点发起加锁请求

  • 每次客户端向一个节点发起加锁请求时,会设定一个很小的请求处理超时阈值

  • 客户端依次对2N+1个节点发起加锁请求,只有在小于请求处理超时阈值的时间内完成了加锁操作,才视为一笔加锁成功的请求

  • 过完2N+1个节点后,统计加锁成功的请求数量

  • 倘若加锁请求成功数量大于等于N+1(多数派),则视为红锁加锁成功

  • 倘若加锁请求成功数量小于N+1,视为红锁加锁失败,此时会遍历2N+1个节点进行解锁操作,有利于资源回收,提供后续使用方的取锁效率

红锁 RedLock 加锁流程的核心步骤包括:

  • 遍历对所有的 redis 锁节点,分别执行加锁操作

  • 对每笔 redis 锁节点的交互请求进行时间限制,保证控制在 singleNodesTimeout 之内

  • 对 singleNodesTimeout 请求耗时内成功完成的加锁请求数进行记录

  • 遍历执行完所有 redis 节点的加锁操作后,倘若成功加锁请求数量达到 redis 锁节点总数的一半以上,则视为红锁加锁成功

  • 倘若 redis 节点锁加锁成功数量未达到多数,则红锁加锁失败,此时会调用红锁的解锁操作,尝试对所有的 redis 锁节点执行一次解锁操作

在红锁 RedLock 的解锁流程中,会对遍历所有的 redis 锁节点,依次执行解锁操作.会基于 lua 脚本先检查后删数据,保证解锁操作的合法性.

当然红锁也有一些问题:

https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html

第一个例子:

假设系统有五个Redis节点(A、B、C、D和E),以及两个客户端(1和2)

如果其中一个Redis节点上的时钟向前跳跃会发生什么?

客户端1在节点A、B、C上获取锁。由于网络问题,无法访问D和E。节点C上的时钟向前跳跃,导致锁过期。

客户端2在节点C、D、E上获取锁。由于网络问题,无法访问A和B。

现在客户端1和2都认为自己持有该锁。如果在将锁持久化到磁盘之前发生了类似情况,则可能出现类似问题,并立即重新启动。因此,Redlock 文档建议延迟崩溃节点的重启至少等同于最长寿命锁定的存活时间。但是这种重启延迟再次依赖于对时间相当准确地测量,并且且如果时钟跳变则会失败。

第二个例子:

客户端1请求对节点A、B、C、D、E上锁。

当客户端1的响应正在传输时,客户端1进入全局停顿垃圾回收(GC)。所有Redis节点上的锁都过期了。

客户端2获取了对节点A、B、C、D和E的锁。

客户端1完成GC,并接收来自Redis节点的响应,指示它成功获取了锁(它们在进程暂停期间保存在客户端1的内核网络缓冲区中)。

现在客户端1和2都认为它们持有该锁。

请注意,尽管 Redis 是用 C 语言编写的,因此没有 GC,但这并不能帮我们解决这个问题:在任何系统中,客户端都可能遇到 GC 暂停。你只能通过防止客户端 1 在客户端 2 获得锁后在锁下执行任何操作来确保安全,例如使用上述fencing方法。长时间的网络延迟会产生与进程暂停相同的效果。这或许取决于 TCP 用户超时时间--如果超时时间明显短于 Redis TTL,延迟的网络数据包或许会被忽略,但我们必须详细研究 TCP 实现才能确定。此外,有了超时,我们就又回到了时间测量的准确性问题上!

Watch 回调型

对于实现 watch 回调型分布式锁,一些基本要点和 2.1 小节中聊到的主动轮询型分布式锁类似:

  • 针对于同一把分布式锁,使用一条相同的数据进行标识(唯一、明确的 key)

  • 倘若在存储介质内成功插入该条数据(要求 key 对应的数据不存在),则这一行为被认定为加锁成功

  • 把从存储介质中删除该条数据这行为理解为解锁操作

与主动轮询型分布式锁不同的是,在取锁失败时,watch 回调型分布式锁不会持续轮询,而是会 watch 监听锁的删除事件

  • 倘若在插入数据时,发现该条记录已经存在,说明锁已被他人持有,此时选择监听这条数据记录的删除事件,当对应事件发生时说明锁被释放了,此时才继续尝试取锁

(1)etcd

etcd 是一款适合用于共享配置和服务发现的分布式 kv 存储组件,底层基于分布式共识算法 raft 协议保证了存储服务的强一致和高可用.

在 etcd 中提供了watch 监听器的功能,即针对于指定范围的数据,通过与 etcd 服务端节点创建 grpc 长连接的方式持续监听变更事件. 关于 watch 机制的详细介绍,可以参见我上一周发表的两篇文章—— etcd watch 机制源码解析——客户端篇/服务端篇.

此外,etcd 中写入数据时,还支持通过版本 revision 机制进行取锁秩序的统筹协调,是一款很适合用于实现分布式锁的组件.


为避免死锁问题的产生,etcd 中提供了租约 lease 机制. 租约,顾名思义,是一份具有时效性的协议,一旦达到租约上规定的截止时间,租约就会失去效力. 同时,etcd 中还提供了续约机制(keepAlive),用户可以通过续约操作来延迟租约的过期时间.

  • 用户可以先申请一份租约,设定好租约的截止时间

  • 异步启动一个续约协程,负责在业务逻辑处理完成前,按照一定的时间节奏持续进行续约操作

  • 在执行取锁动作,将对应于锁的 kv 数据和租约进行关联绑定,使得锁数据和租约拥有相同的过期时间属性

在这样的设定之下,倘若分布式锁的持有者出现异常状况导致无法正常解锁,则可以通过租约的过期机制完成对分布式锁的释放,死锁问题因此得以规避. 此外,锁的使用方可以将租约的初始过期时间设定为一个偏小的值,并通过续约机制来对租约的生效周期进行动态延长. 可以看到,此处 etcd 中的租约及续约机制,实现了与 redisson 中 watch dog 机制类似的效果.

惊群效应

在 watch 回调型分布式锁的实现过程中,可能也会存在类似于惊群效应的问题. 这里指的是:倘若一把分布式锁的竞争比较激烈,那么锁的释放事件可能同时被多个的取锁方所监听,一旦锁真的被释放了,所有的取锁方都会一拥而上尝试取锁

etcd 中提供了前缀 prefix 机制以及版本 revision 机制,和 zookeeper 的临时顺序节点功能有些类似:

  • 对于同一把分布式锁,锁记录数据的 key 拥有共同的前缀 prefix,作为锁的标识

  • 每个取锁方取锁时,会以锁前缀 prefix 拼接上自身的身份标识(租约 id),生成完整的 lock key. 因此各取锁方完整的 lock key 都是互不相同的(只是有着相同的前缀),理论上所有取锁方都能成功把锁记录数据插入到 etcd 中

  • 每个取锁方插入锁记录数据时,会获得自身 lock key 处在锁前缀 prefix 范围下唯一且递增的版本号 revision

  • 取锁方插入加锁记录数据不意味着加锁成功,而是需要在插入数据后查询一次锁前缀 prefix 下的记录列表,判定自身 lock key 对应的 revision 是不是其中最小的,如果是的话,才表示加锁成功

  • 如果锁被他人占用,取锁方会 watch 监听 revision 小于自己但最接近自己的那个 lock key 的删除事件.

这样所有的取锁方就会在 revision 机制的协调下,根据取锁序号(revision)的先后顺序排成一条队列,每当锁被释放,只会惊动到下一顺位的取锁方,惊群问题得以避免.

(2)zookeeper

zookeeper 官方文档:https://zookeeper.apache.org/

ZooKeeper是一款开源的分布式应用协调服务,底层基于分布式共识算法 zab 协议保证了数据的强一致性和高可用性.

zookeeper 中提供了临时顺序节点(EPHEMERAL_SEQUENTIAL)类型以及 watch 监听器机制,能够满足实现 watch 回调型分布式锁所需要具备的一切核心能力.

Comment