目录
前言
-
在现代的 Web 应用开发中,接口防抖机制和防重复提交技巧至关重要。对于接口防抖机制,它主要是为了避免接口被频繁触发,尤其是在用户快速操作或者网络不稳定的情况下。比如在一个表单提交场景中,如果用户不小心多次点击提交按钮,没有防抖机制的话,可能会导致重复的请求被发送到服务器,浪费资源并可能引发数据不一致的问题。
-
Spring Boot 可以通过多种方式实现接口防抖。例如,可以使用定时器来限制接口在一定时间内只能被调用一次。当用户触发接口请求时,系统会启动一个定时器,如果在定时器时间内再次收到相同的请求,就会忽略后续的请求,直到定时器超时后才接受新的请求。
-
而防重复提交技巧则是为了确保用户不会意外地提交相同的表单或执行相同的操作多次。在 Spring Boot 中,可以通过多种手段实现防重复提交。一种常见的方法是使用令牌机制,在用户请求页面时生成一个唯一的令牌,将其包含在表单中。当用户提交表单时,服务器会验证令牌的有效性。如果令牌有效,则处理请求并销毁令牌,防止再次使用。如果令牌无效,则说明是重复提交,服务器会拒绝该请求。
-
通过合理运用接口防抖机制和防重复提交技巧,可以大大提高 Spring Boot 应用的稳定性、性能和用户体验,确保系统能够高效、准确地处理用户请求,避免不必要的资源浪费和数据错误。
啥是防抖
一、基本概念
防抖的核心思想是在事件触发后,等待一段时间,如果在这段时间内没有再次触发事件,那么就执行相应的操作;如果在等待期间又触发了事件,则重新开始计时等待。
以用户在网页上输入搜索关键词为例,当用户在搜索框中输入内容时,每次输入一个字符都会触发一个搜索事件。如果不做防抖处理,每次触发事件都会立即发送一个请求到服务器进行搜索,这会导致大量不必要的请求,浪费资源且可能影响性能。而使用防抖技术后,可以设置一个时间间隔,比如 500 毫秒。当用户输入一个字符后,开始计时,如果在 500 毫秒内用户没有继续输入字符,那么就触发搜索操作;如果在 500 毫秒内用户又输入了新的字符,那么就重新开始计时。
二、实际应用场景
-
按钮点击事件处理:在网页或应用程序中,一个按钮可能会被用户快速点击多次。如果每次点击都立即触发相应的操作,可能会导致重复操作或不必要的资源消耗。使用防抖技术,可以确保只有在用户停止点击一段时间后,才真正执行按钮对应的操作。
-
窗口大小调整事件:当用户调整浏览器窗口大小时,会频繁触发窗口大小调整事件。如果在每次触发事件时都进行复杂的布局计算或重新绘制页面,会导致性能问题。通过防抖技术,可以在用户停止调整窗口大小一段时间后,再进行布局调整或页面绘制。
-
输入框实时搜索:如前面提到的搜索框场景,防抖可以减少不必要的搜索请求,提高用户体验和系统性能。
思路解析
前面讲了那么多,我们已经知道接口的防抖是很有必要的了,但是在开发之前,我们需要捋清楚几个问题。
哪一类接口需要防抖?
接口防抖也不是每个接口都需要加,一般需要加防抖的接口有这几类:
-
用户输入类接口:比如搜索框输入、表单输入等,用户输入往往会频繁触发接口请求,但是每次触发并不一定需要立即发送请求,可以等待用户完成输入一段时间后再发送请求。
-
按钮点击类接口:比如提交表单、保存设置等,用户可能会频繁点击按钮,但是每次点击并不一定需要立即发送请求,可以等待用户停止点击一段时间后再发送请求。
-
滚动加载类接口:比如下拉刷新、上拉加载更多等,用户可能在滚动过程中频繁触发接口请求,但是每次触发并不一定需要立即发送请求,可以等待用户停止滚动一段时间后再发送请求。
如何确定接口是重复的?
一、请求参数比较
-
检查 URL 和请求方法:如果两个接口具有相同的 URL 和请求方法(如 GET、POST、PUT、DELETE 等),那么初步可以认为它们可能是重复的接口。例如,
/api/user
的 GET 请求和另一个接口也是/api/user
的 GET 请求,就可能存在重复的嫌疑。 -
分析请求参数:进一步检查接口的请求参数。如果两个接口的 URL 和请求方法相同,并且请求参数也完全一致,那么可以确定这两个接口在功能上是重复的。例如,两个 POST 请求接口都是向
/api/user/create
提交用户信息,且参数都是用户名、密码等相同的字段,那么这两个接口很可能是重复的。
二、功能和业务逻辑分析
-
理解接口的用途:仔细分析每个接口的具体功能和业务逻辑。如果两个接口实现的功能完全相同,即使它们的请求参数或 URL 稍有不同,也可以认为它们是重复的。例如,一个接口用于创建用户,另一个接口也用于创建用户,只是请求参数的命名方式不同,那么这两个接口在业务逻辑上是重复的。
-
查看接口的文档和注释:如果接口有良好的文档和注释,可以通过阅读文档来确定接口的功能和用途。如果发现两个接口的文档描述非常相似或完全相同,那么可能存在重复。
分布式部署下如何做接口防抖?
有两个方案:
使用共享缓存
流程图如下:
使用分布式锁
-
利用分布式锁服务,如 ZooKeeper 或 Redis 的分布式锁实现。当接口被触发时,尝试获取分布式锁。如果成功获取锁,说明这是第一次在防抖时间间隔内触发该接口,可以进行接口处理。
-
在处理完成后,释放分布式锁,并设置一个过期时间,确保在防抖时间间隔内其他相同的请求无法获取锁,从而达到防抖的效果。
流程图如下:
❝
常见的分布式组件有Redis、Zookeeper等,但结合实际业务来看,一般都会选择Redis,因为Redis一般都是Web系统必备的组件,不需要额外搭建。
以 Redis 实现分布式锁为例:
import redis.clients.jedis.Jedis;
public class DebounceWithRedisLock {
private static final String LOCK_KEY_PREFIX = "debounce_lock:";
private static final int DEBOUNCE_INTERVAL_MS = 500;
public static boolean shouldProcessRequest(String requestId) {
Jedis jedis = new Jedis("localhost", 6379);
String lockKey = LOCK_KEY_PREFIX + requestId;
long lockTimeout = System.currentTimeMillis() + DEBOUNCE_INTERVAL_MS;
if (jedis.setnx(lockKey, String.valueOf(lockTimeout)) == 1) {
jedis.expire(lockKey, DEBOUNCE_INTERVAL_MS / 1000);
jedis.close();
return true;
} else {
long existingTimeout = Long.parseLong(jedis.get(lockKey));
if (existingTimeout < System.currentTimeMillis()) {
// 锁已过期,尝试获取锁并更新超时时间
long newTimeout = System.currentTimeMillis() + DEBOUNCE_INTERVAL_MS;
String oldTimeout = jedis.getSet(lockKey, String.valueOf(newTimeout));
if (oldTimeout!= null && Long.parseLong(oldTimeout) < System.currentTimeMillis()) {
jedis.expire(lockKey, DEBOUNCE_INTERVAL_MS / 1000);
jedis.close();
return true;
} else {
jedis.close();
return false;
}
} else {
jedis.close();
return false;
}
}
}
}
Spring Boot与Redis深度整合入口:实战指南
具体实现
现在有一个保存用户的接口
@PostMapping("/addUser")
@RequiresPermissions(value = "addUser")
@Log(methodDesc = "添加用户")
public R<String> addUser(@RequestBody AddReq addReq) {
return userService.add(addReq);
}
AddReq.java
@Data
public class AddReq {
private String userName; /** * 用户名称 */
private String userPhone; /** * 用户手机号 */
private List<Long> roleIdList; /** * 角色ID列表 */
}
❝
目前数据库表中没有对userPhone字段做UK索引,这就会导致每调用一次add就会创建一个用户,即使userPhone相同。
请求锁
根据上面的要求,我定了一个注解@RequestLock
,使用方式很简单,把这个注解打在接口方法上即可。
@Data
public class AddReq {
/**
* 用户名称
*/
private String userName;
/**
* 用户手机号
*/
private String userPhone;
/**
* 角色ID列表
*/
private List<Long> roleIdList;
}
❝
@RequestLock
注解定义了几个基础的属性,redis锁前缀、redis锁时间、redis锁时间单位、key分隔符。其中前面三个参数比较好理解,都是一个锁的基本信息。key分隔符是用来将多个参数合并在一起的,比如userName是张三,userPhone是123456,那么完整的key就是"张三&123456",最后再加上redis锁前缀,就组成了一个唯一key。
唯一key生成
这里有些同学可能就要说了,直接拿参数来生成key不就行了吗?额,不是不行,但我想问一个问题:如果这个接口是文章发布的接口,你也打算把内容当做key吗?要知道,Redis的效率跟key的大小息息相关。所以,我的建议是选取合适的字段作为key就行了,没必要全都加上
。
要做到参数可选,那么用注解的方式最好了,注解如下RequestKeyParam.java
/**
* @description 加上这个注解可以将参数设置为key
*/
@Target({ElementType.METHOD, ElementType.PARAMETER, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface RequestKeyParam {
}
这个注解加到参数上就行,没有多余的属性。
接下来就是lockKey的生成了
代码如下RequestKeyGenerator.java
public class RequestKeyGenerator {
/**
* 获取LockKey
*
* @param joinPoint 切入点
* @return
*/
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;
}
}
由于``@RequestKeyParam``可以放在方法的参数上,也可以放在对象的属性上,所以这里需要进行两次判断,一次是获取方法上的注解,一次是获取对象里面属性上的注解。
重复提交判断
Redis缓存方式
RedisRequestLockAspect.java
/**
* @description 缓存实现
*/
@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, "系统异常");
}
}
}
这里的核心代码是stringRedisTemplate.execute里面的内容,正如注释里面说的“使用RedisCallback接口执行set命令,设置锁键;设置额外选项:过期时间和SET_IF_ABSENT选项”,有些同学可能不太清楚
SET_IF_ABSENT
是个啥,这里我解释一下:SET_IF_ABSENT
是 RedisStringCommands.SetOption 枚举类中的一个选项,用于在执行 SET 命令时设置键值对的时候,如果键不存在则进行设置,如果键已经存在,则不进行设置。
Redisson分布式方式
Redisson分布式需要一个额外依赖,引入方式
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.10.6</version>
</dependency>
由于我之前的代码有一个RedisConfig,引入Redisson之后也需要单独配置一下,不然会和RedisConfig冲突RedissonConfig.java
@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);
}
}
配好之后,核心代码如下RedissonRequestLockAspect.java
/**
* @description 分布式锁实现
*/
@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();
}
}
}
}
Redisson的核心思路就是抢锁,当一次请求抢到锁之后,对锁加一个过期时间,在这个时间段内重复的请求是无法获得这个锁,也不难理解。
测试一下
-
第一次提交,"添加用户成功"
-
短时间内重复提交,"BIZ-0001:您的操作太快了,请稍后重试"
-
过几秒后再次提交,"添加用户成功"
从测试的结果上看,防抖是做到了,但是随着缓存消失、锁失效,还是可以发起同样的请求,所以要真正做到接口幂等性,还需要业务代码的判断、设置数据库表的UK索引等操作。我在文章里面说到生成唯一key的时候没有加用户相关的信息,比如用户ID、IP属地等,真实生产环境建议加上这些,可以更好地减少误判。