如何避免重復(fù)提交問題

一瓤介、簡述

所謂冪等性,就是一個接口赘那,多次發(fā)起同一個請求刑桑,該接口得保證結(jié)果是準(zhǔn)確的,比如不能多扣款募舟、不能多插入一條數(shù)據(jù)祠斧、不能將統(tǒng)計值多統(tǒng)計 1。這就是冪等性拱礁。

1??在編程中常見的冪等
①select 查詢天然冪等
②delete 刪除也是冪等琢锋,刪除同一個多次效果一樣
③update 直接更新某個值的辕漂,冪等
④update 更新累加操作的,非冪等
⑤insert 非冪等操作吴超,每次新增一條

2??產(chǎn)生原因:由于重復(fù)點擊或者網(wǎng)絡(luò)重發(fā)
①點擊提交按鈕兩次
②點擊刷新按鈕
③使用瀏覽器后退按鈕重復(fù)之前的操作钉嘹,導(dǎo)致重復(fù)提交表單
④使用瀏覽器歷史記錄重復(fù)提交表單
⑤瀏覽器重復(fù)的 HTTP 請求
⑥nginx 重發(fā)等情況
⑦分布式 RPC 的 try 重發(fā)等

二、理解

1??問題背景
分布式系統(tǒng)中的接口鲸阻,如何保證冪等性跋涣?做分布式系統(tǒng)的時候,這是一個必須要考慮的生產(chǎn)環(huán)境的技術(shù)問題鸟悴。
假如有個服務(wù)提供付款接口陈辱,結(jié)果這服務(wù)部署在了 5 臺機器上。然后用戶在前端上操作的時候细诸,不知道為啥沛贪,總之就是一個訂單不小心發(fā)起了兩次支付請求,然后該請求分散在了這個服務(wù)部署的不同的機器上揍堰,結(jié)果一個訂單扣款兩次鹏浅。
或者是訂單系統(tǒng)調(diào)用支付系統(tǒng)進行支付,結(jié)果不小心因為網(wǎng)絡(luò)超時了屏歹,然后訂單系統(tǒng)走了重試機制隐砸,支付系統(tǒng)收到一個支付請求兩次,而且因為負(fù)載均衡算法落在了不同的機器上蝙眶,問題由此而生季希。

2??問題剖析
這個不是技術(shù)問題,這個沒有通用的一個方法幽纷,這個應(yīng)該結(jié)合業(yè)務(wù)來保證冪等性式塌。其實保證冪等性主要是三點:

  1. 對于每個請求必須有一個唯一的標(biāo)識。舉個例子:訂單支付請求友浸,肯定得包含訂單 id峰尝,一個訂單 id 最多支付一次。
  2. 每次處理完請求之后收恢,必須有一個記錄標(biāo)識這個請求處理過了武学。常見的方案是在數(shù)據(jù)庫中記錄個狀態(tài),比如支付之前記錄一條這個訂單的支付流水伦意。
  3. 每次接收請求需要進行判斷火窒,判斷之前是否處理過。比如說驮肉,如果有一個訂單已經(jīng)支付了熏矿,就已經(jīng)有了一條支付流水,那么如果重復(fù)發(fā)送這個請求,則此時先插入支付流水票编,orderId 已經(jīng)存在了褪储,唯一鍵約束生效,報錯插入不進去的栏妖。然后系統(tǒng)就不用再扣款了乱豆。

3??實際運作
實際運作過程中,需要結(jié)合自己的業(yè)務(wù)來吊趾,比如說利用 Redis宛裕,用 orderId 作為唯一鍵。只有成功插入這個支付流水论泛,才可以執(zhí)行實際的支付扣款揩尸。

要求是支付一個訂單,必須插入一條支付流水屁奏。order_id 建 unique key岩榆。用戶在支付一個訂單之前,先插入一條支付流水坟瓢,order_id 就已經(jīng)進去了勇边。系統(tǒng)就可以寫一個標(biāo)識到 Redis 里面去,set order_id payed折联,下一次重復(fù)請求過來了粒褒,先查 Redis 的 order_id 對應(yīng)的 value,如果是 payed 就說明已經(jīng)支付過了诚镰,用戶就可以避免重復(fù)支付了奕坟。

三、解決方案

1??前端 js 提交禁止按鈕:可以用一些js組件

