Spring AOP 分布式锁案例

Spring AOP 分布式锁案例

本文基于某企业级系统的真实案例整理(已脱敏,包名、类名、中间件名均做泛化处理),完整拆解"自定义注解 + AOP 切面 + 分布式缓存锁"的设计与实现,并深入到锁组件字节码级别回答三个关键问题:重复加锁在哪校验?抢锁失败异常从哪抛?锁在哪释放?


目录

  1. 它解决什么问题
  2. 三件套总览
  3. 切面逐行解读
  4. 一次调用的完整时序
  5. 深入锁组件:三个关键问题
  6. AOP 概念映射表
  7. 设计亮点与可挑剔之处
  8. 总结

一、它解决什么问题

预约下单场景里有一批并发敏感操作:创建预约单、取消订单、发起支付、退款……应用是多机分布式部署,同一用户的两次并发请求可能落在不同机器上,JVM 内的 synchronized 完全无效。传统解法是在每个方法开头手写"加锁 → try/finally 解锁",结果是大量样板代码且容易漏解锁。

这个案例的方案:自定义注解 @DistributeLock + AOP 切面 + 分布式缓存锁,业务方法只加一行注解,加锁/释放/异常处理全部由切面横切完成。


二、三件套总览

角色 职责
标记 DistributeLock(自定义注解) 声明"这个方法要加锁"及锁 key 规则
执行 DistributeLockAspect(切面) 拦截、拼 key、加锁、放行、释放
使用 OrderServiceImpl 等业务类 业务方法上打注解

2.1 注解定义(仅 2 个属性)

@Retention(RetentionPolicy.RUNTIME)   // 必须 RUNTIME:切面要在运行时反射读它
@Target(ElementType.METHOD)           // 只能标在方法上
public @interface DistributeLock {
    String keyPrefix();   // 锁 key 前缀,标识"哪个业务动作"
    String bizField();    // 第一个入参对象里的字段名,标识"锁哪个业务对象"
}

2.2 业务使用(订单服务中 9 处)

@DistributeLock(keyPrefix = "global:order:create", bizField = "empNo")
public NormalResult<OrderDetailDTO> create(PreOrderRequest req) { ... }

@DistributeLock(keyPrefix = "global:order:cancel", bizField = "orderNo")
public NormalResult userCancel(OrderCancelRequest request) { ... }

最终锁 key 形如 app:global:order:create:{员工工号} —— 锁粒度细到"某员工的下单动作",不同员工互不阻塞。


三、切面逐行解读

@Component     // 注册为 Bean(切面首先得是个 Bean,否则容器不处理它)
@Aspect        // 声明这是切面类,触发 AspectJ 自动代理机制为匹配的 Bean 织入代理
@Slf4j         // Lombok 日志
@Order(1)      // ★切面优先级:数字越小越先执行。@Around 场景下优先级高的在"外层"
public class DistributeLockAspect {

@Order(1) 是刻意设计:锁切面要包在事务、日志等其他切面外面——先进后出,保证"事务提交完成之后才释放锁",避免"锁已放、事务未提交"的竞态窗口。

3.1 切点表达式

    @Around("execution(* com.example.app.application.impl..*.*(..)) "
          + "&& @annotation(com.example.app.common.lock.DistributeLock)")

@Around 环绕通知 + 两个条件取交集的切点表达式:

子表达式 含义
execution(* com.example.app.application.impl..*.*(..)) 限定包范围:应用层 impl 包及子包下的任意方法(.. = 任意层子包,(..) = 任意参数)
@annotation(...DistributeLock) 且方法上标了 @DistributeLock

两个条件同时满足才拦截——比单用 @annotation 多了包范围保险,防止误伤其他模块的同名注解(该项目另一个模块确实有一个姊妹切面管自己的包)。

3.2 通知方法:取元数据、拼锁 key

