黄芪

首页 » 常识 » 预防 » 一文读懂分布式锁
TUhjnbcbe - 2025/4/2 18:31:00

1认识分布式锁的使用场景

1.1业务场景1

APP快速连续点击会向服务器连续发起请求,导致数据库出现重复数据(非阻塞锁)

表单重复提交

重复刷单

APP重复请求

1.2业务场景2

库存超卖问题系统A是一个电商系统,目前是一台机器部署,系统中有一个用户下订单的接口,但是用户下订单之前一定要去检查一下库存,确保库存足够了才会给用户下单。由于系统有一定的并发,所以会预先将商品的库存保存在redis中,用户下单的时候会更新redis的库存。此时系统架构如下:

但是这样一来会产生一个问题:假如某个时刻,redis里面的某个商品库存为1,此时两个请求同时到来,其中一个请求执行到上图的第3步,更新数据库的库存为0,但是第4步还没有执行。而另外一个请求执行到了第2步,发现库存还是1,就继续执行第3步。这样的结果,是导致卖出了2个商品,然而其实库存只有1个。很明显不对啊!这就是典型的库存超卖问题此时,我们很容易想到解决方案:用锁把2、3、4步锁住,让他们执行完之后,另一个线程才能进来执行第2步。

按照上面的图,在执行第2步时,使用Java提供的synchronized或者ReentrantLock来锁住,然后在第4步执行完之后才释放锁。这样一来,2、3、4这3个步骤就被“锁”住了,多个线程之间只能串行化执行。但是好景不长,整个系统的并发飙升,一台机器扛不住了。现在要增加一台机器,如下图:

假设此时两个用户的请求同时到来,但是落在了不同的机器上,那么这两个请求是可以同时执行了,还是会出现库存超卖的问题。为什么呢?因为上图中的两个A系统,运行在两个不同的JVM里面,他们加的锁只对属于自己JVM里面的线程有效,对于其他JVM的线程是无效的。因此,这里的问题是Java提供的原生锁机制在多机部署场景下失效了这是因为两台机器加的锁不是同一个锁(两个锁在不同的JVM里面)。那么,我们只要保证两台机器加的锁是同一个锁,问题不就解决了吗?此时,就该分布式锁隆重登场了,分布式锁的思路是:**在整个系统提供一个全局、唯一的获取锁的“东西”,然后每个系统在需要加锁时,都去问这个“东西”拿到一把锁,这样不同的系统拿到的就可以认为是同一把锁**。至于这个“东西”,可以是Redis、Zookeeper,也可以是数据库。文字描述不太直观,我们来看下图:

通过上面的分析,我们知道了库存超卖场景在分布式部署系统的情况下使用Java原生的锁机制无法保证线程安全,所以我们需要用到分布式锁的方案。

1.3业务场景…

经典场景案例

秒杀

车票

退款

订单…

无论是超卖,还是重复退款,都是没有对需要保护的资源或业务进行完善的保护而造成的,从设计方面一定要避免这种情况的发生

2分布式锁基本概念及基本特性

2.1什么是分布式锁

单机锁(线程锁)synchronized、Lock

分布式锁(多服务共享锁)在分布式的部署环境下,通过锁机制来让多客户端互斥的对共享资源进行访问

2.2分布式锁的基本概念

基本概念

多任务环境中才需要

任务都需要对同一共享资源进行写操作;

对资源的访问是互斥的(串行化)

状态

任务通过竞争获取锁才能对该资源进行操作(①竞争锁);

当有一个任务在对资源进行更新时(②占有锁),

其他任务都不可以对这个资源进行操作(③任务阻塞),

直到该任务完成更新(④释放锁);

特点

排他性:在同一时间只会有一个客户端能获取到锁,其它客户端无法同时获取

避免死锁:这把锁在一段有限的时间之后,一定会被释放(正常释放或异常释放)

高可用:获取或释放锁的机制必须高可用且性能佳

2.3锁和事务的区别

