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: redis://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