    public Object process(ProceedingJoinPoint joinPoint) throws Throwable {
        Signature signature = joinPoint.getSignature();
        MethodSignature methodSignature = (MethodSignature) signature;  // 方法级连接点才能强转
        Method method = methodSignature.getMethod();   // 拿到被拦截的方法对象(反射元数据)
        Object[] args = joinPoint.getArgs();           // 拿到本次调用的实参
        Object arg = args[0];                          // 约定:锁维度信息在第一个参数上

ProceedingJoinPoint@Around 专属:比 JoinPoint 多一个 proceed()调不调、何时调原方法由你决定——这正是"环绕"的含义。

        DistributeLock annotation = AnnotationUtils.findAnnotation(method, DistributeLock.class);

用 Spring 的 AnnotationUtils.findAnnotation 而非 method.getAnnotation:前者能穿透桥接方法、元注解,更健壮。

        String key;
        if (arg instanceof String || arg instanceof Number) {
            key = annotation.keyPrefix() + ":" + arg;              // 分支①:第一个参数本身就是业务ID
        } else if (isCollection(arg)) {
            throw new BizException(ErrorCode.SET_KEY_ERROR);       // 分支②:集合无法定锁粒度,直接拒绝
        } else {
            String bizFieldValue = (String) ReflectUtil.getFieldValue(arg, annotation.bizField());
            key = annotation.keyPrefix() + ":" + bizFieldValue;    // 分支③:反射从入参对象里取 bizField 字段值
        }

三分支拼 key,覆盖了 payAcquire(String orderNo, ...)(分支①)和 create(PreOrderRequest req)(分支③,反射取 req.empNo)两类真实签名。

3.3 通知方法:加锁执行与异常包装

        try {
            Locker<Object, Object> locker = SpringContextHolder.getBeanOfType(CacheLocker.class);

从容器取缓存锁组件(SpringContextHolder 是公共工具包里的 ApplicationContextAware 静态持有者,getBeanOfTypeapplicationContext.getBean(c))。

            CacheLockCallback<Object, Object> callback = new CacheLockCallback<>() {
                @Override
                public String buildKey(Object o) { return "app:" + key; }  // 最终 key 再加应用前缀
                @Override
                @SneakyThrows
                public Object invoke(Object o) {
                    return joinPoint.proceed();      // ★真正执行原业务方法的地方
                }
            };
            return locker.doSafeInLock(callback, new Object(), CacheLockConstant.DEFAULT_EXPIRE_TIME);

把"执行原方法"封装成回调交给 doSafeInLock:加锁 → 执行 callback.invoke(即 proceed() 放行到真实业务)→ 保证释放锁(safe 语义,含超时兜底防死锁)。new Object() 只是占位的锁值。

        } catch (Throwable throwable) {
            throw new BizException(ErrorCode.LOCK_FAILED, throwable.getMessage(), throwable);
        }
    }

四、一次调用的完整时序

以员工 A 双击"预约"为例:

HTTP 请求 → OrderServiceImpl 的 CGLIB 代理(Spring Boot 2.x 默认 CGLIB 代理)
  → 切点匹配成功(包 + 注解)→ DistributeLockAspect.process()
    → 反射取 req.empNo = "A001",key = "global:order:create:A001"
    → CacheLocker 尝试加锁 "app:global:order:create:A001"
      ├─ 抢到锁 → proceed() → 真实 create()(校验名额、扣名额、存订单…)→ 释放锁 → 返回
      └─ 没抢到(并发请求)→ 抛异常 → 包装为 BizException(LOCK_FAILED) → 前端提示稍后重试

业务代码 create() 内部对锁零感知——这就是 AOP"横切关注点与业务逻辑分离"的价值。


五、深入锁组件:三个关键问题

锁组件来自外部二方包,通过反编译字节码还原出其完整实现:

public class CacheLocker<T, K> implements Locker<T, K> {
    private CacheClient cacheClient;   // 分布式缓存客户端

    public T doSafeInLock(CacheLockCallback<T, K> callback, K k, int expireTime) throws Exception {
        String key = callback.buildKey(k);      // ① 向回调要锁 key(即切面里的 "app:" + key)
        lock(key, expireTime);                  // ② 加锁——抢不到在这里抛异常
        T result;
        try {
            result = callback.invoke(k);        // ③ 执行业务(即切面里的 joinPoint.proceed())
        } finally {
            try {
                unlock(key);                    // ④ 释放锁——正常/异常都会执行
            } catch (Exception e) {
                log.error("unlock in doSafeInLock error:" + e.getMessage(), e);  // 释放失败只记日志,不吞业务结果
            }
        }
        return result;
    }

    private void lock(String key, int expireTime) throws Exception {
        CacheResult r = cacheClient.put(key, "", 100, expireTime);   // ★互斥校验在这里
        if (!r.getSuccess()) {
            log.error("lock failed, code:{},msg:{}", r.getCode(), r.getMessage());
            throw new LockException("locked failed");                // ★抢锁失败抛在这里
        }
    }

    private void unlock(String key) throws Exception {
        if (!cacheClient.invalid(key)) {        // 删除缓存中的 key = 释放锁
            log.error("unlock failed.");        // 删除失败仅记日志(key 会靠过期时间兜底消失)
        }
    }
}

问题 1:重复加锁的校验在哪里?

不在切面里,也不在锁组件的 Java 逻辑里,而是下沉给了缓存服务端的原子操作。

关键就是 lock() 里这一行:

cacheClient.put(key, "", 100, expireTime)

这是带版本号(100)的缓存写入,语义是:

