想去放牛

导航

 
 

1 为什么要使用分布式锁的理解

分布式架构图:

例1:在电商业务采用分布式架构后,程序部署在3个tomcat容器中(1个tomcat容器代表一个服务器,3个tomcat可理解在北京上海深圳都有部署电商服务),成员变量A代表商品数量。在北京的Alice,上海的Bob,深圳的Tom,都分别发起了购买或取消iPhone12的用户请求,经过Nginx负载均衡将Alice的请求发给了北京服务器,Bob的请求发给了上海服务器,Tom的请求发给了深圳服务器,这时候每台服务器都会对iPhone12这个商品数量进行更改,Alice的请求是将商品数量加到200,Bob的请求是将商品数量减少100,Tom的请求是将商品数量加1,如果对于商品数量的修改没有任何限制,整体就会乱起来,可能Bob得先减少,Tom的在增加,数据就完全乱了,所以需要分布式锁解决方案。

 

2 分布式锁的实现方案

2.1 基于数据库的实现

在数据库中建一张锁表用来存储存储锁信息,将锁名设置为唯一,向数据库插入数据即表示获取锁,插入成功则获取锁成功,插入失败即加锁失败。将数据库中对应锁名的数据删除表示解锁操作。

这种简单的实现有以下几个问题:

(1)这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。

(2)这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。

(3)这把锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。

(4)这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁,因为数据中数据已经存在了。

2.2 基于redis的实现

基于setnx、expire及del三个命令来实现

1.  通过setnx key命令,来进行加锁操作,如果库中不存在key返回1,则表示该方法未加锁,将key添加到redis,加锁成功;如果存在key,则返回0,表示加锁失败。

2.  为了防止死锁产生,需要expire给加的key设置过期时间,这样即使服务器未发送删除key的指令,key在一定时间后也不会在redis中存在。过程中需要保证加锁与设置过期时间的原子性,在set的时候就加上过期时间,避免出现刚加完锁服务器宕机而未设置过期时间,导致死锁。

3.  执行完加锁方法后,del掉redis中key,表示释放锁。

4.  因为给锁加了过期时间,线程1执行加锁方法时,超过过期时间线程1并未执行完,此时锁已释放;线程2在来到加锁方法并加锁,此时线程1执行完毕去释放锁,会将线程2加的锁释放掉。可以在设置key的时候也将value设置进去,value可以是线程id,在删除key的时候判断一下value是否为目标对象再进行操作。

5.  为了防止key过期时间结束了业务还没有执行完就释放锁,在加锁之后可以再开一线程,定时的去延长锁的过期时间。实现方案:如果锁的过期时间是30s,加锁成功后,可以新开一个线程,每隔10秒就去redis上查询一下锁是否还在,如果还在就将其过期时间重设置为30s。

3 spring中基于redis的分布式锁详情

3.1 快速使用

1.  导入相应依赖

 

<dependency>
<groupId>cn.keking</groupId>
<artifactId>spring-boot-klock-starter</artifactId>
<version>1.4-RELEASE</version>
</dependency>

 

2.  添加配置信息

 

spring:
klock:
address: 127.0.0.1:6379 #redis主机地址
password: 2021 #redis密码
database: 1 #redis数据索引
waitTime: 60 #获取锁最长阻塞时间(默认:60,单位:秒)
leaseTime: 60 #已获取锁后自动释放时间(默认:60,单位:秒)
# cluster-server:
# node-addresses: #redis集群配置 如 127.0.0.1:7000,127.0.0.1:7001,127.0.0.1:7002

 

3.  使用简单,直接在需要加锁的方法上添加注解

 

//患者关注医生
@Override
@Klock(keys = { "#hosDoctorId", "#hosUserId" })
public Boolean followOrCancelDoctor(Long hosDoctorId, Long hosUserId) {
HosUserFollow curHosUserFollow = hosUserFollowMapper.selectOne(new LambdaQueryWrapper<HosUserFollow>()
.eq(HosUserFollow::getHosDoctorId, hosDoctorId).eq(HosUserFollow::getHosUserId, hosUserId));
if (Objects.nonNull(curHosUserFollow)) {
return hosUserFollowMapper.deleteById(curHosUserFollow.getId()) > 0;
}
else {
return hosUserFollowMapper
.insert(new HosUserFollow().setHosUserId(hosUserId).setHosDoctorId(hosDoctorId)) > 0;
}
}

 