2??使用Post/Redirect/Get模式
在提交后執(zhí)行頁面重定向清笨,這就是所謂的 Post-Redirect-Get (PRG) 模式月杉。簡言之,當(dāng)用戶提交了表單后抠艾,去執(zhí)行一個客戶端的重定向苛萎,轉(zhuǎn)到提交成功信息頁面。這能避免用戶按F5導(dǎo)致的重復(fù)提交检号,而且也不會出現(xiàn)瀏覽器表單重復(fù)提交的警告首懈,也能消除按瀏覽器前進和后退導(dǎo)致的同樣問題。

3??在 session 中存放一個特殊標(biāo)志

在服務(wù)器端谨敛,生成一個唯一的標(biāo)識符,將它存入 session滤否,同時將它寫入表單的隱藏字段中脸狸,然后將表單頁面發(fā)給瀏覽器,用戶錄入信息后點擊提交,在服務(wù)器端炊甲,獲取表單中隱藏字段的值泥彤,與 session 中的唯一標(biāo)識符比較,相等說明是首次提交卿啡,就處理本次請求吟吝,然后將 session 中的唯一標(biāo)識符移除;不相等說明是重復(fù)提交颈娜,就不再處理剑逃。

4??其他借助使用 header 頭設(shè)置緩存控制頭 Cache-control 等方式

比較復(fù)雜,不適合移動端 APP 的應(yīng)用官辽。

5??借助數(shù)據(jù)庫

insert 使用唯一索引蛹磺。update 使用樂觀鎖 version 法。這種在大數(shù)據(jù)量和高并發(fā)下效率依賴數(shù)據(jù)庫硬件能力同仆,可針對非核心業(yè)務(wù)萤捆。

6??借助悲觀鎖

使用 select … for update 這種和 synchronized 鎖住先查再 insert or update 一樣,但要避免死鎖俗批,效率也較差俗或。針對單體應(yīng)用,請求并發(fā)不大岁忘,可以推薦使用辛慰。

四、自定義注解@RreventReSubmit

在傳統(tǒng)的 web 項目中臭觉,防止重復(fù)提交昆雀,通常做法是:后端生成一個唯一的提交令牌(uuid),并存儲在服務(wù)端蝠筑。頁面提交請求攜帶這個提交令牌狞膘,后端驗證并在第一次驗證后刪除該令牌,保證提交請求的唯一性什乙。

思路沒有問題挽封,但是需要前后端都稍加改動,如果在業(yè)務(wù)開發(fā)完再加這個的話臣镣,改動量未免有些大了辅愿。無需前端配合,純后端處理忆某,是最清爽的点待。設(shè)計思路如下:

自定義注解@RreventReSubmit標(biāo)記所有 Controller 中的提交請求。通過 AOP 對所有標(biāo)記@RreventReSubmit的方法攔截弃舒。在業(yè)務(wù)方法執(zhí)行前癞埠,獲取當(dāng)前用戶的 token(或者JSessionId)+ 當(dāng)前請求地址状原,作為一個唯一 KEY,去獲取 Redis 分布式鎖(如果此時并發(fā)獲取苗踪,只有一個線程會成功獲取鎖)颠区。當(dāng)有請求調(diào)用接口時,到 Redis 中查找相應(yīng)的 key通铲,如果能找到毕莱,則說明重復(fù)提交,如果找不到颅夺,則執(zhí)行操作朋截。業(yè)務(wù)方法執(zhí)行后,釋放鎖碗啄。

1??導(dǎo)入aop依賴

<dependency>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

2??自定義注解

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RreventReSubmit {
}

3??定義切面類:切面類需要使用@Aspect和@Component這兩個注解做標(biāo)注质和。

@Aspect
@Component
@Slf4j
public class UserAspect {
    @Resource
    private RedisUtil redisUtil;
    @Value("${user.session.key}")
    private String userSessionKey;

    @Pointcut(value = "@annotation(com.xxp.annotation.RreventReSubmit )")
    public void annotationPointCut() {
    }

