防止資料重複提交的6種方法(超簡單)!
阿新 • • 發佈:2020-07-17
有位朋友,某天突然問磊哥:**在 Java 中,防止重複提交最簡單的方案是什麼**?
這句話中包含了兩個關鍵資訊,第一:**防止重複提交**;第二:**最簡單**。
於是磊哥問他,是單機環境還是分散式環境?
得到的反饋是單機環境,那就簡單了,於是磊哥就開始裝*了。
話不多說,我們先來複現這個問題。
## 模擬使用者場景
根據朋友的反饋,大致的場景是這樣的,如下圖所示:
![重複提交-01.gif](https://cdn.nlark.com/yuque/0/2020/gif/92791/1594884822055-1ab9e828-2173-4d4a-95a4-f8df3d754566.gif#align=left&display=inline&height=351&margin=%5Bobject%20Object%5D&name=%E9%87%8D%E5%A4%8D%E6%8F%90%E4%BA%A4-01.gif&originHeight=351&originWidth=639&size=35805&status=done&style=none&width=639)
簡化的模擬程式碼如下(基於 Spring Boot):
```java
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/user")
@RestController
public class UserController {
/**
* 被重複請求的方法
*/
@RequestMapping("/add")
public String addUser(String id) {
// 業務程式碼...
System.out.println("新增使用者ID:" + id);
return "執行成功!";
}
}
```
於是磊哥就想到:通過前、後端分別攔截的方式來解決資料重複提交的問題。
## 前端攔截
前端攔截是指通過 HTML 頁面來攔截重複請求,比如在使用者點選完“提交”按鈕後,我們可以把按鈕設定為不可用或者隱藏狀態。
執行效果如下圖所示:
![前臺攔截.gif](https://cdn.nlark.com/yuque/0/2020/gif/92791/1594886132513-0c9afadc-1f28-444d-8749-30618e184c51.gif#align=left&display=inline&height=248&margin=%5Bobject%20Object%5D&name=%E5%89%8D%E5%8F%B0%E6%8B%A6%E6%88%AA.gif&originHeight=434&originWidth=598&size=18337&status=done&style=none&width=342)
前端攔截的實現程式碼:
```html
```
但前端攔截有一個致命的問題,如果是懂行的程式設計師或非法使用者可以直接繞過前端頁面,通過模擬請求來重複提交請求,比如充值了 100 元,重複提交了 10 次變成了 1000 元(瞬間發現了一個致富的好辦法)。
所以除了前端攔截一部分正常的誤操作之外,後端的攔截也是必不可少。
## 後端攔截
後端攔截的實現思路是在方法執行之前,先判斷此業務是否已經執行過,如果執行過則不再執行,否則就正常執行。
我們將請求的業務 ID 儲存在記憶體中,並且通過新增互斥鎖來保證多執行緒下的程式執行安全,大體實現思路如下圖所示:
![image.png](https://cdn.nlark.com/yuque/0/2020/png/92791/1594888965679-2a9d0bc3-dafe-4c00-9def-f7cdd85ed1fd.png#align=left&display=inline&height=288&margin=%5Bobject%20Object%5D&name=image.png&originHeight=575&originWidth=897&size=45646&status=done&style=none&width=448.5)
然而,將資料儲存在記憶體中,最簡單的方法就是使用 `HashMap` 儲存,或者是使用 Guava Cache 也是同樣的效果,但很顯然 `HashMap` 可以更快的實現功能,所以我們先來實現一個 `HashMap` 的防重(防止重複)版本。
### 1.基礎版——HashMap
```java
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
/**
* 普通 Map 版本
*/
@RequestMapping("/user")
@RestController
public class UserController3 {
// 快取 ID 集合
private Map reqCache = new HashMap<>();
@RequestMapping("/add")
public String addUser(String id) {
// 非空判斷(忽略)...
synchronized (this.getClass()) {
// 重複請求判斷
if (reqCache.containsKey(id)) {
// 重複請求
System.out.println("請勿重複提交!!!" + id);
return "執行失敗";
}
// 儲存請求 ID
reqCache.put(id, 1);
}
// 業務程式碼...
System.out.println("新增使用者ID:" + id);
return "執行成功!";
}
}
```
實現效果如下圖所示:
![最終效果.gif](https://cdn.nlark.com/yuque/0/2020/gif/92791/1594884796524-83dd15fc-1ea3-4c76-bc7a-4c44baa25a8d.gif#align=left&display=inline&height=566&margin=%5Bobject%20Object%5D&name=%E6%9C%80%E7%BB%88%E6%95%88%E6%9E%9C.gif&originHeight=566&originWidth=1712&size=192454&status=done&style=none&width=1712)
**存在的問題**:此實現方式有一個致命的問題,因為 `HashMap` 是無限增長的,因此它會佔用越來越多的記憶體,並且隨著 `HashMap` 數量的增加查詢的速度也會降低,所以我們需要實現一個可以自動“清除”過期資料的實現方案。
### 2.優化版——固定大小的陣列
此版本解決了 `HashMap` 無限增長的問題,它使用陣列加下標計數器(reqCacheCounter)的方式,實現了固定陣列的迴圈儲存。
當陣列儲存到最後一位時,將陣列的儲存下標設定 0,再從頭開始儲存資料,實現程式碼如下:
```java
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Arrays;
@RequestMapping("/user")
@RestController
public class UserController {
private static String[] reqCache = new String[100]; // 請求 ID 儲存集合
private static Integer reqCacheCounter = 0; // 請求計數器(指示 ID 儲存的位置)
@RequestMapping("/add")
public String addUser(String id) {
// 非空判斷(忽略)...
synchronized (this.getClass()) {
// 重複請求判斷
if (Arrays.asList(reqCache).contains(id)) {
// 重複請求
System.out.println("請勿重複提交!!!" + id);
return "執行失敗";
}
// 記錄請求 ID
if (reqCacheCounter >= reqCache.length) reqCacheCounter = 0; // 重置計數器
reqCache[reqCacheCounter] = id; // 將 ID 儲存到快取
reqCacheCounter++; // 下標往後移一位
}
// 業務程式碼...
System.out.println("新增使用者ID:" + id);
return "執行成功!";
}
}
```
### 3.擴充套件版——雙重檢測鎖(DCL)
上一種實現方法將判斷和新增業務,都放入 `synchronized` 中進行加鎖操作,這樣顯然效能不是很高,於是我們可以使用單例中著名的 DCL(Double Checked Locking,雙重檢測鎖)來優化程式碼的執行效率,實現程式碼如下:
```java
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Arrays;
@RequestMapping("/user")
@RestController
public class UserController {
private static String[] reqCache = new String[100]; // 請求 ID 儲存集合
private static Integer reqCacheCounter = 0; // 請求計數器(指示 ID 儲存的位置)
@RequestMapping("/add")
public String addUser(String id) {
// 非空判斷(忽略)...
// 重複請求判斷
if (Arrays.asList(reqCache).contains(id)) {
// 重複請求
System.out.println("請勿重複提交!!!" + id);
return "執行失敗";
}
synchronized (this.getClass()) {
// 雙重檢查鎖(DCL,double checked locking)提高程式的執行效率
if (Arrays.asList(reqCache).contains(id)) {
// 重複請求
System.out.println("請勿重複提交!!!" + id);
return "執行失敗";
}
// 記錄請求 ID
if (reqCacheCounter >= reqCache.length) reqCacheCounter = 0; // 重置計數器
reqCache[reqCacheCounter] = id; // 將 ID 儲存到快取
reqCacheCounter++; // 下標往後移一位
}
// 業務程式碼...
System.out.println("新增使用者ID:" + id);
return "執行成功!";
}
}
```
> 注意:DCL 適用於重複提交頻繁比較高的業務場景,對於相反的業務場景下 DCL 並不適用。
### 4.完善版——LRUMap
上面的程式碼基本已經實現了重複資料的攔截,但顯然不夠簡潔和優雅,比如下標計數器的宣告和業務處理等,但值得慶幸的是 Apache 為我們提供了一個 commons-collections 的框架,裡面有一個非常好用的資料結構 `LRUMap` 可以儲存指定數量的固定的資料,並且它會按照 LRU 演算法,幫你清除最不常用的資料。
> 小貼士:LRU 是 Least Recently Used 的縮寫,即最近最少使用,是一種常用的資料淘汰演算法,選擇最近最久未使用的資料予以淘汰。
首先,我們先來新增 Apache commons collections 的引用:
```xml
org.apache.commons
commons-collections4
4.4
```
實現程式碼如下:
```java
import org.apache.commons.collections4.map.LRUMap;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/user")
@RestController
public class UserController {
// 最大容量 100 個,根據 LRU 演算法淘汰資料的 Map 集合
private LRUMap reqCache = new LRUMap<>(100);
@RequestMapping("/add")
public String addUser(String id) {
// 非空判斷(忽略)...
synchronized (this.getClass()) {
// 重複請求判斷
if (reqCache.containsKey(id)) {
// 重複請求
System.out.println("請勿重複提交!!!" + id);
return "執行失敗";
}
// 儲存請求 ID
reqCache.put(id, 1);
}
// 業務程式碼...
System.out.println("新增使用者ID:" + id);
return "執行成功!";
}
}
```
使用了 `LRUMap` 之後,程式碼顯然簡潔了很多。
### 5.最終版——封裝
以上都是方法級別的實現方案,然而在實際的業務中,我們可能有很多的方法都需要防重,那麼接下來我們就來封裝一個公共的方法,以供所有類使用:
```java
import org.apache.commons.collections4.map.LRUMap;
/**
* 冪等性判斷
*/
public class IdempotentUtils {
// 根據 LRU(Least Recently Used,最近最少使用)演算法淘汰資料的 Map 集合,最大容量 100 個
private static LRUMap reqCache = new LRUMap<>(100);
/**
* 冪等性判斷
* @return
*/
public static boolean judge(String id, Object lockClass) {
synchronized (lockClass) {
// 重複請求判斷
if (reqCache.containsKey(id)) {
// 重複請求
System.out.println("請勿重複提交!!!" + id);
return false;
}
// 非重複請求,儲存請求 ID
reqCache.put(id, 1);
}
return true;
}
}
```
呼叫程式碼如下:
```java
import com.example.idempote.util.IdempotentUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/user")
@RestController
public class UserController4 {
@RequestMapping("/add")
public String addUser(String id) {
// 非空判斷(忽略)...
// -------------- 冪等性呼叫(開始) --------------
if (!IdempotentUtils.judge(id, this.getClass())) {
return "執行失敗";
}
// -------------- 冪等性呼叫(結束) --------------
// 業務程式碼...
System.out.println("新增使用者ID:" + id);
return "執行成功!";
}
}
```
> 小貼士:一般情況下程式碼寫到這裡就結束了,但想要更簡潔也是可以實現的,你可以通過自定義註解,將業務程式碼寫到註解中,需要呼叫的方法只需要寫一行註解就可以防止資料重複提交了,老鐵們可以自行嘗試一下(需要磊哥擼一篇的,評論區留言 666)。
## 擴充套件知識——LRUMap 實現原理分析
既然 `LRUMap` 如此強大,我們就來看看它是如何實現的。
`LRUMap` 的本質是持有頭結點的環回雙鏈表結構,它的儲存結構如下:
```java
AbstractLinkedMap.LinkEntry entry;
```
當呼叫查詢方法時,會將使用的元素放在雙鏈表 header 的前一個位置,原始碼如下:
```java
public V get(Object key, boolean updateToMRU) {
LinkEntry entry = this.getEntry(key);
if (entry == null) {
return null;
} else {
if (updateToMRU) {
this.moveToMRU(entry);
}
return entry.getValue();
}
}
protected void moveToMRU(LinkEntry entry) {
if (entry.after != this.header) {
++this.modCount;
if (entry.before == null) {
throw new IllegalStateException("Entry.before is null. This should not occur if your keys are immutable, and you have used synchronization properly.");
}
entry.before.after = entry.after;
entry.after.before = entry.before;
entry.after = this.header;
entry.before = this.header.before;
this.header.before.after = entry;
this.header.before = entry;
} else if (entry == this.header) {
throw new IllegalStateException("Can't move header to MRU This should not occur if your keys are immutable, and you have used synchronization properly.");
}
}
```
如果新增元素時,容量滿了就會移除 header 的後一個元素,新增原始碼如下:
```java
protected void addMapping(int hashIndex, int hashCode, K key, V value) {
// 判斷容器是否已滿
if (this.isFull()) {
LinkEntry reuse = this.header.after;
boolean removeLRUEntry = false;
if (!this.scanUntilRemovable) {
removeLRUEntry = this.removeLRU(reuse);
} else {
while(reuse != this.header && reuse != null) {
if (this.removeLRU(reuse)) {
removeLRUEntry = true;
break;
}
reuse = reuse.after;
}
if (reuse == null) {
throw new IllegalStateException("Entry.after=null, header.after=" + this.header.after + " header.before=" + this.header.before + " key=" + key + " value=" + value + " size=" + this.size + " maxSize=" + this.maxSize + " This should not occur if your keys are immutable, and you have used synchronization properly.");
}
}
if (removeLRUEntry) {
if (reuse == null) {
throw new IllegalStateException("reuse=null, header.after=" + this.header.after + " header.before=" + this.header.before + " key=" + key + " value=" + value + " size=" + this.size + " maxSize=" + this.maxSize + " This should not occur if your keys are immutable, and you have used synchronization properly.");
}
this.reuseMapping(reuse, hashIndex, hashCode, key, value);
} else {
super.addMapping(hashIndex, hashCode, key, value);
}
} else {
super.addMapping(hashIndex, hashCode, key, value);
}
}
```
判斷容量的原始碼:
```java
public boolean isFull() {
return size >= maxSize;
}
```
**
容量未滿就直接新增資料:
```java
super.addMapping(hashIndex, hashCode, key, value);
```
如果容量滿了,就呼叫 `reuseMapping` 方法使用 LRU 演算法對資料進行清除。
綜合來說:**`LRUMap` 的本質是持有頭結點的環回雙鏈表結構,當使用元素時,就將該元素放在雙鏈表 `header` 的前一個位置,在新增元素時,如果容量滿了就會移除 `header` 的後一個元素**。
## 總結
本文講了防止資料重複提交的 6 種方法,首先是前端的攔截,通過隱藏和設定按鈕的不可用來遮蔽正常操作下的重複提交。但為了避免非正常渠道的重複提交,我們又實現了 5 個版本的後端攔截:HashMap 版、固定陣列版、雙重檢測鎖的陣列版、LRUMap 版和 LRUMap 的封裝版。
> 特殊說明:本文所有的內容僅適用於單機環境下的重複資料攔截,如果是分散式環境需要配合資料庫或 Redis 來實現,想看分散式重複資料攔截的老鐵們,請給磊哥一個「**贊**」,如果**點贊超過 100 個**,咱們**更新分散式環境下重複資料的處理方案**,謝謝你。
#### 參考 & 鳴謝
[https://blog.csdn.net/fenglllle/article/details/82659576](https://blog.csdn.net/fenglllle/article/details/82