写在前面
本文并不讨论分布式八股,而是注重于讨论设计思想,阅读需要一定的门槛,如果读不懂可以参考文章末尾的参考资料进行学习。本文提到的语言相关的技术以 Java 为主。
锁是什么
锁是同步的工具,可以协调并发操作,此处我们主要讨论的是独占锁,因此其作用便是让一组并发操作串行执行。
在我们日常的开发中,我们主动使用的锁基本都是建议锁:
对于锁 L 和资源 R,获取 L 并不是修改 R 的必要条件,获取了 L 也不能阻止别的进程修改 R,最终还是由我们让代码遵守了规则,必须先获取 L,再操作 R。
建议锁可以类比 JUC 中的可重入锁。
与之相对的便是强制锁,强制锁便代表了锁与操作的耦合,可以类比 JUC 中的并发集合,我们操作这些集合,锁是由集合自己维护的。
对于一系列复杂的业务系统,我们希望他们都能享受锁服务,此时建议锁便允许了我们让锁服务与业务服务解耦,我们可以将其抽取出去独立实现,而不需要每个系统都实现一套锁功能。然而,解耦会带来代价,这一切都没有这么简单。
分布式场景下的锁
本地的锁的场景是极其简单的,参考 AQS,我们只是维护锁状态,通过 CAS 等操作获取锁,操作完然后释放。
除非你的系统需要加解锁之间操作的原子性,那么如果进程崩溃了,你需要回滚做了一半的持久化操作,不过一般而言这都不是业务系统需要实现的功能,在此就不考虑了。
然而当本地锁被提升到分布式场景后,一切就变得复杂起来了。在分布式场景下:
所以我们要怎么实现分布式锁呢?让我们从简单的开始。
单机分布式锁
让我们通过单机 Redis 来实现分布式锁,这在网上有着无数的操作案例,此时不再细说编程实现,可以参考 Redisson。
当我们实现完后,一切似乎是可喜的,我们拥有了一台锁服务器,由于 Redis 的事件驱动模型,我们可以保证锁的抢占是并发安全的,并且我们也有租约机制(你可以暂时认为就是超时时间的设置)可以保证当锁的获取者正常操作时,锁不会被释放,当其崩溃时,锁会超时然后被释放。
那么此时的锁就没有问题了么?
首先这里有个很简单的问题,由于我们使用的是单机 Redis,那么当 Redis 崩溃时,整个锁服务不再可用,所有潜在的锁服务的使用者都将受到影响,并且,Redis 可以配置不保证可靠的持久化,会出现数据丢失,如果我们在崩溃恢复后丢失了锁信息,锁的同步功能就失效了。
不过对于小型项目而言,单机 Redis 绝对够用,你可以配置 Redis 进行可靠的 AOF 持久化,你的小项目也可以忍受一定的不可用性(毕竟用的人也不多)。
但这里还存在一个潜在的问题,便是我们设定的锁超时时间,我们无法假设不同机器的时钟是同步的。如果锁服务器的时钟快于客户端,那么当锁服务器认为租约已经超时时,客户端可能还没意识到,此时其他客户端可以正常获取锁,导致锁失效。
让我们来看看这个例子:
假设锁服务器 S 当前时钟为 T1=5,而客户端 C 当前时钟为 T2=0,C 向 S 请求了一个有效期为 10 的租约,那么在 S 看来租约将在 15 过期,然而对于 C 而言,其需要经过 15 个时钟周期才会发现租约已过期,此时当 S 发现过期时,C 还没有意识到这一点,然而其他客户端已经可以再次获取锁了,此时锁不再可靠。
该问题也正是租约机制的限制所在,租约机制以分布式同步时钟为前提。
不过这个时钟的误差通常不会太大,因此可以通过让客户端悲观判断租约超时(客户端眼中的超时时间小于服务端)来缓解这个问题。
引入分布式共识
当你的项目规模增长后,单点分布式锁的可用性已经达不到预期,因此我们需要进行高可用支持,这里的思想很简单,让我们将锁服务器状态进行复制。
我们可以使用任意一种分布式共识算法来达成这点(Raft、Paxos...),该文章不会讨论分布式共识算法的细节,仅看看使用一个正确的算法能带来的效果。
我们假设使用的算法基于 大多数 原则,使用了 复制状态机 思想,此时会带来该效果:
锁服务器的状态在 N 个服务器上进行复制,只要 (N / 2) + 1 个服务器还在运转,锁服务器就是可用的。
我们可以进一步假设你使用的算法有着强一致性保证,即保证了 线性一致性,可以解决在复制情况下的如下问题:
你也许会问,Redis 原生不就是支持集群模式么,也会进行复制,它是否能满足上述要求呢?答案是不行,Redis 是性能优先,其复制是异步的,并不保证线性一致性,甚至无法保证一个成功的操作不被丢失。
不可靠的锁
当我们引入了一个保证了线性一致性的分布式共识算法后,可用性得到了极大提高,那么此时除了之前提到过的租约限制问题,就没有其他问题了么?
当然还是有的,让我们来考虑该情况:
一个持有锁 L 的进程可能发出一个请求 R,然后崩溃了。另一个进程可能获得了锁 L,同时在 R 到达目的地之前执行了一些操作。之后,R 又到达了,这样它就可能在没有锁 L 的保护下进行一些操作,这样就可能产生一些不一致的数据。
谨记:分布式场景下的通信是不确定的。
解决方案
那么我们要怎么解决上述问题呢?
但是该方案需要给业务服务器耦合进锁的校验,而且,业务服务都可以对不同的锁序列号做出反应了,那真的需要分布式锁么?
这和 TCP TimeWait 状态等待特定时间让延迟的旧请求结束有着相似的思想。当然,该方案是不完美的,而且等待时间的设置并不简单。
本文讨论的是松散耦合的分布式锁服务,该方案没啥意义
会话
此时我们的分布式锁服务已经相对完善了,但还缺少一个必要的功能,即客户端与服务端之间的会话,没有会话服务端就分辨不了客户端,也无法实现租约管理等功能。
我们通过引入会话,维护会话状态实现如下功能:
我们可以使用长轮询,客户端发送心跳请求给服务端,服务端挂起请求,在租约快过期时进行响应续约
当有事件发生时,服务端可以提前结束长轮询,将事件进行返回
同样通过长轮询传递缓存失效信息
缓存一致性
我们在上面引入了缓存来提升性能,然而引入缓存意味着我们需要额外的开销来保证缓存一致性,这里让我们通过 Write-Through 策略来保证强一致性。
实现思路和租约论文中提到的一致,某个客户端持有租约意味着其他客户端修改数据需要通知该客户端,此时该客户端可以将缓存销毁。
这也就意味着,当锁服务器执行一个写操作时,修改将会被 阻塞 到直到 Leader 已经向缓存了该数据的所有客户端发送了失效通知之后;该机制建立在会话的基础上。收到一个缓存失效通知后,客户端会销毁缓存失效状态并在下一次心跳中通知 Leader。当 Leader 已经确认每个客户端的缓存都已失效后,才会执行该修改操作。注意到不是所有客户端都能正常响应缓存失效,对于这些客户端,Leader 必须等待直到租约超时。
在引入缓存后,锁服务器还允许客户端缓存锁,这样锁的持有时间会比客户端所必需的期望持有时间要长。
优化
你可能发现了,当前的锁服务器为了维护会话,实现一系列功能,在高并发场景下需要付出极高的代价,一次写入需要服务端与所有持有租约的客户端进行交互,这对于网络和服务器而言都是极大的负担。那么我们如何缓解这点呢?此处给出几种比较简单的方案。
现实场景
那么我们的分布式松散耦合的锁服务到这里也就差不多了,它现在是可靠的了么?
很遗憾不是,即便上述的方案看起来已经足够可靠了,然而现实世界或者说分布式场景下,不存在百分百可靠的松散耦合的分布式锁服务(考虑下时钟不同步的场景)。那么这是否意味着,在实际工程中使用分布式锁,就意味着不可靠?
你可能已经了解过一些分布式锁的实现方案了,例如基于 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…