最近项目上遇到重复提交的情况,虽然前端对按钮进行了禁用,但是不知道是什么原因,后端仍然接收到了多个请求,因为是分布式系统,所以不能简单的使用lock
,最终考虑决定使用redis实现。
一、环境准备
- MySql:测试数据库
- Redis:使用Redis实现
- Another Redis Desktop Manager:跟踪Redis信息
- ApiFox:模拟请求,单线程循环及多线程循环
- Spring Boot:2.7.4
二、准备测试数据及接口
2.1、创建表
创建一个最简单的用户表,只包含id
、name
两列
create table User
(
id int null,
name varchar(200) null
);
2.2、创建接口
2.2.1、配置依赖及数据库、Redis连接信息
项目依赖
<?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.4</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>net.xiangcaowuyu</groupId>
<artifactId>RepeatSubmit</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>RepeatSubmit</name>
<description>RepeatSubmit</description>
<properties>
<java.version>8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Druid -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.16</version>
</dependency>
<!--jdbc-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- mysql -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- mybatis-plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
yaml文件配置数据库及Redis连接信息
spring:
redis:
host: 192.168.236.2
port: 6379
password:
datasource:
#使用阿里的Druid
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://192.168.236.2/TestRepeatSubmit?serverTimezone=UTC
username: root
password: root
2.2.2、创建实体
@Data
@TableName("User")
public class User {
private Long id;
private String name;
}
2.2.3、创建数据访问层
public interface UserMapper extends BaseMapper<User> {
}
2.2.4、创建异常处理类
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ResultRet<T> {
private Integer code;
private String msg;
private T data;
//成功码
public static final Integer SUCCESS_CODE = 200;
//成功消息
public static final String SUCCESS_MSG = "SUCCESS";
//失败
public static final Integer ERROR_CODE = 201;
public static final String ERROR_MSG = "系统异常,请联系管理员";
//没有权限的响应码
public static final Integer NO_AUTH_COOD = 999;
//执行成功
public static <T> ResultRet<T> success(T data){
return new ResultRet<>(SUCCESS_CODE,SUCCESS_MSG,data);
}
//执行失败
public static <T> ResultRet failed(String msg){
msg = StringUtils.isEmpty(msg)? ERROR_MSG : msg;
return new ResultRet(ERROR_CODE,msg,"");
}
//传入错误码的方法
public static <T> ResultRet failed(int code,String msg){
msg = StringUtils.isEmpty(msg)? ERROR_MSG : msg;
return new ResultRet(code,msg,"");
}
//传入错误码的数据
public static <T> ResultRet failed(int code,String msg,T data){
msg = StringUtils.isEmpty(msg)? ERROR_MSG : msg;
return new ResultRet(code,msg,data);
}
}
2.2.5、简单的全局异常
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(value = Throwable.class)
public ResultRet handleException(Throwable throwable){
log.error("错误",throwable);
return ResultRet.failed(500, throwable.getCause().getMessage());
}
}
2.2.6、配置模拟接口
模拟一个get
请求的接口,用户新增用户,orm框架使用mybatis-plus,使用最简单的插入
@RestController
@RequestMapping("/user")
public class UserController {
@Resource
private UserMapper userMapper;
@GetMapping("/add")
public ResultRet<User> add() {
User user = new User();
user.setId(1L);
user.setName("张三");
userMapper.insert(user);
return ResultRet.success(user);
}
}
以上配置完成后,当我们访问/user/add
接口时,肯定访问几次,数据库就会重复插入多少信息。
三、改造接口,防止重复提交
改造的原理起始很简单,我们前端访问接口时,首先在头部都会携带token信息,我们通过切面,拦截请求,获取到token及请求的url,拼接后作为redis的key值,通过redis锁的方式写入key值,如果写入成功,设置一个过期时间,在有效期时间内,多次请求,先判断redis中是否有对应的key,如果有,抛出异常,禁止再次写入。
3.1、配置RedisTemplate
@Configuration
public class RedisConfig {
@Bean
@SuppressWarnings(value = { "unchecked", "rawtypes" })
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory)
{
RedisTemplate<Object, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
Jackson2JsonRedisSerializer serializer = new Jackson2JsonRedisSerializer(Object.class);
// 使用StringRedisSerializer来序列化和反序列化redis的key值
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(serializer);
// Hash的key也采用StringRedisSerializer的序列化方式
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(serializer);
template.afterPropertiesSet();
return template;
}
}
3.2、增加Redis工具类
@Component
@Slf4j
public class RedisUtils {
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* Redis分布式锁
*
* @return 加锁成功返回true,否则返回false
*/
public boolean tryLock(String key, String value, long timeout) {
Boolean isSuccess = stringRedisTemplate.opsForValue().setIfAbsent(key, value);
//设置过期时间,防止死锁
if (Boolean.TRUE.equals(isSuccess)) {
stringRedisTemplate.expire(key, timeout, TimeUnit.SECONDS);
}
return Boolean.TRUE.equals(isSuccess);
}
/**
* Redis 分布式锁释放
*
* @param key
* @param value
*/
public void unLock(String key, String value) {
try {
String currentValue = stringRedisTemplate.opsForValue().get(key);
if (StringUtils.isNotEmpty(currentValue) && StringUtils.equals(currentValue, value)) {
stringRedisTemplate.opsForValue().getOperations().delete(key);
}
} catch (Exception e) {
//这个是我的自定义异常,你可以删了
log.info("报错了");
}
}
}
3.3、添加注解
@Target(ElementType.METHOD) // 注解只能用于方法
@Retention(RetentionPolicy.RUNTIME) // 修饰注解的生命周期
@Documented
public @interface RepeatSubmitAnnotation {
/**
* 防重复操作过期时间,默认1s
*/
long expireTime() default 1;
}
3.4、添加切面
@Slf4j
@Component
@Aspect
public class RepeatSubmitAspect {
@Resource
private RedisUtils redisUtils;
/**
* 定义切点
*/
@Pointcut("@annotation(net.xiangcaowuyu.repeatsubmit.annotation.RepeatSubmitAnnotation)")
public void repeatSubmit() {
}
@Around("repeatSubmit()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder
.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
// 获取防重复提交注解
RepeatSubmitAnnotation annotation = method.getAnnotation(RepeatSubmitAnnotation.class);
// 获取token当做key,小编这里是新后端项目获取不到哈,先写死
String token = request.getHeader("token");
if (StringUtils.isBlank(token)) {
throw new RuntimeException("token不存在,请登录!");
}
String url = request.getRequestURI();
/**
* 通过前缀 + url + token 来生成redis上的 key
* 可以在加上用户id,小编这里没办法获取,大家可以在项目中加上
*/
String redisKey = "repeat_submit_key:"
.concat(url)
.concat(token);
log.info("==========redisKey ====== {}", redisKey);
boolean lock = redisUtils.tryLock(redisKey, redisKey, annotation.expireTime());
if (lock) {
log.info("获取分布式锁成功");
try {
//正常执行方法并返回
return joinPoint.proceed();
} catch (Throwable throwable) {
throw new Throwable(throwable);
} finally {
//释放锁
// redisUtils.unLock(redisKey, redisKey);
// System.out.println("释放分布式锁成功");
}
} else {
// 抛出异常
throw new Throwable("请勿重复提交");
}
}
}
3.5、接口添加注解
这里为了方便演示,我们把提交间隔时间设置为30s
。
@RestController
@RequestMapping("/user")
public class UserController {
@Resource
private UserMapper userMapper;
@GetMapping("/add")
@RepeatSubmitAnnotation(expireTime = 30L)
public ResultRet<User> add() {
User user = new User();
user.setId(1L);
user.setName("张三");
userMapper.insert(user);
return ResultRet.success(user);
}
}
至此,我们所有的配置都完成了,接下来使用ApiFox
模拟一下接口访问。
3.6、模拟测试
我们先把数据库及Redis清空(本来其实就是空的)
配置好自动化测试接口
3.6.1、单线程测试
先模拟单线程操作,循环50次
查看Redis,查看有一个key
打开数据库,可以看到只成功插入了一条
3.6.3、模拟多线程
先把数据库清空,Redis等待过期后自动删除
再次模拟,10个线程,循环10次
此时查看数据库,仍然只有一条插入成功了
很nice