SpringBoot接口+Redis解决用户重复提交问题

前言

1. 为什么会出现用户重复提交

  • 网络延迟的情况下用户多次点击submit按钮导致表单重复提交;
  • 用户提交表单后,点击【刷新】按钮导致表单重复提交(点击浏览器的刷新按钮,就是把浏览器上次做的事情再做一次,因为这样也会导致表单重复提交);
  • 用户提交表单后,点击浏览器的【后退】按钮回退到表单页面后进行再次提交。

2. 重复提交不拦截可能导致的问题

  • 重复数据入库,造成脏数据。即使数据库表有UK索引,该操作也会增加系统的不必要负担;
  • 会成为黑客爆破攻击的入口,大量的请求会导致应用崩溃;
  • 用户体验差,多条重复的数据还需要一条条的删除等。

3. 解决办法

办法有很多,我这里只说一种,利用Redis的set方法搞定(不是redisson)

项目代码

项目结构

配置文件

pom.xml

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.9</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.example</groupId>
    <artifactId>RequestLock</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>RequestLock</name>
    <description>Demo project for Spring Boot</description>
    <properties>
        <java.version>1.8</java.version>
    </properties>
    <dependencies>
        <!-- redis依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <!-- web依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- 切面 -->
        <dependency>
            <groupId>org.aspectj</groupId>
            <artifactId>aspectjweaver</artifactId>
            <version>1.9.5</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

application.properties

properties 复制代码
spring.application.name=RequestLock
server.port=8080

# Redis服务器地址
spring.redis.host=127.0.0.1
# Redis服务器连接端口
spring.redis.port=6379
# Redis服务器连接密码(默认为空)
spring.redis.password=
# 连接池最大连接数(使用负值表示没有限制)
spring.redis.jedis.pool.max-active=20
# 连接池最大阻塞等待时间(使用负值表示没有限制)
spring.redis.jedis.pool.max-wait=-1
# 连接池中的最大空闲连接
spring.redis.jedis.pool.max-idle=10
# 连接池中的最小空闲连接
spring.redis.jedis.pool.min-idle=0
# 连接超时时间(毫秒)
spring.redis.timeout=1000

代码文件

RequestLockApplication.java

java 复制代码
package com.example.requestlock;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class RequestLockApplication {

    public static void main(String[] args) {
        SpringApplication.run(RequestLockApplication.class, args);
    }

}

User.java

java 复制代码
package com.example.requestlock.model;


import com.example.requestlock.lock.annotation.RequestKeyParam;

public class User {
    
    private String name;

    private Integer age;

    @RequestKeyParam(name = "phone")
    private String phone;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Integer getAge() {
        return age;
    }

    public void setAge(Integer age) {
        this.age = age;
    }

    public String getPhone() {
        return phone;
    }

    public void setPhone(String phone) {
        this.phone = phone;
    }

    @Override
    public String toString() {
        return "User{" +
                "name='" + name + '\'' +
                ", age=" + age +
                ", phone='" + phone + '\'' +
                '}';
    }
}

RequestKeyParam.java

java 复制代码
package com.example.requestlock.lock.annotation;

import java.lang.annotation.*;

/**
 * @description 加上这个注解可以将参数也设置为key,唯一key来源
 */
