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