springboot实现后端防重复提交(AOP+redis分布式锁)单机情况下


为什么要实现这个功能呢,可能用户在提交一份数据后,可能因为网络的原因、处理数据的速度慢等原因导致页面没有及时将用户刚提交数据的后台处理结果展示给用户,这时用户可能会进行如下操作:

  1. 点击提交按钮两次,导致重复提交表单。
  2. 使用浏览器后退按钮重复之前的操作,导致重复提交表单
    数据库中会存在大量重复的信息。

怎么实现呢:进入正题?

0、依赖

		<dependency>
            <groupId>org.aspectj</groupId>
            <artifactId>aspectjweaver</artifactId>
            <version>1.6.8</version>
        </dependency>
        <dependency>
            <groupId>org.apache.tomcat.embed</groupId>
            <artifactId>tomcat-embed-core</artifactId>
            <version>9.0.21</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.data</groupId>
            <artifactId>spring-data-redis</artifactId>
            <version>2.6.3</version>
        </dependency>

1、自定义接口

/**
 * 自定义注解防止表单重复提交
 * @author Yuan Haozhe
 */
@Inherited
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RepeatSubmit {
	 /**
     * 防重复操作过期时间,默认1s
     */
    long expireTime() default 1;
}

2、实现redis分布式锁

@Component
public class RedisLock {
	@Autowired
    private StringRedisTemplate redisTemplate;
	
	 /**
     * 该加锁方法仅针对单实例 Redis 可实现分布式加锁
     * 对于 Redis 集群则无法使用
     *
     * 支持重复,线程安全
     *
     * @param lockKey   加锁键
     * @param clientId  加锁客户端唯一标识(采用UUID)
     * @param seconds   锁过期时间
     * @return
     */
    public boolean tryLock(String lockKey, String clientId, long seconds) {
        //
        return redisTemplate.opsForValue().setIfAbsent(lockKey, clientId, seconds, TimeUnit.SECONDS);
    }


}

对参数进行介绍:
第一个为key,我们使用key来当锁,因为key是唯一的。我们传的是lockKey
第二个为value,通过给value赋值为clientId,我们就知道这把锁是哪个请求加的了,在解锁的时候就可以有依据。clientId可以使用UUID.randomUUID().toString()方法生成。
第三个为time,代表key的过期时间
第四个为time的单位
setIfAbsent 相当于NX,设置过期时间相当于EX

也可用Jedis实现:

  1. 添加依赖:
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>2.9.0</version>
</dependency>
  1. 加锁代码
	private static final String LOCK_SUCCESS = "OK";
    private static final String SET_IF_NOT_EXIST = "NX";
    private static final String SET_WITH_EXPIRE_TIME = "PX";
    private static final String RELEASE_LOCK_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";

    @Autowired
    private Jedis jedis;
    
	public boolean tryLock(String lockKey, String clientId, long seconds) {
        String result = jedis.set(lockKey, clientId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, seconds);
        if (LOCK_SUCCESS.equals(result)) {
            return true;
        }
        return false;
    }
    /**
     * 与 tryLock 相对应,用作释放锁
     *
     * @param lockKey
     * @param clientId
     * @return
     */
    public boolean releaseLock(String lockKey, String clientId) {
    	//这里使用Lua脚本的方式,尽量保证原子性。
       return jedis.eval(RELEASE_LOCK_SCRIPT,Collections.singletonList(lockKey),Collections.singletonList(clientId)).equals(1L);
        
    }

对参数进行介绍:
第一个为key,我们使用key来当锁,因为key是唯一的。我们传的是lockKey
第二个为value,通过给value赋值为clientId,我们就知道这把锁是哪个请求加的了,在解锁的时候就可以有依据。clientId可以使用UUID.randomUUID().toString()方法生成。
第三个为nxxx,这个参数我们填的是NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作;
第四个为expx,这个参数我们传的是PX,意思是我们要给这个key加一个过期的设置,具体时间由第五个参数决定。
第五个为time,与第四个参数相呼应,代表key的过期时间。

可以看到,我们的加锁就一行代码,保证了原子性,总的来说,执行上面的set()方法就只会导致两种结果:1. 当前没有锁(key不存在),那么就进行加锁操作,并对锁设置个有效期,同时value表示加锁的客户端。2. 已有锁存在,不做任何操作。

3、统一返回值ReturnT

public class ReturnT<T> implements Serializable {
	public static final long serialVersionUID = 42L;

	public static final int SUCCESS_CODE = 200;
	public static final int FAIL_CODE = 500;
	public static final ReturnT<String> SUCCESS = new ReturnT<String>(null);
	public static final ReturnT<String> FAIL = new ReturnT<String>(FAIL_CODE, null);
	
	private int code;
	private String msg;
	private T data;
	
	public ReturnT(int code, String msg) {
		this.code = code;
		this.msg = msg;
	}
	public ReturnT(T data) {
		this.code = SUCCESS_CODE;
		this.data = data;
	}
	
	public int getCode() {
		return code;
	}
	public void setCode(int code) {
		this.code = code;
	}
	public String getMsg() {
		return msg;
	}
	public void setMsg(String msg) {
		this.msg = msg;
	}
	public T getData() {
		return data;
	}
	public void setData(T data) {
		this.data = data;
	}

}

4、CookieUtil

public class CookieUtil {

/**
	 * 查询value
	 *
	 * @param request
	 * @param key
	 * @return
	 */
	public static String getValue(HttpServletRequest request, String key) {
		Cookie cookie = get(request, key);
		if (cookie != null) {
			return cookie.getValue();
		}
		return null;
	}


}

5、自定义AOP

@Component
@Aspect
@Slf4j
public class NoRepeatSubmitAspect {
    @Pointcut("@annotation(com.xxl.sso.base.annotation.RepeatSubmit)")
    public void repeatSubmit(){}

    @Autowired
    private HttpServletRequest request;

    @Autowired
    private RedisLock redisLock;


    @Around("repeatSubmit()")
    public ReturnT around(ProceedingJoinPoint joinPoint) {
        log.info("校验重复提交");
        //用户的身份标识,这里用cookie只是方便做测试
        String userId = CookieUtil.getValue(request, Conf.SSO_SESSIONID).split("_")[0];
        String key = getKey(userId, request.getServletPath());
        String clientId = getClientId();
        
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        // 获取防重复提交注解
        RepeatSubmit annotation = method.getAnnotation(RepeatSubmit.class);
        
        boolean isSuccess = redisLock.tryLock(key, clientId, annotation.expireTime());
        // 如果缓存中有这个url视为重复提交
        if (isSuccess) {
            Object result = null;
            try {
                result = joinPoint.proceed();

            } catch (Throwable e) {
                log.error(e.getMessage());
            }
            //finall{
            //redisLock.releaseLock(key, clientId)
            //}
            return ReturnT.SUCCESS;
        } else {
            log.error("重复提交");
            return ReturnT.FAIL;
        }
    }

    private String getKey(String token, String path) {
        return token + path;
    }

    private String getClientId() {
        return UUID.randomUUID().toString();
    }
}

6、测试

 //测试重复提交
    @GetMapping("/test-get")
    //添加RepeatSubmit注解,默认1s,方便测试查看,写3s
    @RepeatSubmit(expireTime = 3)
    public ReturnT repeatTest(){
        return ReturnT.SUCCESS;
    }

结果:
在这里插入图片描述