分布式锁详解

什么是分布式锁

在单机系统中,经常会有多个线程访问同一种资源的情况,我们把这样的资源叫做共享资源,或者叫做临界资源。为了维护线程操作的有效性和正确性,我们需要某种机制来减少低效率的操作,避免同时对相同数据进行不一样的操作,维护数据的一致性,防止数据丢失。也就是说,我们需要一种互斥机制,按照某种规则对多个线程进行排队,依次、互不干扰地访问共享资源。

这个机制指的是,为了实现线程间的互斥,我们需要在某个地方做个标记,让每个线程都能看到这个标记,标记不存在时可以设置该标记,当标记被设置后,其他线程只能等待拥有该标记的线程执行完成,并释放该标记后,才能去设置该标记以及访问共享资源。这里的标记,就是我们常说的锁。

也就是说,锁是多线程同时访问同一资源的场景下,为了让线程互不干扰地访问共享资源,从而保证操作的有效性和正确性的一种标记。

与普通锁不同的是,分布式锁是指分布式环境下,系统部署在多个机器中,实现多进程分布式互斥的一种锁。为了保证多个进程能看到锁,锁被存在公共存储(比如 Redis、Memcached、数据库等三方存储中),以实现多个进程并发访问同一个临界资源,同一时刻只有一个进程可访问共享资源,确保数据的一致性。

分布式锁的实现方式

目前分布式锁的实现有3 种主流方法,即:

  • 基于数据库实现分布式锁,这里的数据库指的是关系型数据库;
  • 基于缓存实现分布式锁;
  • 基于 ZooKeeper 实现分布式锁。

基于数据库实现分布式锁

实现分布式锁最直接的方式通过数据库进行实现,首先创建一张表用于记录共享资源信息,然后通过操作该表的数据来实现共享资源信息的修改。

当我们要锁住某个资源时,就在该表中增加一条记录,想要释放锁的时候就删除这条记录。数据库对共享资源做了唯一性约束,如果有多个请求被同时提交到数据库的话,数据库会保证只有一个操作可以成功,操作成功的那个线程就获得了访问共享资源的锁,可以进行操作。

基于数据库实现的分布式锁,是最容易理解的。但是,因为数据库需要落到硬盘上,频繁读取数据库会导致 IO 开销大,因此这种分布式锁适用于并发量低,对性能要求低的场景。对于双 11、双 12 等需求量激增的场景,数据库锁是无法满足其性能要求的。而在平日的购物中,我们可以在局部场景中使用数据库锁实现对资源的互斥访问。

该方法依赖于数据库,主要有两个缺点:

  • 单点故障问题。一旦数据库不可用,会导致整个系统崩溃。
  • 死锁问题。数据库锁没有失效时间,未获得锁的进程只能一直等待已获得锁的进程主动释放锁。倘若已获得共享资源访问权限的进程突然挂掉、或者解锁操作失败,使得锁记录一直存在数据库中,无法被删除,而其他进程也无法获得锁,从而产生死锁现象

基于缓存实现分布式锁

数据库的性能限制了业务的并发量,那么对于双 11、双 12 等需求量激增的场景是否有解决方法呢?

基于缓存实现分布式锁的方式,非常适合解决这种场景下的问题。所谓基于缓存,也就是说把数据存放在计算机内存中,不需要写入磁盘,减少了 IO 读写。

Redis 通常可以使用 setnx(key, value) 函数来实现分布式锁。key 和 value 就是基于缓存的分布式锁的两个属性,其中 key 表示锁 id,value = currentTime + timeOut,表示当前时间 + 超时时间。也就是说,某个进程获得 key 这把锁后,如果在 value 的时间内未释放锁,系统就会主动释放锁。

setnx 函数的返回值有 0 和 1:

  • 返回 1,说明该服务器获得锁,setnx 将 key 对应的 value 设置为当前时间 + 锁的有效时间。
  • 返回 0,说明其他服务器已经获得了锁,进程不能进入临界区。该服务器可以不断尝试 setnx 操作,以获得锁。

总结来说,Redis 通过队列来维持进程访问共享资源的先后顺序。Redis 锁主要基于 setnx 函数实现分布式锁,当进程通过 setnx<key,value> 函数返回 1 时,表示已经获得锁。排在后面的进程只能等待前面的进程主动释放锁,或者等到时间超时才能获得锁。

相对于基于数据库实现分布式锁的方案来说,基于缓存实现的分布式锁的优势表现在以下几个方面:

  • 性能更好。数据被存放在内存,而不是磁盘,避免了频繁的 IO 操作。
  • 很多缓存可以跨集群部署,避免了单点故障问题。
  • 使用方便。很多缓存服务都提供了可以用来实现分布式锁的方法,比如 Redis 的 setnx 和 delete 方法等。
  • 可以直接设置超时时间(例如 expire key timeout)来控制锁的释放,因为这些缓存服务器一般支持自动删除过期数据。

