关于分布式锁的思考

发布时间 2023-08-18 16:28:34作者: 心渐渐失空

词汇:

任务:能在CPU上运行的指令段

资源:能存储在内存或硬盘的任何数据

节点:执行任务的实例

背景:

由于现代计算机的发展,大任务大部分时候需要拆分成小任务去并行执行。单台计算机:批处理脚本->多线程->多核并行->协程,再到现在的虚拟机->容器->微服务等等。都是做隔离和拆分,将大任务拆解成小任务。

当大任务拆分成多个小任务后,他们并不能完全完全拆解,而是需要互相通信,运行期间经常需要知道其他小任务的运行情况。

主要通信需求有:1.互斥:有的资源不能被多个小任务同时访问(例如共享内存);2.同步:需要等待其他小任务通知才能开始做接下来的工作(例如队列的生产者和消费者);3.其他需求都可以拆解为这两个需求的叠加或组合;

在多线程环境下:可以使用互斥量、信号量、信号等机制实现通信需求。

在多计算机环境下:通过tcp/ip实现通信需求,然而tcp/ip是端对端的传输,如果任务数大于2,则大部分时候会选择一个中心节点用来负责传输数据。

正文:

对于互斥这个需求,在多计算机(分布式)环境下,如何做到呢?(上面都是废话,今天主要是想总结这个问题)

首先,由于tcp/ip是p2p的,所以需要选出一个中心节点,其他节点都去连接这个节点。(为什么这样做?因为这样做比较简单直观)

例如:redis、zookeeper、etcd等项目,都是作为中心节点的作用。由于中心节点非常重要(中心节点挂了的话,所有节点都无法通信了),所以中心节点一般会做一些容灾策略(故障发现、主备切换等),为了做到这些能力,中心节点就被做成了中间件,以集群的方式部署。

由于现在大部分业务都做成微服务的样子(不依赖本地数据,只依赖DB、redis等中间件集群提供的接口和其他微服务的接口,对上游提供可重入的接口,挂了可以随便找个地方再跑,可以部署多个)。在业务开发中,最常用的就是微服务想要访问一个资源,但是担心这个微服务的其他实例也在访问,就需要用到分布式锁。

锁的原理主要就是:

1.服务向中心节点(redis为例)发送tcp报文,中心节点帮忙登记这个锁被该服务占用;

2.如果中心节点发现已经被其他服务占用,就返回占用失败;

3.如果占用失败,就等一会再重试;(具体等多久重试, 看业务需求自己拍脑袋定)

4.占用到这个锁的服务可以访问资源。

这种不停重试占用的轮询方法,也成为乐观锁。对应的,悲观锁则是抢占锁抢不到时则睡眠,等待中心节点发来的解锁通知。分布式锁大部分时候采用的是乐观锁。

分布式锁和单计算机内的锁有什么区别呢?其实就相当于固定一个中心节点,所有节点都把加解锁请求发给这个中心节点,又由于中心节点是单机的,所以就可以把这些锁在本机内使用操作系统提供的原子操作或者互斥量实现。

原理虽然简单,但是分布式锁和单进程内部的锁不一样的地方在于硬件故障的影响。单进程可以稳定运行直到硬件故障时整个进程里面的所有线程都死了。

而分布式的节点分布到多个硬件上,可能部分故障了,部分还在运行。这里带来的影响就是一个节点加锁后挂了该如何处理这个锁、或者挂了一会又活过来了又该怎么处理。总之,由于硬件故障(运行任务的机器、tcp/ip通信中间的网络交换设备),节点可能出现任何问题,导致节点与节点之间是互不信任的。(单进程的话硬件故障则所有线程都没了,也就不用考虑一部分线程活着、一部分线程死了的问题)。

分布式集群的痛点:

当然,分布式集群也可以采用单进程的方式,发现一个任务节点挂了,立刻整个集群所有节点都别运行了。这样靠谱吗?不靠谱,如果一个机器运行1年会挂1次,那一个365台机器的集群则每天都有节点挂掉,所以采用这种策略的话,集群几乎每天都要出问题。

相当于你要在一堆不可靠的硬件上,运行一些软件,构建一个可靠的系统。当然,没有拜占庭问题那么恶劣(拜占庭已经被证明是无解的了),这里的节点只会因为硬件故障而挂掉,或者挂掉一会又活过来继续工作。在这样的集群之上,是能构建出完全可靠的软件系统的。参考CAP理论。

