文章目录
引言
在分布式系统中,接口防抖(Idempotent)是一个重要的概念。它确保同一请求在短时间内不会被重复处理,从而避免重复提交带来的数据不一致问题。本文将详细介绍如何通过 AOP 和 Redisson 实现接口防抖,并提供完整的代码逻辑说明和使用说明。
接口防抖的前提和意义
什么是接口防抖?
接口防抖是指在一定的时间窗口内,相同的请求只会被处理一次,即使该请求被多次发送。这种机制可以防止由于网络延迟、用户误操作等原因导致的重复提交问题。
为什么需要接口防抖?
- 避免重复数据:在某些业务场景下,如支付、订单创建等,重复提交会导致重复的数据记录,造成数据不一致。
- 提高系统稳定性:通过防抖机制,可以减少不必要的请求处理,降低服务器负载,提升系统的稳定性和性能。
- 用户体验优化:防抖机制可以避免用户因重复点击按钮而导致的错误提示或重复操作,提升用户体验。
技术实现
为了实现接口防抖,我们需要以下几个关键组件:
- 注解定义:用于标记需要防抖的方法。
- 锁键生成器:根据方法参数生成唯一的锁键。
- AOP切面:拦截带有防抖注解的方法,并使用 Redisson 实现分布式锁。
依赖引入
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.13.6</version> <!-- 请根据需要选择最新版本 -->
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.10</version> <!-- 请根据需要选择最新版本 -->
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!--redis依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
</dependencies>
注解定义
@Target(ElementType.METHOD)
- 解释:
@Target
注解用于指定注解可以应用的目标元素类型。ElementType.METHOD
表示该注解只能应用于方法上。 - 作用:在这个上下文中,
Idempotent
注解仅适用于方法,标识哪些方法需要进行幂等性检查。
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
- 解释:
@Retention
注解用于指定注解的保留策略。RetentionPolicy.RUNTIME
表示该注解将在运行时保留,可以通过反射读取。 - 作用:在这个上下文中,
Idempotent
注解需要在运行时通过反射获取其配置信息,因此需要设置为RUNTIME
策略。
@Retention(RetentionPolicy.RUNTIME)
@Documented
- 解释:
@Documented
注解表示该注解应该包含在生成的文档中,如 Javadoc。 - 作用:在这个上下文中,
RequestKeyParam
注解会被记录在生成的文档中,方便开发者查阅。
java深色版本
@Documented
@Inherited
- 解释:
@Inherited
注解表示该注解可以被子类继承。 - 作用:在这个上下文中,
RequestKeyParam
注解可以被子类继承,方便复用。
@Inherited
Idempotent 注解
- 解释:
Idempotent
注解用于标记需要进行幂等性检查的方法,并提供一些配置项,如超时时间、前缀、分隔符等。 - 属性:
timeout
:幂等的超时时间,默认为1秒。timeUnit
:时间单位,默认为秒。keyPrefix
:Redis 锁的前缀,默认为idempotent
。delimiter
:键值之间的分隔符,默认为:
。message
:当检测到重复请求时返回的提示信息,默认为“重复请求,请稍后重试”。
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
/**
* 幂等的超时时间,默认为 100毫秒
*/
int timeout() default 100;
/**
* 时间单位,默认为 MILLISECONDS 毫秒
*/
TimeUnit timeUnit() default TimeUnit.MILLISECONDS;
/**
* redis锁前缀
*
* @return
*/
String keyPrefix() default "idempotent";
/**
* key分隔符
*
* @return
*/
String delimiter() default ":";
/**
* 提示信息,正在执行中的提示
*/
String message() default "重复请求,请稍后重试";
}
RequestKeyParam 注解
- 解释:
RequestKeyParam
注解用于标记方法参数或对象字段,表示这些参数或字段需要参与生成锁键。 - 作用:通过该注解标记的参数或字段会被用于构建唯一锁键,以确保每个请求都有唯一的标识。
@Target({ElementType.METHOD, ElementType.PARAMETER, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface RequestKeyParam {}
锁键生成器
锁键生成器用于根据方法参数生成唯一的锁键。以下是锁键生成器的主要逻辑:
- 获取方法签名和方法对象。
- 获取方法上的
Idempotent
注解。 - 遍历方法参数,如果参数上有
RequestKeyParam
注解,则将其值加入到锁键中。 - 如果参数上没有
RequestKeyParam
注解,则遍历参数对象的字段,查找带有RequestKeyParam
注解的字段,并将其值加入到锁键中。 - 最终返回以
keyPrefix
开头,以delimiter
分隔的字符串作为锁键。
public class RequestKeyGenerator {
/**
* 获取LockKey
*
* @param joinPoint 切入点
* @return
*/
public static String getLockKey(ProceedingJoinPoint joinPoint) {
// 获取连接点的方法签名对象
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
// Method对象
Method method = methodSignature.getMethod();
// 获取Method对象上的注解对象
Idempotent idempotent = method.getAnnotation(Idempotent.class);
// 获取方法参数
final Object[] args = joinPoint.getArgs();
// 获取Method对象上所有的注解
final Parameter[] parameters = method.getParameters();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < parameters.length; i++) {
final RequestKeyParam keyParam = parameters[i].getAnnotation(RequestKeyParam.class);
// 如果属性不是RequestKeyParam注解,则不处理
if (keyParam == null) {
continue;
}
// 如果属性是RequestKeyParam注解,则拼接 连接符 "& + RequestKeyParam"
sb.append(idempotent.delimiter()).append(args[i]);
}
// 如果方法上没有加RequestKeyParam注解
if (StrUtil.isEmpty(sb.toString())) {
// 获取方法上的多个注解(为什么是两层数组:因为第二层数组是只有一个元素的数组)
final Annotation[][] parameterAnnotations = method.getParameterAnnotations();
// 循环注解
for (int i = 0; i < parameterAnnotations.length; i++) {
final Object object = args[i];
// 获取注解类中所有的属性字段
final Field[] fields = object.getClass().getDeclaredFields();
for (Field field : fields) {
// 判断字段上是否有RequestKeyParam注解
final RequestKeyParam annotation = field.getAnnotation(RequestKeyParam.class);
// 如果没有,跳过
if (annotation == null) {
continue;
}
// 如果有,设置Accessible为true(为true时可以使用反射访问私有变量,否则不能访问私有变量)
field.setAccessible(true);
// 如果属性是RequestKeyParam注解,则拼接 连接符" & + RequestKeyParam"
sb.append(idempotent.delimiter()).append(ReflectionUtils.getField(field, object));
}
}
}
// 返回指定前缀的key
return idempotent.keyPrefix() + sb;
}
}
AOP切面
AOP 切面用于拦截带有 Idempotent
注解的方法,并使用 Redisson 实现分布式锁。以下是 AOP 切面的主要逻辑:
- 使用
@Aspect
和@Configuration
注解定义切面类。 - 在构造函数中注入
RedissonClient
。 - 使用
@Around
注解定义环绕通知,拦截所有带有Idempotent
注解的公共方法。 - 获取方法签名和方法对象,并获取
Idempotent
注解。 - 调用锁键生成器生成锁键。
- 使用 Redisson 的
RLock
实现分布式锁,尝试抢占锁。 - 如果成功抢占锁,则执行目标方法并设置过期时间;否则抛出异常提示重复请求。
- 在
finally
块中释放锁。
@Aspect
@Configuration
@Slf4j
public class IdempotentAspect {
private RedissonClient redissonClient;
@Autowired
public IdempotentAspect(RedissonClient redissonClient) {
this.redissonClient = redissonClient;
}
@Around("execution(public * * (..)) && @annotation(com.lqp.annoation.Idempotent)")
public Object interceptor(ProceedingJoinPoint joinPoint) {
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
Method method = methodSignature.getMethod();
Idempotent idempotent = method.getAnnotation(Idempotent.class);
if (StrUtil.isEmpty(idempotent.keyPrefix())) {
throw new RuntimeException("重复提交前缀不能为空");
}
// 获取自定义key
final String lockKey = RequestKeyGenerator.getLockKey(joinPoint);
// 使用Redisson分布式锁的方式判断是否重复提交
RLock lock = redissonClient.getLock(lockKey);
boolean isLocked = false;
try {
// 尝试抢占锁
isLocked = lock.tryLock();
// 没有拿到锁说明已经有了请求了
if (!isLocked) {
throw new RuntimeException(idempotent.message());
}
// 拿到锁后设置过期时间
lock.lock(idempotent.timeout(), idempotent.timeUnit());
try {
return joinPoint.proceed();
} catch (Throwable throwable) {
log.info("系统异常,", throwable);
throw new RuntimeException("系统异常," + throwable.getMessage());
}
} catch (Exception e) {
throw new RuntimeException(e.getMessage());
} finally {
// 释放锁
if (isLocked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
使用示例
以下是如何在实际代码中使用这些注解的示例:
@RestController
@RequestMapping("/api")
public class MyController {
/**
*打上参数注解
*/
@PostMapping("/submit")
@Idempotent(keyPrefix = "service", delimiter = ":")
public ResponseEntity<String> submit(@RequestParam("param1") @RequestKeyParam String param1, @RequestBody MyRequest request) {
// Method implementation
return ResponseEntity.ok("Success");
}
/**
* 对实体类的某些参数打上 @RequestKeyParam参数注解,如果实体类没有注解,则只有默认值
* redis的key就是默认值+分隔符+field1的值
*/
@PostMapping("/submit2")
@Idempotent(keyPrefix = "service", delimiter = ":")
public ResponseEntity<String> submit2(@RequestBody MyRequest request) {
// Method implementation
return ResponseEntity.ok("Success");
}
/**
* 对请求体打上 @RequestKeyParam参数注解,不管实体类的字段有没有加注解,都是全部参数
* redis的key就是默认值+分隔符+MyRequest(field1=null,field2=null)
*/
@PostMapping("/submit2")
@Idempotent(keyPrefix = "service", delimiter = ":")
public ResponseEntity<String> submit3(@RequestBody @RequestKeyParam MyRequest request) {
// Method implementation
return ResponseEntity.ok("Success");
}
}
@Data
public class MyRequest {
@RequestKeyParam
private String field1;
private String field2;
}