冪等性設(shè)計(jì):震驚!吃一碗粉竟付了兩碗的錢(qián)?
?這是一篇絕對(duì)細(xì)節(jié)的避坑指南,是可以救命的那種,極富實(shí)踐意義。一共有十多張圖,強(qiáng)烈推薦你收藏、細(xì)讀。
我們從一個(gè)故事開(kāi)始:
話說(shuō)有一天,支付組的小王開(kāi)了一上午的會(huì),終于在12點(diǎn)半的時(shí)候結(jié)束了。饑腸轆轆的他掏出了手機(jī)準(zhǔn)備點(diǎn)外賣(mài),突然,他想起半個(gè)小時(shí)后還有個(gè)會(huì)。得了,外賣(mài)肯定來(lái)不及了,只能下樓隨便吃點(diǎn)了。
下樓的路上,小王想起前幾天聽(tīng)同事說(shuō),馬路對(duì)過(guò)開(kāi)了一家新的嗦粉店。那家的粉不貴,也不好吃。小王一想,這家人肯定不多,滿足我快速就餐的需求,就這家了!
剛到門(mén)口小王就震驚了,原以為只有一兩個(gè)人,沒(méi)想到,居然一個(gè)人都沒(méi)有!小王咽了咽口水,看了看時(shí)間,咱們賭一把這東西吃了不拉肚子吧。于是就坐下了。
點(diǎn)了碗菜單上的招牌“招牌炒粉”。上菜果然很快,味道也是“名副其實(shí)”,沒(méi)想到的是,這家店居然開(kāi)通了小王公司研發(fā)的支付工具。吃完后,小王就用自己負(fù)責(zé)的支付工具做了支付。剛做完支付,小王收到兩條銀行扣款通知,各扣了18塊錢(qián)。納尼?!難道是銀行重復(fù)發(fā)了消息?小王點(diǎn)進(jìn)自己的支付賬單,看到了毛骨悚然的一幕,居然扣了兩次錢(qián)!
小王心想,完了,肯定是冪等性出問(wèn)題了。于是顧不上退款,趕緊就跑回了公司。因?yàn)樾⊥鯎?dān)心,明天他可能就一碗粉都吃不上了!
01什么是冪等性
所以,我們今天就來(lái)聊聊冪等性這個(gè)話題。冪等性設(shè)計(jì)可以說(shuō)是系統(tǒng)設(shè)計(jì)中最重要的一點(diǎn),設(shè)計(jì)不好分分鐘就發(fā)生資損。輕則一年白干,重則卷鋪蓋走人,更重則公司倒閉。
我們先解釋一下“冪等性”這個(gè)詞。
用大白話來(lái)說(shuō)就是:“同一個(gè)動(dòng)作無(wú)論重復(fù)多少次,結(jié)果都是一樣的”。這里要注意的是“結(jié)果”兩個(gè)字。一個(gè)動(dòng)作可能帶來(lái)多個(gè)結(jié)果,所以冪等性是針對(duì)其中的一個(gè)結(jié)果的。
我們拿洗碗舉例:你洗了一個(gè)碗,然后放在水池邊,過(guò)一會(huì)兒忙完了回到水池邊又看到這個(gè)碗,但是你忘記了之前是否洗過(guò)(或者你不確定中間是否又被人使用過(guò)),保險(xiǎn)起見(jiàn)你就是再洗一次。
那么對(duì)于碗來(lái)說(shuō),洗碗就是具備冪等性的。一個(gè)碗你洗一次、兩次、n次,結(jié)果都是一樣的,就是變干凈了。但對(duì)于洗潔精來(lái)說(shuō),洗碗就不具備冪等性。一個(gè)碗你洗的次數(shù)越多,洗潔精就越少。
用數(shù)學(xué)公式來(lái)說(shuō)就是:f(x) = f(f(x))。比如,計(jì)算絕對(duì)值就具備冪等性,abs(x) = abs(abs(x))。?
回到開(kāi)頭的例子。你吃了一碗粉,然后使用某支付工具支付。app往后端服務(wù)器發(fā)起了一筆支付請(qǐng)求,但是因?yàn)槌瑫r(shí),app沒(méi)有拿到這個(gè)支付結(jié)果,于是重試了一次。假設(shè)兩次請(qǐng)求都到達(dá)了服務(wù)器但是沒(méi)有做好冪等設(shè)計(jì),就會(huì)扣兩次錢(qián),就出現(xiàn)了“吃一碗粉,付兩碗錢(qián)”的結(jié)果。
這種事情如果出現(xiàn)多了,各種投訴及舉報(bào)分分鐘就可以讓公司閉門(mén)歇業(yè)。
你也許會(huì)說(shuō),只要不發(fā)起重試就好了!那如果你是提供了一個(gè)支付接口呢?如果支付系統(tǒng)是收到了上游訂單系統(tǒng)的異步消息然后進(jìn)行支付,消息重發(fā)了呢?
你也許想到了自己系統(tǒng)的冪等性設(shè)計(jì),你也許想到了一些最耳熟能詳?shù)姆椒ㄕ?,但是相信我,好的冪等性設(shè)計(jì)遠(yuǎn)沒(méi)有你想象的那么簡(jiǎn)單。
很多的冪等性設(shè)計(jì)都是存在漏洞的。甚至在大廠,冪等性設(shè)計(jì)都是一個(gè)重點(diǎn)話題。
02操作分類(lèi)與冪等性
在具體講設(shè)計(jì)之前,我們先聊下操作的分類(lèi)以及對(duì)應(yīng)的冪等性問(wèn)題。
所有的操作無(wú)外乎CURD四種類(lèi)型(CURD = Create Update Read Delete)。
【Read】讀操作一般來(lái)說(shuō)是天然具備冪等性的。
【Delete】刪除操作也是天然具備冪等性,無(wú)論你帶不帶where條件,執(zhí)行一次和執(zhí)行一百次結(jié)果是一樣的。
【Update】更新操作不具備天然的冪等性。例如:UPDATE 余額表 SET 余額=余額-1 WHERE 用戶=CodingBetterLife。這個(gè)語(yǔ)句執(zhí)行一次扣一塊錢(qián),執(zhí)行了多次就反復(fù)扣。但是Update的問(wèn)題是很好解決的,只需要在where條件中加上原始值就可以了。比如把上面的語(yǔ)句改為:UPDATE 余額表 SET 余額=余額-1 WHERE 用戶=CodingBetterLife and 余額=100。
【Create】新建操作也不具備天然冪等性。比如app重試支付請(qǐng)求,每次支付都會(huì)插入一條支付記錄,需要有唯一鍵來(lái)控制(這個(gè)我們后面細(xì)說(shuō),僅僅唯一鍵是不夠的)。
處理冪等性,最難的地方其實(shí)就在Create的部分。我們細(xì)細(xì)看來(lái)。
03冪等性如何設(shè)計(jì)
我們就拿開(kāi)頭吃粉的例子來(lái)看看如何設(shè)計(jì)冪等性。我們上面提到,冪等性是針對(duì)其中一個(gè)結(jié)果的,我們討論的是針對(duì)支付結(jié)果的冪等性。因?yàn)榻Y(jié)果冪等才是我們最關(guān)心的。
我們先一起確認(rèn)下,冪等性設(shè)計(jì)的目標(biāo):
【目標(biāo)1】無(wú)論是有意還是無(wú)意的重復(fù)支付請(qǐng)求,都不能出現(xiàn)扣兩次錢(qián)的情況。
【目標(biāo)2】要能夠獲得正確的支付結(jié)果(必須能獲得,并且必須正確)。
開(kāi)始我們的設(shè)計(jì)之旅:
(我們會(huì)從應(yīng)對(duì)app支付的重復(fù)請(qǐng)求,過(guò)渡到一個(gè)支持重試的支付服務(wù)設(shè)計(jì))
吃完粉以后,你掏出手機(jī)進(jìn)行支付,整個(gè)過(guò)程如下所示:
99.99%的操作,都可以這樣順利地完成,但生活吧,意外總是不期而遇:
這種情況下,如果我們不做任何設(shè)計(jì),自然就會(huì)重復(fù)支付。
要杜絕這種問(wèn)題,最直接的思路就是:不要重試!不要重試!不要重試!(學(xué)一下三體)
針對(duì)【意外1】:app可以設(shè)計(jì)成點(diǎn)擊后將按鈕失效。
針對(duì)【意外2】和【意外3】:可以關(guān)閉相關(guān)的重試功能。
這是采用了“逃避”的思路,也就是不要讓問(wèn)題發(fā)生。但這真不是你能控制的。況且,一旦整個(gè)架構(gòu)體系變得復(fù)雜,你很難評(píng)估是不是某個(gè)點(diǎn)會(huì)有重試的邏輯。
所以,解決冪等性問(wèn)題,不能依賴別人“不重試”,而要以“肯定會(huì)重試”作為前提條件來(lái)設(shè)計(jì)。
但這并不是說(shuō)所有的邏輯可以在后端完成,app側(cè)起碼要做一個(gè)基本的改造,那就是每次用戶的點(diǎn)擊請(qǐng)求,會(huì)生成唯一一個(gè)ID,并且把這個(gè)ID一路帶下來(lái)。
然后,后端可以這樣來(lái)設(shè)計(jì):
注意:從這里開(kāi)始,我們的后端設(shè)計(jì)不僅應(yīng)對(duì)“不小心”的重復(fù)支付,更針對(duì)故意的調(diào)用方重試。你也可以理解為我們?cè)谧鲆粋€(gè)“支付服務(wù)”的設(shè)計(jì)。
(方案1)
此時(shí),如果原始請(qǐng)求超時(shí)異常,然后重試的話,會(huì)被攔截,如下圖:
據(jù)我了解,大部分冪等的設(shè)計(jì)都是這種方式,你可以對(duì)比下你的系統(tǒng)。
但這樣設(shè)計(jì)會(huì)有個(gè)不容易想到的嚴(yán)重缺陷,看下圖:
這種情況非常嚴(yán)重。你可以想象,如果調(diào)用方認(rèn)為失敗,但其實(shí)支付成功,會(huì)是什么結(jié)果?!
這里的關(guān)鍵問(wèn)題在于:需要控制在任何時(shí)刻,任何一個(gè)唯一鍵請(qǐng)求,只有一個(gè)線程在執(zhí)行。所以,我們需要在業(yè)務(wù)檢驗(yàn)之前,就做一個(gè)分布式鎖,保證只有一個(gè)線程處理支付。
這里我們有兩個(gè)方案。
第一個(gè)方案是:將落支付流水的動(dòng)作提到業(yè)務(wù)檢驗(yàn)之前。如下圖:
(方案2)
這個(gè)方案的問(wèn)題在于,會(huì)有很多業(yè)務(wù)校驗(yàn)失敗的流水在庫(kù)中。這無(wú)論對(duì)檢索的性能還是存儲(chǔ)的成本來(lái)說(shuō),都是一個(gè)需要考慮的點(diǎn)。
另外,所有的請(qǐng)求直接落庫(kù),對(duì)數(shù)據(jù)庫(kù)壓力很大。例如有黑產(chǎn)用高并發(fā)掃你的接口,你不先做一次黑名單檢查直接落庫(kù),對(duì)db來(lái)說(shuō)風(fēng)險(xiǎn)極高,可能會(huì)橫向影響其他業(yè)務(wù)。
如果你認(rèn)為沒(méi)有這種場(chǎng)景,并且有很多廢流水沒(méi)問(wèn)題,這個(gè)方案是可以的。事實(shí)上,有些銀行的接口就是這么設(shè)計(jì)的。
如果你不想有那么多廢流水,你可以采用第二個(gè)方案,那就是在業(yè)務(wù)檢驗(yàn)前加一個(gè)分布式鎖。同時(shí),如果分布式鎖獲取失敗,則查一下流水庫(kù),返回流水狀態(tài)。如下圖:
(方案3)
上述方案采用的是redis分布式鎖,也可以使用db的冪等表來(lái)實(shí)現(xiàn)。
但是,這個(gè)方案是有問(wèn)題的。
如果原始請(qǐng)求在搶到分布式鎖以后異常中斷了(例如服務(wù)器重啟)。重試的請(qǐng)求都只能獲得“訂單不存在”的狀態(tài)。但是訂單不存在有可能是因?yàn)橹袛?,有可能是因?yàn)樵颊?qǐng)求還沒(méi)有走到落數(shù)據(jù)庫(kù)這一步。對(duì)于調(diào)用方來(lái)說(shuō)不敢直接認(rèn)為失敗。
我們看下圖:
這種情況下,我們往往會(huì)給到調(diào)用方一個(gè)約定。約定:如果原始請(qǐng)求后超過(guò)一段時(shí)間(例如1小時(shí),以下都以1小時(shí)舉例)重試,依然獲取到訂單不存在,則可以認(rèn)定為失敗!服務(wù)端要保證1小時(shí)內(nèi),原始請(qǐng)求一定執(zhí)行完(無(wú)論是成功、失敗、還是異常終止)。?
到這里總該萬(wàn)事大吉了吧?
沒(méi)錯(cuò),到這里確實(shí)就可以了。很多大廠都是這么設(shè)計(jì)的。?
但是,這里有一個(gè)問(wèn)題。那就是,對(duì)于調(diào)用方來(lái)說(shuō),如果服務(wù)端發(fā)生異常中斷(例如機(jī)器重啟)的情況,他只能等到約定的1小時(shí)后換號(hào)重新支付。
不要小看換號(hào)這個(gè)事情。調(diào)用方對(duì)一筆支付換號(hào)重試是高危操作,一旦換號(hào),所有的冪等都失效。所以,如果調(diào)用方想要盡量保證支付成功,同時(shí)忌諱換號(hào)來(lái)做重試。該怎么辦呢?
上面的方案中,之所以需要換號(hào),是因?yàn)槲覀兊姆植际芥i不會(huì)釋放。那么,我們?nèi)绻?小時(shí)后刪除冪等,就可以做原號(hào)重試了。如下圖:
(方案4)
不同于換號(hào)重試的是,原號(hào)重試依然在支付流水?dāng)?shù)據(jù)庫(kù)層面有冪等控制,不會(huì)重復(fù)支付。這樣,我們就實(shí)現(xiàn)了不換號(hào)重試的功能。
我們來(lái)總結(jié)一下,我們一共有三種方案來(lái)實(shí)現(xiàn)冪等,我們匯總?cè)缦聢D:
這三個(gè)方案有自己的使用場(chǎng)景,我最后來(lái)說(shuō)一下:
【方案2】如果你確保沒(méi)有惡意請(qǐng)求給數(shù)據(jù)庫(kù)帶來(lái)壓力,并且接受大量廢流水,可以直接使用這個(gè)方案。同時(shí)確保整個(gè)“從流水入庫(kù)到支付完成”在一個(gè)事務(wù)中。如果不在一個(gè)事務(wù)中,會(huì)存在支付異常時(shí)支付流水懸掛的問(wèn)題。需要通過(guò)補(bǔ)償?shù)姆绞酵七M(jìn)。這個(gè)點(diǎn)我們此文不細(xì)講了。如果有問(wèn)題可以公眾號(hào)給我留言。
【方案3】如果你可以要求調(diào)用方接受一段時(shí)間后換號(hào)重試。你可以使用這個(gè)方案。
【方案4】如果你的調(diào)用方無(wú)法接受換號(hào)重試,你可以選擇這個(gè)方案。
事實(shí)上,【方案3】和【方案4】是大廠的最佳實(shí)踐。你可以在設(shè)計(jì)自己系統(tǒng)時(shí)酌情參考。當(dāng)然,有一些變種的實(shí)現(xiàn),但原理上和核心環(huán)節(jié)上的設(shè)計(jì)是一致的。?
你現(xiàn)在再回頭看看方案1,是不是就深刻體會(huì)到,冪等性設(shè)計(jì)并沒(méi)有那么容易吧。
04結(jié)尾
到這里,我們就把冪等性問(wèn)題講完了。
在多年的工作過(guò)程中,我面試過(guò)很多候選人,我經(jīng)常會(huì)結(jié)合候選人的工作,考察其在冪等性設(shè)計(jì)上的思考。因?yàn)閮绲刃允且粋€(gè)大家一定會(huì)碰到的點(diǎn),其中的細(xì)節(jié)很能反映候選人的嚴(yán)謹(jǐn)性和技術(shù)能力。
對(duì)于架構(gòu)來(lái)說(shuō),“異步”和“重試”是我們常用且重要的設(shè)計(jì)思路,而這兩者都需要嚴(yán)格考慮“冪等性”。
所以,千萬(wàn)不要讓你的用戶發(fā)生“吃一碗粉付兩碗錢(qián)”的情況,不然,也許沒(méi)幾天,你自己連一碗粉都付不起了。
建議你可以收藏本文,在你需要做系統(tǒng)或者架構(gòu)設(shè)計(jì)的時(shí)候,拿出來(lái)做個(gè)參考。
本文轉(zhuǎn)載自微信公眾號(hào)「 CodingBetterLife??」,作者「 趙志強(qiáng) 」,可以通過(guò)以下二維碼關(guān)注。
轉(zhuǎn)載本文請(qǐng)聯(lián)系「 ?CodingBetterLife??」公眾號(hào)。