大廠都怎么防止重復(fù)下單?
1.問題背景
最簡單的:DB事務(wù)。如創(chuàng)建訂單時(shí),同時(shí)往訂單表、訂單商品表插數(shù)據(jù),這些Insert須在同一事務(wù)執(zhí)行。
Order服務(wù)調(diào)用Pay服務(wù),剛好網(wǎng)絡(luò)超時(shí),然后Order服務(wù)開始重試機(jī)制,于是Pay服務(wù)對同一支付請求,就接收到了兩次,而且因?yàn)檩喸冐?fù)載均衡算法,落在了不同業(yè)務(wù)節(jié)點(diǎn)!所以一個(gè)分布式系統(tǒng)接口,須保證冪等性。
2.如何避免重復(fù)下單?
前端頁面也可直接防止用戶重復(fù)提交表單,但網(wǎng)絡(luò)錯(cuò)誤會(huì)導(dǎo)致重傳,很多RPC框架、網(wǎng)關(guān)都有自動(dòng)重試機(jī)制,所以重復(fù)請求在前端側(cè)無法完全避免!問題最后還是如何保證服務(wù)接口的冪等性。
2.1 如何判斷請求是重復(fù)的?
- 插入訂單前,先查一下訂單表,有無重復(fù)訂單? 不優(yōu):難以用SQL條件定義到底什么是“重復(fù)訂單”
- 訂單的用戶、商品、價(jià)格一樣就是重復(fù)訂單? 萬一這用戶就是連續(xù)下了倆一模一樣訂單呢?
所以保證冪等性要做到:
2.1.1 每個(gè)請求須有唯一標(biāo)識
比如訂單支付請求,得包含訂單id,一個(gè)訂單id最多只能成功支付一次。
2.1.2 每次處理完請求后,須有記錄標(biāo)識該請求已被處理
在MySQL中記錄一個(gè)狀態(tài)字段。如支付之前記錄一條這個(gè)訂單的支付流水。
2.1.3 每次接收請求時(shí),判斷之前是否處理過
若有一個(gè)訂單已支付,就肯定已有一條支付流水。若重復(fù)發(fā)送這個(gè)請求,則此時(shí)先插入支付流水,發(fā)現(xiàn)orderId已存在,唯一約束生效,報(bào)錯(cuò)重復(fù)Key。就不會(huì)再重復(fù)扣款。
在往DB插記錄時(shí),一般不提供主鍵,而由DB在插入時(shí)自動(dòng)生成。這樣重復(fù)的請求就會(huì)導(dǎo)致插入重復(fù)的數(shù)據(jù)。MySQL的主鍵自帶唯一性約束,若在一條INSERT語句提供主鍵,且該主鍵值在表中已存在,則該條INSERT會(huì)執(zhí)行失敗。因此可利用DB的“主鍵唯一約束”,在插數(shù)據(jù)時(shí)帶上主鍵,以此實(shí)現(xiàn)創(chuàng)建訂單接口的冪等性。
給Order服務(wù)添加一個(gè)“orderId生成”的接口,無參,返回值就是一個(gè)【全局唯一】訂單號。在用戶進(jìn)入創(chuàng)建訂單頁面時(shí),前端頁面先調(diào)用該orderId生成接口得到一個(gè)訂單號,在用戶提交訂單時(shí),在創(chuàng)建訂單的請求中攜帶該訂單號。
該訂單號其實(shí)就是訂單表的主鍵,于是,重復(fù)請求中帶的都是同一訂單號。訂單服務(wù)在訂單表中插入數(shù)據(jù)的時(shí)候,執(zhí)行的這些重復(fù)INSERT語句中的主鍵,也都是同一個(gè)訂單號。而DB唯一約束保證,只有一次INSERT執(zhí)行成功。
實(shí)際要結(jié)合業(yè)務(wù),如使用Redis,用orderId作為唯一K。只有成功插入這個(gè)支付流水,才可執(zhí)行扣款。
要求是支付一個(gè)訂單,須插入一條支付流水,order_id建立一個(gè)唯一鍵。你在支付一個(gè)訂單前,先插入一條支付流水,order_id就已經(jīng)傳過去了。就能寫一個(gè)標(biāo)識到Redis中,set order_id payed,當(dāng)重復(fù)請求過來時(shí),先查Redis的order_id對應(yīng)的value,若為payed說明已支付,就別再重復(fù)支付!
然后再重復(fù)支付訂單時(shí),寫嘗試插入一條支付流水,DB會(huì)報(bào)唯一鍵沖突,整個(gè)事務(wù)回滾。保存一個(gè)是否處理過的標(biāo)識也可以,服務(wù)的不同實(shí)例可以一起操作Redis。
若因重復(fù)訂單導(dǎo)致插入 t_order 失敗,則Order服務(wù)不要把該錯(cuò)誤返給前端頁面。否則,就可能出現(xiàn)用戶點(diǎn)擊創(chuàng)建訂單按鈕后,頁面提示創(chuàng)建訂單失敗,而實(shí)際上訂單創(chuàng)建成功了。
正確做法:這種case,訂單服務(wù)直接返回訂單創(chuàng)建成功。
3.解決ABA
3.1 什么是ABA
如訂單支付后,seller要發(fā)貨,發(fā)貨完成后要填個(gè)快遞單號。假設(shè)seller填個(gè)666,剛填完,發(fā)現(xiàn)填錯(cuò)了,趕緊再修改成888。對訂單服務(wù),這就是2個(gè)更新訂單的請求。系統(tǒng)異常時(shí)666請求到了,單號更成666,接著888請求到了,單號又更新成888,但是666更新成功的響應(yīng)丟了,調(diào)用方?jīng)]收到成功響應(yīng),自動(dòng)重試,再次發(fā)起666請求,單號又被更新成666了,這數(shù)據(jù)顯然就錯(cuò)了!
3.2 解決方案
訂單主表增加version列。每次查詢訂單時(shí),版本號要隨著訂單數(shù)據(jù)返回給頁面。頁面在更新數(shù)據(jù)的請求中,把這個(gè)版本號作為更新請求的參數(shù),帶回給訂單更新接口。
訂單服務(wù)在更新數(shù)據(jù)的時(shí)候,需要比較訂單的版本號是否和消息中的一致:
- 不一致拒絕更新數(shù)據(jù)
- 一致還需再更新數(shù)據(jù)的同時(shí),將version+1。“比較版本號、更新數(shù)據(jù)和版本號+1”的過程須在同一事務(wù)執(zhí)行
UPDATE orders set tracking_number = 666,
version = version + 1
WHERE version = 8;
在這條SQL的WHERE條件中,version值需要頁面在更新的時(shí)候通過請求傳進(jìn)來。
通過該版本號,就能保證,從我打開這條訂單記錄開始,一直到我更新這條訂單記錄成功,期間沒有其他人修改過該訂單數(shù)據(jù)。若有,則DB中的version就會(huì)改變,那我的更新操作就會(huì)執(zhí)行失敗。我就只能重新查詢新版本的訂單數(shù)據(jù),再嘗試更新。
有了這個(gè)版本號,前文的ABA即有兩個(gè)case:
- 把運(yùn)單號更新為666成功,更新為888的請求帶著舊版本號,就更新失敗,頁面提示用戶更新888失敗
- 666更新成功后,888帶著新版本號,888更新成功。這時(shí)即使重試的666請求再來,因?yàn)樗蜕弦粭l666請求帶相同版本號,上一條請求更新成功后,這個(gè)版本號已經(jīng)變了,所以重試請求的更新必然失敗
無論哪種情況,DB中的數(shù)據(jù)與頁面上給用戶的反饋都是一致的。這就實(shí)現(xiàn)了冪等更新且避免ABA。
4.總結(jié)
- 創(chuàng)建訂單服務(wù),可通過預(yù)生成訂單號,然后利用DB的訂單號唯一約束,避免重復(fù)寫入訂單,實(shí)現(xiàn)創(chuàng)建訂單服務(wù)的冪等性
- 更新訂單服務(wù),通過一個(gè)版本號機(jī)制,每次更新數(shù)據(jù)前校驗(yàn)版本號,更新數(shù)據(jù)同時(shí)自增版本號,這樣的方式,來解決ABA問題,確保更新訂單服務(wù)的冪等性
兩種冪等的實(shí)現(xiàn)方法,就可以保證,無論請求是不是重復(fù),訂單表中的數(shù)據(jù)都是正確的。
實(shí)現(xiàn)訂單冪等的方法,完全可以套用在其他需要實(shí)現(xiàn)冪等的服務(wù)中,只需要這個(gè)服務(wù)操作的數(shù)據(jù)保存在數(shù)據(jù)庫中,并且有一張帶有主鍵的數(shù)據(jù)表即可。