拿redis来说。如果一个节点请求redis加锁后,这个节点挂了,那这个锁就一直处于锁定状态,其他节点无法获取锁进行正常工作。

为解决这个问题,redis支持设置锁的超时时间,相当于节点获取锁时告诉redis这个锁10秒没人操作就自动释放,然后自己在10秒内完成资源访问并请求redis释放锁。如果该节点挂了,10秒后redis自动释放这个锁。

有了这个机制,

1.节点只要在自己设置的时间内完成任务并释放锁,集群就能正常工作;

2.如果超过设置的时间还没完成资源访问、没释放锁,则redis自动释放锁,其他节点获取锁并访问资源,则出现并发访问资源的问题;

3.如果节点获取锁之后挂了,那锁超时释放后其他节点能正常工作;

4.如果节点获取锁并访问资源后,断网了,锁超时后它又连上网来释放锁,则可能把其他节点加的锁给释放了

5.如果节点获取锁后卡住了(内存GC、申请内存、进程调度等原因.......),锁超时后它又继续访问资源,这时候出现并发访问资源的问题;

对于问题4,可以在加锁时设置一个本节点的唯一标识,在释放锁时检查标识是自己才释放,否则不做释放,就能规避误释放锁引发的不确定问题

对于问题2,redis也提供了修改超时时间的接口,如果加锁后发现10秒访问时间不够,则可以再调用redis延长超时时间。

对于问题5,没有什么好的办法解决,只能祈祷节点别这么坑,长时间假死。

以上是节点故障导致的问题和解决办法。

接下来再讨论下,redis作为中心节点,它因为硬件故障挂了,集群怎么办?

众所周知,大部分计算机是不能无故障永久运行的。所以作为运行在计算机上的服务,也一定要考虑这个问题,redis也不例外

假设redis部署在一台计算机上,这台计算机爆炸了,整个集群都无法访问redis,无法加锁,则无法正常使用redis的分布式锁功能,另外redis的数据也全部丢失,无法找回。

于是,redis提供了主备部署,和大多数数据库一样。定时把写到主节点的数据发给其他从节点。如果主节点炸了,业务节点就去访问从节点。这样保证了redis作为中间节点的可靠性。

由于性能问题,redis主从复制采用的是异步复制,也就是先给客户端返回成功,再找时间发给从节点,那中间这段时间写到主节点的数据就可能永久丢失。

例如:A进程找redis主节点加锁后,redis主节点返回加锁成功,然后正准备发给从节点,突然主节点挂了,redis从节点开始工作,B进程来找新的主节点加锁,成功。则A和B进程出现并发访问资源问题。

针对这个问题,一个粗暴的方法就是把主从同步改为同步的,当然,为了这偶尔一次的硬件故障,每个redis的请求都要同步到从节点后,再返回给客户端,这样有点得不偿失。

另外一个解决方法是redlock:客户端访问多个redis集群进行加锁,只要有一半以上的redis集群加锁成功,则加锁成功。如果小于集群数量一半的redis加锁成功,则认为加锁不成功,立刻调用解锁释放刚刚成功的节点。

这样做的话,如果其中一个redis集群挂了或者主从切换数据丢失,那剩下的节点还能成功加解锁,也就避免了单个redis丢数据的问题。对客户端来说,找多个redis集群加锁是同时进行的,也不会成倍增加请求延迟。

为什么要一半以上成功才算成功?因为只要有一个锁获得了一半以上的redis加锁,则这些redis没加这个锁的节点数量不到一半,后面这个锁就无法再次被占用,直到有一半以上的redis释放后才能再次被占用。(paxos、raft等一致性算法的原理)

 

最终,不管怎么做,都无法解决问题5。只能靠加锁的节点在redis主动释放锁前完成操作并释放锁。如果无法做到,那也需要这个节点在超时后不要再去访问互斥资源。这需要每个节点都严格守信,只要其中有节点出现纰漏,欺骗了其他节点(没在锁时间范围内访问资源,而是在锁时间外访问资源),则这个集群就变成了拜占庭问题,有撒谎的虚假节点存在,这个集群就无法做到一致,就会出现问题。

当然,CAP理论也提出,C、A、P三者不可全得。要性能就放弃可靠性,要可靠性就放弃性能。需要业务系统做出权衡,在对系统影响小的地方选择性能,在对系统致命性打击的地方选择可靠性。