掘金 后端 ( ) • 2024-04-28 10:45

theme: smartblue

yuque_diagram.jpg

分布式锁是一种在分布式环境下,用于实现多进程分布式互斥的锁机制。在分布式系统中,由于数据或资源可能部署在多个节点或机器上,因此需要一种机制来确保在并发访问时,同一时刻只有一个进程或线程能够访问共享资源,从而维护数据的一致性和避免数据竞争或冲突。我将通过四个方面来带你了解分布式锁的相关知识。

分布式锁介绍

1、资源不一致问题

当多个进程或者线程去争抢同一共享资源时,会造成资源的不一致性,举个经典的库存超卖问题来理解一下。

P1,P2两个进程同时进行库存扣减操作,同一时刻读取数据库库存为1,此时符合进程内扣减库存要求,执行扣减操作,最后数据库扣减两次库存,造成超卖。

2、单进程锁

在单一进程内,我们通过本地锁来保证资源的独占性。如果所示,在单一进程A中,有两个线程t1,t2,当t1线程获取到锁时,t1被允许操作共享资源。Golang中的sync.mutex就是这个效果。

3、分布式锁

在分布式架构中,本地锁无法在多个服务器之间生效,我们就需要分布式锁来保证资源的一致性。如图所示,在两个服务器上的进程p1,p2,分别去获取分布式锁,当进程p2拿到锁后才能操作共享资源。

分布式锁的实现

分布式锁可以由一些中间件来实现

  • Redis
  • 数据库
  • Etcd
  • Zookeeper

这些常见的中间件都可以实现,本篇文章我们基于Redis来讲解如何实现并优化一个分布式锁。

Redis实现分布式锁

1、获取锁

setnx key value

在redis中使用 setnx 来模拟加锁,它用于设置键的值,但是仅当该键尚不存在时才执行此操作。

  • 如果键被成功设置,返回 1。
  • 如果键已经存在,不做任何操作,返回 0。

2、释放锁

del key

在redis中通过del命令删除key键,来模拟释放锁的操作。

3、伪代码实现

func AcquireLock() bool {
    res := redis.Do("SET NX MyLock value")
    if res == 1{
        return true
    }
    return false 
} 
func ReleaseLock() {
    res := redis.Do("Del MyLock")
} 

func main(){
    for {
        //循环直到获取到锁
        if AcquireLock() == true{
            ... //业务逻辑代码
            break 
        }else{
         time.sleep(200ms)   //其他进程获取到了锁,200ms后重试
        }
    }
    ReleaseLock()
}

分布式锁的优化

1、如何解决死锁问题

我们的进程并不是一直稳定运行的,考虑这么一种情况:

  • 进程成功获取到分布式锁
  • 进程所在服务器出现宕机、进程内部出现崩溃

此时进程并未正确地释放分布式锁,那其他进程将永远无法获取到该锁,造成死锁。

expire key seconds

解决该问题的方法很简单,redis中支持通过expire命令给键值对设置超时时间。我们在获取锁时,给锁加一个超时时间,这样即使进程异常结束,锁也会在一定时间后释放。

我们通过setnx命令设置锁,通过expire命令来设置超时时间,这个对于redis来说是两步操作,不是原子性的,所以会存在并发问题。考虑一下,如果执行setnx命令后进程崩溃,此时锁还是没有被设置超时时间,所以我们通过redis的另一个原子性命令来实现加锁并设置超时时间的操作。

set key value nx ex seconds

通过这个命令,我们对获取锁的操作做了优化,解决死锁问题,优化后的伪代码如下

func AcquireLock() bool {
    res := redis.Do("SET MyLock value NX EX seconds")
    if res == 1{
        return true
    }
    return false 
} 

2、如何保证锁的唯一性

在解决死锁问题时,我们设置了锁的超时时间,但是这样会产生新的问题。。我们先来看一下这个场景:

  • 进程A 0S时获取到分布式锁,设置超时时间10S,开始操作共享资源。
  • 10S后,锁到期,此时进程A在访问共享资源。
  • 锁到期后,进程B成功获取到锁,然后开始操作共享资源。
  • 15S后,进程A处理完成业务,释放锁,此时释放的为进程B获取的锁。

针对这种情况,我们考虑给每个锁在设置时加入唯一标识,能够标识当前的锁是哪个进程设置的,并且进程在删除锁时判断该锁是否为自己所设置的。

获取锁优化

唯一标识我们采用uuid来实现,保证分布式场景下资源的唯一性。

func AcquireLock(pid string) bool {
    res := redis.Do("SET MyLock pid NX EX time")
    if res == 1{
        return true
    }
    return false 
} 

释放锁优化

get key

del key

我们通过get命令获取锁的值,然后判断该值是否等于当前进程设置的pid来删除锁,获取和删除是两步操作,不是原子性的,所以我们通过lua脚本来实现原子操作。

lua是一种小巧、轻量的脚本语言,在Redis中,Lua脚本可以通过EVAL命令执行,完成一些复杂的操作和功能。Lua脚本可以在一次请求中完成多步操作,从而将多步命令转换成原子命令。

func ReleaseLock(pid string) {
    lua := `  
		if redis.call("get", MyLock) == pid then  
			return redis.call("del", pid)  
		else  
			return 0  
		end  
	`  
	result ,err := redis.Eval(lua).Result()
} 

3、如何解决锁的续期问题

在上一个场景中,我们会发现一个问题。

  • 进程A获取了一个分布式锁,在第10s进程A的锁失效,但是进程A继续在访问共享资源。
  • 进程B在第10S时获取了新的分布式锁锁,并开始访问共享资源。

此时就出现两个进程同时操作共享资源,这是不符合分布式锁设计要求的(同一时刻只有一个进程或线程能够访问共享资源)。

ttl key

解决上述问题的一个方法就是开启一个监控线程(进程)去实时判断当前进程设置的锁是否马上到期(通过redis的ttl命令),如果到期时间小于某个阈值(比如3S),则进行续期处理。

image.png

  • P1进程获取锁,设置超时时间(30S)。
  • P3的监控进程启动,当监控的锁到期时间小于某个阈值(比如3S),通过expire key seconds 命令进行续期。
  • P1业务逻辑执行完成,通知P3关闭监控,并释放锁。
  • P2获取分布式锁,处理业务逻辑。

如果仔细思考的话,当我们解决了锁的续期问题,那么就不存在两个进程同时访问共享资源的问题,也不会出现进程B释放进程A的锁这样混乱的问题,分布式锁在一定范围内是唯一的,谁持有,谁释放,在这期间其他进程无法获取。为了更好的排查问题,我们还是需要给锁加上唯一标识,这样能够清楚地知道哪个进程在持有锁。

总结

分布式场景本来就极具挑战,在实现和使用分布式锁时我们也会遇到很多问题,大部分情况下,我们只要设置合适的超时时间即可,因为对于这些公共资源操作,大部分业务都是10S以内。过长的时间对于用户端也不合理。所以根据自己的业务选择合适的方法是最好的。

我是烤鱼,一名Golang,C++后端开发人员,喜欢用图来输出知识,欢迎关注我,后续将发布更多关于面试知识点的图解。