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