MySQL-高可用篇-④分布式锁
MySQL-高可用篇-④分布式锁
学习核心
- 分布式锁概念核心
- 分布式锁基础
- 如何实现分布式锁?(实现原理和案例场景拆解)
- 三种主流实现分布式锁的方式(实现思路、案例分析、衍生问题)
学习资料
分布式锁概念核心
1.分布式锁基础
什么是锁?
在单机系统中,经常会有多个线程访问同一种资源的情况,一般把这样的资源叫做共享资源,或者叫做临界资源。为了维护线程操作的有效性和正确性,需要某种机制来减少低效率的操作,避免同时对相同数据进行不一样的操作,维护数据的一致性,防止数据丢失。即需要一种互斥机制,按照某种规则对多个线程进行排队,依次、互不干扰地访问共享资源。
这个机制核心在于做标记,为了实现分布式互斥,在某个地方做个标记,这个标记每个线程都能看到,到标记不存在时可以设置该标记,当标记被设置后,其他线程只能等待拥有该标记的线程执行完成,并释放该标记后,才能去设置该标记和访问共享资源。而此处的标记就是锁。
锁是多线程同时访问同一资源的场景下,为了让线程互不干扰地访问共享资源,从而保证操作的有效性和正确性的一种标记
什么是分布式锁?
分布式锁是指分布式环境下,系统部署在多个机器中,实现多进程分布式互斥的一种锁。为了保证多个进程能看到锁,锁被存在公共存储(比如Redis、Memcached、数据库等三方存储中),以实现多个进程并发访问同一个临界资源,同一时刻只有一个进程可访问共享资源,确保数据的一致性。
在JVM 中,在多线程并发的情况下,可以使用同步锁或 Lock 锁,保证在同一时间内,只能有一个线程修改共享变量或执行代码块。但对于分布式集群的共享资源(例如电商场景的库存问题)而言,在分布式环境下使用 Java 锁的方式就失去作用了。
什么场景需要使用分布式锁?
- 场景分析
比如,现在某电商要售卖某大牌吹风机(以下简称“吹风机”),库存只有2个,但有5个来自不同地区的用户{A,B,C,D,E}几乎同时下单,那么这2个吹风机到底会花落谁家呢?
一种最简单的方案:先来后到,谁先提交订单请求,谁就购买成功。但实际业务中,为了高并发地接收大量用户订单请求,很少有电商网站真正实施这么简单的措施。
此外,对于订单的优先级,不同电商往往采取不同的策略,比如有些电商根据下单时间判断谁可以购买成功,而有些电商则是根据付款时间来判断。但无论采用什么样的规则去判断谁能购买成功,都必须要保证吹风机售出时,数据库中更新的库存是正确的。
- 解决方案
为了便于理解,下述分析以下单时间作为购买成功的判断依据。为了确保吹风机售出时数据库中更新的库存是正确的。
一种最简单的方案:给吹风机的库存数加一个锁。当有一个用户提交订单后,后台服务器给库存数加一个锁,根据该用户的订单修改库存。而其他用户必须等到锁释放以后,才能重新获取库存数,继续购买。
此处,吹风机的库存就是共享资源,不同的购买者对应着多个进程,后台服务器对共享资源加的锁就是告诉其他进程当前这个资源已经被占据了,其他进程需排队等待。
- 问题分析
理想状态可以考虑通过上述方式来解决共享资源的访问问题,但问题就这样解决了吗?可以来看单机锁可能会出现的一种情况
A 用户 | B用户 | |
---|---|---|
步骤【1】 | A 确认库存剩余为2台,想购买1台 | B 确认库存剩余为2,想购买2台 |
步骤【2】 | A 网速好抢到了1台,下单成功 | |
步骤【3】 | B紧跟其后下单购买2台 | |
步骤【4】 | A 更新库存 | |
步骤【5】 | B 更新库存 |
用户A想买1个吹风机,用户B想买2个吹风机。在理想状态下,用户A网速好先买走了1个,库存还剩下1个,此时应该提示用户B库存不足,用户B购买失败。但实际情况是,用户A和用户B同时获取到商品库存还剩2个,用户A买走1个,在用户A更新库存之前,用户B又买走了2个,此时用户B更新库存,商品还剩0个。此处很明显出现问题了,总共2个吹风机,却卖出去了3个。
基于此,会发现如果只使用单机锁将会出现不可预知的后果。因此,在高并发场景下,为了保证临界资源同一时间只能被一个进程使用,从而确保数据的一致性,因此引入分布式锁。
此外,在大规模分布式系统中,单个机器的线程锁无法管控多个机器对同一资源的访问,这时使用分布式锁,就可以把整个集群当作一个应用一样去处理,实用性和扩展性更好。
除此之外,分布式锁也经常用来避免分布式中的不同节点执行重复性的工作,例如一个定时发短信的任务,在分布式集群中,我们只需要保证一个服务节点发送短信即可,一定要避免多个节点重复发送短信给同一个用户。
2.如何实现分布式锁?
分布式锁设计需要考虑的问题
在选用分布式锁方案(实现分布式锁)的时候,应该先明确分布式锁可能会出现什么问题,针对这些问题有哪些应对方案(如何解决)
- 可用问题:无论何时都要保证锁服务的可用性(这是系统正常执行锁操作的基础)
- 死锁问题:客户端一定可以获得锁,即使锁住某个资源的客户端在释放锁之前崩溃或者网络不可达(这是避免死锁的设计原则)
- 脑裂问题:集群同步时产生的数据不一致,导致新的进程有可能拿到锁,但之前的进程以为自己还有锁,那么就出现两个进程拿到了同一个锁的问题
基于上述对分布式锁可能出现的问题分析,为确保分布式锁的可用性,可从下述几方面切入
- 互斥性:即在分布式系统环境下,对于某一共享资源,需要保证在同一时间只能一个线程或进程对该资源进行操作(这是锁设计的基础)
- 防死锁:具备锁失效机制,防止死锁。即使出现进程在持有锁的期间崩溃或者解锁失败的情况,也能被动解锁,保证后续其他进程可以获得锁
- 可重入性:即进程未释放锁时,可以多次访问临界资源
- 性能:有高可用的获取锁和释放锁的功能,且性能要好
分布式锁的3种实现主流方法
- 基于数据库实现分布式锁(这里的数据库指的是关系型数据库);
- 基于缓存实现分布式锁;
- 基于ZooKeeper实现分布式锁;
基于数据库实现分布式锁
(1)实现思路
实现分布式锁最直接的方式通过数据库进行实现,首先创建一张表用于记录共享资源信息,然后通过操作该表的数据来实现共享资源信息的修改。
当要锁住某个资源时,就在该表中增加一条记录,想要释放锁的时候就删除这条记录。数据库对共享资源做了唯一性约束,如果有多个请求被同时提交到数据库的话,数据库会保证只有一个操作可以成功,操作成功的那个线程就获得了访问共享资源的锁,可以进行操作。
基于数据库实现的分布式锁,是最容易理解的。但是因为数据库需要落到硬盘上,频繁读取数据库会导致IO开销大,因此这种分布式锁适用于并发量低,对性能要求低的场景。对于双11、双12等需求量激增的场景,数据库锁是无法满足其性能要求的。而在平日的购物中,可以在局部场景中使用数据库锁实现对资源的互斥访问。
(2)案例分析
以电商售卖吹风机的场景为例:吹风机库存是2个,有3个来自不同地区的用户{A,B,C}想要购买,其中用户A想买1个,用户B想买2个,用户C想买1个。分析其工作流程。
【1】用户A和用户B几乎同时下单,但用户A的下单请求最先到达服务器。因此,该商家的产品数据库中增加了一条关于用户A的记录,用户A获得了锁,他的订单请求被处理,服务器修改吹风机库存数,目前剩余库存1个
【2】当用户A的订单请求处理完成后,有关用户A的记录被删除。服务器开始处理用户B的订单请求,此时库存只有1个,无法满足用户B的订单需求,因此用户B购买失败
【3】用户B的订单请求处理完成,从数据库中删除用户B的记录。服务器开始处理用户C的订单请求,数据库中增加了一条关于用户C的记录,用户C获得了锁,且现有库存满足用户C的订单需求,他的订单请求被处理,服务器修改吹风机数量,目前剩余库存0个
(3)衍生问题
**基于数据库实现分布式锁比较简单,其核心在于创建一张锁表,为申请者在锁表里建立一条记录,记录建立成功则获得锁,消除记录则释放锁。**该方法依赖于数据库,主要有两个缺点:
- 单点故障问题:一旦数据库不可用,会导致整个系统崩溃
- 死锁问题:数据库锁没有失效时间,未获得锁的进程只能一直等待已获得锁的进程主动释放锁。倘若已获得共享资源访问权限的进程突然挂掉、或者解锁操作失败,使得锁记录一直存在数据库中,无法被删除,而其他进程也无法获得锁,从而产生死锁现象。针对死锁问题常见的解决方案有超时方法和死锁检测,对应基于数据库实现的分布式所而言,解决死锁问题也是一个非常复杂的过程
- 超时方法(最简单):超时方式是在创建分布式线程的时候,对每个线程都设置一个超时时间。当该线程的超时时间到期后,无论该线程是否执行完毕,都要关闭该线程并释放该线程所占用的系统资源。之后其他线程就可以访问该线程释放的资源,这样就不会造成分布式死锁问题。但是这种设置超时时间的方法也有很多缺点,最主要的就是很难设置一个合适的超时时间。如果时间设置过短,可能造成线程未执行完相关的处理逻辑,就因为超时时间到期就被迫关闭,最终导致程序执行出错。
- 死锁检测:死锁检测是处理死锁问题的另一种方法,它解决了超时方法的缺陷。与超时方法相比,死锁检测方法主动检测发现线程死锁,在控制死锁问题上更加灵活准确。可以把死锁检测理解为一个运行在各个服务器系统上的线程或方法,该方法专门用来探索发现应用服务上的线程是否发生了死锁。如果发生死锁,就会触发相应的预设处理方案。
基于缓存实现分布式锁
(1)实现思路
基于数据库实现分布式锁的方案,其并发支持受限于数据库性能,可用于局部场景的一些应用(例如并发量和性能要求低的场景)。但对于双11、双12这类需求量激增的场景则需要引入其他方案解决
基于缓存实现分布式锁的方式,非常适合解决这种场景下的问题。**所谓基于缓存,也就是说把数据存放在计算机内存中,不需要写入磁盘,减少了IO读写。**下述以Redis为例进行场景说明
Redis通常可以使用setnx(key, value)函数
来实现分布式锁。key和value就是基于缓存的分布式锁的两个属性,其中key表示锁id,value = currentTime + timeOut,表示当前时间+超时时间。即某个进程获得key这把锁后,如果在value的时间内未释放锁,系统就会主动释放锁。
- setnx函数的返回值有0和1:
- 返回1:说明该服务器获得锁,setnx将key对应的value设置为当前时间 + 锁的有效时间
- 返回0:说明其他服务器已经获得了锁,进程不能进入临界区。该服务器可以不断尝试setnx操作,以获得锁
实现说明
加锁:SET lock_key unique_value NX PX 10000
- lock_key : key 键;
- unique_value :客户端生成的唯一的标识;
- NX :代表只在 lock_key 不存在时,才对 lock_key 进行设置操作;(用于保证当分布式锁不存在时,只有一个client能写入此key成功,获取到此锁)
- PX 10000 :表示设置 lock_key 的过期时间为 10s,用于避免客户端发生异常而无法释放锁
解锁:解锁的过程是将lock_key键删除,但不能乱删,必须保证执行加锁和解锁操作的客户端是同一个。此处则通过unique_value进行校验,借助LUA脚本判断unique_value是否为加锁客户端(选用 Lua 脚本是为了保证解锁操作的原子性)
// 释放锁时,先比较 unique_value 是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
(2)案例分析
以电商售卖吹风机的场景为例,假设现在库存数量是足够的。有3个来自不同地区的用户{A,B,C}想要购买,其中用户A想买1个,用户B想买2个,用户C想买1个。分析其工作流程。
【1】用户A的请求因为网速快,最先到达Server2,setnx操作返回1,并获取到购买吹风机的锁;用户B和用户C的请求,几乎同时到达了Server1和Server3,但因为这时Server2获取到了吹风机数据的锁,所以B、C的请求只能加入等待队列
【2】Server2获取到锁后,负责管理吹风机的服务器执行业务逻辑,只用了1s就完成了订单。订单请求完成后,删除锁的key,从而释放锁。此时,排在第二顺位的Server1获得了锁,可以访问吹风机的数据资源。但不巧的是,Server1在完成订单后发生了故障,无法主动释放锁
【3】此时排在第三顺位的Server3只能等设定的有效时间(比如30分钟)到期,等待锁自动释放后,才能继续访问吹风机的数据资源,也就是说用户C只能到00:30:01以后才能继续抢购。
Redis通过队列来维持进程访问共享资源的先后顺序。Redis锁主要基于setnx函数实现分布式锁,当进程通过setnx函数返回1时,表示已经获得锁。排在后面的进程只能等待前面的进程主动释放锁,或者等到时间超时才能获得锁。
相对于基于数据库实现分布式锁的方案来说,基于缓存实现的分布式锁的优势表现在以下几个方面:
- 性能更好:数据被存放在内存,而不是磁盘,避免了频繁的IO操作
- 避免单点故障:很多缓存可以跨集群部署,避免了单点故障问题
- 使用方便:很多缓存服务都提供了可以用来实现分布式锁的方法,比如Redis的setnx和delete方法等
- 避免死锁问题:可以通过设置超时时间(例如expire key timeout)来控制锁的释放,从而避免一些因业务进程突然挂掉导致锁资源无法正常释放的问题。但此方案存在不足:因为通过超时时间来控制锁的失效时间并不是十分靠谱,例如一个进程执行时间可能比较长,或受系统进程做内存回收等影响,导致时间超时,从而不正确地释放了锁
(3)衍生问题
💊设置了超时时间为什么死锁问题还会存在?
解决方案:确保加锁命令和设置锁时间的操作是原子性
此处需要注意一个问题,在 Redis2.6.12 的之前的版本中,由于加锁命令和设置锁过期时间命令是两个操作(不是原子性的),当出现某个线程操作完成 setnx 之后,还没有来得及设置过期时间,线程就挂掉了,就会导致当前线程设置 key 一直存在,后续的线程无法获取锁,最终造成死锁的问题,所以要选型 Redis 2.6.12 后的版本或通过 Lua 脚本执行加锁和设置超时时间(Redis 允许将 Lua 脚本传到 Redis 服务器中执行, 脚本中可以调用多条 Redis 命令,并且 Redis 保证脚本的原子性)。
💊如何合理设置超时时间?
锁超时失效导致的误操作:通过超时时间来控制锁的失效时间并不太靠谱。比如在有些场景中,一个线程 A 获取到了锁之后,由于业务代码执行时间可能比较长,导致超过了锁的超时时间,自动失效,后续线程 B 又意外的持有了锁,当线程 A 再次恢复后,通过 del 命令释放锁,就错误的将线程 B 中同样 key 的锁误删除了。
如果锁的超时时间设置过长,会影响性能,如果设置的超时时间过短,有可能业务阻塞没有处理完成,能否合理设置超时时间,是基于缓存实现分布式锁很难解决的一个问题。
如何合理设置超时时间呢? =》可以基于续约的方式设置超时时间:先给锁设置一个超时时间,然后启动一个守护线程,让守护线程在一段时间后,重新设置这个锁的超时时间。实现方式就是:写一个守护线程,然后去判断锁的情况,当锁快失效的时候,再次进行续约加锁,当主线程执行完成后,销毁续约锁即可。不过这种方式实现起来相对复杂,所以针对超时时间的设置,要站在实际的业务场景中进行衡量。
💊Redis主备切换导致的安全性问题
使用基于Redis实现的分布式锁,为了解决单点故障问题,一般采用集群部署的方式来支持分布式锁的高可用。例如构建一个主备版的Redis服务,其至少具备一个Slave节点,Redis是基于主备异步复制协议实现的Master-Slave数据同步。结合下述场景分析:
如下图所示,若client A执行SET key value EX 10 NX命令,redis-server返回给client A成功后,Redis Master节点突然出现crash等异常,这时候Redis Slave节点还未收到此命令的同步。若部署了Redis Sentinel等主备切换服务,那么它就会以Slave节点提升为主,此时Slave节点因并未执行SET key value EX 10 NX命令,因此它收到client B发起的加锁的此命令后,它也会返回成功给client。那么在同一时刻,集群就出现了两个client同时获得锁,分布式锁的互斥性、安全性就被破坏了。
上述流程简单说明如下:
- 【1】ClientA在Master执行指令获取到锁后,数据还没有同步到Slave而Master却突然宕机
- 【2】由于主备切换机制,此时Slave就会被提升为Master,但此时Slave中并没有上述操作结果数据,则此时如果ClientB申请执行指令也会成功获取到锁
那么此时的集群环境中就会出现两个client同时获取到同一把锁,则分布式锁的互斥性和安全性就被破坏了
💊Redis 脑裂 导致的安全性问题
在发生网络分区等场景下可能会导致出现脑裂,Redis集群出现多个Master,进而也会导致多个client同时获得锁。
如下图所示,Master节点在可用区1,Slave节点在可用区2,当可用区1和可用区2发生网络分区后,部署在可用区2的Redis Sentinel服务就会将可用区2的Slave提升为Master,而此时可用区1的Master也在对外提供服务。因此集群就出现了脑裂,出现了两个Master,都可对外提供分布式锁申请与释放服务,分布式锁的互斥性被严重破坏。
主备切换、脑裂是Redis分布式锁的两个典型不安全的因素,本质原因是Redis为了满足高性能,采用了主备异步复制协议,同时也与负责主备切换的Redis Sentinel服务是否合理部署有关
Redis作者为了解决SET key value [EX] 10 [NX]
命令实现分布式锁不安全的问题,提出了RedLock算法。它是基于多个独立的Redis Master节点的一种实现(一般为5)。client依次向各个节点申请锁,若能从多数个节点中申请锁成功并满足一些条件限制,那么client就能获取锁成功。
它通过独立的N个Master节点,避免了使用主备异步复制协议的缺陷,只要多数Redis节点正常就能正常工作,显著提升了分布式锁的安全性、可用性。
但是,它的实现建立在一个不安全的系统模型上的,它依赖系统时间,当时钟发生跳跃时,也可能会出现安全性问题。可以详细阅读下分布式存储专家Martin对RedLock的分析文章,Redis作者的也专门写了一篇文章进行了反驳。
其核心实现思路是:假设目前有 N 个独立的 Redis 实例, 客户端先按顺序依次向 N 个 Redis 实例执行加锁操作。这里的加锁操作和在单实例上执行的加锁操作一样,但是需要注意的是,Redlock 算法设置了加锁的超时时间,为了避免因为某个 Redis 实例发生故障而一直等待的情况。当客户端完成了和所有 Redis 实例的加锁操作之后,如果有超过半数的 Redis 实例成功的获取到了锁,并且总耗时没有超过锁的有效时间,那么就是加锁成功
基于ZooKeeper实现分布式锁
(1)实现思路
ZooKeeper基于树形数据存储结构实现分布式锁,来解决多个进程同时访问同一临界资源时,数据的一致性问题。ZooKeeper的树形数据存储结构主要由4种节点构成:
- 持久节点(PERSISTENT):默认的节点类型,一直存在于ZooKeeper中
- 持久顺序节点(PERSISTENT_SEQUENTIAL):在创建节点时,ZooKeeper根据节点创建的时间顺序对节点进行编号命名
- 临时节点(EPHEMERAL):当客户端与Zookeeper连接时临时创建的节点。与持久节点不同,当客户端与ZooKeeper断开连接后,该进程创建的临时节点就会被删除
- 临时顺序节点(EPHEMERAL_SEQUENTIAL):就是按时间顺序编号的临时节点
根据它们的特征,ZooKeeper基于临时顺序节点实现了分布锁。
实现思路
可以使用Zookeeper实现两种比较常用的锁:排他锁和共享锁
- 排他锁(独占锁):当给某一个数据对象设置了排他锁后,只有具有该锁的事务线程可以访问该条数据对象,直到该条事务主动释放锁。否则,在这期间其他事务不能对该数据对象进行任何操作
- 共享锁:在性能上要优于排他锁(因为在共享锁的实现中,只对数据对象的写操作加锁,而不为对象的读操作进行加锁),这种“只对写操作加锁”的实现方式既保证了数据对象的完整性,也兼顾了多事务情况下的读取操作。可以说,共享锁是写入排他,而读取操作则没有限制
其实无论是共享锁还是排他锁,在锁的实现方式上都是一样的。唯一的区别在于,共享锁为一个数据事务创建两个数据节点,来区分是写入操作还是读取操作。参考下图所示,在Zookeeper数据模型上的shared_lock节点创建临时顺序节点,其名称中带有相应的请求操作类型用于区分R-读取操作、W-写入操作
基于Zookeeper实现分布式锁的核心实现思路分析如下:
【1】在与该方法对应的持久节点shared_lock的目录下,为每个进程创建一个临时顺序节点
【2】每个进程获取shared_lock目录下的所有临时节点列表,注册Watcher,用于监听子节点变更的信息
- 当监听到自己的临时节点是顺序最小的,则可以使用共享资源
【3】每个节点确定自己的编号是否是shared_lock下所有子节点中最小的
若最小则获得锁,继续执行业务逻辑,执行完成则删除lock节点以释放锁资源
若本进程对应的临时节点编号不是最小的,则分为两种情况:
本进程为读请求,如果比自己序号小的节点中有写请求,则等待;
本进程为写请求,如果比自己序号小的节点中有请求,则等待;
(2)案例分析
以电商售卖吹风机的场景为例。假设用户A、B、C同时在11月11日的零点整提交了购买吹风机的请求,ZooKeeper会采用如下方法来实现分布式锁(对照到场景案例中分析如下):
- 此处吹风机就是一个拥有shared_lock的目录,当有人买吹风机时,会为他创建一个临时顺序节点
- 每个进程获取shared_lock目录下的所有临时节点列表,注册Watcher,用于监听子节点变更的信息,当监听到自己的临时节点是顺序最小的,则可以使用共享资源,假设目前的注册顺序为:A =》LockNode1、B =》LockNode3、C =》LockNode2
- 临时节点监听:
- 假设用户A的订单最先到服务器,创建了编号为1的临时顺序节点LockNode1。该节点的编号是持久节点目录下最小的,因此获取到分布式锁,可以访问临界资源,从而可以购买吹风机
- 假设用户B也想要买吹风机,但在他之前,用户C想看看吹风机的库存量。因此,用户B只能等用户A买完吹风机、用户C查询完库存量后,才能购买吹风机
可以看到,使用ZooKeeper实现的分布式锁,可以解决前两种方法提到的各种问题,比如单点故障、不可重入、死锁等问题。但该方法实现较复杂,且需要频繁地添加和删除节点,所以性能不如基于缓存实现的分布式锁。
三种方式的实现对比
对比 | 说明 |
---|---|
理解的容易程度(由易到难) | 数据库 > 缓存 > Zookeeper |
实现的复杂度(从低到高)针对分布式锁的实现复杂性 | Zookeeper <= 缓存 < 数据库 |
性能(从高到低) | 缓存 > Zookeeper >= 数据库 |
可靠性(从高到低) | Zookeeper > 缓存 > 数据库 |
需注意此处的实现的复杂度是针对分布式锁的实现复杂性,而不是概念理解上基于数据库实现的简单程度
总结分析
基于数据库实现的分布式锁
- 实现方式:通过一张表对临界资源进行唯一约束(加锁:添加记录;释放锁:删除记录)
- 优势:方案简单易懂,不需要依赖第三方组件
- 缺点:存在单点故障和死锁问题,仅仅利用数据库技术去解决单点故障和死锁问题,是非常复杂的
- 适用场景:适用于对并发量和性能要求低的场景
基于缓存实现的分布式锁
- 实现方式:以Redis为例,通过sernx函数实现(返回1:获得锁;返回0:获取锁失败),可设定key关联的锁的失效时间(超出时效自动释放锁资源)
- 优势:
- 其操作时基于内存的,避免了IO频繁交互,性能加高
- 可以通过集群部署解决单点故障问题,通过设定锁的有效时间来解决死锁问题
- 缺点:
- 可能由于Redis版本和集群部署衍生一些破坏分布式锁安全性的问题
- 由于无法明确实际业务执行时间和锁有效时间的定义是否冲突,则可能导致锁在业务执行过程中锁就被释放掉进而导致异常问题
- 适用场景:适用于对并发量和性能要求高的场景(例如双11、12并发量临时激增的情况)
基于ZooKeeper实现的分布式锁(ZooKeeper分布式锁的可靠性最高,有封装好的框架可以容易实现分布式锁的功能,几乎解决了数据库锁和缓存式锁的不足,是实现分布式锁的首选方法)
- 实现方式:在对应的持久节点shared_lock的目录下,为每个进程创建一个临时顺序节点。进程通过Watcher监听自己的临时节点是否为顺序最小的,如果是则可获得锁,否则等待更小编号的节点释放锁
- 优势:ZooKeeper已定义相关的功能组件,可以很轻易地解决设计分布式锁时遇到的各种问题
- 缺点:可能理解容易程度上对比前面的方案来说会比较难,但是瑕不掩瑜
- 适用场景:要实现一个完整的、无任何缺陷的分布式锁,ZooKeeper是一个最简单的选择
分布式锁扩展
去分布式锁概念
何为去分布式锁?
在一些场景下是可以考虑去掉分布式锁的。严格来说,应该是原本这些场景就不该用分布式锁,现在是回归本源了。那究竟怎么去分布式锁呢?
- 【思路1】用数据库乐观锁来取代分布式锁
- 【思路2】利用一致性哈希负载均衡算法
【思路1】用数据库乐观锁取代分布式锁
可以尝试用数据库乐观锁来取代分布式锁。比如说一些场景是加了分布式锁之后执行一些计算,最后更新数据库。在这种场景下,完全可以抛弃分布式锁,直接计算,最后计算完成之后,利用乐观锁来更新数据库。缺点就是没有分布式锁的话,可能会有多个线程在计算。但是问题不大,因为只要最终更新数据库控制住了并发,就没关系。
在数据库层面,select for update 是悲观锁,会一直阻塞直到事务提交,所以为了不产生锁等待而消耗资源,可以基于乐观锁的方式来实现分布式锁,比如基于版本号的方式,首先在数据库增加一个 int 型字段 ver,然后在 SELECT 同时获取 ver 值,最后在 UPDATE 的时候检查 ver 值是否为与第 2 步或得到的版本值相同。
# SELECT 同时获取 ver 值
select amount, old_ver from order where order_id = xxx
# UPDATE 的时候检查 ver 值是否与第 2 步获取到的值相同
update order set ver = old_ver + 1, amount = yyy where order_id = xxx and ver = old_ver
此时,如果更新结果的记录数为1,就表示成功,如果更新结果的记录数为 0,就表示已经被其他应用更新过了,需要做异常处理。
【思路2】利用一致性哈希负载均衡算法
利用一致性哈希负载均衡算法。在使用这种算法的时候,同一个业务的请求肯定发到同一个节点上。这时候就没必要使用分布式锁了,本地直接加锁,或者用 Singleflight 模式就可以。