1、重复提交原因

客户端的抖动,快速操作,网络通信或者服务器响应慢,造成服务器重复处理。防止重复提交,除了从前端控制,后台也需要控制。因为前端的限制不能解决彻底。接口实现,通常要求幂等性,保证多次重复提交只有一次有效。对于更新操作,达到幂等性很难。

2 、后端防止重复提交方案

1、基于token

访问请求到达服务器,服务器端生成token,分别保存在客户端和服务器。提交请求到达服务器,服务器端校验客户端带来的token与此时保存在服务器的token是否一致,如果一致,就继续操作,删除服务器的token。如果不一致,就不能继续操作,即这个请求是重复请求。

这种方案,每次提交要发送两次请求。对前端不是特别友好。

2、基于缓存

request进来,没有就先存在缓存中,继续操作业务,最后删除缓存或者缓存设置生命周期。如果存在,就直接对request进行验证,就不能继续操作业务。

6887f65f8e1d3dad757f780efad35794.png

从该图中可以得知,如果当前提交的请求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 {
   
   
   StringTOKEN = "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

image.png

第一次访问 image.png

多次点击

image.png

5、总结

基于JAVA注解+AOP切面快速实现防重复提交功能,该方案实现可以完全胜任非高并发场景下实施应用。但是在高并发场景下仍然有不足之处,存在线程安全问题(可以采用Jemeter复现问题)

if (!redisTemplate.hasKey(redisKey)) { // 设置防重复操作限时标记(前置通知) redisTemplate.opsForValue() .set(redisKey, redisValue, annotation.expireSeconds(), TimeUnit.SECONDS);

主要是这个操作不是原子的,在高并发场景会有问题。可以使用 redisson 分布式锁进行解决。

来源:https://juejin.cn/post/7091860233693167647
Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