分布式锁详解,谈谈分布式锁的高可用

342 阅读12分钟

一、分布式锁的介绍

分布式锁是我们在分布式系统中用于控制对共享资源访问的一个机制,不同于Golang中互斥锁的使用,互斥锁常用于同一进程下多线程或多协程对共享资源进行并发访问时进行限制,而分布式锁则用于控制某个资源在同一时刻只能被一个分布式应用所操作。

分布式锁的主要目的是确保在多进程或服务实例之间对某个资源的互斥访问,以防止数据不一致或竞争条件的发生。

单机系统

对于单机系统来说,多线程程序在运行时,锁可以通过一个变量的状态来表示,而Golang中互斥锁的实现本质上也是对变量进行操作,一个线程通过修改变量的状态来标识获取到锁,而其他线程在获取锁时,发现变量的状态为已加锁,则加锁失败。释放锁则是将变量恢复到未加锁的状态,以便其他线程能够获取到锁。

image.png

分布式系统

与单机系统类似,分布式系统同样也可以通过一个锁变量来实现。客户端加锁与解锁的操作逻辑类似,通过操作所变量来表示是否获取到锁。

但与单机系统不同的是,单机系统中的多线程可以操作他们所在进程的锁变量资源从而获取到锁,而分布式系统则需要有一个多系统(多进程)能够共享的存储系统来维护锁变量。只有这样,多个客户端才可以通过访问共享存储系统来访问锁变量。加锁与释放锁的操作变成读取、判断、设置共享存储系统中的锁变量。

并且在分布式系统中实现分布式锁,需要满足两个特点

  • 分布式锁在加锁与解锁时,涉及到多个操作,加锁与解锁过程需要保证原子性
  • 共享存储系统存储分布式锁变量,需要考虑保证共享存储系统的可靠性,如果共享存储系统故障或者宕机,则客户端无法进行锁操作。

image.png

二、分布式锁的实现方式

一般来说,支持排他性的中间件都可以作为分布式锁的实现方式。

什么是排他性

排他性通常指的是某个资源、权利或特性只能被一个实体独占或使用,而不能被其他实体同时使用。排他性通常与资源的访问控制和管理相关。

基于数据库

在数据库中,排他性通常与锁机制相关。通过排他锁来控制只有一个事务对某个数据进行操作,而其他事务在此期间无法操作该数据。排他锁确保了数据的一致性,防止了并发事务之间的冲突。

我们可以通过MySQLSELECT FOR UPDATE语法实现分布式锁的,对记录进行当前读,在可重复读隔离级别下加上临键锁。

基于Redis

Redis在实现分布式锁上,提供了简单而高效的实现方式,常用的方式是通过SETNX命令来获取锁,并通过EXPIRE命令设置锁的过期时间,以防止死锁。

Redis实现分布式锁也是我们在工作上最常用到的实现,这篇文章也着重探讨Redis实现分布式锁的细节。

基于Zookeeper

Zookeeper是一个分布式协调服务,可以用于实现分布式锁。通过创建临时节点来表示锁的状态,其他实例在尝试获取锁时检查这些节点。

基于etcd

etcd是一个分布式键值存储系统,也可以用于实现分布式锁。通过使用租约(lease)机制来管理锁的过期和释放。

三、Redis实现分布式锁

加锁

Redis在实现分布式锁时可以作为共享存储系统来存储锁变量,通过Redis的键值对来保存分布式锁变量,同时Redis本身可以被多个客户端共享访问,可以接受客户端发送的加解锁操作请求。另外,Redis在读写性能高,可以应对高并发下的锁操作。

Redis分布式锁的实现方式是通过setnx(set if not exists)命令来实现,setnx的特点在于

  • 如果写入的key不存在,则执行成功,表示加锁成功。
  • 如果写入的key存在,则执行失败,表示加锁失败。

等待机制

Redis客户端在执行请求时是通过单线程来执行的,因此两个客户端先后请求加锁后,其中会有一个线程会加锁失败,客户端在加锁失败后,我们可以选择直接返回,同样也可以等待一段时间,等其他客户端释放锁后再获取到锁。

那么当加锁失败时,我们需要等待多长的时间呢?

一般来说,在等待的时间内尽可能的获取到锁,这个等待时间我们可以根据锁的持有时间来设置,比方说锁的持有时间在99%的情况下都是1s,则我们可以在加锁失败时,将等待时间设置到1s。

当我们设置了等待时间,那么在这段时间内,我们如何能够知道锁是否被释放呢?假设我们的等待时间为1s,我们可以通过两种方式来实现:

  • 轮询:当加锁失败后,我们可以每次睡眠100ms后就尝试加锁一次,直到成功加锁或整个轮询过程到达1s中。
  • 监听删除事件:在加锁失败后,订阅这个锁键值对,当键值对被删除解锁后,说明锁已经被释放,这个时候就再次尝试加锁。监听删除事件相对来说实时性会比较好,但是实现却比较的麻烦。

超时重试

当我们在正常向Redis客户端请求加锁时,也有可能会遇到超时的情况,这个时候我们可以进行重试加锁,假设我们需要给key_1加分布式锁,在加锁时,我们可以随机生成一个唯一的value_1值,例如UUID生成,重试的主要逻辑如下:

  • 检查Redis当前是否有存在key_1,若不存在,则上一次加锁请求没有加锁成功。
  • 如果key_1存在,则检查值是否为value_1,判断是否是当前key_1在上一次加锁成功后写入的值,如果是value_1,则说明上一次加锁成功,但上一次加锁与这一次加锁存在一定的时间差,需要重置锁的过期时间。
  • 如果不是value_1,说明当前分布式锁已被其他人获取,则加锁失败。

过期时间