3.2 @Klock的参数说明

1.  String name

锁的名称,redis中创建的key名称,默认key是加在redis的第16个库的。如果自己配了,对应的锁名就是lock.<name>-[key1]-[key2].....,如果没有配锁名就是lock.<全路径类名.方法名>-[key1]-[key2].....,其中key就是下列第五参数keys配置的。

 

例如这样的配置@Klock(name = "test")
在redis中就会创建一个key为lock.test-.....的value,value的类型是hash类型,其中有一个对象,每次加锁时,这个对象名都不一样(例如b9da7ea2-e9f2-46d7-a44c-ab0c65b6c93c:52),确保锁的唯一性,避免出现误删锁的情况。

 

2.  LockType lockType

锁的类型,可以是公平锁、读锁、写锁和可重入锁,默认是可重入锁,可重入锁可以解决死锁问题。

3.  Long waitTime

尝试加锁的最多等待时间,默认60s。超时就会去执行加锁超时策略,默认策略是什么都不做。默认策略下,只会报一个警告,如果一个线程等待加锁超时,会直接进入到加锁方法。

4.  Long leaseTime

上锁以后自动解锁时间,默认60s。这个主要是为了防止死锁产生,如果一个业务加了锁之后,在未释放锁的时候宕机,再次启动时,该锁就会一直存在。

5.  String[] keys

自定义业务key,定义这个可以根据定义的key来判断是否加锁,只有当key相同的时候才会加锁,key不同的直接放行。

例如:加锁方法只是在同一时间只允许一个请求对一个用户进行操作,此时可以将用户的id作为key,其他再来的请求如果用户的id相同则阻塞,不同则放行。

6.  LockTimeoutStrategy lockTimeoutStrategy

加锁超时的处理策略。默认的策略是继续执行业务逻辑,只会报一个警告,相当于是加锁超时后就无视锁的存在了,直接去执行加了锁的方法。有如下三个策略:

 

public enum LockTimeoutStrategy implements LockTimeoutHandler {

/**
* 继续执行业务逻辑,不做任何处理
*/
NO_OPERATION() {
@Override
public void handle(LockInfo lockInfo, Lock lock, JoinPoint joinPoint) {
// do nothing
}
},

/**
* 快速失败
*/
FAIL_FAST() {
@Override
public void handle(LockInfo lockInfo, Lock lock, JoinPoint joinPoint) {

String errorMsg = String.format("Failed to acquire Lock(%s) with timeout(%ds)", lockInfo.getName(), lockInfo.getWaitTime());
throw new KlockTimeoutException(errorMsg);
}
},

/**
* 一直阻塞,直到获得锁,在太多的尝试后,仍会报错
*/
KEEP_ACQUIRE() {

private static final long DEFAULT_INTERVAL = 100L;

private static final long DEFAULT_MAX_INTERVAL = 3 * 60 * 1000L;

@Override
public void handle(LockInfo lockInfo, Lock lock, JoinPoint joinPoint) {

long interval = DEFAULT_INTERVAL;

while(!lock.acquire()) {

if(interval > DEFAULT_MAX_INTERVAL) {
String errorMsg = String.format("Failed to acquire Lock(%s) after too many times, this may because dead lock occurs.",
lockInfo.getName());
throw new KlockTimeoutException(errorMsg);
}

try {
TimeUnit.MILLISECONDS.sleep(interval);
interval <<= 1;
} catch (InterruptedException e) {
throw new KlockTimeoutException("Failed to acquire Lock", e);
}
}
}
}
}

 

7.  String customLockTimeoutStrategy

自定义加锁超时的处理策略。像这样使用:

 

    @Override
@Klock(name = "test",keys = {"#name"},waitTime=2,customLockTimeoutStrategy= "customLockTimeout")
public void testRedisLock(String name) {
System.out.println(name+"进入了加锁方法");
try{
TimeUnit.SECONDS.sleep(20);
}catch (Exception e){

}
System.out.println(name+"执行完加锁方法");
}

private String customLockTimeout(String name) {
System.out.println("test:"+name);
return name;
}

 

注意:自定义的方法要在@Klock的方法类里面,且参数要和注解方法一致才会进入自定义的策略方法中。

8.  ReleaseTimeoutStrategy releaseTimeoutStrategy

释放锁超时的处理策略。释放锁的时候会验证是不是自己的锁,即使锁名相同,也会通过其value验证。

 