这个方案的不足是,通过超时时间来控制锁的失效时间,并不是十分靠谱,因为一个进程执行时间可能比较长,或受系统进程做内存回收等影响,导致时间超时,从而不正确地释放了锁。当然这里也有一些解决方案,比如开启子进程给父进程续命锁,但是其实现的难度就大大增加了。为了解决基于缓存实现的分布式锁的这些问题,我们再来看看基于 ZooKeeper 实现的分布式锁吧。

基于 ZooKeeper 实现分布式锁

ZooKeeper 基于树形数据存储结构实现分布式锁,来解决多个进程同时访问同一临界资源时,数据的一致性问题。ZooKeeper 的树形数据存储结构主要由 4 种节点构成:

  • 持久节点(PERSISTENT)。这是默认的节点类型,一直存在于 ZooKeeper 中。
  • 持久顺序节点(PERSISTENT_SEQUENTIAL)。在创建节点时,ZooKeeper 根据节点创建的时间顺序对节点进行编号命名。
  • 临时节点(EPHEMERAL)。当客户端与 Zookeeper 连接时临时创建的节点。与持久节点不同,当客户端与 ZooKeeper 断开连接后,该进程创建的临时节点就会被删除。
  • 临时顺序节点(EPHEMERAL_SEQUENTIAL)。就是按时间顺序编号的临时节点。

根据它们的特征,ZooKeeper 基于临时顺序节点实现了分布锁。

假设用户 A、B、C 同时在 11 月 11 日的零点整提交了购买吹风机的请求,ZooKeeper 会采用如下方法来实现分布式锁:

  • 在与该方法对应的持久节点 shared_lock 的目录下,为每个进程创建一个临时顺序节点。吹风机就是一个拥有 shared_lock 的目录,当有人买吹风机时,会为他创建一个临时顺序节点。
  • 每个进程获取 shared_lock 目录下的所有临时节点列表,注册 Watcher,用于监听子节点变更的信息。当监听到自己的临时节点是顺序最小的,则可以使用共享资源。
  • 每个节点确定自己的编号是否是 shared_lock 下所有子节点中最小的,若最小,则获得锁。例如,用户 A 的订单最先到服务器,因此创建了编号为 1 的临时顺序节点 LockNode1。该节点的编号是持久节点目录下最小的,因此获取到分布式锁,可以访问临界资源,从而可以购买吹风机。
  • 若本进程对应的临时节点编号不是最小的,则分为两种情况:
    • 本进程为读请求,如果比自己序号小的节点中有写请求,则等待;
    • 本进程为写请求,如果比自己序号小的节点中有请求,则等待。

可以看到,使用 ZooKeeper 实现的分布式锁,可以解决前两种方法提到的各种问题,比如单点故障、不可重入、死锁等问题。但该方法实现较复杂,且需要频繁地添加和删除节点,所以性能不如基于缓存实现的分布式锁。

三种实现方式对比

下面是三种实现方式的对比:

在这里插入图片描述

为了确保分布式锁的可用性,我们在设计时应考虑到以下几点:

  • 互斥性,即在分布式系统环境下,对于某一共享资源,需要保证在同一时间只能一个线程或进程对该资源进行操作。
  • 具备锁失效机制,防止死锁。即使出现进程在持有锁的期间崩溃或者解锁失败的情况,也能被动解锁,保证后续其他进程可以获得锁。
  • 可重入性,即进程未释放锁时,可以多次访问临界资源。
  • 有高可用的获取锁和释放锁的功能,且性能要好。

知识扩展:如何解决分布式锁的羊群效应问题?

在分布式锁问题中,会经常遇到羊群效应。

所谓羊群效应,就是在整个 ZooKeeper 分布式锁的竞争过程中,大量的进程都想要获得锁去使用共享资源。每个进程都有自己的“Watcher”来通知节点消息,都会获取整个子节点列表,使得信息冗余,资源浪费。

当共享资源被解锁后,Zookeeper 会通知所有监听的进程,这些进程都会尝试争取锁,但最终只有一个进程获得锁,使得其他进程产生了大量的不必要的请求,造成了巨大的通信开销,很有可能导致网络阻塞、系统性能下降。

那如何解决这个问题呢?具体方法可以分为以下三步。

  • 在与该方法对应的持久节点的目录下,为每个进程创建一个临时顺序节点。
  • 每个进程获取所有临时节点列表,对比自己的编号是否最小,若最小,则获得锁。
  • 若本进程对应的临时节点编号不是最小的,则注册 Watcher,监听自己的上一个临时顺序节点,当监听到该节点释放锁后,获取锁。

