因為一個重復提交,被面試官瘋狂diss
平時開發(fā)項目的時候,你是否遇到這樣的困惑,用戶不停的點擊按鈕向后端提交數(shù)據(jù),而你卻束手無策!
一、故事
記得以前面試的時候,面試官拋出來這么一個問題,就是后端如何防止重復提交訂單?
當時的我剛工作一年多,工作經(jīng)歷也不是很豐富,腦子里第一個想到的就是,這個前端就可以解決吧,然后面試官說必須要在后臺處理這個問題,之后這場面試也就涼了。
面試結束之后,就開始百度查詢資料,除了廣告占頭條比較吸引人以外,也沒找到啥可行的答案,然后請教各路大佬之后,終算是有了一個比較可靠的解決方案。(后文會詳細分享)
前些天在群里也看到有個朋友在討論這個問題,這讓我也想起了之前的那段經(jīng)歷,今天小編就和大家一起來討論一下如何防止重復提交這個問題!
二、問題場景
重復提交,從名字上看,顧名思義,就是多次提交數(shù)據(jù),例如支付的時候,假如同一筆訂單多次支付,就會造成多次扣款,其后果可想而知!
像這樣的案例比比皆是,如果將場景進行歸納,我們會發(fā)現(xiàn)主要有兩類:
- 第一類:由于用戶誤操作或者網(wǎng)絡卡頓,可能會造成多次點擊表單提交按鈕或者刷新提交頁面,就會造成重復提交;
- 第二類:黑客或惡意用戶使用postman、jmeter等工具重復惡意提交表單,攻擊網(wǎng)站,從而造成重復提交;
這兩類嚴重的時候,甚至會直接造成系統(tǒng)宕機!
三、解決方案
說了這么多,那如何防止重復提交數(shù)據(jù)呢?
毫無疑問,肯定是從前端、后端同時入手!
3.1、前端解決方法
通過 JavaScript 來屏蔽提交按鈕,當用戶點擊提交按鈕后,屏幕彈出遮罩層提示數(shù)據(jù)加載中....!
直到后端返回結果或者前端請求超時時,再將其遮罩層關閉,從而實現(xiàn)防止表單重復提交!
3.2、后端解決方法
雖然前端通過屏蔽操作按鈕,防止用戶重復提交數(shù)據(jù),但是如果黑客直接繞過前端給后端提交數(shù)據(jù)時,那么后端肯定也必須要做防止重復提交的驗證。
方案一:給數(shù)據(jù)庫增加唯一鍵約束(不推薦)
起初,最開始想到的就是,在控制層給數(shù)據(jù)做驗證,例如用戶注冊,當用戶手機號或者郵箱已經(jīng)存在,則直接提示提交失敗。
- @RequestMapping(value = "/register")
- public boolean register(@RequestBody UserDto userDto) throws Exception {
- //檢查郵件是否已經(jīng)注冊
- QueryWrapper<User> queryWrapper = new QueryWrapper();
- queryWrapper.eq("user_email",userDto.getUserEmail());
- User dbUser = userService.getOne(queryWrapper);
- if(dbUser ! = null){
- throw new CommonExecption("當前郵箱已被注冊,請使用新的郵箱注冊或者通過密碼找回操作!");
- }
- return userService.insert(userDto);
- }
如果想更加安全一點,可以在數(shù)據(jù)庫中給關鍵字段增加唯一鍵約束,如果用戶郵箱已經(jīng)插入到數(shù)據(jù)庫,會直接拋異常,提示當前郵箱已經(jīng)注冊!
- try {
- userService.insert(userDto);
- } catch (Exception e) {
- log.error("用戶插入失敗",e);
- throw new CommonExecption("當前郵箱已被注冊,請使用新的郵箱注冊!");
- }
這種方案在某些場景下是有效果的,例如請求不是非常頻繁,可以采用這種方式。
那如果請求非常頻繁,而且服務層需要處理的邏輯非常多的時候,這種方案就會遇到很大的瓶頸。
以訂單支付為例,當用戶支付時,首先會對訂單數(shù)據(jù)做各種基礎驗證,接著走風控系統(tǒng),鑒別是否是機器人操作,風控系統(tǒng)通過之后,再對接銀行系統(tǒng)查詢用戶金額是否充足,如果充足就申請扣款,扣款成功之后,更新訂單狀態(tài),同時將訂單的數(shù)據(jù)推送給中心倉庫,等待發(fā)貨。
當然這個只是一個基礎的流程,實際的處理邏輯比這個要復雜的多,此時我們也不能像上面介紹的那樣對某個關鍵字做唯一約束,同時整個處理邏輯所需的時間也相對比較長,假如有幾個請求同時過來,其結果可想而知!
方案二:利用緩存ID防止重復提交(推薦)
設想一下,前端在請求后端的時候,先從后端緩存中獲取一個唯一的ID,在請求提交數(shù)據(jù)的時候帶上這個唯一的ID,后端檢查緩存中是否存在這個ID,如果存在,就進行業(yè)務處理,處理完畢之后,從緩存中將這個ID移除掉,如果在處理過程中,前端又再次提交,此時緩存中的ID狀態(tài)還沒有被移除,直接提示:數(shù)據(jù)處理中,不要重復提交....,具體流程如下!
- 先編寫一個緩存工具類
- /**
- * 緩存工具類
- */
- public class CacheUtil {
- //hashMap線程安全類
- private static Map<String,Object> cacheMap = new ConcurrentHashMap<>();
- /**
- * 添加緩存
- * @param key
- * @param value
- */
- public static void addCache(String key,Object value){
- cacheMap.put(key, value);
- }
- /**
- * 設置緩存
- * @param key
- * @param value
- */
- public static void setValue(String key,Object value){
- cacheMap.put(key, value);
- }
- /**
- * 獲取緩存
- * @param key
- * @return
- */
- public static Object getValue(String key){
- return cacheMap.get(key);
- }
- /**
- * 判斷key是存在
- * @param key
- * @return
- */
- public static boolean containKey(String key){
- return cacheMap.containsKey(key);
- }
- /**
- * 移除緩存
- * @param key
- */
- public static void removeCache(String key){
- cacheMap.remove(key);
- }
- }
- 再編寫一個獲取唯一ID的方法
- @PostMapping("/getSubmitToken")
- public Object getSubmitToken(){
- String submitToken = UUID.randomUUID().toString();
- //將事務請求唯一ID放入緩存池
- CacheUtil.addCache(submitToken, "false");
- //將ID返回給前端
- JSONObject result = new JSONObject();
- result.put("submitToken", submitToken);
- return result;
- }
- 接著編寫一個注解,用于需要驗證重復提交的方法上
- @Target({ElementType.METHOD, ElementType.TYPE})
- @Retention(RetentionPolicy.RUNTIME)
- public @interface SubmitToken {
- boolean value() default true;
- }
- 然后編寫一個攔截器,用于類或者方法上有@SubmitToken注解的驗證處理
- /**
- * 重復提交攔截器
- */
- public class SubmitTokenInterceptor implements HandlerInterceptor {
- @Override
- public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
- //如果不是映射到方法,直接通過
- if(!(handler instanceof HandlerMethod)){
- return true;
- }
- //如果類或者方法有SubmitToken注解,則進行重復提交驗證
- HandlerMethod handlerMethod = (HandlerMethod) handler;
- if (handlerMethod.getBeanType().isAnnotationPresent(SubmitToken.class) || handlerMethod.getMethod().isAnnotationPresent(SubmitToken.class)) {
- final String submitToken = request.getParameter("submitToken");
- if(StringUtils.isEmpty(submitToken)){
- throw new CommonException("submitToken不能為空!");
- }
- if(!CacheUtil.containKey(submitToken)){
- throw new CommonException("submitToken失效,請重新獲取!");
- }
- Object value = CacheUtil.getValue(submitToken);
- if(!"false".equals(value)){
- throw new CommonException("數(shù)據(jù)正在處理,請不要重復提交");
- }
- //驗證通過之后,將submitToken對應的值設置為正在處理
- CacheUtil.setValue(submitToken, "true");
- }
- return true;
- }
- @Override
- public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
- //業(yè)務處理完畢之后,將submitToken從緩存中移除
- final String submitToken = request.getParameter("submitToken");
- if(StringUtils.isNotEmpty(submitToken)){
- CacheUtil.removeCache(submitToken);
- }
- }
- }
- 最后將@SubmitToken注解用于需要進行重復提交的方法或者類上
- /**
- * 將SubmitToken用于增、刪、改的方法或者類上
- */
- @SubmitToken
- @RequestMapping(value = "/register")
- public boolean register(@RequestBody UserDto userDto) throws Exception {
- //......
- }
在開發(fā)的時候,我們只需將@SubmitToken用于增、刪、改的方法上即可,當前端在提交數(shù)據(jù)的時候,先通過/getSubmitToken接口獲取一個submitToken也就是唯一ID,然后再提交請求的時候,帶上這個參數(shù)即可!
當你真正在使用的時候,對于緩存類你會發(fā)現(xiàn)還有很大的優(yōu)化空間,本例采用的是ConcurrentHashMap作為緩存類,隨著提交請求量越來越多,緩存類所占用的空間也越來越大,最后很有可能會OOM。
因此有兩種解決辦法:
- 第一種:編寫一個緩存實體類,里面存放有效期,然后弄一個線程來掃描緩存map,到達過期的數(shù)據(jù)就將其移除。
- 第二種:將需要緩存的數(shù)據(jù)寫入到redis,同時設置過期時間。
如果是小項目,第一種方法就基本可以解決,如果是中大型項目,那么推薦使用 redis 搭建高可用的緩存集群,同時一定要注意 key 的設計,最好采用單獨的前綴,例如submittoken-uuid-+ 項目名稱作為前綴,方便后期擴展的時候緩存數(shù)據(jù)遷移!
四、總結
本文主要圍繞后端如何防止重復提交數(shù)據(jù)問題進行一些總結,可能也有遺漏的地方,歡迎網(wǎng)友點評、吐槽!