  • key 不存在 → 写入成功 → success=true → 拿到锁;
  • key 已存在(别人持有锁)→ 版本校验不通过 → 写入失败 → success=false

"判断锁是否已被占用"和"占锁"是缓存服务端一个原子操作内完成的,不存在"检查完、写入前被别人插队"的窗口。多机并发时,无论多少台机器同时 put,服务端保证只有一个成功——这就是分布式互斥的来源。所以在 Java 代码里找不到 if (已加锁) 这样的判断,因为它被中间件的原子语义吸收了。

问题 2:没抢到锁的请求会怎样?异常从哪抛?

锁只能被拿到一次,其他并发请求全部快速失败抛异常,不等待、不重试。 异常链是:

lock() 内 put 失败
  → throw new LockException("locked failed")        ← 抛出点:doSafeInLock 的第一步 lock()
  → 沿 doSafeInLock 向上抛(注意:此时还没执行到 callback.invoke,业务方法根本没跑)
  → 切面 catch (Throwable)
  → 包装为 BizException(LOCK_FAILED) 返回给前端

这是典型的非阻塞锁设计:对"下单/取消"这类用户操作,抢不到锁说明同一业务对象正在被处理,直接提示"稍后重试"比排队等待更合理。

问题 3:锁在哪里释放?

两道保险:

保险①(主动释放)doSafeInLockfinally 结构。callback.invoke()(即业务方法 proceed())无论正常返回还是抛异常,都会走到 unlock(key)cacheClient.invalid(key),即把缓存里的 key 删掉。且 unlock 自身失败被 catch 住只记日志,不会覆盖业务结果。

保险②(被动过期)lock() 传入的 expireTime(切面里传的 DEFAULT_EXPIRE_TIME)。如果持有锁的机器在 finally 执行前宕机/进程被杀,主动释放永远不会发生,此时靠缓存到期自动删 key,避免死锁——这就是方法名里 "Safe" 的含义。

并发全景图

请求A ──→ 切面 ──→ put("app:global:order:create:A001", v=100) ──→ 缓存: key不存在,写入成功
         │                                                              ↓
         │                                              callback.invoke → proceed() → 真实 create()
         │                                                              ↓
         └──────────────── finally: invalid(key) 删key,锁释放 ←─────────┘

请求B ──→ 切面 ──→ put(同一个key, v=100) ──→ 缓存: key已存在,版本冲突,success=false
         │
         └──→ LockException("locked failed") → 切面包装为 LOCK_FAILED → 前端提示重试

一个必须注意的边界:锁 ≠ 幂等

请求 B 若在请求 A 的 finally 删 key 之后才到达,put 会成功——锁只保证"同一时刻只有一个请求在临界区",不保证"永远只执行一次"。幂等需要业务层自己做,比如 create() 里的"已有进行中订单则报错"校验就是干这个的。


六、AOP 概念映射表

AOP 术语 在本案例中
切面 Aspect DistributeLockAspect@Aspect + @Component
连接点 JoinPoint 应用层 impl 包下每次方法调用
切点 Pointcut execution(...) && @annotation(...) 表达式
通知 Advice process(),类型是 @Around(最强的通知类型,可控制是否放行)
织入方式 运行时动态代理(Spring Boot 2.x 默认 CGLIB 子类代理)
目标对象 OrderServiceImpl 等被打注解的实现类

七、设计亮点与可挑剔之处

亮点

  1. 注解驱动零侵入:业务方一行注解接入,锁逻辑集中一处维护;
  2. @Order(1) 保证锁在最外层:锁释放晚于内层事务提交,规避"锁已放、事务未提交"的经典竞态;
  3. doSafeInLock 兜底释放 + 过期时间:不会因异常或宕机产生死锁;
  4. key = 动作前缀 + 业务字段:锁粒度精确到"某人的某个动作",并发损耗最小;
  5. 双条件切点:包范围 + 注解,避免跨模块误拦截。

可挑剔之处(真实缺陷,值得学习时留意)

  1. 异常吞并catch (Throwable)proceed() 内部抛出的业务异常也一并包装成 LOCK_FAILED,原始错误码丢失——严谨写法应区分"加锁失败"与"锁内业务异常",后者原样重抛;
  2. args[0] 硬编码:无参方法会数组越界,方法签名被隐式约束;
  3. 反射取字段无校验bizField 写错时反射返回 null,key 变成 xxx:null,导致所有请求互相阻塞且难排查;
  4. AOP 自调用失效通病:同类内 this.xxx() 调用带注解的方法不经过代理,锁会失效;
  5. 锁 ≠ 幂等:锁释放后的迟到请求仍能拿锁,幂等需业务层兜底;
  6. key 规则靠"前缀 + 字段名"字符串约定,更灵活的方案是 SpEL 表达式(参考 Spring @Cacheablekey = "#req.empNo" 写法)。

八、总结

  1. 结构上@DistributeLock 注解(声明)+ @Around 切面(执行)+ 缓存锁组件(互斥原语)三件套,是"注解驱动 AOP"最典型的工程落地形态;
  2. 互斥原理:重复加锁校验不在 Java 层,而在缓存服务端带版本号的原子写入——key 存在即失败,天然跨机器互斥;
  3. 失败语义:非阻塞快速失败,抢锁异常从 doSafeInLocklock() 步骤抛出,经切面统一包装为业务错误码;
  4. 释放语义finally 主动删 key + 过期时间被动兜底,双保险防死锁;
  5. 边界认知:分布式锁只保证临界区互斥,不保证幂等;AOP 代理机制决定了自调用会绕过锁——这两点是使用此类方案时最容易踩的坑。
posted @ 2026-09-09 15:28  cwp0  阅读(6)  评论(0)    收藏  举报