知识扩展:如何使用Redis缓存实现分布式锁

如果我们现在要提供售票服务,那么如何通过Redis缓存实现分布式锁机制,以保证最终结果的正确性呢?

版本0

public class class1{
	private int a = 100;              // 堆
	private static int a = 100;       // 方法区
    public void sale(){
        int a = 100;                  // 栈,线程安全,但是业务不对
    	if( a > 0 ){
    		a--;
    		卖票;
    	} else {
    		票卖完了;
    	}
    }
}

存在问题:多线程的并发问题。

版本1

解决方案:jvm锁

public class class1{
	private int a = 100;
	
    public void sale(){
    	加jvm锁;
    	// 业务代码
    	if( a > 0 ){
    		a--;
    		卖票;
    	} else {
    		票卖完了;
    	}
    	jvm解锁;
    }
}

存在问题:多个应用时候,jvm锁不好使,需要分布式锁

版本2

解决方案:通过Redis缓存实现分布式锁

public class class1{
	private int a = 100;
	
    public void sale(){
    	加jvm锁;
    	while ( !redis : setnx( key , value) ){   // 若key不存在则设置key,值为value;若key存在则失败。
    		sleep(5);
    	}                                     // while循环等待加锁成功
    	try{
    		// 业务代码
            if( a > 0 ){
                a--;
                卖票;
            } else {
                票卖完了;
            }
    	} catch(Exception e) {
    	
    	} finaly {
    		redis : deleteKey(key);
    	}
    	解jvm锁;
    }
}

存在问题:如果执行到中间的业务代码,jvm崩了,锁就一直存在,就死锁了

版本3

解决方案:给分布式锁加超时时间

public class class1{
	private int a = 100;
	
    public void sale(){
    	加jvm锁;
    	while ( !(redis : setnx key value EXPIRE 30) ){   // 设置key及其超时时间。
    		sleep(5);
    	}                                     // while循环等待加锁成功
    	
    	
    	try{
    		// 业务代码
            if( a > 0 ){
                a--;
                卖票;
            } else {
                票卖完了;
            }
    	} catch(Exception e) {
    	
    	} finaly {
    		redis : deleteKey(key);
    	}
    	解jvm锁;
    }
}

存在问题:

业务代码执行不完,30s后锁过期了。

则第二个程序会抢到锁,开始执行。此时有并发问题。

过了一段时间,第一个程序执行完了,释放锁。

第三个程序又抢到了锁,开始执行,并发问题。

过了一段时间,第二个程序执行完了,释放锁。

此时分布式锁已经名存实亡。而且在这种场景下,不可以通过加时间解决:如果jvm真的挂了,锁的超时时间决定了死锁时间的长短。

版本4

解决方案:每个线程设置独立的value避免不同的线程会释放相同的锁

public class class1{
	private int a = 100;
	
    public void sale(){
    	加jvm锁;
    	value = UUID;
    	while ( !(redis : setnx key value EXPIRE 30) ){   
    		sleep(5);
    	}                                     // while循环等待加锁成功
    	
    	
    	try{
    		// 业务代码
            if( a > 0 ){
                a--;
                卖票;
            } else {
                票卖完了;
            }
    	} catch(Exception e) {
    	
    	} finaly {
    		if (value.equels(redis : getKey(key))){
    			redis : deleteKey(key);
    		}
    	}
    	解jvm锁;
    }
}

存在问题:仅仅解决了不同线程释放相同锁的问题,但是还存在多线程的并发问题

版本5

解决方案:添加子进程,给锁续命

public class class1{
	private int a = 100;
	
    public void sale(){
    	加jvm锁;
    	value = UUID;
    	while ( !(redis : setnx key value EXPIRE 30) ){   
    		sleep(5);
    	}                                     // while循环等待加锁成功
    	
    	folk子进程{
    		定时检查锁的TTL。给锁续命。
    		父子进程通讯。
    	}
    	try{
    		// 业务代码
            if( a > 0 ){
                a--;
                卖票;
            } else {
                票卖完了;
            }
    	} catch(Exception e) {
    	
    	} finaly {
    		if (value.equels(redis : getKey(key))){
    			redis : deleteKey(key);
    		}
    	}
    	解jvm锁;
    }
}

存在问题:实现难度较大

总结

分布式锁是解决多个进程同时访问临界资源的常用方法,在分布式系统中非常常见。常见的分布式锁的实现方法有三种,包括基于数据库实现、基于缓存实现(以 Redis 为例),以及基于 ZooKeeper 实现。

接下来,我把今天的内容通过下面的一张思维导图再全面总结下。
在这里插入图片描述

参考

1、极客时间课程:《分布式技术原理与算法解析》— 07 分布式锁:关键重地,非请勿入

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值