锁单进程的系统中,存在多线程同时操作一个公共变量,此时需要加锁对变量进行同步操作,保证多线程的操作线性执行消除并发修改。解决的是单进程中的多线程并发问题。分布式锁只要的应用场景是在集群模式的多个相同服务,可能会部署在不同机器上,解决进程间安全问题,防止多进程同时操作一个变量或者数据库。解决的是多进程的并发问题事务解决一个会话过程中,上下文的修改对所有数据库表的操作要么全部成功,要不全部失败。所以应用在service层。解决的是一个会话中的操作的数据一致性。分布式事务解决一个联动操作,比如一个商品的买卖分为添加商品到购物车、修改商品库存,此时购物车服务和商品库存服务可能部署在两台电脑,这时候需要保证对两个服务的操作都全部成功或者全部回退。解决的是组合服务的数据操作的一致性问题

3DB实现分布式锁方案

3.1乐观锁

3.2悲观锁

4Redis实现分布式锁方案

4.1获取锁

根据以上图示及思考,可的以下加锁代码∶

publicstaticvoidrongGetock(JedisjedisStringlockkeyStringrequestid,intexpireTime){Longresult=jedis.setnx(lockKey,rquestid);if(result==1){//若在这里程序突然崩溃,则无法设置过期时间,将发生死锁jedis.expire(lockKey,expireTime);}}

非原子操作

setnx和expire的非原子性

解决方案

SETmy_keymy_valueNXPXmilliseconds(加锁)

/***尝试获取分布式锁*

paramjedisRedis客户端*

paramlockKey锁*

paramrequestid请求标识。*

paramexpireTime超期时间*/publicboleantrySetDitibutedLock(Jedisjedis,StringlockKey,Stringrequestid,intexpireTime){Stringresult=jedis.set(lockkey,requestid,SET_IF_NOTEXIST,SET_WITH_EXPIRE_TIME,expireTime);if(LOCK_SUCESS.equals(result)){returntrue;}returnfalse;}

4.2释放锁

错误删除锁

线程成功得到了锁,并且设置的超时时间是30秒。线程A执行的很慢很慢,过了30秒都没执行完,这时候锁过期自动释放,线程B得到了锁。线程A执行完了任务,线程A接着执行del指令来释放锁。但这时候线程B还没执行完,线程A实际上删除的是线程B加的锁。

解决方案

加锁的时候把当前的线程ID当做value,并在删除之前验证key对应的value是不是自己线程的ID。

StringthreadId=Thread.currentThread().getld()//加锁set(key,threadId,30,NX);//解锁if(threadId.equals(redisClient.get(key)){del(key)}

但是,这样做又隐含了一个新的问题,if判断和释放锁是两个独立操作,不是原子性。

Lua脚本释放锁,保证释放锁的方法的原子性

/**

paramrequestld请求标识*

return是否释放成功*/publicstaticboleanreleaseDistributedLock(Jedisjedis,StringlockKey,Stringrequestid){Stringscript="ifredis.call(get,KEYs[1)==ARGV[1])thenreturnredis.call(del,KEYs[1])elsereturn0end";Objectresult=jedis.eval(script,Colletions.singletonList(lockKey),Colletions.sigletonList(requestd));if(RELEASE_SUCCESS.equals(result)){returntrue;}returnfalse;}

锁续航问题

获得锁的线程开启一个守护线程,用来给快要过期的锁“续航”过去了29秒,线程A还没执行完,这时候守护线程会执行expire指令,为这把锁“续命”20秒。守护线程从第29秒开始执行,每20秒执行一次当线程A执行完任务,会显式关掉守护线程。

TestpublicvoidexecutiveBusiness(){StringlockKey="order";StringlockValue=Thread.currentThread().getId()+"";longtime=30;while(true){if(tryLock(lockKey,lockValue,time)){ThreaddaemonThread=newThread("守护线程"){

Overridepublicvoidrun(){inti=0;while(true){if(redisTemplate.opsForValue().getOperations().getExpire(lockKey).intValue()1){while(i++=3){//续命三次redisTemplate.expire(lockKey,20,TimeUnit.SECONDS);//每次续命20秒}}try{Thread.sleep();//每秒查询一次}catch(InterruptedExceptione){e.printStackTrace();}}}};daemonThread.setDaemon(true);daemonThread.start();try{Thread.sleep(3);//业务执行31秒}catch(Exceptione){e.printStackTrace();}}}}publicBooleantryLock(StringlockKey,StringlockeValue,longtime){returnredisTemplate.opsForValue().setIfAbsent(lockKey,lockeValue,time,TimeUnit.SECONDS);}

4.3要点回顾

一定要用SETkeyvalueNXPXmilliseconds命令

value要具有唯一性

释放锁一定要使用lua脚本

4.4Redis分布式锁的可靠性思考

redis有3种部署方式:

单机模式

master-slave+sentinel选举模式

rediscluster模式

RedLock

分布式缓存锁—Redlock

5Zookeeper实现分布式锁方案

5.1Zookeeper实现分布式锁逻辑

06.png

5.2Zookeeper实现分布式锁的实现流程

client1获取锁:center

/centerclient2获取锁:

client3获取锁:

client1释放锁:

client2获取锁及释放锁:

性能上可能并没有缓存服务那么高,因为每次在创建锁和释放锁的过程中,都要动态创建、销毁临时节点来实现锁功能

ZK中创建和删除节点只能通过Leader服务器来执行,然后将数据同步到所有的Follower机器上

取舍

5.3分布式锁可靠性思考

6三种分布式锁方案小结

上面几种方式,哪种方式都无法做到完美。就像CAP一样,在复杂性、可靠性、性能等方面无法同时满足。所以,根据不同的应用场景选择最适合自己的才是王道。

从理解的难易程度角度(从低到高)数据库缓存Zookeeper

从实现的复杂性角度(从低到高)Zookeeper=缓存数据库

从性能角度(从高到低)缓存Zookeeper=数据库

从可靠性角度(从高到低)Zookeeper缓存数据库

7幂等性接口设计

7.1幂等操作

多个线程(并发)操作同一个接口(同一个方法),对最终的结果没有影响,这样的操作叫做幂等性操作。

基本的CURD操作,哪些是幂等性的操作?1、查询---幂等性操作select*fromuserwhereid=1如∶线程同时访问以上SQL语句,得到结果都一样,对最终的结果没有影响。2、添加--非幂等性操作insertintouservalues(xx);如∶线程同时访问以上SQL语句,对操作结果有影响,将会向数据库插入新的数据。3、更新--非幂等性操作updateuserset.whereid=1如∶线程同时访问以上SQL语句,对操作结果有影响,将会改变数据。4、删除--幂等性操作deletefromuserwhereid=1

7.2应用场景

在什么业务场景下才使用幂等性接口?1、项目分层架构模式下,由于网络抖动,超时请求重发(退款接口)2、SOA、微服务架构模式下,跨服务调用(为了保证服务高可用,采用了超时重发机制)的超时重发3、利用消息中间件将任务进行异步处理时,任务消息一旦重发,消费者业务操作重复处理

7.3如何设计幂等性接口

以退款为例。在单机模式下,并发请求退款接口,退款接口当中先校验是否存在重复性的id,然后再决定是否进行退款处理。重复性的校验借助于第三方的库,比如使用MySQL、Redis。如果是MySQL,可以设计一张去重表,把orderId设计为表的主键,根据orderId查询去重表。如果不存在的话则可以执行退款操作,先将数据插入,然后执行退款;如果存在的话,则证明已经退过款了,则直接返回。当然,由于是并发操作,项目中需要设计本地锁。如果使用Redis则直接利用分布式锁的特点即可使用,不需要使用本地锁。如果项目是集群服务,利用MySQL进行重复性校验,则需要借助本地锁保证接口的幂等性了。

1
查看完整版本: 一文读懂分布式锁