防止表单重复提交网上有很多文章描述了和解决方案,这里做个汇总。另最近的项目在重复提交上出现了较少见的情况,做个记录。
防表单重复提交
发生场景
- 提交表单,提交按钮点了多次。
- 提交表单后,【刷新】了浏览器。
- 提交表单后跳转到其它页面,点击【回退】按钮回退到表单提交页面。
- 同行或外部技术人员提交相同数据恶意多线程并发请求。
- 网络延迟导致同一条数据在毫秒差级别产生两个请求。
解决方案
前端解决:提交表单后将按钮设置为不可用或隐藏按钮,不建议。
前端解决:提交表单后跳转到其它页面,如果仍需停留在提交页面(如列表多条件查询),清空表单,不建议。
前端解决:在提交的
js
里添加提交标记,提交表单前判断标记若为可提交,则执行提交请求,并设置标记为不可提交。后端解决:前端初始化表单页面时向后发请求,后端生成一个唯一随机ID作为
token
存入Session
并发送给前端,前端表单使用隐藏字段接收token
, 在提交表单时,把这个token
一起提交,后台取表单的 token 与 Session 里的 token 进行比较, 相同则处理提交并清空Session
里的token
; 若不相同则拒绝处理此次提交。通常会使用拦截器(Interceptor)或过滤器(Filter)结合注解来做统一处理。
拦截器实现
前端每次初始化页面表单时向后端请求获取 NO-REPEAT TOKEN
后端定义接口返回 NO-REPEAT TOKEN
@RestController @RequestMapping("/token") public class TokenController { private static final Logger logger = LogManager.getLogger(TokenController.class); @GetMapping("/noRepeatToken") public void genNoRepeatToken(HttpServletRequest request, HttpServletResponse response) { logger.info("/token/noRepeatToken -> genNoRepeatToken()"); Account account = (Account) request.getSession().getAttribute("auth_user"); String value = String value = request.getRequestedSessionId() + ":" + account.getId() + ":" + System.currentTimeMillis(); String token = DigestUtils.md5DigestAsHex(value.getBytes(Charset.defaultCharset())); HttpSession session = request.getSession(); session.setAttribute("NO-REPEAT", token); } }
备注:上面的处理是没有把 token 回传给前端的。有些处理会把 token 回传给前端,前端在提交表单时带上这个 token,在拦截器里判断 session 里的 token 不为空,且与表单一起提交的 token 比较为相等,则表示首次提交并清空 session 中的 token。如下示例:
//token 返回给前端 Map<String,String> map = new HashMap<>(); map.put("NO-REPEAT", token); return ResultHelper.success(map);
//前端提交表单带上 token, 拦截器中取出随表单一起提交的 token 与 session 中的 token 比较 String sessionNoRepeatToken = (String) session.getAttribute("NO-REPEAT"); String noRepeatParam = request.getParameter("NO-REPEAT"); if (null != sessionNoRepeatToken && sessionNoRepeatToken.equals(noRepeatParam)) { session.removeAttribute("NO-REPEAT"); return true; }
定义防止重复提交的注解,用于标记那些接口需要做防止重复提交操作
@Documented @Retention(RetentionPolicy.RUNTIME) @Target({ElementType.TYPE, ElementType.METHOD}) public @interface NoRepeatCommit { }
定义防止重复提交拦截器,在进入控制器前就判断是否存在重复提交
@Order(Integer.MAX_VALUE - 800) public class NoRepeatInterceptor implements HandlerInterceptor { private static final Logger logger = LogManager.getLogger(NoRepeatInterceptor.class); /** * 通过判断 Session 中的 Token 来处理重复提交 * 适用于单体应用,或使用了共享 Session 方案的集群系统 * * @param request * @param response * @param handler * @return * @throws Exception */ @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { logger.info("进入防止重复提交拦截器............."); HandlerMethod handlerMethod = (HandlerMethod) handler; Class<?> beanType = handlerMethod.getBeanType(); //注解支持作用在类上,则所有方法都需要做防重复提交 NoRepeatCommit classNoRepeat = beanType.getAnnotation(NoRepeatCommit.class); NoRepeatCommit MethodNoRepeat = handlerMethod.getMethodAnnotation(NoRepeatCommit.class); //方法注解优先 NoRepeatCommit noRepeatCommit = (null != MethodNoRepeat ? MethodNoRepeat : classNoRepeat); if (null != noRepeatCommit) { //如果有防重复提交的注解,则进入业务处理 HttpSession session = request.getSession(); String noRepeatToken = (String) session.getAttribute("NO-REPEAT"); if (null != noRepeatToken) { session.removeAttribute("NO-REPEAT"); return true; } else { //如果防重复提交 Token 不存在,或不相等,则认为已处理并拒绝提交 ServletOutputStream output = response.getOutputStream(); output.write("{\"msg\":\"请不要重复提交\"}".getBytes("UTF-8")); output.flush(); output.close(); return false; } } return true; } }
添加拦截器到 WebMvc 中使其生效
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor()) .addPathPatterns("/**") .excludePathPatterns("/login"); registry.addInterceptor(noRepeatInterceptor()) .addPathPatterns("/**"); } @Bean public NoRepeatInterceptor noRepeatInterceptor(){ return new NoRepeatInterceptor(); } @Bean public LoginInterceptor loginInterceptor(){ return new LoginInterceptor(); } }
Controller 控制层使用 NoRepeatCommit 注解标注那些类或方法需要做防重复提交
@NoRepeatCommit @RestController @RequestMapping("/user") public class UserController { private Logger logger = LogManager.getLogger(UserController.class); @Autowired private UserService userService; @NoRepeatCommit @PostMapping("/save") public ResultBean<User> saveUser(User user) { logger.info("保存用户信息:{}", JSON.toJSONString(user)); //执行保存 userService.save(user); return ResultHelper.success(user); } }
备注:有的可能不是采用注解加拦截器的方式,而是在 Controller 层需要做防重复提交的处理方法中直接通过 HttpServletRequest 取的 Session 中的 Token 来判断是首次提交,还是重复提交,这也是可以的,只是没有注解那么灵活,若很多接口需要做防重处理,则会存在很多重复代码。
前端提交表单时将
NO-REPEAT
回传给服务端。
Spring AOP 实现
定义注解和注解的使用,与上面拦截器中的一样使用。关闭上面拦截器的注册,便于查看效果。
定义防止重复提交的 AOP
@Aspect @Component public class NoRepeatCommitAspect { private static final Logger logger = LogManager.getLogger(NoRepeatCommitAspect.class); @Pointcut("@annotation(com.springboot.aop.common.annotation.NoRepeatCommit)") public void pointcut() { } /** * 通过判断 Session 中的 Token 来处理重复提交 * 适用于单体应用,或使用了共享 Session 方案的集群系统 * * @param joinPoint * @return * @throws Throwable */ @Around("pointcut()") public Object around(ProceedingJoinPoint joinPoint) throws Throwable { logger.info("进入防止重复提交切面.........."); HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest(); HttpServletResponse response = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getResponse(); HttpSession session = request.getSession(); String sessionNoRepeatToken = (String) session.getAttribute("NO-REPEAT"); String requestNoRepeatToken = request.getParameter("NO-REPEAT"); if (null != sessionNoRepeatToken && sessionNoRepeatToken.equals(requestNoRepeatToken)) { Object result = joinPoint.proceed(); session.removeAttribute("NO-REPEAT"); return result; } else { //如果防重复提交 Token 不存在,或不相等,则认为已处理并拒绝提交 // ServletOutputStream output = response.getOutputStream(); // output.write("{\"msg\":\"请不要重复提交\"}".getBytes("UTF-8")); // output.flush(); // output.close(); return ResultHelper.repeatCommit(); } } }
项目中重复提交
最近项目注册表单出现了重复提交的问题,前端已做了防重复提交,按常规操作的话应该不会再有重复提交的问题,测试环境也多种情况测试没问题;
数据库建了唯一约束,在插入数据之前执行了查询操作,没有时才会执行插入;
但线上环境的日志仍抛出插入操作时数据唯一约束字段存在重复(duplicate
)的异常, 再详查日志, 发现了重复提交问题,重点是两次请求的时间只相差1 毫秒。
初步判断此重复提交不是人为重复点击提交按钮造成,也就可能是【4、5】两种场景,在测试环境针对第【 4 】种场景进行多线程并发测试没有重现, 最后只能认为是网络延迟重发造成;
也就只能采用第【 4 】解方案,取表单的 token 与 session 里的 token 进行比较来处理。
最终结论:若只在前端处理重复提交,并不能完全杜绝重复提交的发生,还可能存在网络延迟超时重发的情况,需要前端方案和后端 Session 方案共同处理, 前端可以尽可能降低重复请求的连接, 后端 Session 方案可以完全阻止重复提交。
补充:2019-01-07
对生产系统日志持续跟踪,日志中存在一个请求内执行了两次登录提交,发现以上方案仍不能完全解决彻底解决重复提交问题,但个人根据已有数据预估实施了以上方案网络延迟导致的重复提交的问题降低到 0.6% 左右,但这个概率仍是比较大。目前还没找到彻底的解决方案。
//同一时间同时进入两条完全相同的请求,
2019-01-07 16:30:43,259 INFO [LogId:1546849843259] [c.q.c.LoginController->doLogin:75] [http-nio-8080-exec-7] 执行登录验证,userVo:{"imgCode":"xxxx","pageNum":1,"pageSize":10,"phoneCode":"xxxxxx","phoneNum":"189xxxxxx63","realName":"xx","sysUserId":xxx,"urlCode":"cLxxxxH"}
2019-01-07 16:30:43,259 INFO [LogId:1546849843259] [c.q.c.LoginController->doLogin:75] [http-nio-8080-exec-5] 执行登录验证,userVo:{"imgCode":"xxxx","pageNum":1,"pageSize":10,"phoneCode":"xxxxxx","phoneNum":"189xxxxxx63","realName":"xx","sysUserId":xxx,"urlCode":"cLxxxxH"}
注意:本文归作者所有,未经作者允许,不得转载