public enum ReleaseTimeoutStrategy implements ReleaseTimeoutHandler {

/**
* 继续执行业务逻辑,不做任何处理
*/
NO_OPERATION() {
@Override
public void handle(LockInfo lockInfo) {
// do nothing
}
},
/**
* 快速失败
*/
FAIL_FAST() {
@Override
public void handle(LockInfo lockInfo) {

String errorMsg = String.format("Found Lock(%s) already been released while lock lease time is %d s", lockInfo.getName(), lockInfo.getLeaseTime());
throw new KlockTimeoutException(errorMsg);
}
}
}

 

9.  String customReleaseTimeoutStrategy

自定义释放锁超时的处理策略。与自定义加锁超时策略用法一样。

3.3 实现理论

一个线程访问@Klock注解的方法,先判断该方法是否被加锁,判断方法是根据Klock的加锁名称,去redis上查询是否存在key为该锁名的值:

1.  如果存在,就会进入阻塞,若超过加锁超时时间还未获取到锁,则根据加锁超时策略进入后续操作;

2.  如果不存在,则创建key为锁名的值到redis,防止其他线程进入,待执行完相应业务之后,删除掉redis中key,表示释放锁,此时其他线程又可以加锁进入,执行相同的步骤,以此来实现分布式锁。

3.4 场景

3.4.1 场景1-正常访问

一个线程到达@Klock的方法,在redis中创建一个锁的key,然后执行业务逻辑,执行完后删除掉redis中的key以释放锁,结束。

3.4.2 场景2-业务执行超过锁的最短释放时间

一个线程在执行加锁业务逻辑时,由于耗时较长导致执行时间超过了设置的锁的自动过期时间,当方法执行完时,会执行释放锁超时策略。默认是什么都不做,也可以自定义。

3.4.3 场景3-加锁超时

一个线程到达@Klock的方法时,如果此时已经方法已被锁住,则会尝试去获取锁,如果超过了最多等待时间还未成功加锁,就会执行加锁超时策略,默认是什么都不做,直接无视锁去执行加锁方法,也可以通过自定义加锁超时来控制。

3.4.4 场景4-获得锁的线程执行一半宕机

一个线程获取到锁后,执行到中途服务器宕机,导致锁未被主动释放,等到锁到了过期时间自动解锁。

3.4.5 场景5-释放锁超时且期间有其他线程进入(释放锁超时为快速失败下)

第一个线程获取到锁后执行,超过锁自动释放时间任未执行完,期间又有第二个线程进入,当其他线程是在自动释放锁到期之后进入,其他线程会进入加锁方法并加锁正常运行:

1.  如果第二个线程在第一个线程执行完之前结束,第二个线程可以正常结束,第一个线程抛超时异常。

2.  如果第二个线程在第一个线程执行完之后结束,此时两个线程都会抛超时异常。

3.  以此类推,多个线程进入加锁方法,只要在抛异常方法之前结束就可以正常运行,在抛异常方法之后结束就会跟着抛超时异常。

 

4 例1的解决方案

通过以上对分布式锁的分析,可以得到例1中问题的解决方案。通过spring基于的redis的分布式锁,在加减库存的方法上添加@Klock注解来实现加锁,这样在Alice、Tom和Bob同时发起操作库存的请求时,会依次进入加减库存的方法,保证操作的有序性以及数据正确性。

 

5 注意

1.  在同一个类中,加了@Klock的方法调用另一个Klock方法时,第二个加锁是不会生效的,不同类之间的调用加锁是可以生效的,因为Klock分布式锁是直接锁这个类,而不是只锁方法。

2.  不同类型的锁字符串前缀最好不一样,防止字符串相同导致多个执行不同操作方法的锁同时被锁住。

 

6 其他

1.  为什么要用锁?

为了控制系统中同一时间只有一个用户对共享资源的访问,需要用到用到锁。

2.  如果一个业务执行时间长,不能给锁续时吗?

没有,如果怕还没有执行完锁先释放了,需要对释放锁超时来进行控制处理。

3.  为什么锁要设置成hash类型?

因为@Klock的设计中,不同的业务有不同锁,而同一个业务中也是可能会出现多个锁的情况。key主要是用于区分不同业务的锁,hash中的键主要用于区分同一业务下,不同线程加的锁。

posted on 2023-07-09 21:26  想去放牛  阅读(64)  评论(0编辑  收藏  举报