    @Around("annotationPointCut()")
    public Object NoReSubmit(ProceedingJoinPoint joinPoint) {
        ServletRequestAttributes attributes = 
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        //獲取request
        HttpServletRequest request = attributes.getRequest();
        HttpSession session = request.getSession();
        //從session中獲取登錄的user對象,如果為null,則要求重新登錄
        Object sessionUser = session.getAttribute(userSessionKey);
        if (sessionUser == null) {
            return Response.FAIL("頁面超時,請重新登錄");
        }
        User user = (User) sessionUser;
        Integer userId = user.getId();
        //獲取接口的請求參數(shù),如果時Article類型乖仇,則保存為Article對象栋操,使用Article對象里的title屬性
        Object[] args = joinPoint.getArgs();
        Article article = null;
        for (Object object : args) {
            if (object instanceof Article) {
                article = (Article) object;
            }
        }
        if (args == null) {
            return Response.FAIL("請求參數(shù)錯誤");
        }
        //組裝redis key 從redis中獲取對應(yīng)的值
        String key = userId + "_" + article.getTitle();
        Object flag = redisUtil.getStr(key);
        //如果redis中不存在對應(yīng)的值,則執(zhí)行原有的代碼邏輯(插入文章操作)
        if (flag == null) {
            //redis設(shè)置key,value值為1
            redisUtil.setStr(key, "1");
            //設(shè)置有效期為5分鐘
            redisUtil.strSetExpireSeconds(key, 5 * 60L);
            try {
                return joinPoint.proceed();
            } catch (Throwable throwable) {
                redisUtil.delStr(key);
                return Response.FAIL("系統(tǒng)錯誤,請聯(lián)系管理員!");
            }
        } else {
            //如果redis中存在對應(yīng)的值,則證明重復(fù)提交国夜,返回對應(yīng)的信息
            log.info("{}:重復(fù)提交", key);
            return Response.FAIL("重復(fù)提交");
        }
    }
}

在想要防止重復(fù)提交的接口上添加注解即可使用。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末短绸,一起剝皮案震驚了整個濱河市车吹,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌醋闭,老刑警劉巖窄驹,帶你破解...
    沈念sama閱讀 218,755評論 6 507
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異证逻,居然都是意外死亡乐埠,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,305評論 3 395
  • 文/潘曉璐 我一進店門囚企,熙熙樓的掌柜王于貴愁眉苦臉地迎上來丈咐,“玉大人,你說我怎么就攤上這事龙宏】醚罚” “怎么了?”我有些...
    開封第一講書人閱讀 165,138評論 0 355
  • 文/不壞的土叔 我叫張陵银酗,是天一觀的道長辆影。 經(jīng)常有香客問我掩浙,道長,這世上最難降的妖魔是什么秸歧? 我笑而不...
    開封第一講書人閱讀 58,791評論 1 295
  • 正文 為了忘掉前任,我火速辦了婚禮衅澈,結(jié)果婚禮上键菱,老公的妹妹穿的比我還像新娘。我一直安慰自己今布,他們只是感情好经备,可當(dāng)我...
    茶點故事閱讀 67,794評論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著部默,像睡著了一般侵蒙。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上傅蹂,一...
    開封第一講書人閱讀 51,631評論 1 305
  • 那天纷闺,我揣著相機與錄音,去河邊找鬼份蝴。 笑死犁功,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的婚夫。 我是一名探鬼主播浸卦,決...
    沈念sama閱讀 40,362評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼案糙!你這毒婦竟也來了限嫌?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,264評論 0 276
  • 序言:老撾萬榮一對情侶失蹤时捌,失蹤者是張志新(化名)和其女友劉穎怒医,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體匣椰,經(jīng)...
    沈念sama閱讀 45,724評論 1 315
  • 正文 獨居荒郊野嶺守林人離奇死亡裆熙,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,900評論 3 336
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了禽笑。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片入录。...
    茶點故事閱讀 40,040評論 1 350
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖佳镜,靈堂內(nèi)的尸體忽然破棺而出僚稿,到底是詐尸還是另有隱情,我是刑警寧澤蟀伸,帶...
    沈念sama閱讀 35,742評論 5 346
  • 正文 年R本政府宣布蚀同,位于F島的核電站缅刽,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏蠢络。R本人自食惡果不足惜衰猛,卻給世界環(huán)境...
    茶點故事閱讀 41,364評論 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望刹孔。 院中可真熱鬧啡省,春花似錦、人聲如沸髓霞。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,944評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽方库。三九已至结序,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間纵潦,已是汗流浹背徐鹤。 一陣腳步聲響...
    開封第一講書人閱讀 33,060評論 1 270
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留酪穿,地道東北人凳干。 一個月前我還...
    沈念sama閱讀 48,247評論 3 371
  • 正文 我出身青樓,卻偏偏與公主長得像被济,于是被迫代替她去往敵國和親救赐。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 44,979評論 2 355