摸鱼闲谈:分布式锁

2023年 10月 3日 21.7k 0

写在前面

本文并不讨论分布式八股,而是注重于讨论设计思想,阅读需要一定的门槛,如果读不懂可以参考文章末尾的参考资料进行学习。本文提到的语言相关的技术以 Java 为主。

锁是什么

锁是同步的工具,可以协调并发操作,此处我们主要讨论的是独占锁,因此其作用便是让一组并发操作串行执行。

锁是什么.png

在我们日常的开发中,我们主动使用的锁基本都是建议锁:

对于锁 L 和资源 R,获取 L 并不是修改 R 的必要条件,获取了 L 也不能阻止别的进程修改 R,最终还是由我们让代码遵守了规则,必须先获取 L,再操作 R。

建议锁可以类比 JUC 中的可重入锁。
与之相对的便是强制锁,强制锁便代表了锁与操作的耦合,可以类比 JUC 中的并发集合,我们操作这些集合,锁是由集合自己维护的。
对于一系列复杂的业务系统,我们希望他们都能享受锁服务,此时建议锁便允许了我们让锁服务与业务服务解耦,我们可以将其抽取出去独立实现,而不需要每个系统都实现一套锁功能。然而,解耦会带来代价,这一切都没有这么简单。

分布式场景下的锁

本地的锁的场景是极其简单的,参考 AQS,我们只是维护锁状态,通过 CAS 等操作获取锁,操作完然后释放。

除非你的系统需要加解锁之间操作的原子性,那么如果进程崩溃了,你需要回滚做了一半的持久化操作,不过一般而言这都不是业务系统需要实现的功能,在此就不考虑了。

