前言
1. 为什么会出现用户重复提交
网络延迟的情况下用户多次点击submit按钮导致表单重复提交;用户提交表单后,点击【刷新】按钮导致表单重复提交(点击浏览器的刷新按钮,就是把浏览器上次做的事情再做一次,因为这样也会导致表单重复提交);用户提交表单后,点击浏览器的【后退】按钮回退到表单页面后进行再次提交。2. 重复提交不拦截可能导致的问题
重复数据入库,造成脏数据。即使数据库表有UK索引,该操作也会增加系统的不必要负担;会成为黑客爆破攻击的入口,大量的请求会导致应用崩溃;用户体验差,多条重复的数据还需要一条条的删除等。3. 解决办法
办法有很多,我这里只说一种,利用Redis的set方法搞定(不是redisson)
项目代码
项目结构
配置文件
pom.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
spring.application.name=RequestLockserver.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
package com.example.requestlock;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;@SpringBootApplicationpublic class RequestLockApplication { public static void main(String[] args) { SpringApplication.run(RequestLockApplication.class, args); }}
User.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
package com.example.requestlock.lock.annotation;import java.lang.annotation.*;/** * @description 加上这个注解可以将参数也设置为key,唯一key来源 */@Target({ElementType.METHOD, ElementType.PARAMETER, ElementType.FIELD})@Retention(RetentionPolicy.RUNTIME)@Documented@Inheritedpublic @interface RequestKeyParam { /** * key值名称 * * @return 默认为空 */ String name() default "";}
RequestLock.java
package com.example.requestlock.lock.annotation;import java.lang.annotation.*;import java.util.concurrent.TimeUnit;/** * @description 请求防抖锁,用于防止前端重复提交导致的错误 */@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)@Documented@Inheritedpublic @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
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@Configurationpublic 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
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
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;@Servicepublic 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
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 "添加成功"; }}
效果展示
调用addUser1接口
这里无论点击多少次提交,都会展示添加“添加成功”,这样是不行的。
调用addUser2接口
第一次提交,“添加成功”。
快速点击第二次提交,就会出现“您的操作太快了,请稍后重试”提示。
原理解释
该RequestLock(请求锁)利用了Redis的单线程处理以及Key值过期特点,核心通过RequestLock、RequestKeyParam注解生成一个唯一的key值,存入redis后设置一个过期时间(1-3秒),当第二次请求的时候,判断生成的key值是否在Redis中存在,如果存在则认为第二次提交是重复的。