简单来说分布式锁就是在分布式环境下,多个节点多个实例之间抢同一把锁
本地锁通常用于某个进程中多个线程之间对共享资源竞争访问需要保证同步的场景;那么分布式锁基本同理,只不过提供锁的服务在某个节点上,例如一台提供 Redis 服务的服务器,而竞争资源的对象变成了其它节点,实例;而且竞争时相关操作需要通过网络来完成
分布式锁的难点就在于网络,其实分布式系统中很多问题都来自于网络,网络是个永恒的问题,我们永远无法避开它
实现一个分布式锁的起点,就是利用 setnx 命令来设置一个键值对,这个命令可以确保排它性地设置键值对,即多并发的情况下只会有一个客户端能成功拿到锁。
setnx 的结果可能是成功,返回 1,即成功拿到锁了;
也可能是返回 0,失败,可能是别人先把锁拿到了;
也有可能请求超时了,根本没有到达锁服务器;
也可能请求发过去了,也确实拿到锁了,但是服务端的响应由于网络问题没有发回来。
所以最简单实现就是,加锁操作为 setnx 命令设置一个 key,value 任意;然后解锁操作就是 del 掉这个 key 即可
看上去没有任何问题,多节点可以同步地获取分布式锁。但分布式系统中我们不得不考虑的是节点宕机的问题。如果节点加锁后宕机了无法正常解锁,那么这个分布式锁就会一直处于加锁的状态,其它节点就无法获取锁
所以为了解决这个问题,节点加锁时需要指定超时时间,如果锁超过超时时间还没被解锁就自动解锁。很多因素都是不确定,但时间是确定的,所以经常被拿来兜底
加入分布式锁超时机制,可以防止加锁的节点宕机导致锁无法及时解锁的问题,所以分布式锁是一定需要设置过期时间的。但又会出现新的问题。假设节点加锁后,因为业务处理时间太长,或者 GC,网络延迟等问题,导致锁超时自动解锁了,而且被第二个节点加了锁。
那么当第一个节点稍后要来解锁时,它可能会解锁成功,但是解的却不是自己的锁是第二个节点加的锁。更严重的,此时第二个节点还是认为自己是锁的持有者,但此时锁已经被第一个节点删掉了,那就有可能被第三个节点加锁,此时第二跟第三个节点都认为自己是锁的持有者,这就违反了分布式锁最基本的语义。
为了解决这个问题,需要确保节点在解锁时这个锁确实是自己加的才可以解锁,可以在加锁时设定键值对的 value 为一个唯一标识
节点加锁时指定键值对的 value 为一个唯一标识,例如 UUID。在节点解锁时,判断 value 是否为加锁时所指定的 value,是的话说明这个锁上次确实是自己加的,可以解锁
这种操作也存在问题:解锁时需要先取出 value 值,然后判断是否是自己加的锁,如果是才删除 key,这三个操作是分开的不是原子进行的。就有可能,节点在取出 value 值时,确实是自己加的锁,但此时节点由于某些原因阻塞,导致锁超时自动释放,且第二个节点拿到了锁,等第一个节点阻塞结束,再来判断取出的 value 值,判断出是自己加的锁,然后就解锁,但此时解的已经是第二个节点加的锁了
所以需要保证取 value,判断,跟解锁三个操作原子性进行,在 Redis 中可以使用 Lua 脚本 来解决这个问题
前面提到,分布式锁一定要设置过期时间。那么过期时间究竟应该设计多长?
对于加锁的用户来说,他很难确定锁的过期时间应该多长,设置短了,可能业务还没完成,锁就过期了;设置长了,万一实例真的宕机崩溃了,那么就导致其它节点长期拿不到锁直到锁自动超时删除;更严重的,业务处理时间无法确定,可能出现业务执行时间超过锁时间的情况
我们可以引入续约机制,即在锁还没有过期的时候,再一次延长超时时间。这样的话,过期时间就不必设置得很长,因为如果真的出现业务执行时间可能超过锁原本的超时时间情况,也可以通过续约延长超时时间;而如果实例真的崩溃了,续约也就不会继续了,过一段时间自然就失效了
在 Redis 中,如果使用手动续约的方式,就可以通过 Lua 脚本原子性地进行取出锁的 value 值,判断是否是自己加的锁,是的话使用 expire 命令或者 pexpire (设置毫秒级过期时间) 重新设置键值对的超时时间 这几步来完成
手动续约的操作并不复杂,难点在于使用的细节:间隔多久续约一次?如果续约失败了怎么处理?失败有可能是请求超时了,此时可以采取重试策略;但如果是发生了超时以外的其它问题,又该如何处理?如果真的确认续约失败了,正在执行的业务如何处置?如果只能中断掉业务,又该怎么中断
对大部分用户来讲,处理续约过程中的各种异常情况还是比较棘手的,所以可以考虑通过设计中间件为用户提供自动续约的方式。当然,设计者还是躲不开那些异常问题:
隔多久续一次约,每次续多长? 由于续约操作受网络,Redis 服务器影响较大,不是设计者能把握的问题,因此让用户选择续约的间隔;至于续多长,可以简单地使用一开始加锁时设置的超时时间。但其实续约间隔跟超时时间是存在一定关系的,肯定不能说等到锁超时都被删了我才来续约,需要考虑从服务的可用性。假如我们将分布式锁的超时时间设置为 10s,而且 预期 2s 内绝大概率能续约成功,那么相应地就可以考虑把续约间隔设置为 8s。这个预期续约成功时间的概率其实就是对可用性的要求
如何处理超时?超时时间应设为多久? 这里的超时时间指的是对 Redis 服务器的续约请求的超时,这种超时的情况我们并不知道究竟有没有续约成功,而且大多数时候超时都是 偶发性 的,所以我们选择重试续约请求。缺点在于万一某次超时不是偶发性的,而是真的 Redis 服务器崩溃了或者网络不通,那么会导致无限次尝试续约。至于超时时间我们同样让用户来指定
如何通知用户续约失败? 我们只处理超时引起的续约失败,通过重试来处理;发生其它错误的情况下,我们直接告诉用户遇到了无法处理的 error。在考虑操作过程中可能发生的错误时,往往只需要分为超时跟其它错误两类即可,超时是区别于其它错误的一种错误
要不要设置续约次数上限? 假如一个业务,不断续约以至于几分钟都没释放分布式锁,我们要不要强制释放掉?我们的答案是不设置,理由是如果用户有这种需求,如业务确确实实需要执行这么久,那么他应该采取手动续约的方式。作为一个中间件的设计者,我们可以尽可能为用户考虑并解决所有的问题,但是像这种比较客户个性化的需求,我们是无法考虑到的,只能让用户自己操作
加锁时也可能遇到偶发性地失败,此时我们可以尝试重试。其实,只有是超时失败的情况下我们才会去重试,其它错误大多都是重试也无法解决问题的。在分布式环境下,超时错误就是与其它错误要不同。
类似于自动续约,加锁重试也需要考虑一些问题:怎么重试?重试逻辑如上。隔多久重试一次,总共重试几次?这个也应当让用户来指定。什么情况下应该重试,什么情况下不应该重试?这个我们也说过,超时错误就应该重试,其它错误就没必要重试
在非常 高并发,且 热点集中,即多个节点中多个线程竞争的都是那几把锁,的情况下,可以考虑结合 singleflight 来优化
具体来说,就是单个节点本地的所有线程/协程自己先在本地竞争一把锁,胜利者再去抢全局的分布式锁
同个节点上的线程先竞争,胜利的线程再去和其它节点的胜利者竞争全局的分布式锁,系统中共有多少个节点最终就会有最多几个线程去竞争分布式锁
可以看出来,其实只有在单个节点中参与竞争分布式锁的线程数很多的情况下,singleflight 才能发挥更大的优势;如果每个节点只有那么一两个线程会参与分布式锁的竞争,那最终参与分布式锁的竞争的线程数跟各个线程不经过 singleflight 而直接竞争分布式锁的线程数相差无几,甚至每个节点上还要经历一次本地锁的竞争,最终整个流程下来造成的开销可能比使用 singleflight 还要大