1. 程式人生 > >防止資料重複提交的6種方法(超簡單)!

防止資料重複提交的6種方法(超簡單)!

有位朋友,某天突然問磊哥:**在 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