目录
防抖也即防重复提交,那么如何确定两次接口就是重复的呢?首先,我们需要给这两次接口的调用加一个时间间隔,大于这个时间间隔的一定不是重复提交;其次,两次请求提交的参数比对,不一定要全部参数,选择标识性强的参数即可;最后,如果想做的更好一点,还可以加一个请求地址的对比。
一.流程分析:
客户端请求 → Spring MVC接收 → AOP拦截 → 生成防抖Key → Redis加锁 →
↓ ↑
↓ (加锁失败) ↑ (加锁成功)
↓ ↑
返回防抖错误信息 执行业务逻辑 → 返回结果
客户端发起请求后根据@RequestMapping找到对应的Controller方法,自定义一个@RequestLock,
Controller方法上标注@RequestLock
注解,RedisRequestLockAspect
切面会拦截这个方法调用
案例整体架构:
src/main/java/com/example/debounce/
├── annotation
│ ├── RequestLock.java
│ └── RequestKeyParam.java
├── aspect
│ └── RedisRequestLockAspect.java
├── generator
│ └── RequestKeyGenerator.java
├── exception
│ └── BizException.java
├── controller
│ └── DebounceController.java
└── DemoApplication.java
二.案例代码详解:
前提条件:
首先引入依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
编写配置文件:
spring.redis.host=localhost
spring.redis.port=6379
# 可选配置
spring.redis.password=
spring.redis.database=0
1.自定义注解:
首先定义@RequestLock注解:
@RequestLock
注解定义了几个基础的属性,redis锁前缀、redis锁时间、redis锁时间单位、key分隔符。其中前面三个参数比较好理解,都是一个锁的基本信息。key分隔符是用来将多个参数合并在一起的,比如userName是张三,userPhone是123456,那么完整的key就是"张三&123456",最后再加上redis锁前缀,就组成了一个唯一key。
package com.example.debounce.annotation;
import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RequestLock {
/**
* redis锁key的前缀
*/
String prefix() default "";
/**
* 过期时间,默认5秒
*/
long expire() default 5;
/**
* 过期时间单位,默认秒
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
/**
* 分隔符,默认使用冒号
*/
String delimiter() default "&";
}
以及@RequestKeyParam:
这里有些同学可能就要说了,直接拿参数来生成key不就行了吗?额,不是不行,但我想问一个问题:如果这个接口是文章发布的接口,你也打算把内容当做key吗?要知道,Redis的效率跟key的大小息息相关。所以,我的建议是选取合适的字段作为key就行了,没必要全都加上
。
要做到参数可选,那么用注解的方式最好了,注解如下RequestKeyParam.java
package com.example.debounce.annotation;
import java.lang.annotation.*;
/**
* @description 加上这个注解可以将参数设置为key
*/
@Target({ElementType.METHOD, ElementType.PARAMETER, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface RequestKeyParam {
/**
* 是否忽略该参数
*/
boolean ignore() default false;
/**
* 自定义参数别名(可选)
*/
String value() default "";
}
2.编写异常类:
package com.example.debounce.exception;
public class BizException extends RuntimeException {
private String code;
public BizException(String code, String message) {
super(message);
this.code = code;
}
public String getCode() {
return code;
}
}
3.创建key生成器:
接下来就是lockKey的生成了,代码如下RequestKeyGenerator.java
由于``@RequestKeyParam``可以放在方法的参数上,也可以放在对象的属性上,所以这里需要进行两次判断,一次是获取方法上的注解,一次是获取对象里面属性上的注解。
package com.example.debounce.generator;
import com.example.debounce.annotation.RequestKeyParam;
import com.example.debounce.annotation.RequestLock;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.util.ReflectionUtils;
import org.springframework.util.StringUtils;
import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
/**
* 防抖锁Key生成器
*
* <p>负责根据方法参数和注解配置生成唯一的Redis防抖Key</p>
* <p>工作流程:</p>
* <ol>
* <li>解析方法上的@RequestLock注解获取基础配置</li>
* <li>扫描方法参数的@RequestKeyParam注解</li>
* <li>检查参数对象内部字段的@RequestKeyParam注解</li>
* <li>组合生成最终防抖Key</li>
* </ol>
*/
public class RequestKeyGenerator {
/**
* 生成防抖锁Key
*
* @param joinPoint 切入点对象,包含被拦截方法的所有信息
* @return 格式为"prefix + delimiter + param1 + delimiter + param2..."的字符串
* @throws IllegalArgumentException 如果@RequestLock注解配置错误
*/
public static String getLockKey(ProceedingJoinPoint joinPoint) {
// 获取方法元数据
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
Method method = methodSignature.getMethod();
// 校验必须存在@RequestLock注解
RequestLock requestLock = method.getAnnotation(RequestLock.class);
if (requestLock == null) {
throw new IllegalArgumentException("方法必须标注@RequestLock注解");
}
// 初始化Key构建器
StringBuilder keyBuilder = new StringBuilder(requestLock.prefix());
String delimiter = requestLock.delimiter();
Object[] args = joinPoint.getArgs();
Parameter[] parameters = method.getParameters();
// 第一层处理:方法参数级别的注解
processMethodParameters(keyBuilder, parameters, args, delimiter);
// 第二层处理:参数对象内部字段的注解(当第一层未生成有效内容时触发)
if (keyBuilder.length() == requestLock.prefix().length()) {
processNestedFieldAnnotations(keyBuilder, method, args, delimiter);
}
return keyBuilder.toString();
}
/**
* 处理方法参数级别的@RequestKeyParam注解
*
* @param keyBuilder Key构建器
* @param parameters 方法参数数组
* @param args 实际参数值数组
* @param delimiter 分隔符
*/
private static void processMethodParameters(
StringBuilder keyBuilder,
Parameter[] parameters,
Object[] args,
String delimiter) {
for (int i = 0; i < parameters.length; i++) {
RequestKeyParam keyParam = parameters[i].getAnnotation(RequestKeyParam.class);
// 跳过未标注或标记忽略的参数
if (shouldSkipParameter(keyParam, args[i])) {
continue;
}
// 添加参数值到Key中
appendKeyComponent(
keyBuilder,
delimiter,
resolveParameterValue(keyParam, args[i])
);
}
}
/**
* 处理嵌套对象字段级别的@RequestKeyParam注解
*
* @param keyBuilder Key构建器
* @param method 目标方法
* @param args 实际参数值数组
* @param delimiter 分隔符
*/
private static void processNestedFieldAnnotations(
StringBuilder keyBuilder,
Method method,
Object[] args,
String delimiter) {
Annotation[][] parameterAnnotations = method.getParameterAnnotations();
for (int i = 0; i < parameterAnnotations.length; i++) {
Object arg = args[i];
// 跳过基本类型和String类型参数
if (arg == null || arg.getClass().isPrimitive() || arg instanceof String) {
continue;
}
// 扫描对象字段
Field[] fields = arg.getClass().getDeclaredFields();
for (Field field : fields) {
RequestKeyParam keyParam = field.getAnnotation(RequestKeyParam.class);
if (shouldSkipParameter(keyParam, arg)) {
continue;
}
// 反射获取字段值
field.setAccessible(true);
Object fieldValue = ReflectionUtils.getField(field, arg);
// 添加字段值到Key中
appendKeyComponent(
keyBuilder,
delimiter,
resolveParameterValue(keyParam, fieldValue)
);
}
}
}
/**
* 判断是否应该跳过参数处理
*
* @param keyParam @RequestKeyParam注解实例
* @param paramValue 参数值
* @return true表示应该跳过,false表示需要处理
*/
private static boolean shouldSkipParameter(RequestKeyParam keyParam, Object paramValue) {
return keyParam == null || // 无注解
keyParam.ignore() || // 标记忽略
paramValue == null; // 空值
}
/**
* 解析参数最终值(优先使用注解指定的别名)
*
* @param keyParam @RequestKeyParam注解实例
* @param rawValue 原始参数值
* @return 当注解指定value时返回value,否则返回rawValue的字符串形式
*/
private static String resolveParameterValue(RequestKeyParam keyParam, Object rawValue) {
return StringUtils.hasText(keyParam.value()) ?
keyParam.value() :
String.valueOf(rawValue);
}
/**
* 安全添加Key组件(智能处理分隔符)
*
* @param builder StringBuilder实例
* @param delimiter 分隔符
* @param component 要添加的组件值
*/
private static void appendKeyComponent(
StringBuilder builder,
String delimiter,
String component) {
if (StringUtils.isEmpty(component)) {
return;
}
// 只在需要时添加分隔符(避免开头/重复分隔符)
if (builder.length() > 0 && !builder.toString().endsWith(delimiter)) {
builder.append(delimiter);
}
builder.append(component);
}
}
4.自定义AOP切面:
package com.example.debounce.aspect;
import com.example.debounce.annotation.RequestLock;
import com.example.debounce.exception.BizException;
import com.example.debounce.generator.RequestKeyGenerator;
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.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.data.redis.connection.RedisStringCommands;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.types.Expiration;
import org.springframework.util.StringUtils;
@Aspect
@Configuration
@Order(2)
public class RedisRequestLockAspect {
private final StringRedisTemplate stringRedisTemplate;
@Autowired
public RedisRequestLockAspect(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Around("execution(public * * (..)) && @annotation(com.example.debounce.annotation.RequestLock)")
public Object interceptor(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
Method method = methodSignature.getMethod();
RequestLock requestLock = method.getAnnotation(RequestLock.class);
if (StringUtils.isEmpty(requestLock.prefix())) {
throw new BizException("BIZ_CHECK_FAIL", "重复提交前缀不能为空");
}
//获取自定义key
final String lockKey = RequestKeyGenerator.getLockKey(joinPoint);
// 使用RedisCallback接口执行set命令,设置锁键;设置额外选项:过期时间和SET_IF_ABSENT选项
final Boolean success = stringRedisTemplate.execute(
(RedisCallback<Boolean>) connection -> connection.set(lockKey.getBytes(), new byte[0],
Expiration.from(requestLock.expire(), requestLock.timeUnit()),
RedisStringCommands.SetOption.SET_IF_ABSENT));
if (!success) {
throw new BizException("BIZ_CHECK_FAIL", "您的操作太快了,请稍后重试");
}
try {
return joinPoint.proceed();
} catch (Throwable throwable) {
throw new BizException("BIZ_CHECK_FAIL", "系统异常");
}
}
}
这里的核心代码是stringRedisTemplate.execute里面的内容,正如注释里面说的“使用RedisCallback接口执行set命令,设置锁键;设置额外选项:过期时间和SET_IF_ABSENT选项”,有些同学可能不太清楚
SET_IF_ABSENT
是个啥,这里我解释一下:SET_IF_ABSENT
是 RedisStringCommands.SetOption 枚举类中的一个选项,用于在执行 SET 命令时设置键值对的时候,如果键不存在则进行设置,如果键已经存在,则不进行设置。
5.Controller:
package com.example.debounce.controller;
import com.example.debounce.annotation.RequestKeyParam;
import com.example.debounce.annotation.RequestLock;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api")
public class DebounceController {
// 基本防抖示例 - 5秒内只能调用一次
// 生成的Key: debounce:basic
@GetMapping("/basic")
@RequestLock(prefix = "debounce:basic")
public String basicDebounce() {
return "请求成功 - " + System.currentTimeMillis();
}
// 带参数的防抖示例 & 相同userId在5秒内只能调用一次
// 生成的Key: debounce:user&[userId值],例如userId=123 → debounce:user&123
@GetMapping("/user")
@RequestLock(prefix = "debounce:user")
public String userDebounce(@RequestParam @RequestKeyParam String userId) {
return "用户请求成功 - " + userId + " - " + System.currentTimeMillis();
}
// 对象参数防抖示例
// 生成的Key: debounce:order&[order.orderId值],例如orderId="ORD123" → debounce:order&ORD123
@PostMapping("/order")
@RequestLock(prefix = "debounce:order")
public String orderDebounce(@RequestBody OrderRequest order) {
return "订单请求成功 - " + order.getOrderId() + " - " + System.currentTimeMillis();
}
// 静态内部类模拟请求对象
@Data
public static class OrderRequest {
@RequestKeyParam // 只有这个字段被标记,参与Key生成
private String orderId;
private String productName; // 无注解 → 不参与Key生成
private int quantity; // 无注解 → 不参与Key生成
}
}
三.Redission分布式解决方案:
1.实现思路:
2.代码实现:
Redisson分布式需要一个额外依赖,引入方式:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.10.6</version>
</dependency>
(1)Redisson配置:
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
publicclass RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
// 这里假设你使用单节点的Redis服务器
config.useSingleServer()
// 使用与Spring Data Redis相同的地址
.setAddress("redis://127.0.0.1:6379");
// 如果有密码
//.setPassword("xxxx");
// 其他配置参数
//.setDatabase(0)
//.setConnectionPoolSize(10)
//.setConnectionMinimumIdleSize(2);
// 创建RedissonClient实例
return Redisson.create(config);
}
}
(2)自定义切面:
Redisson的核心思路就是抢锁,当一次请求抢到锁之后,对锁加一个过期时间,在这个时间段内重复的请求是无法获得这个锁,也不难理解。
import java.lang.reflect.Method;
import com.summo.demo.exception.biz.BizException;
import com.summo.demo.model.response.ResponseCodeEnum;
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.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.util.StringUtils;
/**
* @description 分布式锁实现
*/
@Aspect
@Configuration
@Order(2)
publicclass RedissonRequestLockAspect {
private RedissonClient redissonClient;
@Autowired
public RedissonRequestLockAspect(RedissonClient redissonClient) {
this.redissonClient = redissonClient;
}
@Around("execution(public * * (..)) && @annotation(com.example.debounce.annotation.RequestLock)")
public Object interceptor(ProceedingJoinPoint joinPoint) {
MethodSignature methodSignature = (MethodSignature)joinPoint.getSignature();
Method method = methodSignature.getMethod();
RequestLock requestLock = method.getAnnotation(RequestLock.class);
if (StringUtils.isEmpty(requestLock.prefix())) {
thrownew BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "重复提交前缀不能为空");
}
//获取自定义key
final String lockKey = RequestKeyGenerator.getLockKey(joinPoint);
// 使用Redisson分布式锁的方式判断是否重复提交
RLock lock = redissonClient.getLock(lockKey);
boolean isLocked = false;
try {
//尝试抢占锁
isLocked = lock.tryLock();
//没有拿到锁说明已经有了请求了
if (!isLocked) {
thrownew BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "您的操作太快了,请稍后重试");
}
//拿到锁后设置过期时间
lock.lock(requestLock.expire(), requestLock.timeUnit());
try {
return joinPoint.proceed();
} catch (Throwable throwable) {
thrownew BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "系统异常");
}
} catch (Exception e) {
thrownew BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "您的操作太快了,请稍后重试");
} finally {
//释放锁
if (isLocked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}