跳至主要內容

MySQL-高可用篇-④分布式锁

holic-x2024年7月11日55大约 31 分钟JAVAMySQL

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种实现主流方法

基于数据库实现分布式锁

(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个

image-20240711093706463

(3)衍生问题

​ **基于数据库实现分布式锁比较简单,其核心在于创建一张锁表,为申请者在锁表里建立一条记录,记录建立成功则获得锁,消除记录则释放锁。**该方法依赖于数据库,主要有两个缺点:

基于缓存实现分布式锁

(1)实现思路

​ 基于数据库实现分布式锁的方案,其并发支持受限于数据库性能,可用于局部场景的一些应用(例如并发量和性能要求低的场景)。但对于双11、双12这类需求量激增的场景则需要引入其他方案解决

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

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

实现说明

加锁SET lock_key unique_value NX PX 10000

解锁:解锁的过程是将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以后才能继续抢购。

image-20240711094941039

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

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

(3)衍生问题
💊设置了超时时间为什么死锁问题还会存在?

解决方案确保加锁命令和设置锁时间的操作是原子性

​ 此处需要注意一个问题,在 Redis2.6.12 的之前的版本中,由于加锁命令和设置锁过期时间命令是两个操作(不是原子性的),当出现某个线程操作完成 setnx 之后,还没有来得及设置过期时间,线程就挂掉了,就会导致当前线程设置 key 一直存在,后续的线程无法获取锁,最终造成死锁的问题,所以要选型 Redis 2.6.12 后的版本或通过 Lua 脚本执行加锁和设置超时时间(Redis 允许将 Lua 脚本传到 Redis 服务器中执行, 脚本中可以调用多条 Redis 命令,并且 Redis 保证脚本的原子性)。

💊如何合理设置超时时间?

锁超时失效导致的误操作:通过超时时间来控制锁的失效时间并不太靠谱。比如在有些场景中,一个线程 A 获取到了锁之后,由于业务代码执行时间可能比较长,导致超过了锁的超时时间,自动失效,后续线程 B 又意外的持有了锁,当线程 A 再次恢复后,通过 del 命令释放锁,就错误的将线程 B 中同样 key 的锁误删除了。

image-20240711140047515

​ 如果锁的超时时间设置过长,会影响性能,如果设置的超时时间过短,有可能业务阻塞没有处理完成,能否合理设置超时时间,是基于缓存实现分布式锁很难解决的一个问题。

如何合理设置超时时间呢? =》可以基于续约的方式设置超时时间:先给锁设置一个超时时间,然后启动一个守护线程,让守护线程在一段时间后,重新设置这个锁的超时时间。实现方式就是:写一个守护线程,然后去判断锁的情况,当锁快失效的时候,再次进行续约加锁,当主线程执行完成后,销毁续约锁即可。不过这种方式实现起来相对复杂,所以针对超时时间的设置,要站在实际的业务场景中进行衡量。

💊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同时获得锁,分布式锁的互斥性、安全性就被破坏了。

image-20240711143538963

​ 上述流程简单说明如下:

​ 那么此时的集群环境中就会出现两个client同时获取到同一把锁,则分布式锁的互斥性和安全性就被破坏了

💊Redis 脑裂 导致的安全性问题

​ 在发生网络分区等场景下可能会导致出现脑裂,Redis集群出现多个Master,进而也会导致多个client同时获得锁。

​ 如下图所示,Master节点在可用区1,Slave节点在可用区2,当可用区1和可用区2发生网络分区后,部署在可用区2的Redis Sentinel服务就会将可用区2的Slave提升为Master,而此时可用区1的Master也在对外提供服务。因此集群就出现了脑裂,出现了两个Master,都可对外提供分布式锁申请与释放服务,分布式锁的互斥性被严重破坏。

image-20240711144621268

主备切换、脑裂是Redis分布式锁的两个典型不安全的因素,本质原因是Redis为了满足高性能,采用了主备异步复制协议,同时也与负责主备切换的Redis Sentinel服务是否合理部署有关

​ Redis作者为了解决SET key value [EX] 10 [NX]命令实现分布式锁不安全的问题,提出了RedLock算法open in new window。它是基于多个独立的Redis Master节点的一种实现(一般为5)。client依次向各个节点申请锁,若能从多数个节点中申请锁成功并满足一些条件限制,那么client就能获取锁成功。

​ 它通过独立的N个Master节点,避免了使用主备异步复制协议的缺陷,只要多数Redis节点正常就能正常工作,显著提升了分布式锁的安全性、可用性。

但是,它的实现建立在一个不安全的系统模型上的,它依赖系统时间,当时钟发生跳跃时,也可能会出现安全性问题。可以详细阅读下分布式存储专家Martin对RedLock的分析文章open in new window,Redis作者的也专门写了一篇文章进行了反驳open in new window

​ 其核心实现思路是:假设目前有 N 个独立的 Redis 实例, 客户端先按顺序依次向 N 个 Redis 实例执行加锁操作。这里的加锁操作和在单实例上执行的加锁操作一样,但是需要注意的是,Redlock 算法设置了加锁的超时时间,为了避免因为某个 Redis 实例发生故障而一直等待的情况。当客户端完成了和所有 Redis 实例的加锁操作之后,如果有超过半数的 Redis 实例成功的获取到了锁,并且总耗时没有超过锁的有效时间,那么就是加锁成功

基于ZooKeeper实现分布式锁

(1)实现思路

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

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

实现思路

​ 可以使用Zookeeper实现两种比较常用的锁:排他锁和共享锁

​ 其实无论是共享锁还是排他锁,在锁的实现方式上都是一样的。唯一的区别在于,共享锁为一个数据事务创建两个数据节点,来区分是写入操作还是读取操作。参考下图所示,在Zookeeper数据模型上的shared_lock节点创建临时顺序节点,其名称中带有相应的请求操作类型用于区分R-读取操作、W-写入操作

/
/shared_lock
W-0000000001
R-0000000001
W-0000000002
W-0000000002

​ 基于Zookeeper实现分布式锁的核心实现思路分析如下:

image-20240711111153448
(2)案例分析

​ 以电商售卖吹风机的场景为例。假设用户A、B、C同时在11月11日的零点整提交了购买吹风机的请求,ZooKeeper会采用如下方法来实现分布式锁(对照到场景案例中分析如下):

image-20240711103241915

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

三种方式的实现对比

对比说明
理解的容易程度(由易到难)数据库 > 缓存 > Zookeeper
实现的复杂度(从低到高)针对分布式锁的实现复杂性Zookeeper <= 缓存 < 数据库
性能(从高到低)缓存 > Zookeeper >= 数据库
可靠性(从高到低)Zookeeper > 缓存 > 数据库

​ 需注意此处的实现的复杂度是针对分布式锁的实现复杂性,而不是概念理解上基于数据库实现的简单程度

总结分析

分布式锁扩展

去分布式锁概念

何为去分布式锁?

​ 在一些场景下是可以考虑去掉分布式锁的。严格来说,应该是原本这些场景就不该用分布式锁,现在是回归本源了。那究竟怎么去分布式锁呢?

【思路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 模式就可以。

image-20240726165145121

评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v3.1.3