在项目中,用户在操作某些重要操作时,经常会出现手抖,导致重复提交,该怎么处理呢?
我的答案是交给前端,用户提交的时候禁用按钮,等接口返回后再启用。
哈哈,开个玩笑,今天给大家示范一下后端如何处理。
首先通常我们为何要避免表单重复提交?这其实跟接口幂等有关,既然说到这里,那么我们就来回顾一下什么是接口幂等吧!
1. 接口幂等的定义:
一个接口多次调用而没有副作用,那么我们称之为接口幂等。所谓没有副作用简单来讲就是一个接口被执行多次数据不会乱。每次返回的结果都一致。
2.哪些地方天然幂等,那么地方可能导致接口不幂等?
基本业务操作中,查询和删除,天然幂等。更新和插入是接口不幂等的高发地。
3.业务中重点触发场景
- 用户重复操作:例如我们的系统中,用户开方后点击确认,手抖触发了两次,发了两次请求给后台。
- 代码重试:很多RPC框架在进行远程调用时都有重试机制,当第一次请求发出后在指定时间内没有收到回复,会再发一次。例如dubbo,默认2次。
- 消息重复消费:多次触发消费者消费逻辑。
- 网络波动:同一个请求服务端收到多次。
4.常用解决方案
-
Token+Redis:每次接口先获取token,后台将生成的token放入redis,请求时在header中加上token,收到请求后验证redis中是否存在改token,通过后删除token。-------保证网络波动导致的重复请求。(最常用的解决方案)。
- 唯一索引去重:保证最终插入数据库只有一天数据。
- 状态机:解决状态update问题,多线程下,多用户对同一订单处理问题。
- 乐观锁:(虽然比悲观锁效率好点,但任然用的不多)
- 悲观锁:for update (效率问题,不推荐,除非安全等级要求极高的地方)
- 先查询后判断+版本控制
- 建去重表
- 前端拦截
- JVM锁:仅单机状态
- 分布式锁
5.我们遇到的重复请求问题
1.对于较重的请求,因为比较耗时,有时候用户会重复点击,例如查询某个复杂报表,用户查询后迟迟未收到回复,便再次多次点击,导致重复请求过多,浪费系统资源。
2.网络波动,用户客户端提交了一次,服务端却收到了多次请求。
3.用户手抖,同一时间内点击了两次表单提交按钮,导致服务端收到两次请求。
6.解决思路
对于上面所诉的三个问题,重点就是过滤掉重复的请求。我们采用redis+token 的思路来处理,由于我们在gateway中已经在请求参数中追加了用户信息(里面拥有accountId,因系统只允许单点登录,可唯一区分是同一个用户发出的请求)具体实现参见我的另一篇博客合理使用gateWay过滤器,实现Concroller自动注入用户信息
,因此可以省去获取token这一步骤,我们在请求进入controller之前,采用aop拦截,将获取的请求的方法签名和参数(方法参数 + 用户信息 )作为参数 使用MD5运算出一个key存入redis(存入时使用redis set if not exists来保证原子性)。根据存入结果判断当前请求是否正在被处理,若正在被处理,则不再执行当前请求,直接返回,否则正常执行,当请求执行完成后后置处理将redis中的key移除。
7.实现代码
1.添加注解,方便后续使用。代码如下
package com.lvyuanji.common.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface FormRepeatSubmitValidation {
String value() default "表单重复提交验证";
//禁止重试时间
int timeout() default 8*1000;
}
2.aop切面
package com.lvyuanji.common.aspect;
import cn.hutool.crypto.digest.MD5;
import com.lvyuanji.common.annotation.FormRepeatSubmitValidation;
import com.lvyuanji.common.asserts.BusinessAsserts;
import com.lvyuanji.common.exception.Exceptions;
import org.apache.dubbo.common.utils.StringUtils;
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.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
/**
* 防止表单重复提交 Aspect,仅用于Controller层
*
* @author moky
* @date 2021/04/21.
*/
@Aspect
@Component
public class FormRepeatSubmitAspect {
//日志记录
private static final Logger logger = LoggerFactory.getLogger(FormRepeatSubmitAspect.class);
//redis客户端
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Around("@annotation(formRepeatSubmitValidation)")
public Object doProcess(ProceedingJoinPoint joinPoint, FormRepeatSubmitValidation formRepeatSubmitValidation) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
//获取方法签名
String methodSignature = signature.toLongString();
// 请求的方法参数值,因小蓝本机制,会在请求参数中写入accountEntity,因此通过参数可以唯一定位到具体用户
Object[] args = joinPoint.getArgs();
String argString = StringUtils.toArgumentString(args);
//通过MD5运算加密
MD5 md5 = new MD5();
String digexStr = md5.digestHex(methodSignature + argString);
//将字符串存入 redis,根据返回值判断是否该请求仍然正在处理
ValueOperations<String, String> operations = redisTemplate.opsForValue();
Boolean isSuccess = operations.setIfAbsent(digexStr, "lock", formRepeatSubmitValidation.timeout(), TimeUnit.MILLISECONDS);
BusinessAsserts.isTrue(isSuccess, Exceptions.System.repet_submit);
//执行被被代理的方法
try {
return joinPoint.proceed(args);
} finally {
//方法执行结束,解锁
redisTemplate.delete(digexStr);
}
}
}
3.完成以上两步,后面我们面对需要拦截重复提交的请求时,只需要在controller中添加注解即可实现动态拦截。

注意此处仅处理了客户端重复请求的问题,远程服务调用重复请求问题我会在后续给出处理方案。