然而当本地锁被提升到分布式场景后,一切就变得复杂起来了。在分布式场景下:

  • 消息通讯是不可靠的,你可以使用诸如 TCP 的协议保证可靠传输,但依旧会有消息延迟的情况
  • 每个进程都不是可靠的,随时都可能会崩溃,消失在系统中
  • 网络可能出现分区
  • 每个机器的时钟是不保证同步的,时钟可能会回退,可能会出现偏移。
  • 所以我们要怎么实现分布式锁呢?让我们从简单的开始。

    单机分布式锁

    让我们通过单机 Redis 来实现分布式锁,这在网上有着无数的操作案例,此时不再细说编程实现,可以参考 Redisson。
    当我们实现完后,一切似乎是可喜的,我们拥有了一台锁服务器,由于 Redis 的事件驱动模型,我们可以保证锁的抢占是并发安全的,并且我们也有租约机制(你可以暂时认为就是超时时间的设置)可以保证当锁的获取者正常操作时,锁不会被释放,当其崩溃时,锁会超时然后被释放。
    那么此时的锁就没有问题了么?
    首先这里有个很简单的问题,由于我们使用的是单机 Redis,那么当 Redis 崩溃时,整个锁服务不再可用,所有潜在的锁服务的使用者都将受到影响,并且,Redis 可以配置不保证可靠的持久化,会出现数据丢失,如果我们在崩溃恢复后丢失了锁信息,锁的同步功能就失效了。

    不过对于小型项目而言,单机 Redis 绝对够用,你可以配置 Redis 进行可靠的 AOF 持久化,你的小项目也可以忍受一定的不可用性(毕竟用的人也不多)。

    但这里还存在一个潜在的问题,便是我们设定的锁超时时间,我们无法假设不同机器的时钟是同步的。如果锁服务器的时钟快于客户端,那么当锁服务器认为租约已经超时时,客户端可能还没意识到,此时其他客户端可以正常获取锁,导致锁失效。
    让我们来看看这个例子:

    假设锁服务器 S 当前时钟为 T1=5,而客户端 C 当前时钟为 T2=0,C 向 S 请求了一个有效期为 10 的租约,那么在 S 看来租约将在 15 过期,然而对于 C 而言,其需要经过 15 个时钟周期才会发现租约已过期,此时当 S 发现过期时,C 还没有意识到这一点,然而其他客户端已经可以再次获取锁了,此时锁不再可靠。

    该问题也正是租约机制的限制所在,租约机制以分布式同步时钟为前提。

    不过这个时钟的误差通常不会太大,因此可以通过让客户端悲观判断租约超时(客户端眼中的超时时间小于服务端)来缓解这个问题。

    引入分布式共识

    当你的项目规模增长后,单点分布式锁的可用性已经达不到预期,因此我们需要进行高可用支持,这里的思想很简单,让我们将锁服务器状态进行复制。

    高可用复制.png

    我们可以使用任意一种分布式共识算法来达成这点(Raft、Paxos...),该文章不会讨论分布式共识算法的细节,仅看看使用一个正确的算法能带来的效果。
    我们假设使用的算法基于 大多数 原则,使用了 复制状态机 思想,此时会带来该效果:

    锁服务器的状态在 N 个服务器上进行复制,只要 (N / 2) + 1 个服务器还在运转,锁服务器就是可用的。

    我们可以进一步假设你使用的算法有着强一致性保证,即保证了 线性一致性,可以解决在复制情况下的如下问题:

  • 写操作幂等问题:客户端请求了锁,该操作已在大多数节点上进行了复制,但 Leader 在响应前崩溃了,之后客户端超时重试请求,然而锁已经被该客户端获取过了。
  • 读的一致性问题:允许客户端在非 Leader 节点上进行读取,此时客户端请求到了锁,其他客户端读取了某个非 Leader 节点,然而该节点并未同步到操作,其他客户端认为锁还不存在。
  • 你也许会问,Redis 原生不就是支持集群模式么,也会进行复制,它是否能满足上述要求呢?答案是不行,Redis 是性能优先,其复制是异步的,并不保证线性一致性,甚至无法保证一个成功的操作不被丢失。

    不可靠的锁

    当我们引入了一个保证了线性一致性的分布式共识算法后,可用性得到了极大提高,那么此时除了之前提到过的租约限制问题,就没有其他问题了么?
    当然还是有的,让我们来考虑该情况:

    一个持有锁 L 的进程可能发出一个请求 R,然后崩溃了。另一个进程可能获得了锁 L,同时在 R 到达目的地之前执行了一些操作。之后,R 又到达了,这样它就可能在没有锁 L 的保护下进行一些操作,这样就可能产生一些不一致的数据。

    延迟的请求.png

    谨记:分布式场景下的通信是不确定的。

    解决方案

    那么我们要怎么解决上述问题呢?

  • 第一种方案:给锁引入序列号,每一次锁的获取,序列号递增,然后给业务服务器引入序列号校验,保证序列号必须是递增的,这样就可以防止延迟的旧请求的影响。
  • 引入序列号.webp

    但是该方案需要给业务服务器耦合进锁的校验,而且,业务服务都可以对不同的锁序列号做出反应了,那真的需要分布式锁么?

  • 第二种方案:如果客户端以正常的方式释放了一把锁,那么该锁可以立即被抢占,但如果锁由于租约超时而失效(可能是客户端挂了或者网络问题等等),那么我们认为可能存在延迟的旧请求,此时需要等待特定时间,才允许该锁再次被抢占。
  • 这和 TCP TimeWait 状态等待特定时间让延迟的旧请求结束有着相似的思想。当然,该方案是不完美的,而且等待时间的设置并不简单。

  • 第三种方案:让锁和业务服务耦合,当操作业务服务时,由业务服务自行进行锁的控制。任何拥有并发控制能力的项目皆是如此。
  • 本文讨论的是松散耦合的分布式锁服务,该方案没啥意义

    会话

    此时我们的分布式锁服务已经相对完善了,但还缺少一个必要的功能,即客户端与服务端之间的会话,没有会话服务端就分辨不了客户端,也无法实现租约管理等功能。
    我们通过引入会话,维护会话状态实现如下功能:

  • 心跳机制,服务端可以感知到客户端的消失,可以通过心跳进行续约
  • 我们可以使用长轮询,客户端发送心跳请求给服务端,服务端挂起请求,在租约快过期时进行响应续约

  • 事件机制,服务端可以通知客户端某些事件的发生
  • 当有事件发生时,服务端可以提前结束长轮询,将事件进行返回

  • 缓存机制,服务端可以给客户端提供一致的缓存
  • 同样通过长轮询传递缓存失效信息

    缓存一致性

    我们在上面引入了缓存来提升性能,然而引入缓存意味着我们需要额外的开销来保证缓存一致性,这里让我们通过 Write-Through 策略来保证强一致性。
    实现思路和租约论文中提到的一致,某个客户端持有租约意味着其他客户端修改数据需要通知该客户端,此时该客户端可以将缓存销毁。
    这也就意味着,当锁服务器执行一个写操作时,修改将会被 阻塞 到直到 Leader 已经向缓存了该数据的所有客户端发送了失效通知之后;该机制建立在会话的基础上。收到一个缓存失效通知后,客户端会销毁缓存失效状态并在下一次心跳中通知 Leader。当 Leader 已经确认每个客户端的缓存都已失效后,才会执行该修改操作。注意到不是所有客户端都能正常响应缓存失效,对于这些客户端,Leader 必须等待直到租约超时。
    在引入缓存后,锁服务器还允许客户端缓存锁,这样锁的持有时间会比客户端所必需的期望持有时间要长。

    优化

    你可能发现了,当前的锁服务器为了维护会话,实现一系列功能,在高并发场景下需要付出极高的代价,一次写入需要服务端与所有持有租约的客户端进行交互,这对于网络和服务器而言都是极大的负担。那么我们如何缓解这点呢?此处给出几种比较简单的方案。

  • 引入客户端代理,由代理和服务端进行会话通讯,对于客户端读操作可以仅和代理交互,写操作则进行转发。如果一个代理可以负责 N 个客户端,那么对于锁服务器而言消息通讯开销就可以降低 N 倍。
  • 引入分片机制,由于锁服务器不需要很复杂的数据模型(其实 KV 模型就够了),数据之间也没有什么约束和关联,这也就意味着数据可以很直观地分片,然而引入分片也意味着需要一个分片映射的管理服务,这也是额外的开销。
  • 增大租约超时时间,更大的超时时间意味着更不频繁的消息通讯,然而超时时间越大,在客户端崩溃时需要等待的时间也越长,需要根据自己的业务场景具体设置。
  • 现实场景

    那么我们的分布式松散耦合的锁服务到这里也就差不多了,它现在是可靠的了么?
    很遗憾不是,即便上述的方案看起来已经足够可靠了,然而现实世界或者说分布式场景下,不存在百分百可靠的松散耦合的分布式锁服务(考虑下时钟不同步的场景)。那么这是否意味着,在实际工程中使用分布式锁,就意味着不可靠?
    你可能已经了解过一些分布式锁的实现方案了,例如基于 Redis、Zookeeper 的。尤其是基于 Redis 的方案,细想之下是特别不可靠的,无论是 Redisson 还是 RedLock(基于 Redis 集群),让我们考虑如下场景:

    客户端向 Redis 集群请求锁,成功收到响应,此时 Redis 集群主节点崩溃,由于 Redis 是异步复制,新选举出的从节点可能还没同步到该数据,因此其他客户端同样可以请求到锁,锁不再可靠。更进一步的,为了性能考虑我们可能不被允许使用 Lua 脚本,此时连最基础的机制都无法保证(删除了别人的锁)。

    正因如此,我们在实际工程中一开始就不会假设分布式锁是可靠的,我们需要一些兜底的可靠同步机制。
    你可能会疑问,既然已经说了不存在完全可靠的松散耦合的分布式锁,那我们哪来的同步机制。注意到这里是松散耦合的分布式锁,但如果是将锁耦合进业务中的项目,就存在可靠的同步了,任何实现了并发控制的项目都能做到这点,例如 MySql。
    我们可以使用如下方案:

    获取 Redis 分布式锁,获取成功后执行一些前置逻辑,当执行完后使用基于 Mysql 的乐观锁(版本号)来判断是否可以继续执行。

    Redis 分布式锁可以提供不错的性能,而且在大部分场景下都是可靠同步的,即使出现了不可靠情况,也可以通过兜底的 MySql 同步来解决,实际上在这里只使用 MySql 乐观锁也是可以的,引入 Redis 悲观锁只是为了减少资源开销,降低做无用功的概率。
    上述便是一个实际工程中会使用到的方案,当然,能实现分布式场景下可靠同步的方案数不尽数,这里仅作参考,如果读者有其他方案也可以分享一下。

    写在最后

    本文虽然聊的是分布式锁,但并没有聊八股,因此对于面试并不会有太大作用,本文的作用更在于让你对分布式锁有进一步的理解。本文总体还是比较粗糙的,因此可能存在问题,如果有发现问题欢迎告知作者改进。
    顺带一提,部分读者可能注意到了,本文大体是描写的 Chubby 的实现,只不过碍于篇幅,论文中的很多细节都没有展示,如果感兴趣想进一步了解分布式锁的细节,又或者你不太看得懂本文,我在最后列出了参考资料供研究学习。

    参考资料

    The Chubby lock service for loosely-coupled distributed systems
    Leases: An Efficient Fault-Tolerant Mechanism for Distributed File Cache Consistency
    In Search of an Understandable Consensus Algorithm (Extended Version)
    juejin.cn/post/704958…

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论