Spring AOP 分布式锁案例
Spring AOP 分布式锁案例
本文基于某企业级系统的真实案例整理(已脱敏,包名、类名、中间件名均做泛化处理),完整拆解"自定义注解 + AOP 切面 + 分布式缓存锁"的设计与实现,并深入到锁组件字节码级别回答三个关键问题:重复加锁在哪校验?抢锁失败异常从哪抛?锁在哪释放?
目录
一、它解决什么问题
预约下单场景里有一批并发敏感操作:创建预约单、取消订单、发起支付、退款……应用是多机分布式部署,同一用户的两次并发请求可能落在不同机器上,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 静态持有者,getBeanOfType 即 applicationContext.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:锁在哪里释放?
两道保险:
保险①(主动释放):doSafeInLock 的 finally 结构。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 等被打注解的实现类 |
七、设计亮点与可挑剔之处
亮点
- 注解驱动零侵入:业务方一行注解接入,锁逻辑集中一处维护;
@Order(1)保证锁在最外层:锁释放晚于内层事务提交,规避"锁已放、事务未提交"的经典竞态;doSafeInLock兜底释放 + 过期时间:不会因异常或宕机产生死锁;- key = 动作前缀 + 业务字段:锁粒度精确到"某人的某个动作",并发损耗最小;
- 双条件切点:包范围 + 注解,避免跨模块误拦截。
可挑剔之处(真实缺陷,值得学习时留意)
- 异常吞并:
catch (Throwable)把proceed()内部抛出的业务异常也一并包装成LOCK_FAILED,原始错误码丢失——严谨写法应区分"加锁失败"与"锁内业务异常",后者原样重抛; args[0]硬编码:无参方法会数组越界,方法签名被隐式约束;- 反射取字段无校验:
bizField写错时反射返回 null,key 变成xxx:null,导致所有请求互相阻塞且难排查; - AOP 自调用失效通病:同类内
this.xxx()调用带注解的方法不经过代理,锁会失效; - 锁 ≠ 幂等:锁释放后的迟到请求仍能拿锁,幂等需业务层兜底;
- key 规则靠"前缀 + 字段名"字符串约定,更灵活的方案是 SpEL 表达式(参考 Spring
@Cacheable的key = "#req.empNo"写法)。
八、总结
- 结构上:
@DistributeLock注解(声明)+@Around切面(执行)+ 缓存锁组件(互斥原语)三件套,是"注解驱动 AOP"最典型的工程落地形态; - 互斥原理:重复加锁校验不在 Java 层,而在缓存服务端带版本号的原子写入——key 存在即失败,天然跨机器互斥;
- 失败语义:非阻塞快速失败,抢锁异常从
doSafeInLock的lock()步骤抛出,经切面统一包装为业务错误码; - 释放语义:
finally主动删 key + 过期时间被动兜底,双保险防死锁; - 边界认知:分布式锁只保证临界区互斥,不保证幂等;AOP 代理机制决定了自调用会绕过锁——这两点是使用此类方案时最容易踩的坑。

浙公网安备 33010602011771号