
一、前言
在面試中,經(jīng)常會有一道經(jīng)典面試題,那就是:怎么防止接口重復(fù)提交?小編也是背過的,好幾種方式,但是一直沒有實(shí)戰(zhàn)過,做多了管理系統(tǒng),發(fā)現(xiàn)這個事情真的沒有過多的重視。最近在測試過程中,發(fā)現(xiàn)了多次提交會保存兩條數(shù)據(jù),進(jìn)而導(dǎo)致程序出現(xiàn)問題!
問題已經(jīng)出現(xiàn)我們就解決一下吧??!
本次解決是對于高并發(fā)不高的情況,適用于一般的管理系統(tǒng),給出的解決方案?。「卟l(fā)的還是建議加分布式鎖?。?/p>
下面我們來聊聊冪等性是什么?
二、什么是冪等性
接口冪等性就是用戶對于同一操作發(fā)起的一次請求或者多次請求的結(jié)果是一致的,不會因?yàn)槎啻吸c(diǎn)擊而產(chǎn)生了副作用;比如說經(jīng)典的支付場景:用戶購買了商品支付扣款成功,但是返回結(jié)果的時候網(wǎng)絡(luò)異常,此時錢已經(jīng)扣了,用戶再次點(diǎn)擊按鈕,此時會進(jìn)行第二次扣款,返回結(jié)果成功,用戶查詢余額返發(fā)現(xiàn)多扣錢了,流水記錄也變成了條,這就沒有保證接口的冪等性;可謂:商家美滋滋,買家罵咧咧!
防接口重復(fù)提交,這是必須要做的一件事情!
三、REST風(fēng)格與冪等性
以常用的四種來分析哈!
REST
| 是否支持冪等
| SQL例子
|
GET
| 是
| SELECT * FROM table WHER id = 1
|
PUT
| 是
| UPDATE table SET age=18 WHERE id = 1
|
DELETE
| 是
| DELETE FROM table WHERE id = 1
|
POST
| 否
| INSERT INTO table (id,age) VALUES(1,21)
|
所以我們要解決的就是POST請求!
四、解決思路
大概主流的解決方案:
- token機(jī)制(前端帶著在請求頭上帶著標(biāo)識,后端驗(yàn)證)
- 加鎖機(jī)制
- 數(shù)據(jù)庫悲觀鎖(鎖表)
- 數(shù)據(jù)庫樂觀鎖(version號進(jìn)行控制)
- 業(yè)務(wù)層分布式鎖(加分布式鎖redisson)
- 全局唯一索引機(jī)制
- redis的set機(jī)制
- 前端按鈕加限制
小編的解決方案就是redis的set機(jī)制!
同一個用戶,任何POST保存相關(guān)的接口,1s內(nèi)只能提交一次。
完全使用后端來進(jìn)行控制,前端可以加限制,不過體驗(yàn)不好!
后端通過自定義注解,在需要防冪等接口上添加注解,利用AOP切片,減少和業(yè)務(wù)的耦合!在切片中獲取用戶的token、user_id、url構(gòu)成redis的唯一key!
第一次請求會先判斷key是否存在,如果不存在,則往redis添加一個主鍵key,設(shè)置過期時間。
如果有異常會主動刪除key,萬一沒有刪除失敗,等待1s,redis也會自動刪除,時間誤差是可以接受的!
第二個請求過來,先判斷key是否存在,如果存在,則是重復(fù)提交,返回保存信息??!
五、實(shí)戰(zhàn)
SpringBoot版本為2.7.4
1. 導(dǎo)入依賴
<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>
2、編寫yml
server:
port: 8087
spring:
redis:
host: localhost
port: 6379
password: 123456
datasource:
#使用阿里的Druid
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/test?serverTimezone=UTC
username: root
password:
3、redis序列化
/**
* @author wangzhenjun
* @date 2022/11/17 15:20
*/
@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;
}
}
4、自定義注解
/**
* 自定義注解防止表單重復(fù)提交
* @author wangzhenjun
* @date 2022/11/17 15:18
*/
@Target(ElementType.METHOD) // 注解只能用于方法
@Retention(RetentionPolicy.RUNTIME) // 修飾注解的生命周期
@Documented
public @interface RepeatSubmit {
/**
* 防重復(fù)操作過期時間,默認(rèn)1s
*/
long expireTime() default 1;
}
5、編寫切片
異常信息大家換成自己想拋的異常,小編這里就沒有詳細(xì)劃分異常,就是為了寫博客而記錄的不完美項(xiàng)目哈??!
/**
* @author wangzhenjun
* @date 2022/11/16 8:54
*/
@Slf4j
@Component
@Aspect
public class RepeatSubmitAspect {
@Autowired
private RedisTemplate redisTemplate;
/**
* 定義切點(diǎn)
*/
@Pointcut("@annotation(com.example.demo.annotation.RepeatSubmit)")
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();
// 獲取防重復(fù)提交注解
RepeatSubmit annotation = method.getAnnotation(RepeatSubmit.class);
// 獲取token當(dāng)做key,小編這里是新后端項(xiàng)目獲取不到哈,先寫死
// String token = request.getHeader("Authorization");
String tokenKey = "hhhhhhh,nihao";
if (StringUtils.isBlank(token)) {
throw new RuntimeException("token不存在,請登錄!");
}
String url = request.getRequestURI();
/**
* 通過前綴 + url + token 來生成redis上的 key
* 可以在加上用戶id,小編這里沒辦法獲取,大家可以在項(xiàng)目中加上
*/
String redisKey = "repeat_submit_key:"
.concat(url)
.concat(tokenKey);
log.info("==========redisKey ====== {}",redisKey);
if (!redisTemplate.hasKey(redisKey)) {
redisTemplate.opsForValue().set(redisKey, redisKey, annotation.expireTime(), TimeUnit.SECONDS);
try {
//正常執(zhí)行方法并返回
return joinPoint.proceed();
} catch (Throwable throwable) {
redisTemplate.delete(redisKey);
throw new Throwable(throwable);
}
} else {
// 拋出異常
throw new Throwable("請勿重復(fù)提交");
}
}
}
6、統(tǒng)一返回值
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result<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 = "系統(tǒng)異常,請聯(lián)系管理員";
//沒有權(quán)限的響應(yīng)碼
public static final Integer NO_AUTH_COOD = 999;
//執(zhí)行成功
public static <T> Result<T> success(T data){
return new Result<>(SUCCESS_CODE,SUCCESS_MSG,data);
}
//執(zhí)行失敗
public static <T> Result failed(String msg){
msg = StringUtils.isEmpty(msg)? ERROR_MSG : msg;
return new Result(ERROR_CODE,msg,"");
}
//傳入錯誤碼的方法
public static <T> Result failed(int code,String msg){
msg = StringUtils.isEmpty(msg)? ERROR_MSG : msg;
return new Result(code,msg,"");
}
//傳入錯誤碼的數(shù)據(jù)
public static <T> Result failed(int code,String msg,T data){
msg = StringUtils.isEmpty(msg)? ERROR_MSG : msg;
return new Result(code,msg,data);
}
}
7、簡單的全局異常處理
這是殘缺版,大家不要模仿!!
/**
* @author wangzhenjun
* @date 2022/11/17 15:33
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(value = Throwable.class)
public Result handleException(Throwable throwable){
log.error("錯誤",throwable);
return Result.failed(500, throwable.getCause().getMessage());
}
}
8、controller測試
/**
* @author wangzhenjun
* @date 2022/10/26 16:51
*/
@RestController
@RequestMapping("/test")
public class TestController {
@Autowired
private SysLogService sysLogService;
// 默認(rèn)1s,方便測試查看,寫10s
@RepeatSubmit(expireTime = 10)
@PostMapping("/saveSysLog")
public Result saveSysLog(@RequestBody SysLog sysLog){
return Result.success(sysLogService.saveSyslog(sysLog));
}
}
9、service
/**
* @author wangzhenjun
* @date 2022/11/10 16:45
*/
@Service
public class SysLogServiceImpl implements SysLogService {
@Autowired
private SysLogMapper sysLogMapper;
@Override
public int saveSyslog(SysLog sysLog) {
return sysLogMapper.insert(sysLog);
}
}
六、測試
1、postman進(jìn)行測試
輸入請求:
http://localhost:8087/test/saveSysLog請求參數(shù):
{
"title":"你好",
"method":"post",
"operName":"我是測試冪等性的"
}
發(fā)送請求兩次:

2、查看數(shù)據(jù)庫
只會有一條保存成功!

3、查看redisKey
在10s會自動刪除,就可以在次提交!

4、控制臺

七、總結(jié)
這樣就解決了冪等性問題,再也不會有錯誤數(shù)據(jù)了,減少了一個bug提交!這是一個都要重視的問題,必須要解決,不然可能會出現(xiàn)問題。