基于 AOP 与 Redisson 的分布式锁实现:自动加锁、解锁与 SpEL 参数解析

4 阅读5分钟

基于 AOP 与 Redisson 的分布式锁实现:自动加锁、解锁与 SpEL 参数解析

一、前言

在分布式系统中,为了保证多个服务实例间的数据一致性,分布式锁是常见的技术手段。本文将介绍如何使用 Spring AOPRedissonSpEL 表达式,实现一套声明式的分布式锁方案:通过注解标注方法,自动完成加锁、解锁,并支持从方法参数中灵活解析锁的 Key。

核心特性:

  • 注解驱动,无需在业务代码中手写加锁逻辑
  • 支持 Redisson 分布式锁,基于 Redis 实现
  • 支持 SpEL 表达式解析,从方法参数动态构建锁 Key
  • 支持“立即失败”与“等待重试”两种加锁策略

二、注解设计

2.1 @Key 注解:参数级 SpEL 解析

@Documented
@Retention(RUNTIME)
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER})
public @interface Key {

    String expr() default "";

}

作用: 标注在方法参数上,表示该参数参与锁 Key 的生成。通过 expr 可指定 SpEL 表达式,从参数对象中提取字段值;若 expr 为空,则直接使用参数的 toString()

2.2 @Lock 注解:方法级加锁配置

@Documented
@Retention(RUNTIME)
@Target(METHOD)
public @interface Lock {

    String prefix();                              // 锁 Key 前缀,用于业务区分
    boolean isWait() default false;               // 是否等待获取锁
    long waitTime() default 3L;                   // 等待时长(秒),仅 isWait=true 时有效
    ErrorCodeEnum failCode() default ErrorCodeEnum.OK;  // 获取锁失败时抛出的错误码

}

参数说明:

参数说明
prefix锁 Key 的固定前缀,便于按业务分类和排查
isWaitfalse:获取不到锁立即失败;true:等待重试
waitTime等待秒数,仅在 isWait=true 时生效
failCode加锁失败时抛出的业务错误码

三、切面实现:LockAspect

package com.trendinguse.cwcopen.novel.core.aspect;

import com.trendinguse.cwcopen.novel.core.annotation.Key;
import com.trendinguse.cwcopen.novel.core.annotation.Lock;
import com.trendinguse.cwcopen.novel.common.exception.BusinessException;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.common.TemplateParserContext;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.util.concurrent.TimeUnit;

@Aspect
@Component
@RequiredArgsConstructor
public class LockAspect {

    private final RedissonClient redissonClient;

    private static final String KEY_PREFIX = "Lock";

    private static final String KEY_SEPARATOR = "::";

    @Around(value = "@annotation(com.trendinguse.cwcopen.novel.core.annotation.Lock)")
    @SneakyThrows
    public Object doAround(ProceedingJoinPoint joinPoint) {
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        Method targetMethod = methodSignature.getMethod();
        Lock lock = targetMethod.getAnnotation(Lock.class);
        String lockKey = KEY_PREFIX + buildLockKey(lock.prefix(), targetMethod,
            joinPoint.getArgs());
        RLock rLock = redissonClient.getLock(lockKey);
        if (lock.isWait() ? rLock.tryLock(lock.waitTime(), TimeUnit.SECONDS) : rLock.tryLock()) {
            try {
                return joinPoint.proceed();
            } finally {
                rLock.unlock();
            }
        }
        throw new BusinessException(lock.failCode());
    }

    private String buildLockKey(String prefix, Method method, Object[] args) {
        StringBuilder builder = new StringBuilder();
        if (StringUtils.hasText(prefix)) {
            builder.append(KEY_SEPARATOR).append(prefix);
        }
        Parameter[] parameters = method.getParameters();
        for (int i = 0; i < parameters.length; i++) {

            if (parameters[i].isAnnotationPresent(Key.class)) {
                builder.append(KEY_SEPARATOR);                
                Key key = parameters[i].getAnnotation(Key.class);
                builder.append(parseKeyExpr(key.expr(), args[i]));
            }
        }
        return builder.toString();
    }

    private String parseKeyExpr(String expr, Object arg) {
        if (!StringUtils.hasText(expr)) {
            return arg.toString();
        }
        ExpressionParser parser = new SpelExpressionParser();
        Expression expression = parser.parseExpression(expr, new TemplateParserContext());
        return expression.getValue(arg, String.class);
    }

}

3.1 执行流程

  1. 拦截@Around 拦截所有带 @Lock 的方法
  2. 构建锁 KeybuildLockKey 根据 prefix 及带 @Key 的参数生成唯一 Key
  3. 加锁:根据 isWait 选择 tryLock()tryLock(waitTime, SECONDS)
  4. 执行业务:加锁成功后执行 proceed()
  5. 解锁:在 finally 中调用 unlock(),确保异常时也会释放锁
  6. 失败处理:加锁失败时抛出 BusinessException,使用 failCode

3.2 锁 Key 规则

最终格式:Lock::prefix::param1::param2::...。例如:

  • prefix = "order"
  • 参数 1:@Key Long orderId123
  • 参数 2:@Key(expr = "#{userId}") User user10086

则锁 Key 为:Lock::order::123::10086

3.3 SpEL 解析说明

