前言
所谓防抖机制,其核心目的有二:一是预防用户因手部不稳而导致的误操作,二是应对网络环境的不稳定(即网络抖动)。在Web系统的设计中,表单提交是一个极为常见的功能。若缺乏适当的控制,很容易因为用户的无意重复点击或网络延迟,导致同一请求被多次发送,进而在数据库中产生冗余的数据记录。为了缓解用户误操作带来的问题,前端开发者通常会实现一种机制,使得在用户点击提交按钮后,按钮进入加载(loading)状态,从而阻止用户在同一时间段内进行多次点击。然而,针对网络波动可能引起的请求重复发送问题,仅仅依靠前端的措施是不足以完全解决的。因此,后端系统也需要部署相应的防抖逻辑,以确保在网络条件不稳定的情况下,不会错误地接收并处理同一请求的多次传输。
二、思路
哪些接口需要实现防抖?
用户输入类接口,如搜索框输入或表单输入等场景,用户的输入行为往往十分频繁,但这并不意味着每次输入都需要立即触发接口请求。为了提高效率和用户体验,可以设计一种机制,使得系统在检测到用户输入停止一段时间后,再统一发送请求。这样既能减少不必要的请求次数,又能确保在用户完成输入后,数据能够及时更新。
对于按钮点击类接口,如提交表单或保存设置等操作,用户可能会因为各种原因(如误操作、网络延迟反馈等)而频繁点击按钮。为了避免因此产生的重复请求,可以引入防抖逻辑,即在用户停止点击按钮并等待一段时间后,再发送请求。这样能够有效防止因用户连续点击而导致的重复处理和数据冗余。
在滚动加载类接口中,如下拉刷新或上拉加载更多内容的场景,用户滚动页面的行为可能非常频繁。然而,频繁地触发接口请求不仅会增加服务器的负担,还可能影响用户体验。因此,可以设置一个防抖策略,即在用户停止滚动并等待一段时间后,再触发接口请求以加载新的内容。这样既能保证内容的及时加载,又能避免不必要的请求开销。
三、解决方案
使用共享缓存:
使用分布式锁:
四、代码示例
controller层
@PostMapping("/add")
@RequiresPermissions(value = "add")
@Log(methodDesc = "添加用户")
public ResponseEntity<String> add(@RequestBody AddReq addReq) {
return userService.add(addReq);
}
request层
/**
* @description 新增入参
* @author kyle0432
* @date 2024/03/01 15:17
*/
public class AddReq {
/**
* 用户名称
*/
private String userName;
/**
* 用户手机号
*/
private String userPhone;
/**
* 角色ID列表
*/
private List<Long> roleIdList;
}
自定义注解
package com.example.requestlock.lock.annotation;
import java.lang.annotation.*;
/**
* @description 加上这个注解可以将参数设置为key
* @author kyle0432
* @date 2024/03/01 15:27
*/
@Target({ElementType.METHOD, ElementType.PARAMETER, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface RequestLock {
}
import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.util.ReflectionUtils;
import org.springframework.util.StringUtils;
public class RequestKeyGenerator {
/**
* @description 获取LockKey
* @param joinPoint 切入点
* @author kyle0432
* @date 2024/03/01 15:48
*/
public static String getLockKey(ProceedingJoinPoint joinPoint) {
//获取连接点的方法签名对象
MethodSignature methodSignature = (MethodSignature)joinPoint.getSignature();
//Method对象
Method method = methodSignature.getMethod();
//获取Method对象上的注解对象
RequestLock requestLock = method.getAnnotation(RequestLock.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(requestLock.delimiter()).append(args[i]);
}
//如果方法上没有加RequestKeyParam注解
if (StringUtils.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(requestLock.delimiter()).append(ReflectionUtils.getField(field, object));
}
}
}
//返回指定前缀的key
return requestLock.prefix() + sb;
}
}
> 由于``@RequestLock``可以放在方法的参数上,也可以放在对象的属性上,所以这里需要进行两次判断,一次是获取方法上的注解,一次是获取对象里面属性上的注解。
Redis缓存方式
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.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;
/**
* @description 缓存实现
* @author kyle0432
* @date 2024/03/01 16:01
*/
@Aspect
@Configuration
@Order(2)
public class RedisRequestLockAspect {
private final StringRedisTemplate stringRedisTemplate;
@Autowired
public RedisRequestLockAspect(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Around("execution(public * * (..)) && @annotation(com.summo.demo.config.requestlock.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())) {
throw new BizException(ResponseCodeEnum.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(ResponseCodeEnum.BIZ_CHECK_FAIL, "您的操作太快了,请稍后重试");
}
try {
return joinPoint.proceed();
} catch (Throwable throwable) {
throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "系统异常");
}
}
}
Redisson分布式方式
分布式需要一个额外依赖,引入方式
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.10.6</version>
</dependency>
引入Redisson之后也需要单独配置一下,不然会和RedisConfig冲突RedissonConfig.java
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;
/**
* @description redis配置类
* @author kyle0432
* @date 2024/03/01 16:16
*/
@Configuration
public class 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);
}
}
核心代码
mport 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 分布式锁实现
* @author kyle0432
* @date 2024/03/01 14:36
*/
@Aspect
@Configuration
@Order(2)
public class RedissonRequestLockAspect {
private RedissonClient redissonClient;
@Autowired
public RedissonRequestLockAspect(RedissonClient redissonClient) {
this.redissonClient = redissonClient;
}
@Around("execution(public * * (..)) && @annotation(com.summo.demo.config.requestlock.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())) {
throw new 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) {
throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "您的操作太快了,请稍后重试");
}
//拿到锁后设置过期时间
lock.lock(requestLock.expire(), requestLock.timeUnit());
try {
return joinPoint.proceed();
} catch (Throwable throwable) {
throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "系统异常");
}
} catch (Exception e) {
throw new BizException(ResponseCodeEnum.BIZ_CHECK_FAIL, "您的操作太快了,请稍后重试");
} finally {
//释放锁
if (isLocked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
代码演示
第一次提交,"添加用户成功
短时间内重复提交