SpringBoot/Web項目防止表單/請求重複提交(單體和分佈式)
一、場景/方案
說起web項目的防止表單/請求重複提交,不得不說冪等性。
冪等性, 通俗的說就是一個接口, 多次發起同一個請求, 必須保證操作只能執行一次。
1.1、常見場景:
• 訂單接口, 不能多次創建訂單
• 支付接口, 重複支付同一筆訂單隻能扣一次錢
• 支付寶回調接口, 可能會多次回調, 必須處理重複回調
• 普通表單提交接口, 因爲網絡超時,卡頓等原因多次點擊提交, 只能成功一次等等
1.2、常見方案
解決思路:
- 從數據庫方面考慮,數據設計的時候,如果有唯一性,考慮建立唯一索引。
- 從應用層面考慮,首先判斷是單機服務還是分佈式服務?
- 單機服務:考慮一些緩存Cacha,利用緩存,來保證數據的重複提交。
- 分佈式服務,考慮將用戶的信息,例如token和請求的url進行組裝在一起形成令牌,存儲到緩存中,例如redis,並設置超時時間爲xx秒,如此來保證數據的唯一性。(利用了redis的分佈式鎖)
解決方案大致總結爲:
- 唯一索引 -- 防止新增髒數據
- token機制 -- 防止頁面重複提交,實現接口冪等性校驗
- 分佈式鎖 -- redis(jedis、redisson)或zookeeper實現
- 悲觀鎖 -- 獲取數據的時候加鎖(鎖表或鎖行)
- 樂觀鎖 -- 基於版本號version實現, 在更新數據那一刻校驗數據
- 狀態機 -- 狀態變更, 更新數據時判斷狀態
前三種方式最爲常見,本文則從應用層面考慮,給出單機服務還是分佈式服務下的解決方案。
二、單體服務項目:
比如你的項目是一個單獨springboot項目,SSM項目,或者其他的單體服務,就是打個jar或者war直接扔服務器上跑的。
採用【AOP解析自定義註解+google的Cache緩存機制】來解決表單/請求重複的提交問題。
思路:
- 建立自定義註解 @NoRepeatSubmit 標記所有Controller中的提交請求。
- 通過AOP機制對所有標記了@NoRepeatSubmit 的方法攔截。
- 在業務方法執行前,使用google的緩存Cache技術,來保證數據的重複提交。
- 業務方法執行後,釋放緩存。
好了,接下里就是新建一個springboot項目,然後開整了。
2.1 pom.xml新增依賴
需要新增一個google.common.cache.Cache;
源碼如下:
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>24.0-jre</version>
</dependency>
2.2 新建NoRepeatSubmit.java自定義註解類
一個自定義註解接口類,是interface類喲 ,裏面什麼都不寫,爲了就是個重構。
源碼如下:
package com.gitee.taven.aop;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* @title: NoRepeatSubmit
* @Description: 自定義註解,用於標記Controller中的提交請求
* @Author: ZouTao
* @Date: 2020/4/14
*/
@Target(ElementType.METHOD) // 作用到方法上
@Retention(RetentionPolicy.RUNTIME) // 運行時有效
public @interface NoRepeatSubmit {
}
2.3 新建NoRepeatSubmitAop.java
這是個AOP的解析註解類,使用到了Cache緩存機制。
以cache.getIfPresent(key)的url值來進行if判斷,如果不爲空,證明已經發過請求,那麼在規定時間內的再次請求,視爲無效,爲重複請求。如果爲空,則正常響應請求。
源碼如下:
package com.gitee.taven.aop;
import javax.servlet.http.HttpServletRequest;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import com.google.common.cache.Cache;
/**
* @Description: aop解析註解-配合google的Cache緩存機制
* @Author: Zoutao
* @Date: 2020/4/14
*/
@Aspect
@Component
public class NoRepeatSubmitAop {
private Log logger = LogFactory.getLog(getClass());
@Autowired
private Cache<String, Integer> cache;
@Pointcut("@annotation(noRepeatSubmit)")
public void pointCut(NoRepeatSubmit noRepeatSubmit) {
}
@Around("pointCut(noRepeatSubmit)")
public Object arround(ProceedingJoinPoint pjp, NoRepeatSubmit noRepeatSubmit) {
try {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
String sessionId = RequestContextHolder.getRequestAttributes().getSessionId();
HttpServletRequest request = attributes.getRequest();
String key = sessionId + "-" + request.getServletPath();
if (cache.getIfPresent(key) == null) {// 如果緩存中有這個url視爲重複提交
Object o = pjp.proceed();
cache.put(key, 0);
return o;
} else {
logger.error("重複請求,請稍後在試試。");
return null;
}
} catch (Throwable e) {
e.printStackTrace();
logger.error("驗證重複提交時出現未知異常!");
return "{\"code\":-889,\"message\":\"驗證重複提交時出現未知異常!\"}";
}
}
}
2.4 新建緩存類UrlCache.java
用來獲取緩存和設置有效期,目前設置有效期爲2秒。
源碼如下:
package com.gitee.taven;
import java.util.concurrent.TimeUnit;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
/**
* @Description: 內存緩存配置類
* @Author: Zoutao
* @Date: 2020/4/14
*/
@Configuration
public class UrlCache {
@Bean
public Cache<String, Integer> getCache() {
return CacheBuilder.newBuilder().expireAfterWrite(2L, TimeUnit.SECONDS).build();// 緩存有效期爲2秒
}
}
2.5 新建CacheTestController.java
一個請求控制類,用來模擬響應請求和業務處理。
源碼如下:
package com.gitee.taven.controller;
import com.gitee.taven.ApiResult;
import com.gitee.taven.aop.NoRepeatSubmit;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @Description: 測試Cache方式的Controller
* @Author: Zoutao
* @Date: 2020/4/14
*/
@RestController
public class CacheTestController {
private Object data;
@RequestMapping("/TestSubmit")
@NoRepeatSubmit()
public Object test() {
data = "程序邏輯返回,假設是一大堆DB來的數據。。。";
return new ApiResult(200, "請求成功",data);
// 也可以直接返回。return (",請求成功,程序邏輯返回");
}
}
ps:這裏可以在建立一個ApiResult.java類,來規範返回的數據格式體:
ApiResult.java(非必須)
源碼如下:
package com.gitee.taven;
/**
* @title: ApiResult
* @Description: 統一規範結果格式
* @Param: code, message, data
* @return: ApiResult
* @Author: ZouTao
* @Date: 2020/4/14
*/
public class ApiResult {
private Integer code; //狀態碼
private String message; //提示信息
private Object data; //具體數據
public ApiResult(Integer code, String message, Object data) {
this.code = code;
this.message = message;
this.data = data;
}
public Integer getCode() {
return code;
}
public void setCode(Integer code) {
this.code = code;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message == null ? null : message.trim();
}
public Object getData() {
return data;
}
public void setData(Object data) {
this.data = data;
}
@Override
public String toString() {
return "ApiResult{" +
"code=" + code +
", message='" + message + '\'' +
", data=" + data +
'}';
}
}
純粹爲了規範而加的,你可以不用。
2.6 啓動項目
運行springboot項目,啓動成功後,在瀏覽器輸入:http://localhost:8080/TestSubmit
然後F5刷新(模擬重複發起請求),查看效果:
可以看到只有一次請求被成功響應,返回了數據,在有效時間內,其他請求被判定爲重複提交,不予執行。
三、分佈式服務項目
如果你的spirngboot項目,後面要放到分佈式集羣中去使用,那麼這個單體的Cache機制怕是會出問題,所以,爲了解決項目在集羣部署時請求可能會落到多臺機器上的問題,我們把內存緩存換成了redis。
利用token機制+redis的分佈式鎖(jedis)來防止表單/請求重複提交。
思路如下:
- 自定義註解 @NoRepeatSubmit 標記所有Controller中的提交請求。
- 通過AOP 對所有標記了 @NoRepeatSubmit 的方法攔截。
- 在業務方法執行前,獲取當前用戶的 token(或JSessionId)+ 當前請求地址,形成一個唯一Key,然後去獲取 Redis 分佈式鎖(如果此時併發獲取,只有一個線程會成功獲取鎖)。
- 最後業務方法執行完畢,釋放鎖。
3.1 Application配置redis
打開application.properties或application.yml配置redis:
內容如下:
server.port=8080
# Redis數據庫索引(默認爲0)
spring.redis.database=0
# Redis服務器地址
spring.redis.host=localhost
# Redis服務器連接端口
spring.redis.port=6379
# Redis服務器連接密碼(默認爲空)
#spring.redis.password=yourpwd
# 連接池最大連接數(使用負值表示沒有限制)
spring.redis.jedis.pool.max-active=8
# 連接池最大阻塞等待時間
spring.redis.jedis.pool.max-wait=-1ms
# 連接池中的最大空閒連接
spring.redis.jedis.pool.max-idle=8
# 連接池中的最小空閒連接
spring.redis.jedis.pool.min-idle=0
# 連接超時時間(毫秒)
spring.redis.timeout=5000ms
3.2 pom.xml新增依賴
pom.xml需要一些redis的依賴,使用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 http://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.1.3.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.gitee.taven</groupId>
<artifactId>repeat-submit-intercept</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>repeat-submit-intercept</name>
<description>Demo project for Spring Boot</description>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
</properties>
<dependencies>
<!--方式一:緩存類-->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>24.0-jre</version>
</dependency>
<!--方式二:redis類-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</exclusion>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
3.3 自定義註解NoRepeatSubmit.java
也是一個自定義註解,其中設置請求鎖定時間。
package com.gitee.taven.aop;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* @title: NoRepeatSubmit
* @Description: 自定義註解,用於標記Controller中的提交請求
* @Author: ZouTao
* @Date: 2020/4/14
*/
@Target(ElementType.METHOD) // 作用到方法上
@Retention(RetentionPolicy.RUNTIME) // 運行時有效
public @interface NoRepeatSubmit {
/*
* 防止重複提交標記註解
* 設置請求鎖定時間
* @return
*/
int lockTime() default 10;
}
3.4 AOP類RepeatSubmitAspect:
一個AOP解析註解類。
獲取當前用戶的Token(或者JSessionId)+ 當前請求地址,作爲一個唯一 KEY,然後以Key去獲取 Redis 分佈式鎖(如果此時併發獲取,只有一個線程會成功獲取鎖。)
源碼如下:
package com.gitee.taven.aop;
import com.gitee.taven.ApiResult;
import com.gitee.taven.utils.RedisLock;
import com.gitee.taven.utils.RequestUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import javax.servlet.http.HttpServletRequest;
import java.util.UUID;
/**
* @title: RepeatSubmitAspect
* @Description: AOP類解析註解-配合redis-解決程序集羣部署時請求可能會落到多臺機器上的問題。
* 作用:對標記了@NoRepeatSubmit的方法進行攔截
* @Author: ZouTao
* @Date: 2020/4/14
*/
@Aspect
@Component
public class RepeatSubmitAspect {
private static final Logger LOGGER = LoggerFactory.getLogger(RepeatSubmitAspect.class);
@Autowired
private RedisLock redisLock;
@Pointcut("@annotation(noRepeatSubmit)")
public void pointCut(NoRepeatSubmit noRepeatSubmit) {
}
/**
* @title: RepeatSubmitAspect
* @Description:在業務方法執行前,獲取當前用戶的
* token(或者JSessionId)+ 當前請求地址,作爲一個唯一 KEY,
* 去獲取 Redis 分佈式鎖(如果此時併發獲取,只有一個線程會成功獲取鎖。)
* @Author: ZouTao
* @Date: 2020/4/14
*/
@Around("pointCut(noRepeatSubmit)")
public Object around(ProceedingJoinPoint pjp, NoRepeatSubmit noRepeatSubmit) throws Throwable {
int lockSeconds = noRepeatSubmit.lockTime();
HttpServletRequest request = RequestUtils.getRequest();
Assert.notNull(request, "request can not null");
// 此處可以用token或者JSessionId
String token = request.getHeader("Authorization");
String path = request.getServletPath();
String key = getKey(token, path);
String clientId = getClientId();
boolean isSuccess = redisLock.tryLock(key, clientId, lockSeconds);
LOGGER.info("tryLock key = [{}], clientId = [{}]", key, clientId);
// 主要邏輯
if (isSuccess) {
LOGGER.info("tryLock success, key = [{}], clientId = [{}]", key, clientId);
// 獲取鎖成功
Object result;
try {
// 執行進程
result = pjp.proceed();
} finally {
// 解鎖
redisLock.releaseLock(key, clientId);
LOGGER.info("releaseLock success, key = [{}], clientId = [{}]", key, clientId);
}
return result;
} else {
// 獲取鎖失敗,認爲是重複提交的請求。
LOGGER.info("tryLock fail, key = [{}]", key);
return new ApiResult(200, "重複請求,請稍後再試", null);
}
}
// token(或者JSessionId)+ 當前請求地址,作爲一個唯一KEY
private String getKey(String token, String path) {
return token + path;
}
// 生成uuid
private String getClientId() {
return UUID.randomUUID().toString();
}
}
3.5 請求控制類SubmitController
這是一個測試接口的請求控制類,模擬業務場景,
package com.gitee.taven.controller;
import com.gitee.taven.ApiResult;
import com.gitee.taven.aop.NoRepeatSubmit;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
/**
* @title: SubmitController
* @Description: 測試接口
* @Author: ZouTao
* @Date: 2020/4/14
*/
@RestController
public class SubmitController {
@PostMapping("submit")
@NoRepeatSubmit()
public Object submit(@RequestBody UserBean userBean) {
try {
// 模擬業務場景
Thread.sleep(1500);
} catch (InterruptedException e) {
e.printStackTrace();
}
return new ApiResult(200, "成功", userBean.userId);
}
public static class UserBean {
private String userId;
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId == null ? null : userId.trim();
}
}
}
3.6 Redis分佈式鎖實現
需要一個工具類來實現Redis分佈式鎖,具體實現原理請參考另外一篇文章。這裏貼出源碼。
新建RedisLock類,如下:
package com.gitee.taven.utils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import redis.clients.jedis.Jedis;
import java.util.Collections;
/**
* @title: RedisLock
* @Description: Redis 分佈式鎖實現
* @Author: ZouTao
* @Date: 2020/4/14
*/
@Service
public class RedisLock {
private static final Long RELEASE_SUCCESS = 1L;
private static final String LOCK_SUCCESS = "OK";
private static final String SET_IF_NOT_EXIST = "NX";
// 當前設置 過期時間單位, EX = seconds; PX = milliseconds
private static final String SET_WITH_EXPIRE_TIME = "EX";
// if get(key) == value return del(key)
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 StringRedisTemplate redisTemplate;
/**
* 該加鎖方法僅針對單實例 Redis 可實現分佈式加鎖
* 對於 Redis 集羣則無法使用
*
* 支持重複,線程安全
*
* @param lockKey 加鎖鍵
* @param clientId 加鎖客戶端唯一標識(採用UUID)
* @param seconds 鎖過期時間
* @return
*/
public boolean tryLock(String lockKey, String clientId, long seconds) {
return redisTemplate.execute((RedisCallback<Boolean>) redisConnection -> {
Jedis jedis = (Jedis) redisConnection.getNativeConnection();
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) {
return redisTemplate.execute((RedisCallback<Boolean>) redisConnection -> {
Jedis jedis = (Jedis) redisConnection.getNativeConnection();
Object result = jedis.eval(RELEASE_LOCK_SCRIPT, Collections.singletonList(lockKey),
Collections.singletonList(clientId));
if (RELEASE_SUCCESS.equals(result)) {
return true;
}
return false;
});
}
}
順便新建一個RequestUtils工具類,用來獲取一下getRequest的。
RequestUtils.java 如下:
package com.gitee.taven.utils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
/**
* @title: RequestUtils
* @Description: 獲取 Request 信息
* @Author: ZouTao
* @Date: 2020/4/14
*/
public class RequestUtils {
public static HttpServletRequest getRequest() {
ServletRequestAttributes ra= (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
return ra.getRequest();
}
}
3.7 自動測試類RunTest
在上一個示例代碼中,我們採用了啓動項目,訪問瀏覽器,手動測試的方式,接下里這個,
參考以前的一篇文章springboot啓動項目自動運行測試方法,使用自動測試類來模擬測試。
模擬了10個併發請求同時提交:
package com.gitee.taven.test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* @title: RunTest
* @Description: 多線程測試類
* @Param: 模擬十個請求併發同時提交
* @return:
* @Author: ZouTao
* @Date: 2020/4/14
*/
@Component
public class RunTest implements ApplicationRunner {
private static final Logger LOGGER = LoggerFactory.getLogger(RunTest.class);
@Autowired
private RestTemplate restTemplate;
@Override
public void run(ApplicationArguments args) throws Exception {
System.out.println("=================執行多線程測試==================");
String url="http://localhost:8000/submit";
CountDownLatch countDownLatch = new CountDownLatch(1);
ExecutorService executorService = Executors.newFixedThreadPool(10); //線程數
for(int i=0; i<10; i++){
String userId = "userId" + i;
HttpEntity request = buildRequest(userId);
executorService.submit(() -> {
try {
countDownLatch.await();
System.out.println("Thread:"+Thread.currentThread().getName()+", time:"+System.currentTimeMillis());
ResponseEntity<String> response = restTemplate.postForEntity(url, request, String.class);
System.out.println("Thread:"+Thread.currentThread().getName() + "," + response.getBody());
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
countDownLatch.countDown();
}
private HttpEntity buildRequest(String userId) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "yourToken");
Map<String, Object> body = new HashMap<>();
body.put("userId", userId);
return new HttpEntity<>(body, headers);
}
}
3.8 啓動項目
啓動項目,先啓動redis,再運行springboot,會自動執行測試方法,然後控制檯查看結果。
成功防止重複提交,控制檯日誌,可以看到十個線程的啓動時間幾乎同時發起,只有一個請求提交成功了。
ps:
有些人使用jedis3.1.0版本貌似已經沒有這個set方法,則可以改爲:
String result = jedis.set(lockKey, clientId, new SetParams().nx().px(seconds));
也ok了
整體項目結構圖:
兩套解決方案都在裏面了,其中NoRepeatSubmit自定義註解類是共用的,區別在於有一個int lockTime()方法,不是使用redis的時候,註釋掉即可。
上述就是SpringBoot/Web項目中防止表單/請求重複提交的一個方案,分爲單機和分佈式環境下。有什麼疑問請留言吧。需要源碼可以評論留下郵箱,後期會貼出git地址。
參考地址:
[1]: https://www.jianshu.com/p/09c6b05b670a