这里写目录标题
前言
刚开始我们使用的redis工具是自己写的,因为觉得redisson没必要(其实是没有人想因为自己不懂redisson导致线上问题吧。。。毕竟公共组件)
恰好这次有一个需求是我来接,大致介绍一下:
首先有一个大的蓄水池(化名),当用户要喝水的时候,就从蓄水池拿出来一部分(订购),如果没用完就将剩下的水倒回去(退订),很显然多个用户对蓄水池进行操作的时候需要分布式锁的支持才能保证蓄水池中的水不会出现数量的错误。
其次,用户在用水的期间觉得自己的碗有点小了,所以就想对自己的碗进行扩容(扩容),如果觉得自己碗大了(太贵),就想对自己的碗进行缩容,那么扩容和缩容过程中少的水和多的水需要蓄水池的支持,这个业务场景同样需要蓄水池的支持(多退少补)。
OK,业务场景就这么"简单",接下来开始设计:
首先每一个用户线程操作蓄水池的时候,计算机的操作过程就是,查 - 增加/减少 - 写,很显然这三个步骤需要原子性,并且用户线程之间的操作需要互斥的前提下才能正常运行。资源互斥,上锁!由于业务场景不仅仅对单个字段进行修改,同时存在众多其他相关业务(账单等等),因此mysql的写锁[for update]无法使用了。
接下来分析用户线程类型:
用户线程分三种类型:
1、新来的(订购),用新的完接水的,仅使用订购方法(减少蓄水池操作)
2、要走的(退订),用完了要归还剩下的,仅使用退订方法(增加蓄水池操作)
3、要更换的(修改),用了一半觉得碗大了或者小了,想换碗规格(扩容、缩容操作)
这里三个操作都需要互斥,所以分布式锁的标志key相同。
用户线程3的设计就是,先2,后1,也就是退订原来的,订购新规格,所以存在一个场景,进入3的临界区时,调用1和2的时候会再次加锁。
如果看了之前的分布式锁的blog的同学会发现,如果再次加锁,很显然重新生成uuid的话会让redis认为是另外一个线程,这样必会死锁。
这个时候需要锁的可重入性,需要重新设计了。。。
基于注解的reids分布式锁
DistributeLock.java
package top.swzhao.project.workflow.common.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* @Discreption <> 分布式锁注解
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface DistributeLock {
/**
* 临界区标识,支持SPEL表达式
* @return
*/
String key() default "";
/**
* 锁过期时间(s)
* @return
*/
int timeout() default 10;
/**
* 是否是互斥场景
* true:取号器场景,等待自旋
* false:互斥场景,执行后不再执行
* @return
*/
boolean loopWithLockFail() default false;
/**
* 自旋时间(s)
* @return
*/
int tryTime() default 100;
}
DistributeLockAspect.java
package top.swzhao.project.workflow.common.annotation.aspect;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.Expression;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.stereotype.Component;
import top.swzhao.project.workflow.common.annotation.DistributeLock;
import top.swzhao.project.workflow.common.utils.RedisUtil;
import java.util.Objects;
import java.util.UUID;
/**
* @Discreption <>
*/
@Aspect
@Component
@Slf4j
public class DistributeLockAspect {
/**
* 当前线程标识UUID
*/
private static final ThreadLocal<String> uniqueIdThreadLocal = new ThreadLocal<>();
@Autowired
private RedissonClient redissonClient;
@Pointcut(value = "@annotation(top.swzhao.project.workflow.common.annotation.DistributeLock)")
public void scheduleLock(){
}
@Around(value = "scheduleLock() && @annotation(lock)")
public Object around(ProceedingJoinPoint proceedingJoinPoint, DistributeLock lock) {
// 解析当前需要加锁的业务标识
String key = generateKeyBySpEL(lock.key(), proceedingJoinPoint);
// 锁过期的时间
int timeout = lock.timeout();
// 是否自旋
boolean isLoop = lock.loopWithLockFail();
// 自选尝试次数
int loopTime = lock.tryTime();
boolean setResult = false;
RLock rLock = redissonClient.getLock(key);
try {
if (rLock.isLocked() && !isLoop) {
// 不再尝试直接退出
log.info("当前线程没有获取到分布式锁[key:{}],锁已被其他线程占用,退出...", key);
return null;
}
// 到这里要么是场景没有锁,要么就是需要自旋获取锁
boolean success = rLock.tryLock(loopTime, timeout, TimeUnit.SECONDS);
if (!success) {
log.warn("分布式锁获取失败!请检查key:{} 是否长时间未释放!", key);
throw new Exception("分布式锁抢占失败");
}
return proceedingJoinPoint.proceed();
} catch (Exception e) {
// 这里是锁异常,正常业务异常需要提前捕获并抛出
log.error("分布式锁加锁异常:", e);
throw e;
} catch (Throwable throwable) {
throwable.printStackTrace();
throw throwable;
} finally {
// 释放锁
if (Objects.nonNull(rLock) && rLock.isHeldByCurrentThread()) {
rLock.unlock();
}
}
}
private SpelExpressionParser spelExpressionParser = new SpelExpressionParser();
private DefaultParameterNameDiscoverer defaultParameterNameDiscoverer = new DefaultParameterNameDiscoverer();
/**
* 将入参替换到key中的占位符
* @param key
* @param proceedingJoinPoint
* @return
*/
private String generateKeyBySpEL(String key, ProceedingJoinPoint proceedingJoinPoint) {
// 转换表达式
Expression expression = spelExpressionParser.parseExpression(key);
// 获取上下文
EvaluationContext context = new StandardEvaluationContext();
// 从当前方法中获取用户输入的参数
MethodSignature signature = (MethodSignature) proceedingJoinPoint.getSignature();
// 注解方法中的入参
Object[] args = proceedingJoinPoint.getArgs();
// 从方法中获取未被压缩过的参数名:正常来说压缩过的为arg1,arg2,这样找不到用户的真实参数名称
String[] parameterNames = defaultParameterNameDiscoverer.getParameterNames(signature.getMethod());
if (parameterNames == null || parameterNames.length == 0) {
return key;
}
// 判断是否存在spel表达式
boolean flag = false;
for (int i = 0; i < parameterNames.length; i++) {
if (StringUtils.isNotBlank(parameterNames[i]) && key.contains(parameterNames[i])) {
flag = true;
context.setVariable(parameterNames[i], args);
}
}
return !flag ? key : Objects.requireNonNull(expression.getValue(context).toString());
}
}
感悟
首先可重入的场景我以为在我目前的项目中应该不会遇到的,其实确实实现方案有很多,其中有一个方案就是将三个步骤都写在一个class中,这样3步骤调用1和2的时候可以不用通过代理进来,也就不会走aspect,确实能够解决这个问题,但是我觉得不能完全解决,所以最终还是决定了改造我们的redis分布式锁。
改造完成后我发现redisson并不是仅仅只有分布式锁的功能,它基本上覆盖了java中所有的数据结构,也就是将java的内存存储扩展到了redis中,如果你愿意,你可以将所有的对象存储在redis中,将redis作为你的java堆,这样能够在集群的场景下,java应用之间能够数据共享,但也只有部分场景需要这么用,毕竟是远程调用。
最后,redisson的可重入锁的设计思想,我认为就是一种操作系统的信号量思想,redisson存储的value是一个hash,key存储了当前线程,value存储了当前线程加锁的次数。不论是java中的ReentrentLock,还是其他的可重入机制,基本上都延续了操作系统的进程同步原理,所以万变不离其宗,计算机基础是有用的!