@Target({ElementType.METHOD, ElementType.PARAMETER, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface RequestKeyParam {

    /**
     * key值名称
     *
     * @return 默认为空
     */
    String name() default "";
}

RequestLock.java

java 复制代码
package com.example.requestlock.lock.annotation;

import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;

/**
 * @description 请求防抖锁,用于防止前端重复提交导致的错误
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface RequestLock {
    /**
     * redis锁前缀
     *
     * @return 默认为空,但不可为空
     */
    String prefix() default "";

    /**
     * redis锁过期时间
     *
     * @return 默认2秒
     */
    int expire() default 2;

    /**
     * redis锁过期时间单位
     *
     * @return 默认单位为秒
     */
    TimeUnit timeUnit() default TimeUnit.SECONDS;

    /**
     * redis  key分隔符
     *
     * @return 分隔符
     */
    String delimiter() default ":";
}

RequestLockMethodAspect.java

java 复制代码
package com.example.requestlock.lock.aspect;

import com.example.requestlock.lock.annotation.RequestLock;
import com.example.requestlock.lock.keygenerator.RequestKeyGenerator;
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.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisStringCommands;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.types.Expiration;
import org.springframework.util.StringUtils;

import java.lang.reflect.Method;

/**
 * @description 请求锁切面处理器
 */
@Aspect
@Configuration
public class RequestLockMethodAspect {

    private final StringRedisTemplate stringRedisTemplate;
    private final RequestKeyGenerator requestKeyGenerator;


    @Autowired
    public RequestLockMethodAspect(StringRedisTemplate stringRedisTemplate, RequestKeyGenerator requestKeyGenerator) {
        this.requestKeyGenerator = requestKeyGenerator;
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Around("execution(public * * (..)) && @annotation(com.example.requestlock.lock.annotation.RequestLock)")
    public Object interceptor(ProceedingJoinPoint joinPoint) {
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        Method method = methodSignature.getMethod();
        RequestLock requestLock = method.getAnnotation(RequestLock.class);
        if (StringUtils.isEmpty(requestLock.prefix())) {
//            throw new RuntimeException("重复提交前缀不能为空");
            return "重复提交前缀不能为空";
        }
        //获取自定义key
        final String lockKey = requestKeyGenerator.getLockKey(joinPoint);
        final Boolean success = stringRedisTemplate.execute(
                (RedisCallback<Boolean>) connection -> connection.set(lockKey.getBytes(), new byte[0], Expiration.from(requestLock.expire(), requestLock.timeUnit())
                        , RedisStringCommands.SetOption.SET_IF_ABSENT));
        if (!success) {
//            throw new RuntimeException("您的操作太快了,请稍后重试");
            return "您的操作太快了,请稍后重试";
        }
        try {
            return joinPoint.proceed();
        } catch (Throwable throwable) {
//            throw new RuntimeException("系统异常");
            return "系统异常";
        }
    }
}

RequestKeyGenerator.java

java 复制代码
package com.example.requestlock.lock.keygenerator;

import org.aspectj.lang.ProceedingJoinPoint;

/**
 * 加锁key生成器
 */
public interface RequestKeyGenerator {
    /**
     * 获取AOP参数,生成指定缓存Key
     *
     * @param joinPoint 切入点
     * @return 返回key值
     */
    String getLockKey(ProceedingJoinPoint joinPoint);
}

RequestKeyGeneratorImpl.java

java 复制代码
package com.example.requestlock.lock.keygenerator.impl;

import com.example.requestlock.lock.annotation.RequestKeyParam;
import com.example.requestlock.lock.annotation.RequestLock;
import com.example.requestlock.lock.keygenerator.RequestKeyGenerator;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Service;
import org.springframework.util.ReflectionUtils;
import org.springframework.util.StringUtils;

import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;

@Service
public class RequestKeyGeneratorImpl implements RequestKeyGenerator {

    @Override
    public String getLockKey(ProceedingJoinPoint joinPoint) {
        //获取连接点的方法签名对象
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        //Method对象
        Method method = methodSignature.getMethod();
        //获取Method对象上的注解对象
        RequestLock requestLock = method.getAnnotation(RequestLock.class);
        //获取方法参数
        final Object[] args = joinPoint.getArgs();
        //获取Method对象上所有的注解
        final Parameter[] parameters = method.getParameters();
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < parameters.length; i++) {
            final RequestKeyParam cacheParams = parameters[i].getAnnotation(RequestKeyParam.class);
            //如果属性不是CacheParam注解,则不处理
            if (cacheParams == null) {
                continue;
            }
            //如果属性是CacheParam注解,则拼接 连接符(:)+ CacheParam
            sb.append(requestLock.delimiter()).append(args[i]);
        }
        //如果方法上没有加CacheParam注解
        if (StringUtils.isEmpty(sb.toString())) {
            //获取方法上的多个注解(为什么是两层数组:因为第二层数组是只有一个元素的数组)
            final Annotation[][] parameterAnnotations = method.getParameterAnnotations();
            //循环注解
            for (int i = 0; i < parameterAnnotations.length; i++) {
                final Object object = args[i];
                //获取注解类中所有的属性字段
                final Field[] fields = object.getClass().getDeclaredFields();
                for (Field field : fields) {
                    //判断字段上是否有CacheParam注解
                    final RequestKeyParam annotation = field.getAnnotation(RequestKeyParam.class);
                    //如果没有,跳过
                    if (annotation == null) {
                        continue;
                    }
                    //如果有,设置Accessible为true(为true时可以使用反射访问私有变量,否则不能访问私有变量)
                    field.setAccessible(true);
                    //如果属性是CacheParam注解,则拼接 连接符(:)+ CacheParam
                    sb.append(requestLock.delimiter()).append(ReflectionUtils.getField(field, object));
                }
            }
        }
        //返回指定前缀的key
        return requestLock.prefix() + sb;
    }
}

UserController.java

java 复制代码
package com.example.requestlock.controller;

import com.example.requestlock.lock.annotation.RequestLock;
import com.example.requestlock.model.User;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/user")
public class UserController {

    @PostMapping("/addUser1")
    public String addUser1(@RequestBody User user) {
        System.out.println("不做任何处理" + user);
        return "添加成功";
    }

    @PostMapping("/addUser2")
    @RequestLock(prefix = "addUser")
    public String addUser2(@RequestBody User user) {
        System.out.println("防重提交" + user);
        return "添加成功";
    }
}

原理解释

该RequestLock(请求锁)利用了Redis的单线程处理以及Key值过期特点,核心通过RequestLock、RequestKeyParam注解生成一个唯一的key值,存入redis后设置一个过期时间(1-3秒),当第二次请求的时候,判断生成的key值是否在Redis中存在,如果存在则认为第二次提交是重复的。

流程图如下:

用法说明

1. 在controller的方法上增加@RequestLock注解,并给一个前缀

java 复制代码
 @PostMapping("/addUser2")
 @RequestLock(prefix = "addUser")
 public String addUser2(@RequestBody User user)

加了@RequestLock注解代表这个方法会进行重复提交校验,没有加则不会进行校验。通过注解的方式可以使用法变得灵活。

2. @RequestKeyParam注解用在对象的属性上

java 复制代码
@RequestKeyParam(name = "phone")
private String phone;

在对象的属性上加@RequestKeyParam注解后,Redis的key则由 @RequestLock定义的prefix加上字段的值组成,比如当传入传入phone是123456789,那么当前的key值则为: addUser:123456789

效果展示

调用addUser1接口

这里无论点击多少次提交,都会展示添加"添加成功",这样是不行的。

调用addUser2接口

第一次提交,"添加成功"。 快速点击第二次提交,就会出现"您的操作太快了,请稍后重试"提示。

相关推荐
小灰灰__13 分钟前
IDEA加载通义灵码插件及使用指南
java·ide·intellij-idea
夜雨翦春韭17 分钟前
Java中的动态代理
java·开发语言·aop·动态代理
程序媛小果37 分钟前
基于java+SpringBoot+Vue的宠物咖啡馆平台设计与实现
java·vue.js·spring boot
掘金-我是哪吒42 分钟前
微服务mysql,redis,elasticsearch, kibana,cassandra,mongodb, kafka
redis·mysql·mongodb·elasticsearch·微服务
追风林43 分钟前
mac m1 docker本地部署canal 监听mysql的binglog日志
java·docker·mac
芒果披萨1 小时前
El表达式和JSTL
java·el
duration~2 小时前
Maven随笔
java·maven
zmgst2 小时前
canal1.1.7使用canal-adapter进行mysql同步数据
java·数据库·mysql
跃ZHD2 小时前
前后端分离,Jackson,Long精度丢失
java
blammmp2 小时前
Java:数据结构-枚举
java·开发语言·数据结构