前任開發在代碼里下毒,支付下單居然沒加冪等
故事
又是一個風和日麗美好的一天,小貓戴著耳機,安逸地聽著音樂,擼著代碼,這種沒有會議的日子真的是巴適得板。
不料禍從天降,組長火急火燎地跑過來找到了小貓?!翱炫挪橐幌?,目前有A公司用戶反饋積分被多扣了”。
小貓回憶了一下“不對啊,這接口我也沒動過啊,前幾天對外平臺的老六直接找我要個支付接口,我就給他了的,以前的代碼,我都沒有動過的......”。
于是小貓一邊疑惑一邊翻看著以前的代碼,越看臉色越差......
小貓做的是一個標準的積分兌換商城,以前和客戶合作的時候,客戶直接用的是小貓單位自己定制的h5頁面。這次合作了一家公司有點特殊,由于公司想要定制化自己個性化的H5,加上本身A公司自己有開發能力,所以經過討論就以接口的方式直接將相關接口給出去,A客戶H5開發完成之后自己來對接。
慢慢地,原因也水落石出,之前好好的業務一直沒有問題是因為商城的本身H5頁面做了防重復提交,由于量小,并且一般對接方式用的都是純H5,所以都沒有什么問題,然后這次是直接將接口給出去了,完了接口居然沒有加冪等......
小貓躺槍,數據訂正當然是少不了了,事故報告當然也少不了了。
正所謂前人挖坑,后人遭殃,前人鍋后人背。
聊聊冪等
1.接口冪等梗概
這個案例其實就是一個典型的接口冪等案例。那么老貓就和大家從以下幾個方面好好剖析一下接口冪等吧。
2.什么是接口冪等
比較專業的術語:其任意多次執行所產生的影響均與第一次執行的影響相同。大白話:多次調用的情況下,接口最終得到的結果是一致的。
3.那么為什么需要冪等呢?
- 用戶進行提交動作的時候,由于網絡波動等原因導致后端同步響應不及時,這樣用戶就會一直點點點,這樣機會發生重復提交的情況。
- 分布式系統之間調用的情況下,例如RPC調用,為了防止網絡波動超時等造成的請求失敗,都會添加重試機制,導致一個請求提交多次。
- 分布式系統經常會用到消息中間件,當由于網絡原因,mq沒有收到ack的情況下,就會導致消息的重復投遞,從而就會導致重復提交行為。
還有就是惡意攻擊了,有些業務接口做的比較粗糙,黑客找到漏洞之后會發起重復提交,這樣就會導致業務出現問題。打個比方,老貓曾經干過,鄰居小孩報名了一個畫畫比賽,估計是機構培訓發起的,功能做的也差,需要靠投票贏得某些禮品,然后老貓抓到接口信息之后就模擬投票進行重復刷了投票。
4.那么哪些接口需要做冪等呢?
首先我們說是不是所有的接口都需要冪等?是不是加了冪等就好呢?顯然不是。因為接口冪等的實現某種意義上是要消耗系統性能的,我們沒有必要針對所有業務接口都加上冪等。
這個其實并不能做一個完全的定義說哪個就不用冪等,因為很多時候其實還是得結合業務邏輯一起看。但是其中也是有規律可循的。
既然我們說冪等就是多次調用,接口最終得到結果一致,那么很顯然,查詢接口肯定是不要加冪等的,另外一些簡單刪除數據的接口,無論是邏輯刪除還是物理刪除,看場景的情況下其實也不用加冪等。
但是大部分涉及到多表更新行為的接口,咱們最好還是得加上冪等。
接口冪等實戰方案
1.前端防抖處理
前端防抖主要可以有兩種方案,一種是技術層面的,一種是產品層面的:
- 技術層面:例如提交控制在100ms內,同一個用戶最多只能做一次訂單提交的操作。
- 產品層面:當然用戶點擊提交之后,按鈕直接置灰。
2.基于數據庫唯一索引
利用數據庫唯一索引。我們具體來看一下流程,咱們就用小貓遇到的例子。如下:
過程描述:
- 建立一張去重表,其中某個字段需要建立唯一索引,例如小貓這個場景中,咱們就可以將訂單提交流水單號作為唯一索引存儲到我們的數據庫中,就模型上而言,可以將其定義為支付請求流水表。
- 客戶端攜帶相關流水信息到后端,如果發現編號重復,那么此時就會插入失敗,報主鍵沖突的錯誤,此時我們針對該錯誤做一下業務報錯的二次封裝給到客戶另一個友好的提示即可。
3.數據庫樂觀鎖實現
什么是樂觀鎖,它假設多用戶并發的事務在處理時不會彼此互相影響,各事務能夠在不產生鎖的情況下處理各自影響的那部分數據。說得直白一點樂觀鎖就是一個馬大哈。總是假設最好的情況,每次去拿數據的時候都認為別人不會修改,所以不會上鎖,只在更新的時候會判斷一下在此期間別人有沒有去更新這個數據。
例如提交訂單的進行支付扣款的時候,本來可能更新賬戶金額扣款的動作是這樣的:
update Account set balance = balance-#{payAmount} where accountCode = #{accountCode}
加上版本號之后,咱們的代碼就是這樣的:
update Account set balance = balance-#{payAmount},version=version +1 where accountCode = #{accountCode} and version = #{currVersion}
這種情況下其實就要求客戶端每次在請求支付下單的時候都需要上層客戶端指定好當前的版本信息。不過這種冪等的處理方式,老貓用的比較少。
4.數據庫悲觀鎖實現
悲觀鎖的話具有強烈的獨占和排他特性。大白話誰都不信的主。所以我們就用select ... for update這樣的語法進行行鎖,當然老貓覺得單純的select ... for update只能解決同一時刻大并發的冪等,所以要保證單號重試這樣非并發的冪等請求還是得去校驗當前數據的狀態才行。就拿當前的小貓遇到的場景來說,流程如下:
悲觀鎖
begin; # 1.開始事務
select * from order where order_code='666' for update # 查詢訂單,判斷狀態,鎖住這條記錄
if(status !=處理中){
//非處理中狀態,直接返回;
return ;
}
## 處理業務邏輯
update order set status='完成' where order_code='666' # 更新完成
update stock set num = num - 1 where spu='xxx' # 庫存更新
commit; # 5.提交事務
這里老貓一再想要強調的是在校驗的時候還是得帶上本身的業務狀態去做校驗,select ... for update并非萬能冪等。
5.后端生成token
這個方案的本質其實是引入了令牌桶的機制,當提交訂單的時候,前端優先會調用后端接口獲取一個token,token是由后端發放的。當然token的生成方式有很多種,例如定時刷新令牌桶,或者定時生成令牌并放到令牌池中,當然目的只有一個就是保住token的唯一性即可。
生成token之后將token放到redis中,當然需要給token設置一個失效時間,超時的token也會被刪除。
當后端接收到訂單提交的請求的時候,會先判斷token在緩存中是否存在,第一次請求的時候,token一定存在,也會正常返回結果,但是第二次攜帶同一個token的時候被拒絕了。
流程如下:
token機制
有個注意點大家可以思考一下:如果用戶用程序惡意刷單,同一個token發起了多次請求怎么辦?想要實現這個功能,就需要借助分布式鎖以及Lua腳本了,分布式鎖可以保證同一個token不能有多個請求同時過來訪問,lua腳本保證從redis中獲取令牌->比對令牌->生成單號->刪除令牌這一系列行為的原子性。
6.分布式鎖+狀態機(訂單狀態)
現在很多的業務服務都是分布式系統,所以就拿分布式鎖來說,關于分布式鎖,老貓在此不做贅述,之前老貓寫過redis的分布式鎖和實現,還有zk鎖和實現,具體可見鏈接:
- 鎖的演化
- 手撕redis分布式鎖
- 手擼ZK鎖
當然和上述的數據庫悲觀鎖類似,咱們的分布式鎖也只能保證同一個訂單在同一時間的處理。其次也是要去校訂單的狀態,防止其重復支付的,也就是說,只要支付的訂單進入后端,都要將原先的訂單修改為支付中,防止后續支付中斷之后的重復支付。
在上述小貓的流程中還沒有涉及到現金補充,如果涉及到現金補充的話,例如對接了微信或者支付寶的情況,還需要根據最終的支付回調結果來最終將訂單狀態進行流轉成支付完成或者是支付失敗。
總結
在我們日常的開發中,一些重要的接口還是需要大家謹慎對待,即使是前任開發留下的接口,沒有任何改動,當有人咨詢的時候,其實就要好好去了解一下里面的實現,看看方案有沒有問題,看看技術實現有沒有問題,這應該也是每一個程序員的基本素養。
另外的,在一些重要的接口上,尤其是資金相關的接口上,冪等真的是相當的重要。小伙伴們,你們覺得呢?