由防重复点击引发的幂等性问题思考
HTTP/1.1中對冪等性的定義是:一次和多次請求某一個(gè)資源對于資源本身應(yīng)該具有同樣的結(jié)果(網(wǎng)絡(luò)超時(shí)等問題除外)。也就是說,用戶對于同一操作發(fā)起的一次請求或者多次請求的結(jié)果是一致的,不會因?yàn)槎啻吸c(diǎn)擊而產(chǎn)生了副作用。舉個(gè)最簡單的例子,那就是支付,用戶購買商品使用約支付,支付扣款成功,但是返回結(jié)果的時(shí)候網(wǎng)絡(luò)異常,此時(shí)錢已經(jīng)扣了,用戶再次點(diǎn)擊按鈕,此時(shí)會進(jìn)行第二次扣款,返回結(jié)果成功,用戶查詢余額返發(fā)現(xiàn)多扣錢了,流水記錄也變成了兩條.
這里需要關(guān)注幾個(gè)重點(diǎn):
1、冪等不僅僅只是一次(或多次)請求對資源沒有副作用(比如查詢數(shù)據(jù)庫操作,沒有增刪改,因此沒有對數(shù)據(jù)庫有任何影響)。
2、冪等還包括第一次請求的時(shí)候?qū)Y源產(chǎn)生了副作用,但是以后的多次請求都不會再對資源產(chǎn)生副作用。
3、冪等關(guān)注的是以后的多次請求是否對資源產(chǎn)生的副作用,而不關(guān)注結(jié)果。
4、網(wǎng)絡(luò)超時(shí)等問題,不是冪等的討論范圍。
冪等性是系統(tǒng)服務(wù)對外一種承諾(而不是實(shí)現(xiàn)),承諾只要調(diào)用接口成功,外部多次調(diào)用對系統(tǒng)的影響是一致的。聲明為冪等的服務(wù)會認(rèn)為外部調(diào)用失敗是常態(tài),并且失敗之后必然會有重試。
那么我們?yōu)槭裁葱枰涌诰哂袃绲刃阅?/strong>?設(shè)想一下以下情形:
- 在App中下訂單的時(shí)候,點(diǎn)擊確認(rèn)之后,沒反應(yīng),就又點(diǎn)擊了幾次。在這種情況下,如果無法保證該接口的冪等性,那么將會出現(xiàn)重復(fù)下單問題。
- 在接收消息的時(shí)候,消息推送重復(fù)。如果處理消息的接口無法保證冪等,那么重復(fù)消費(fèi)消息產(chǎn)生的影響可能會非常大。
- 在分布式環(huán)境中,網(wǎng)絡(luò)環(huán)境更加復(fù)雜,因前端操作抖動、網(wǎng)絡(luò)故障、消息重復(fù)、響應(yīng)速度慢等原因,對接口的重復(fù)調(diào)用概率會比集中式環(huán)境下更大,尤其是重復(fù)消息在分布式環(huán)境中很難避免。
分布式環(huán)境中,有些接口是天然保證冪等性的,如查詢操作。有些對數(shù)據(jù)的修改是一個(gè)常量,并且無其他記錄和操作,那也可以說是具有冪等性的。其他情況下,所有涉及對數(shù)據(jù)的修改、狀態(tài)的變更就都有必要防止重復(fù)性操作的發(fā)生。通過間接的實(shí)現(xiàn)接口的冪等性來防止重復(fù)操作所帶來的影響,成為了一種有效的解決方案。
冪等和防重的區(qū)別
防重復(fù)提交的示例:比如我之前寫的一個(gè)針對簽約系統(tǒng)的審批流,因?yàn)閳鼍靶枰?#xff0c;某一個(gè)業(yè)務(wù)可以提交多次審批,不能做是否重復(fù)提審的限制,但是會遇到重復(fù)提交的問題,比如連續(xù)多次點(diǎn)擊提審按鈕。這個(gè)問題只是重復(fù)提交的情況,和服務(wù)冪等的初衷是不同的。
重復(fù)提交是在第一次請求已經(jīng)成功的情況下,人為的進(jìn)行多次操作,導(dǎo)致不滿足冪等要求的服務(wù)多次改變狀態(tài)。而冪等更多使用的情況是第一次請求不知道結(jié)果(比如超時(shí))或者失敗的異常情況下,發(fā)起多次請求,目的是多次確認(rèn)第一次請求成功,卻不會因多次請求而出現(xiàn)多次的狀態(tài)變化。
冪等可以使得客戶端邏輯處理變得簡單,但是卻以服務(wù)邏輯變得復(fù)雜為代價(jià)。滿足冪等服務(wù)的需要在邏輯中至少包含兩點(diǎn):
1、首先去查詢上一次的執(zhí)行狀態(tài),如果沒有則認(rèn)為是第一次請求;
2、在服務(wù)改變狀態(tài)的業(yè)務(wù)邏輯前,保證防重復(fù)提交的邏輯;
保證冪等策略
冪等需要通過唯一的業(yè)務(wù)單號來保證。也就是說相同的業(yè)務(wù)單號,認(rèn)為是同一筆業(yè)務(wù)。使用這個(gè)唯一的業(yè)務(wù)單號來確保,后面多次的相同的業(yè)務(wù)單號的處理邏輯和執(zhí)行效果是一致的。
下面以支付為例,在不考慮并發(fā)的情況下,實(shí)現(xiàn)冪等很簡單:先查詢一下訂單是否已經(jīng)支付過,如果已經(jīng)支付過,則返回支付成功;如果沒有支付,進(jìn)行支付流程,修改訂單狀態(tài)為‘已支付’。
實(shí)現(xiàn)冪等性的幾種方案
舉個(gè)例子:
有一個(gè)訂單系統(tǒng),對外提供了一個(gè)處理接口,如果有個(gè)訂單001是要扣除用戶的100塊錢,那么訂單001被多次調(diào)用,也只會處理成功一次,也就是只會扣除用戶100塊。也可以理解為去除重復(fù)調(diào)用。
例如:
等等很多重要的情況,這些邏輯都需要冪等的特性來支持。
實(shí)現(xiàn)冪等性的技術(shù)方案
查詢一次和查詢多次,在數(shù)據(jù)不變的情況下,查詢結(jié)果是一樣的,select是天然的冪等操作。
刪除操作也是冪等的,刪除一次和多次刪除都是把數(shù)據(jù)刪除。(注意可能返回結(jié)果不一樣,刪除的數(shù)據(jù)不存在,返回0,刪除的數(shù)據(jù)多條,返回結(jié)果多個(gè))。
3.唯一索引,防止新增臟數(shù)據(jù)
比如:支付寶的資金賬戶,支付寶也有用戶賬戶,每個(gè)用戶只能有一個(gè)資金賬戶,怎么防止給用戶創(chuàng)建資金賬戶多個(gè),那么給資金賬戶表中的用戶ID加唯一索引,所以一個(gè)用戶新增成功一個(gè)資金賬戶記錄。
要點(diǎn):唯一索引或唯一組合索引來防止新增數(shù)據(jù)存在臟數(shù)據(jù) (當(dāng)表存在唯一索引,并發(fā)時(shí)新增報(bào)錯(cuò)時(shí),再查詢一次就可以了,數(shù)據(jù)應(yīng)該已經(jīng)存在了,返回結(jié)果即可)。
業(yè)務(wù)要求:頁面的數(shù)據(jù)只能被點(diǎn)擊提交一次;
發(fā)生原因:由于重復(fù)點(diǎn)擊或者網(wǎng)絡(luò)重發(fā),或者nginx重發(fā)等情況會導(dǎo)致數(shù)據(jù)被重復(fù)提交。
解決辦法:
集群環(huán)境:采用token加redis(redis單線程的,處理需要排隊(duì))
單JVM環(huán)境:采用token加redis或token加jvm內(nèi)存
處理流程:
token特點(diǎn): 要申請,一次有效性,可以限流
注意:redis要用刪除操作來判斷token,刪除成功代表token校驗(yàn)通過,如果用select+delete來校驗(yàn)token,存在并發(fā)問題,不建議使用
獲取數(shù)據(jù)的時(shí)候加鎖獲取
select * from table_xxx where id=‘xxx’ for update;
注意:id字段一定是主鍵或者唯一索引,不然是鎖表,會出事的。
悲觀鎖使用時(shí)一般伴隨事務(wù)一起使用,數(shù)據(jù)鎖定時(shí)間可能會很長,根據(jù)實(shí)際情況選用
樂觀鎖只是在更新數(shù)據(jù)那一刻鎖表,其他時(shí)間不鎖表,所以相對于悲觀鎖,效率更高。樂觀鎖的實(shí)現(xiàn)方式多種多樣可以通過version或者其他狀態(tài)條件:
- 通過版本號實(shí)現(xiàn)
update table_xxx set name=#name#,version=version+1 where version=#version#
- 通過條件限制
update table_xxx set avai_amount=avai_amount-#subAmount# where avai_amount-#subAmount# >= 0
要求:quality-#subQuality# >= ,這個(gè)情景適合不用版本號,只更新是做數(shù)據(jù)安全校驗(yàn),適合庫存模型,扣份額和回滾份額,性能更高。
注意:樂觀鎖的更新操作,最好用主鍵或者唯一索引來更新,這樣是行鎖,否則更新時(shí)會鎖表,上面兩個(gè)sql改成下面的兩個(gè)更好。
update table_xxx set name=#name#,version=version+1 where id=#id# and version=#version#
update table_xxx set avai_amount=avai_amount-#subAmount# where id=#id# and avai_amount-#subAmount# >= 0
還是拿插入數(shù)據(jù)的例子,如果是分布是系統(tǒng),構(gòu)建全局唯一索引比較困難,例如唯一性的字段沒法確定,這時(shí)候可以引入分布式鎖,通過第三方的系統(tǒng)(redis或zookeeper),在業(yè)務(wù)系統(tǒng)插入數(shù)據(jù)或者更新數(shù)據(jù),獲取分布式鎖,然后做操作,之后釋放鎖,這樣其實(shí)是把多線程并發(fā)的鎖的思路,引入多多個(gè)系統(tǒng),也就是分布式系統(tǒng)中得解決思路。
要點(diǎn):某個(gè)長流程處理過程要求不能并發(fā)執(zhí)行,可以在流程執(zhí)行之前根據(jù)某個(gè)標(biāo)志(用戶ID+后綴等)獲取分布式鎖,其他流程執(zhí)行時(shí)獲取鎖就會失敗,也就是同一時(shí)間該流程只能有一個(gè)能執(zhí)行成功,執(zhí)行完成后,釋放分布式鎖(分布式鎖要第三方系統(tǒng)提供)。
并發(fā)不高的后臺系統(tǒng),或者一些任務(wù)JOB,為了支持冪等,支持重復(fù)執(zhí)行,簡單的處理方法是,先查詢下一些關(guān)鍵數(shù)據(jù),判斷是否已經(jīng)執(zhí)行過,在進(jìn)行業(yè)務(wù)處理,就可以了。
注意:核心高并發(fā)流程不要用這種方法。
在設(shè)計(jì)單據(jù)相關(guān)的業(yè)務(wù),或者是任務(wù)相關(guān)的業(yè)務(wù),肯定會涉及到狀態(tài)機(jī)(狀態(tài)變更圖),就是業(yè)務(wù)單據(jù)上面有個(gè)狀態(tài),狀態(tài)在不同的情況下會發(fā)生變更,一般情況下存在有限狀態(tài)機(jī),這時(shí)候,如果狀態(tài)機(jī)已經(jīng)處于下一個(gè)狀態(tài),這時(shí)候來了一個(gè)上一個(gè)狀態(tài)的變更,理論上是不能夠變更的,這樣的話,保證了有限狀態(tài)機(jī)的冪等。
注意:訂單等單據(jù)類業(yè)務(wù),存在很長的狀態(tài)流轉(zhuǎn),一定要深刻理解狀態(tài)機(jī),對業(yè)務(wù)系統(tǒng)設(shè)計(jì)能力提高有很大幫助。
如銀聯(lián)提供的付款接口:需要接入商戶提交付款請求時(shí)附帶:source來源,seq序列號,source+seq在數(shù)據(jù)庫里面做唯一索引,防止多次付款,(并發(fā)時(shí),只能處理一個(gè)請求)。
重點(diǎn):
對外提供接口為了支持冪等調(diào)用,接口有兩個(gè)字段必須傳,一個(gè)是來源source,一個(gè)是來源方序列號seq,這個(gè)兩個(gè)字段在提供方系統(tǒng)里面做聯(lián)合唯一索引,這樣當(dāng)?shù)谌秸{(diào)用時(shí),先在本方系統(tǒng)里面查詢一下,是否已經(jīng)處理過,返回相應(yīng)處理結(jié)果;沒有處理過,進(jìn)行相應(yīng)處理,返回結(jié)果。注意,為了冪等友好,一定要先查詢一下,是否處理過該筆業(yè)務(wù),不查詢直接插入業(yè)務(wù)系統(tǒng),會報(bào)錯(cuò),但實(shí)際已經(jīng)處理了。
最后總結(jié):
冪等性應(yīng)該是合格程序員的一個(gè)基因,在設(shè)計(jì)系統(tǒng)時(shí),是首要考慮的問題,尤其是在像第三方支付平臺,銀行,互聯(lián)網(wǎng)金融公司等涉及的網(wǎng)上資金系統(tǒng),既要高效,數(shù)據(jù)也要準(zhǔn)確,所以不能出現(xiàn)多扣款,多打款等問題,這樣會很難處理,并會大大降低用戶體驗(yàn)。
那么如何設(shè)計(jì)接口才能做到冪等呢?
方法一、單次支付請求,也就是直接支付了,不需要額外的數(shù)據(jù)庫操作了,這個(gè)時(shí)候發(fā)起異步請求創(chuàng)建一個(gè)唯一的ticketId,就是門票,這張門票只能使用一次就作廢,具體步驟如下:
1、異步請求獲取門票
2、調(diào)用支付,傳入門票
3、根據(jù)門票ID查詢此次操作是否存在,如果存在則表示該操作已經(jīng)執(zhí)行過,直接返回結(jié)果;如果不存在,支付扣款,保存結(jié)果
4、返回結(jié)果到客戶端
如果步驟4通信失敗,用戶再次發(fā)起請求,那么最終結(jié)果還是一樣的
方法二、分布式環(huán)境下各個(gè)服務(wù)相互調(diào)用
這邊就要舉例我們的系統(tǒng)了,我們支付的時(shí)候先要扣款,然后更新訂單,這個(gè)地方就涉及到了訂單服務(wù)以及支付服務(wù)了。用戶調(diào)用支付,扣款成功后,更新對應(yīng)訂單狀態(tài),然后再保存流水。而在這個(gè)地方就沒必要使用門票ticketId了,因?yàn)闀容^閑的麻煩
(支付狀態(tài):未支付,已支付)
步驟:
1、查詢訂單支付狀態(tài)
2、如果已經(jīng)支付,直接返回結(jié)果
3、如果未支付,則支付扣款并且保存流水
4、返回支付結(jié)果
如果步驟4通信失敗,用戶再次發(fā)起請求,那么最終結(jié)果還是一樣的。
總結(jié)
以上是生活随笔為你收集整理的由防重复点击引发的幂等性问题思考的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: MyBatis自定义类型处理器 Type
- 下一篇: 自定义类型处理器的应用