一、整体目的
解决 接口被重复调用 的问题。比如用户连续点 3 次"提交订单"按钮、网络重试、定时任务重跑------业务方法只应该真正执行一次,其余拦截。
实现手段: AOP 拦截 + Redis 分布式锁 + 幂等成功标志位 。
注意它用了 两个 Redis key :
-
LOCK:{key} :短时锁(10s),防止并发抢占
-
REPEAT_LIMIT:{key} :幂等标志(durationTime),防止窗口期内重复提交
二、7 步执行流详解
java
String lockName = generateLockName(joinPoint, repeatLimit);
SpEL 解析注解里的 keys ,结合方法参数动态生成。比如 keys = "#userId + #showId" ,最终 lockName 是 order:create:1001:2002 。
Step 2:第一次快速判定(无锁)
java
String flagObject = redisTemplate.opsForValue().get(repeatFlagName);
if (SUCCESS_FLAG.equals(flagObject)) { throw ... }
先无锁查一次 ,如果发现已经有 SUCCESS 标志,直接拒绝。
✅ 优点 :高频重复请求时不用每次都抢锁,省一次 Redis 写操作。
Step 3:抢分布式锁
Boolean locked = redisTemplate.opsForValue().setIfAbsent("LOCK:" + lockName, "1",
10, TimeUnit.SECONDS);
用 SETNX + 过期时间原子加锁。
Step 4:双重检查(DCL)
flagObject = redisTemplate.opsForValue().get(repeatFlagName);
if (SUCCESS_FLAG.equals(flagObject)) { throw ... }
拿到锁后 再查一次 SUCCESS 标志。
✅ 为什么需要? 防止"步骤 2 查询时还没有,步骤 3 抢锁前别人刚好执行完设置了标志"这个时间窗的漏网之鱼。 这是经典的 DCL(Double-Checked Locking)思想。
Step 5:执行业务
Object result = joinPoint.proceed();
Step 6:业务成功后打标
if (durationTime > 0) {
redisTemplate.opsForValue().set(repeatFlagName, SUCCESS_FLAG, durationTime,
TimeUnit.SECONDS);
}
注意 :成功标记只有在业务正常返回后才设置。如果业务抛异常,不会打标,下次还能重试。 durationTime 是注解里配置的"幂等窗口时长",比如 5 分钟。
Step 7:释放锁
redisTemplate.delete("LOCK:" + lockName);
finally 块保证一定释放。
三、Spring AOP 的 代理机制 ,分三步讲清楚。
一、首先:需要两样东西配合
- 自定义注解 (标记作用)
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RepeatExecuteLimit {
String name();
String[] keys();
int durationTime() default 5;
String message() default "请勿重复提交";
}
关键是 @Retention(RetentionPolicy.RUNTIME) ------必须保留到运行时,否则 Spring 反射拿不到。
@Target(ElementType.METHOD)
表示该注解只能打在方法上。也就是说,你只能把它写在普通的 Java 方法(通常是 Controller 接口方法)上,不能写在类或字段上。@Retention(RetentionPolicy.RUNTIME)
表示该注解的生命周期会保留到运行期。这意味着在程序运行时,我们可以通过反射(或 Spring AOP)读取到这个注解的信息,从而执行相关的拦截逻辑。
- 切面类
java
@Aspect // ← 告诉 Spring 这是一个切面
@Component // ← 交给 Spring 容器管理
public class RepeatExecuteLimitAspect {
@Around("@annotation(repeatLimit)") // ← 切入点表达式
public Object around(ProceedingJoinPoint joinPoint, RepeatExecuteLimit repeatLimit) {
// 切面逻辑
}
}
二、Spring 是怎么"发现"并"织入"的
这一步是 最核心的 ,面试被问"为什么加了注解就生效"就答这个:
启动阶段
Step 1:开启 AOP 自动代理
-
Spring Boot 默认通过 AopAutoConfiguration 自动开启
-
实际生效的是 AnnotationAwareAspectJAutoProxyCreator ,它是一个 BeanPostProcessor
Step 2:扫描切面
-
容器启动时,这个后置处理器会扫描 所有 Bean
-
找到带 @Aspect 注解的类(即你的 RepeatExecuteLimitAspect )
-
解析里面所有的 advice 方法( @Around 、 @Before 等)
-
拿到切入点表达式 "@annotation(repeatLimit)" ,意思是"匹配所有带 @RepeatExecuteLimit 注解的方法"
Step 3:创建代理对象
-
遍历每个 Bean,检查它的方法是否能匹配到任何切面的切入点
-
匹配上 → 创建代理对象 (不是匹配上才创建,是 匹配上才代理 )
-
代理方式默认是 CGLIB(继承方式),也可以配成 JDK 动态代理(接口方式)
-
原始 Bean 放进 targetSource ,代理对象替换原始 Bean 放进容器
调用阶段
Step 4:方法调用时拦截
调用 orderService.create(userId, showId)
↓
实际拿到的是 Spring 创建的 CGLIB 代理对象
↓
代理对象的同名方法被拦截,进入 CGLIB 拦截器链
↓
拦截器链找到匹配的 Advisor(你的 @Aspect)
↓
调用 @Around 增强方法(RepeatExecuteLimitAspect.around)
↓
方法签名里的 repeatLimit 参数被 Spring 反射注入
↓
执行你的幂等逻辑
↓
joinPoint.proceed() 真正调用原始 orderService.create()
↓
返回结果
这个问题分三块讲: 注解定义、切面声明、Spring 代理机制 。
注解 用 @Target(METHOD) + @Retention(RUNTIME) 定义,标记在需要幂等的方法上。
切面类 加 @Aspect 和 @Component ,用 @Around("@annotation(repeatLimit)") 写切入点。这个表达式意思是"拦截所有带 @RepeatExecuteLimit 注解的方法",方法参数 RepeatExecuteLimit repeatLimit 是 Spring 通过反射把注解实例注入进来的。
@Around 是环绕通知,可以控制目标方法是否执行。 "@annotation(repeatLimit)" 是切入点表达式, @annotation 是 AspectJ 的指示符,表示"匹配所有带指定注解的方法", repeatLimit 是形参名,Spring 会通过反射把方法上的注解实例注入到这个参数里。这样 新方法只要加 @RepeatExecuteLimit 注解就能自动被拦截,符合开闭原则 。
为什么加了注解就生效? 关键在 Spring 的 AnnotationAwareAspectJAutoProxyCreator ,它是个 BeanPostProcessor 。容器启动时,它会扫描所有 @Aspect 类,解析切入点表达式, 对匹配的方法所在 Bean 创建 CGLIB 代理 。之后每次调用方法,都会先经过代理,进入切面逻辑,调用 joinPoint.proceed() 时才真正执行原方法。