springboot 防止重复提交
1、重复提交原因
客户端的抖动,快速操作,网络通信或者服务器响应慢,造成服务器重复处理。防止重复提交,除了从前端控制,后台也需要控制。因为前端的限制不能解决彻底。接口实现,通常要求幂等性,保证多次重复提交只有一次有效。对于更新操作,达到幂等性很难。
2 、后端防止重复提交方案
1、基于token
访问请求到达服务器,服务器端生成token,分别保存在客户端和服务器。提交请求到达服务器,服务器端校验客户端带来的token与此时保存在服务器的token是否一致,如果一致,就继续操作,删除服务器的token。如果不一致,就不能继续操作,即这个请求是重复请求。
这种方案,每次提交要发送两次请求。对前端不是特别友好。
2、基于缓存
request进来,没有就先存在缓存中,继续操作业务,最后删除缓存或者缓存设置生命周期。如果存在,就直接对request进行验证,就不能继续操作业务。
从该图中可以得知,如果当前提交的请求URL已经存在于缓存中,且当前提交的请求体 跟缓存中该URL对应的请求体一毛一样 且当前请求URL的时间戳跟上次相同请求URL的时间戳 间隔在8s 内,即代表当前请求属于 “重复提交”;如果这其中有一个条件不成立,则意味着当前请求很有可能是第一次请求,或者已经过了8s时间间隔的 第N次请求了,不属于“重复提交”了。
3、代码实现
照着这个思路,接下来我们将采用实际的代码进行实战,其中涉及到的技术:Spring Boot2.6 + 自定义注解 + 拦截器 + Redis缓存 (也可以分布式缓存Redisson);
1、pom 关键依赖如下:
<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>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.0.M4</version>
</dependency>
复制代码
2.配置文件如下:
server.port=8888
# Redis数据库索引(默认为0)
spring.redis.database=0
# Redis服务器地址
spring.redis.host=127.0.0.1
# Redis服务器连接端口
spring.redis.port=6380
# Redis服务器连接密码(默认为空)
spring.redis.password=eco.dameng.com
# 连接池最大连接数(使用负值表示没有限制)
spring.redis.pool.max-active=8
# 连接池最大阻塞等待时间(使用负值表示没有限制)
spring.redis.pool.max-wait=-1
# 连接池中的最大空闲连接
spring.redis.pool.max-idle=8
# 连接池中的最小空闲连接
spring.redis.pool.min-idle=0
# 连接超时时间(毫秒)
spring.redis.timeout=5000
复制代码
3、注解RepeatSubmit 如下:
@Inherited
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RepeatSubmit {
/**
* 防重复操作限时标记数值(存储redis限时标记数值)
*/
String value() default "value" ;
/**
* 防重复操作过期时间(借助redis实现限时控制)
*/
long expireSeconds() default 10;
}
复制代码
4、自定义拦截器
@Slf4j
@Component
@Aspect
public class NoRepeatSubmitAspect {
@Autowired
private RedisTemplate redisTemplate;
/**
* 定义切点
*/
@Pointcut("@annotation(com.example.learn.annotaion.RepeatSubmit)")
public void preventDuplication() {}
@Around("preventDuplication()")
public Object around(ProceedingJoinPoint joinPoint) throws Exception {
/**
* 获取请求信息
*/
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder
.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
// 获取执行方法
Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
//获取防重复提交注解
RepeatSubmit annotation = method.getAnnotation(RepeatSubmit.class);
// 获取token以及方法标记,生成redisKey和redisValue
String token = request.getHeader(IdempotentConstant.TOKEN);
String url = request.getRequestURI();
/**
* 通过前缀 + url + token + 函数参数签名 来生成redis上的 key
*
*/
String redisKey = IdempotentConstant.PREVENT_DUPLICATION_PREFIX
.concat(url)
.concat(token)
.concat(getMethodSign(method, joinPoint.getArgs()));
// 这个值只是为了标记,不重要
String redisValue = redisKey.concat(annotation.value()).concat("submit duplication");
if (!redisTemplate.hasKey(redisKey)) {
// 设置防重复操作限时标记(前置通知)
redisTemplate.opsForValue()
.set(redisKey, redisValue, annotation.expireSeconds(), TimeUnit.SECONDS);
try {
//正常执行方法并返回
//ProceedingJoinPoint类型参数可以决定是否执行目标方法,
// 且环绕通知必须要有返回值,返回值即为目标方法的返回值
return joinPoint.proceed();
} catch (Throwable throwable) {
//确保方法执行异常实时释放限时标记(异常后置通知)
redisTemplate.delete(redisKey);
throw new RuntimeException(throwable);
}
} else {
// 重复提交了抛出异常,如果是在项目中,根据具体情况处理。
throw new RuntimeException("请勿重复提交");
}
}
/**
* 生成方法标记:采用数字签名算法SHA1对方法签名字符串加签
*
* @param method
* @param args
* @return
*/
private String getMethodSign(Method method, Object... args) {
StringBuilder sb = new StringBuilder(method.toString());
for (Object arg : args) {
sb.append(toString(arg));
}
return DigestUtil.sha1Hex(sb.toString());
}
private String toString(Object arg) {
if (Objects.isNull(arg)) {
return "null";
}
if (arg instanceof Number) {
return arg.toString();
}
return JSONUtil.toJsonStr(arg);
}
}
复制代码
5、其他类文件
实体测试类
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Order {
private String orderNo;
private String productName;
private String purchaseName;
}
复制代码
常量类
public interface IdempotentConstant {
String TOKEN = "token";
String PREVENT_DUPLICATION_PREFIX = "PREVENT_DUPLICATION_PREFIX:";
}
复制代码
6、控制器类
@Slf4j
@RestController
@RequestMapping("/web")
public class IdempotentController {
@PostMapping("/sayNoDuplication")
@RepeatSubmit(expireSeconds = 8)
public String sayNoDuplication(@RequestParam("requestNum") String requestNum) {
log.info("sayNoDuplicatin requestNum:{}", requestNum);
return "sayNoDuplicatin".concat(requestNum);
}
@PostMapping("/addOrder")
@RepeatSubmit(expireSeconds = 8)
public String addOrder(@RequestBody Order order) {
log.info("addOrder requestNum:{}", order);
return JSONUtil.toJsonStr(order);
}
}
复制代码
4、测试
访问 http://localhost:8888/web/sayNoDuplication
第一次访问
多次点击
5、总结
基于JAVA注解+AOP切面快速实现防重复提交功能,该方案实现可以完全胜任非高并发场景下实施应用。但是在高并发场景下仍然有不足之处,存在线程安全问题(可以采用Jemeter复现问题)
if (!redisTemplate.hasKey(redisKey)) { // 设置防重复操作限时标记(前置通知) redisTemplate.opsForValue() .set(redisKey, redisValue, annotation.expireSeconds(), TimeUnit.SECONDS);
主要是这个操作不是原子的,在高并发场景会有问题。可以使用 redisson 分布式锁进行解决。