学无先后,达者为师

网站首页 编程语言 正文

用户手抖,连续点了两次?优雅解决表单重复提交

作者:earthly exile丶 更新时间: 2022-07-12 编程语言

在项目中,用户在操作某些重要操作时,经常会出现手抖,导致重复提交,该怎么处理呢?

         我的答案是交给前端,用户提交的时候禁用按钮,等接口返回后再启用。

         哈哈,开个玩笑,今天给大家示范一下后端如何处理。

 首先通常我们为何要避免表单重复提交?这其实跟接口幂等有关,既然说到这里,那么我们就来回顾一下什么是接口幂等吧!

1. 接口幂等的定义:

        一个接口多次调用而没有副作用,那么我们称之为接口幂等。所谓没有副作用简单来讲就是一个接口被执行多次数据不会乱。每次返回的结果都一致。

  2.哪些地方天然幂等,那么地方可能导致接口不幂等?

           基本业务操作中,查询和删除,天然幂等。更新和插入是接口不幂等的高发地。

  3.业务中重点触发场景

  1. 用户重复操作:例如我们的系统中,用户开方后点击确认,手抖触发了两次,发了两次请求给后台。
  2. 代码重试:很多RPC框架在进行远程调用时都有重试机制,当第一次请求发出后在指定时间内没有收到回复,会再发一次。例如dubbo,默认2次。
  3. 消息重复消费:多次触发消费者消费逻辑。
  4. 网络波动:同一个请求服务端收到多次。

4.常用解决方案

  1. Token+Redis:每次接口先获取token,后台将生成的token放入redis,请求时在header中加上token,收到请求后验证redis中是否存在改token,通过后删除token。-------保证网络波动导致的重复请求。(最常用的解决方案)。
  2. 唯一索引去重:保证最终插入数据库只有一天数据。
  3. 状态机:解决状态update问题,多线程下,多用户对同一订单处理问题。
  4. 乐观锁:(虽然比悲观锁效率好点,但任然用的不多)
  5. 悲观锁:for update (效率问题,不推荐,除非安全等级要求极高的地方)
  6. 先查询后判断+版本控制
  7. 建去重表
  8. 前端拦截
  9. JVM锁:仅单机状态
  10. 分布式锁

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中添加注解即可实现动态拦截。

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

        

        

原文链接:https://blog.csdn.net/weixin_47987440/article/details/125695249

栏目分类
最近更新