原生写法

平常在用 Redisson 的时候都是怎么写分布式锁的呢?

1
2
3
4
5
6
7
RLock lock = redissonClient.getLock(key);
boolean lockSuccess = lock.tryLock(waitTime, timeUnit);
if (lockSuccess) {
执行业务代码...
} final {
lock.unlock();
}

是不是都用的这样子的模板,那既然是模板,我们就可以把他抽出来,不用每次都去写这么一大串了。

分布式锁工具类

我们可以把模板抽出来放到一个工具类 LockService 中,每次要加锁的时候只需要传入锁的一些参数,以及需要加锁的代码(通过函数式接口传入)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Service
@Slf4j
public class LockService {

/**
* 使用分布式锁执行给定的操作
*
* @param key 锁的键
* @param waitTime 等待锁的时间
* @param timeUnit 时间单位
* @param supplier 执行的操作
* @param <T> 操作返回的类型
* @return 操作的结果
*/
public <T> T executeWithLock(String key, int waitTime, TimeUnit timeUnit, Supplier<T> supplier) {
RLock lock = redissonClient.getLock(key);
boolean lockSuccess = lock.tryLock(waitTime, timeUnit);
AssertUtil.isTrue(lockSuccess, SystemCommonErrorEnum.LOCK_LIMIT);
try {
return supplier.get();
} finally {
if (lock.isLocked() && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}

在 Java 8 中,Supplier 是一个函数式接口,属于 java.util.function 包。它表示一个不接受任何参数并且返回一个结果的函数。简单来说,可以把一个函数通过这个参数传入,并通过 supplier.get() 调用,

使用:

1
2
3
4
5
lockService.executeWithLock(key, 10, TimeUnit.SECONDS, ()->{
//执行业务逻辑
。。。。。
return null;
});

需要注意的是,由于 Supplier 有返回值,如果业务逻辑代码没有返回,也需要写一个 return null

也可以通过重载方法,编写一个默认不等待的锁,更少了两个参数:

1
2
3
4
5
6
7
8
9
/**
* 使用分布式锁执行给定操作,默认不重试
* @param key
* @param supplier
* @return {@link T}
*/
public <T> T executeWithLock(String key, Supplier<T> supplier) {
return executeWithLock(key, -1, TimeUnit.MILLISECONDS, supplier);
}

有时我们希望业务代码中只包含业务逻辑,加锁显得代码格式有点乱,是否还有更简便的方法?当然有,使用 Spring 提供的 AOP 进行切面处理。

注解实现分布式锁

上述的分布式锁其实已经是核心功能了,使用注解只是为了让使用更加方便。

并且锁的 key 一般都是由入参组成的,我们就可以使用到 Spring EL 直接解析入参,将拼装 key 的操作放在业务逻辑之外。

Spring Expression Language (Spring EL) 是一个功能强大的表达式语言,用于在 Spring Framework 中动态地操作对象图、查询属性、调用方法等。Spring EL 主要用于在 Spring 配置文件、注解、或者 AOP 中动态地计算值。

注解类

首先编写一个注解,用于设置参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/**
* 分布式锁注解
* @author Ershi
* @date 2024/12/08
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RedissonLock {

/**
* key的前缀,默认取当前方法的全限定名,除非希望在不同方法上对同一个资源做分布式锁,就自己指定
*
* @return key的前缀
*/
String prefixKey() default "";

/**
* 锁的主要key值,使用springEl表达式
*
* @return 表达式
*/
String key();

/**
* 等待锁的时间,默认-1,不等待
*
* @return 单位秒
*/
int waitTime() default -1;

/**
* 等待锁的时间单位,默认毫秒
*
* @return 单位
*/
TimeUnit timeUnit() default TimeUnit.MILLISECONDS;
}

秉持着 Spring 约定大于配置的思想,一些参数我们设置默认值。

并且大多数时候,锁是针对于某个特定的方法的,那么锁键就可以由两部分组成:

  • prefixKey:前缀,通常为方法全限定名,用于表示该锁属于哪个方法

全限定名:类名#方法名

  • key:锁的主要键

切面类

切面类用于拦截打上了 @RedissonLock 注解的方法,通过动态代理执行加锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
* Redisson分布式锁切面类
*
* @author Ershi
* @date 2024/12/08
*/
@Aspect
@Component
@Order(0) // 分布式锁要在事务注解前执行
public class RedissonLockAspect {

@Autowired
private LockService lockService;

/**
* 为打上@RedissonLock注解的方法启用Redisson分布式锁,并设置key
*
* @param joinPoint
* @return {@link Object}
*/
@Around("@annotation(com.ershi.hichat.common.common.annotation.RedissonLock)")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
// 获取被拦截方法的方法对象
Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
// 获取方法上的RedissonLock注解
RedissonLock redissonLock = method.getAnnotation(RedissonLock.class);
// 确定锁的前缀key:如果注解的prefixKey属性为空,则使用SpEl表达式获取“类名#方法名”;否则使用prefixKey属性值
String prefix = StrUtil.isBlank(redissonLock.prefixKey()) ? SpElUtils.getMethodKey(method) : redissonLock.prefixKey();//默认方法限定名+注解排名(可能多个)
// 解析SpEl表达式,获取锁的键值
String key = SpElUtils.parseMethodArgsSpEl(method, joinPoint.getArgs(), redissonLock.key());
// 执行拦截方法,加分布式锁
return lockService.executeWithLockThrows(prefix + ":" + key, redissonLock.waitTime(), redissonLock.timeUnit(), joinPoint::proceed);
}
}

分布式锁要在事务外执行,不然就是失去了意义。

可以通过@Order指定运行运行顺序,越小越优先

这里处理 SpringEL 表达式的方法往下看。

需要注意的是 joinPoin.proceed() 方法会抛出一个异常,而我们接收的 Supplier 不抛出异常,那传参就传不进去。

我们可以自定义一个函数式接口,抛出异常,就可以接收这个参数了。

再把工具类中的参数替换:

1
public <T> T executeWithLockThrows(String key, int waitTime, TimeUnit timeUnit, SupplierThrow<T> supplier) throws Throwable

SpringEL 表达式处理类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
/**
* Spring EL表达式解析工具类
* 提供方法参数解析和方法键获取功能
* @author Ershi
* @date 2024/12/08
*/
public class SpElUtils {

// 使用SpelExpressionParser作为表达式解析器
private static final ExpressionParser parser = new SpelExpressionParser();
// 使用DefaultParameterNameDiscoverer来发现参数名
private static final DefaultParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();

/**
* 解析SpringEL表达式,动态获取方法指定参数的值
*
* @param method 要解析的方法
* @param args 方法的参数值数组
* @param spEl SpEL表达式字符串 -> 要获取值的参数名
* @return 解析后的字符串结果,返回目标参数的值
*/
public static String parseMethodArgsSpEl(Method method, Object[] args, String spEl) {
// 解析方法参数名,如果无法解析则使用空数组
String[] params = Optional.ofNullable(parameterNameDiscoverer.getParameterNames(method)).orElse(new String[]{});
// 创建标准的EL上下文对象
EvaluationContext context = new StandardEvaluationContext();
// 将方法参数名-参数值绑定到EL上下文中
for (int i = 0; i < params.length; i++) {
context.setVariable(params[i], args[i]);
}
// 解析SpEL表达式
Expression expression = parser.parseExpression(spEl);
// 返回表达式解析结果
return expression.getValue(context, String.class);
}

/**
* 生成方法的唯一键
*
* @param method 方法对象
* @return 方法的唯一键,格式为:类名#方法名
*/
public static String getMethodKey(Method method) {
// 拼接方法所属类和方法名作为方法键
return method.getDeclaringClass() + "#" + method.getName();
}
}

关于 SpringEL 表达式不懂得可以自己找下教程,这里就不赘述了。

使用

现在使用就非常方便了,只需要在需要加锁的方法上打上注解@RedissonLock,切面类就会自动拦截方法开启锁。

1
@RedissonLock(key = "#idempotentId", waitTime = 5000)

切面失效情况

通常我们会通过切分代码,来达到锁操作去锁最精准位置,这就避免不了类内调用方法,比如:

其实这样我们切面拦截方法 doAcquireItem 并没有生效。因为 Spring AOP 的原理是通过在加载 Bean 的时候,检测到需要切面的方法时,会为该类生成一个动态代理类,通过代理类去执行切面方法。

如果在内类调用,相当于使用 this.doAcquireItem(),是通过本类调用的,而不是通过代理类调用的,切面自然就不会生效。

Spring 只有在执行需要用到切面的方法时,才会使用代理类,平常使用本类。

解决方法

(1)自己注入自己,通过 Spring 注入的 Bean 进行调用

使用 @Lazy 懒加载解决循环依赖。

(2)通过 Spring 上下文获取代理类

这也是我比较推荐的一个做法,更加简单:

使用该方法的话,需要去启动类设置开启获取 Proxy 对象:

img

总结

通过抽象组件可以极大化的增加开发效率。那有没有现成的分布式锁注解框架呢?有,baomidou 的 lock4j,非常灵活。但也因为太过灵活,很多扩展有时候用不到,还要花时间去学习,不如自己写一个。

而且我们这个还支持函数式调用。