理想情况下,如果我们的Redis服务器不会故障宕机、网络一直处于稳定,那么分布式锁Redis的SETNXDEL来加解锁则可以满足场景,然后实际上并没有这么理想的情况,我们在使用分布式锁的时候需要考虑,如果我加锁成功后,加锁所在的线程崩溃了或者整个业务服务不可用了,则会导致后续无法正常解锁,要怎么处理?这个问题本质上其实就是锁没有人去释放。因此,我们在使用分布式锁时,需要给锁的键值对添加一个过期时间。

我们在SETNX命令执行时加上EX/PX选项,设置其过期时间;

SETNX key_1 unique_value_1 PX 10000

通过给锁加上过期时间,可以避免加锁客户端发生异常而无法释放锁。

在给锁加上过期时间后,那么有一个问题,我们在设置过期时间的时候,需要给分布式锁加上多长的过期时间呢?

过期时间的设置需要根据实际的业务来设置,例如我们在获取到锁后,99%的业务在1s内能够完成,那么我们可以将过期时间设置的比1s中稍微长一些,比如2s、5s,保证业务的完成后,进行锁的释放。

锁续约

我们在给分布式锁添加过期时间时,通常会根据业务的执行时间将过期设置的稍微长一些,但是无论过期时间设置的多长,都有可能遇到业务在持有分布式锁的期间仍未执行完成,导致过早的释放锁。

为了防止出现业务在持有锁的时间内不能完成的情况,我们就要考虑给锁进行续约,延长持有锁的时间。所谓续约是指设置了过期时间之后,在快要过期的时候,再次延长这个过期时间。在golang中,我们可以使用协程与time设置一个定时器来续约锁。

例如我们在设置过期时间时设置为1min,我们可以在50s时再次将过期时间重置为1min,在剩余的过期时间内续约锁。在续约锁时留有一定的处理时间,用于超时重试等。

如果续约锁失败,这个时候我需要根据实际的业务来进行处理,是进行业务中断返回,还是继续执行业务逻辑

  • 若进行业务中断,可能需要你的业务处理代码逻辑来检测是否收到中断信号,但实际业务中断是比较困难的,因为你的业务代码并不一定适合去接受中断信号并返回,因此业务中断实际需要看你的业务代码是如何实现的。比方说在一个大循环中,每次循环前检查一下中断信号,判断是否需要中断业务,或者在每到一个关键的业务逻辑处,就判断一次中断信号是否需要中断业务。
func example1() {
    for {
       if interrupted {
           break
       }
       // 业务逻辑
       DoSomething()
    }
}

func example2() {
    // 业务逻辑
    DoSomething1()
    if interrupted {
       return
    }
    // 业务逻辑
    DoSomething2()
    if interrupted {
       return
    }
}
  • 若继续执行业务逻辑,则需要考虑引起的数据一致性等问题是否能够接受。

解锁

正常来说,解锁的过程则是通过Del命令将锁键值对删除,但删除锁的过程中,需要确保执行删除的客户端删除的是自己的锁,避免误删。

例如客户端1加了锁,结果Redis崩溃了又恢复过来,这时候客户端2也加了同一把锁。因此,在释放锁的时候,要先确认锁是不是自己加的,防止因为系统故障或者有人手动操作了Redis导致锁被别人持有了。

确认是否是自己加锁可以通过比较键值对中设置的value值是否匹配,之前我们在加锁时,通过UUID设置键值对的值,来保证锁的唯一性。

并且,判断键值对与删除键值对需要保证整个操作的原子性,我们可以通过Lua脚本来保证整个解锁过程的原子性。

if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

image.png

多节点分布式锁

当我们需要实现高可靠的分布式锁时,通常我们会设置多个Redis节点来避免单机Redis实例故障而导致锁无法使用。多节点下的分布式锁算法,Redis官方已经设计了一个分布式锁算法 Redlock(红锁)。

Redlock是基于多Redis节点的分布式算法,即使某一个Redis节点发生故障,在其他节点仍然有锁存在,已加锁成功的客户端仍然会持有锁。

Redlock的加锁思路让加锁客户端和多个独立的Redis节点依次请求加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,那么可以认为客户端加锁成功,获得分布式锁,反之加锁失败。

具体步骤如下:

  • 加锁客户端获取当前时间(t1)

  • 客户端依次向NRedis节点执行加锁操作。

    • 在每一台Redis节点中,使用SETNX命令设置锁变量,同时带上参数PX/EX设置过期时间,以及在value中设置锁的唯一值。
    • 为了防止整个加锁过程中,某一个Redis节点发生故障,我们需要在加锁的过程中(向Redis节点写入键值)设置超时时间,规定在这个时间内完成一个节点的加锁操作,若这段时间内未完成则表示加锁失败,继续向下个节点加锁。加锁操作的超时时间需要远远地小于锁的有效时间(过期时间),一般设置为几十毫秒。
  • 当加锁客户端与所有的Redis节点交互完成加锁操作后,客户端再次获取当前时间(t2),计算整个加锁的耗时时间(t = t2 - t1)

  • 当客户端获取超过半数节点的锁,且整个加锁过程的耗时(t1)没有超过锁的有效时间(expire过期时间),即 t1 < expire,则我们可以认为加锁成功。

在加锁成功后,需要重新计算这把锁的有效时间,计算的结果是锁的最初有效时间减去客户端为获取锁的总耗时。如果锁的有效时间已经来不及完成共享数据的操作了,我们可以释放锁,以免出现还没完成数据操作,锁就过期了的情况。

如果客户端在和所有实例执行完加锁操作后,没能加锁成功,那么,客户端向所有Redis节点发起释放锁的操作。解锁的过程同样执行Lua脚本释放每个节点的锁即可。