在实际项目中,我们经常遇到像当前网络状态不好,或者系统一时间被太多人访问而导致操作状态延迟,例如点击按钮之后,没有响应,实际上操作未必失败了,但我们肯定下意识会再次点击,这样的话就可能会造成重复提交的情况。那如何来避免重复提交呢?
一、重复提交和接口限流
防重复提交和接口限流看上去虽然很像,但也是有着一些区别的。
接口限流和防重复提交在目的、实现机制以及触发条件等方面有所区别,具体分析如下:
-
目的
-
接口限流:保护系统资源,避免因瞬时流量过大导致的服务崩溃。
-
防重复提交:保证业务操作的准确性,防止生成重复的数据记录。
-
-
实现机制
-
接口限流:通过限制进入系统的请求速率来控制流量,常使用固定窗口、滑动窗口、漏桶和令牌桶等算法5^。
-
防重复提交:通过前端控制、后端校验、令牌机制(如Token)、数据库唯一约束等方式来实现3^。
-
-
触发条件
-
接口限流:当请求速率超过设定的阈值时触发限流。
-
防重复提交:当同一请求被多次发送时进行拦截。
-
-
处理方式
-
接口限流:超出限制的请求被拒绝或排队等待。
-
防重复提交:系统忽略或提醒用户已处理过的重复请求。
-
-
应用场景
-
接口限流:适用于高并发、流量可能突增的系统。
-
防重复提交:适用于表单提交、接口调用等需要保证操作原子性的场合。
-
-
技术选型
-
接口限流:可选用Guava、Sentinel等开源限流框架5^。
-
防重复提交:可使用SpringBoot的AOP功能,结合Redis等分布式锁来实现3^。
-
-
关注点
-
接口限流:关注的是流量的控制和系统整体性能。
-
防重复提交:关注的是单个请求的处理和用户体验。
-
-
反馈机制
-
接口限流:通常返回错误码或提示信息给客户端,告知其请求被限制。
-
防重复提交:可以提供更详细的反馈,如告诉用户操作已完成,无需重复提交。
-
总的来说,接口限流和防重复提交虽然都是为了维护系统的稳定性和数据的准确性,但它们各自关注的侧重点不同。接口限流主要解决的是高并发下如何有效分配服务器资源,避免过载;而防重复提交则是确保每一次用户意图的操作都能得到准确无误的执行。
二、具体实现
1、自定义注解
package com.moon.springbootinit.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RepeatSubmit{
/**
* 锁定时间
* @return 锁定时间,默认是3 TimeUnit 默认是秒
*/
int lockedTime() default 3;
/**
* 时间单位(时分秒等)
* @return 单位
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
}
自定义注解:设置两个参数,一个是锁定时间,指的是在提交之后给提交的标识设置的超时时间,另一个是时间单位。
2、导入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
3、防重复提交切面类
使用SpringAop定义一个切面类
package com.moon.springbootinit.aop;
import com.moon.springbootinit.annotation.RepeatSubmit;
import com.moon.springbootinit.model.entity.User;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.lang.reflect.Method;
import java.util.Objects;
import static com.moon.springbootinit.constant.UserConstant.USER_LOGIN_STATE;
/**
* 防重复提交切面类
* @date 2024-06-05
*/
@Aspect
@Component
@Slf4j
public class RepeatSubmitAspect {
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 环绕通知,围绕着方法执行
* @param pjp
* @return
*/
@Around("@annotation(repeatSubmit)")
public Object around(ProceedingJoinPoint pjp,RepeatSubmit repeatSubmit) {
try {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
Object userObj = request.getSession().getAttribute(USER_LOGIN_STATE);
User currentUser = (User) userObj;
Long userId = currentUser.getId();
// 缓存中的key
String key = "test_userId:"+userId;
// 如果缓存中有这个url视为重复提交
Object a1 = stringRedisTemplate.opsForValue().get(key);
if (a1 == null) {
Object o = pjp.proceed();
// 设置缓存
stringRedisTemplate.opsForValue().set(key, "1");
log.info("请求的userId:" + userId + ",请求URI:" + request.getRequestURI());
// 设置缓存过期时间
stringRedisTemplate.expire(key, repeatSubmit.lockedTime(), repeatSubmit.timeUnit());
return o;
} else {
log.error("重复提交");
return "请勿短时间内重复操作";
}
} catch (Throwable e) {
e.printStackTrace();
log.error("验证重复提交时出现未知异常!");
return "验证重复提交时出现未知异常!";
}
}
}
代码逻辑:
- 扫描项目中有`@repeatSubmit`注解标记的方法,执行切面类逻辑
- 通过RequestContextHolder获取到请求上下文request
- 用户登录信息可以从session中获取到,获取到userId
- 从缓存中获取key
- key存在,抛出异常
- key不存在,则将key和标识一并存入缓存中,并设置过期时间
1、环绕通知@Around("@annotation(repeatSubmit)")
请求中被加上这个注解@RepeatSubmit
时,它就会执行切面类RepeatSubmitAspect下面的逻辑
允许你在目标方法执行前后添加自定义的逻辑。具体来说,环绕通知会在目标方法执行之前和之后执行,并且可以决定是否继续执行目标方法。这种通知类型通常用于日志记录、事务管理、性能监控等场景。
2、获取request
根据request,获取到userId
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
Object userObj = request.getSession().getAttribute(USER_LOGIN_STATE);
User currentUser = (User) userObj;
Long userId = currentUser.getId();
3、查询缓存
把userId和method拼接成一个key
在这之前,先要判断缓存中的key是否存在,也就是用户是否提交过一次按钮
如果为空,设置缓存并设置过期时间
4、代理
point.proceed()
:调用名为point
的对象的proceed
方法。这个方法通常在代理类中实现,用于拦截对目标对象的调用并执行一些额外的操作(如日志记录、权限检查等),然后继续调用目标对象的方法。
简单来说就是,请求会先到达这个切面类,执行完这里的逻辑之后又去重新执行请求对应的方法。
4、接口请求
为了方便测试,lockedTime尽量可以调小一点,30秒。实际项目中应该调大一点
@GetMapping("/test")
@RepeatSubmit(lockedTime = 30, timeUnit = TimeUnit.SECONDS)
public String test() {
return "提交成功";
}
结果:
点击第二次后
缓存存入
今天的分享就到这里~~~
欢迎喜欢的兄弟点赞、收藏、关注········我们下期再见!!!