使用 TemplateParserContext 时,表达式需使用 #{...} 形式,例如:

  • @Key(expr = "#{id}"):从参数对象中取 id 字段
  • @Key(expr = "#{userId}"):取 userId 字段
  • expr 为空:直接使用 arg.toString()
参数遍历与锁 Key 构建

buildLockKey 负责遍历方法参数,将带 @Key 的参数值拼接成锁 Key 的一部分。核心代码如下:

Parameter[] parameters = method.getParameters();
for (int i = 0; i < parameters.length; i++) {
    
    if (parameters[i].isAnnotationPresent(Key.class)) {
        builder.append(KEY_SEPARATOR);
        Key key = parameters[i].getAnnotation(Key.class);
        builder.append(parseKeyExpr(key.expr(), args[i]));
    }
}
return builder.toString();

流程解析:

  1. 获取参数元数据method.getParameters() 返回 Parameter[],与 args[] 下标一一对应,即 parameters[i] 对应 args[i]

  2. 注解判断parameters[i].isAnnotationPresent(Key.class) 判断第 i 个参数是否标注了 @Key,只有带 @Key 的参数才参与锁 Key 的生成。

  3. 逐参数处理:遍历每个参数,先追加 KEY_SEPARATOR::),保证各段之间用分隔符连接。

  4. 读取表达式parameters[i].getAnnotation(Key.class) 获取 @Key 实例,通过 key.expr() 得到 SpEL 表达式(可能为空)。

  5. 解析并追加parseKeyExpr(key.expr(), args[i]) 以当前参数值 args[i] 为根对象,根据 expr 求值得到字符串,并追加到 builder

  6. 返回值:最终 builder 形如 ::prefix::val1::val2,与 KEY_PREFIX 拼接后得到完整锁 Key。

为何必须用 parameters 而非 args 判断注解? 注解是编译期元数据,依附在方法参数的声明上,保存在字节码中;args[]运行时的实际参数值(普通 Java 对象)。运行时对象本身没有“参数声明”信息,无法通过 args[i] 得知它对应的是哪个参数、是否被标注了 @Key。只有通过反射获取的 Parameter 对象(method.getParameters())才带有参数声明的元数据,因此必须用 parameters[i].isAnnotationPresent(Key.class) 来判断该参数是否使用了 @Key,而用 args[i] 仅作为求值时的根对象传给 parseKeyExpr

SpEL 解析流程

parseKeyExpr 中 SpEL 的解析逻辑如下:

private String parseKeyExpr(String expr, Object arg) {
    if (!StringUtils.hasText(expr)) {
        return arg.toString();
    }
    ExpressionParser parser = new SpelExpressionParser();
    Expression expression = parser.parseExpression(expr, new TemplateParserContext());
    return expression.getValue(arg, String.class);
}
  1. 空表达式:若 expr 为空,直接返回 arg.toString(),适用于简单类型(如 LongString)。

  2. TemplateParserContext 作用TemplateParserContext 默认以 #{ 为前缀、} 为后缀,会从 expr 中提取 #{} 之间的内容作为真正的 SpEL 表达式。例如 expr = "#{userId}" 时,实际参与解析的是 userId,而不是整个字符串。

  3. 解析阶段parseExpression(expr, new TemplateParserContext()) 会:

    • 识别 #{} 的边界;
    • 将中间部分 userId 解析为 SpEL 表达式;
    • 返回可复用的 Expression 对象。
  4. 求值阶段expression.getValue(arg, String.class)arg根对象(root object) 执行表达式:

    • #{userId} → 等价于 arg.getUserId()arg["userId"]
    • #{id} → 等价于 arg.getId()
    • 支持链式访问,如 #{order.userId} 表示 arg.getOrder().getUserId()
  5. 类型转换getValue(arg, String.class) 的第二个参数表示期望的返回类型,SpEL 会将求值结果转为 String,便于拼接进锁 Key。


四、使用示例

4.1 基于简单参数

@Lock(prefix = "book", failCode = ErrorCodeEnum.BOOK_ALREADY_BORROWED)
public void borrowBook(@Key Long bookId, @Key Long userId) {
    // 业务逻辑:借书
}

锁 Key:Lock::book::123::10086

4.2 基于对象与 SpEL

public class OrderReq {
    private Long orderId;
    private Long userId;
    // getters / setters
}

@Lock(prefix = "order", isWait = true, waitTime = 5)
public void createOrder(@Key(expr = "#{orderId}") OrderReq req) {
    // 业务逻辑:创建订单
}

锁 Key:Lock::order::10001(从 req.getOrderId() 解析)

4.3 立即失败

@Lock(prefix = "inventory", failCode = ErrorCodeEnum.SYSTEM_BUSY)
public void deductStock(@Key Long productId) {
    // 获取不到锁立即抛出 BusinessException
}

五、依赖配置

<!-- Redisson -->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.17.7</version>
</dependency>

<!-- Spring AOP(通常已有) -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

六、注意事项

  1. 锁粒度:尽量只对必要的业务范围加锁,避免锁 Key 过宽导致并发下降
  2. 异常与解锁finallyunlock() 会保证方法结束时释放锁,异常也不会导致死锁
  3. 空值参数:带 @Key 的参数为 null 时,arg.toString() 会抛 NPE,建议对关键参数做非空校验
  4. SpEL 语法:使用 TemplateParserContext 时,expr 必须为 #{...} 形式