天天看點

談一談大廠都怎麼防止重複下單?

作者:架構師成長曆程

一、問題背景

最簡單的:DB 事務。如建立訂單時,同時往訂單表、訂單商品表插資料,這些 Insert 須在同一事務執行。

Order 服務調用 Pay 服務,剛好網絡逾時,然後 Order 服務開始重試機制,于是 Pay 服務對同一支付請求,就接收到了兩次,而且因為輪詢負載均衡算法,落在了不同業務節點!是以一個分布式系統接口,須保證幂等性。

二、如何避免重複下單

前端頁面也可直接防止使用者重複送出表單,但網絡錯誤會導緻重傳,很多RPC架構、網關都有自動重試機制,是以重複請求在前端側無法完全避免!問題最後還是如何保證服務接口的幂等性。

2.1 如何判斷請求是重複的

  • 插入訂單前,先查一下訂單表,有無重複訂單? 難以用SQL條件定義到底什麼是“重複訂單”
  • 訂單的使用者、商品、價格一樣就是重複訂單? 萬一這使用者就是連續下了倆一模一樣訂單呢?

是以保證幂等性要做到:

2.1.1 每個請求須有唯一辨別

比如訂單支付請求,得包含訂單 id,一個訂單 id 最多隻能成功支付一次。

2.1.2 每次處理完請求後,須有記錄辨別該請求已被處理

在 MySQL 中記錄一個狀态字段。如支付之前記錄一條這個訂單的支付流水。

2.1.3 每次接收請求時,判斷之前是否處理過

若有一個訂單已支付,就肯定已有一條支付流水。若重複發送這個請求,則此時先插入/支付流水,發現 orderId 已存在,唯一限制生效,報錯重複 Key。就不會再重複扣款。

在往 DB 插記錄時,一般不提供主鍵,而由 DB 在插入時自動生成。這樣重複的請求就會導緻插入重複的資料。MySQL 的主鍵自帶唯一性限制,若在一條 INSERT 語句提供主鍵,且該主鍵值在表中已存在,則該條 INSERT 會執行失敗。是以可利用 DB 的“主鍵唯一限制”,在插資料時帶上主鍵,以此實作建立訂單接口的幂等性。

給 Order 服務添加一個“orderId 生成”的接口,無參,傳回值就是一個【全局唯一】訂單号。在使用者進入建立訂單頁面時,前端頁面先調用該 orderId 生成接口得到一個訂單号,在使用者送出訂單時,在建立訂單的請求中攜帶該訂單号。

該訂單号其實就是訂單表的主鍵,于是,重複請求中帶的都是同一訂單号。訂單服務在訂單表中插入資料的時候,執行的這些重複 INSERT 語句中的主鍵,也都是同一個訂單号。而 DB 唯一限制保證,隻有一次 INSERT 執行成功。

實際要結合業務,如使用 Redis,用 orderId 作為唯一K。隻有成功插入這個支付流水,才可執行扣款。

要求是支付一個訂單,須插入一條支付流水,order_id 建立一個唯一鍵。你在支付一個訂單前,先插入一條支付流水,order_id 就已經傳過去了。就能寫一個辨別到 Redis 中,set order_id payed,當重複請求過來時,先查 Redis 的 order_id 對應的 value,若為 payed 說明已支付,就别再重複支付!

然後再重複支付訂單時,寫嘗試插入一條支付流水,DB 會報唯一鍵沖突,整個事務復原。儲存一個是否處理過的辨別也可以,服務的不同執行個體可以一起操作 Redis。

談一談大廠都怎麼防止重複下單?

若因重複訂單導緻插入 t_order 失敗,則 Order 服務不要把該錯誤返給前端頁面。否則,就可能出現使用者點選建立訂單按鈕後,頁面提示建立訂單失敗,而實際上訂單建立成功了。

正确做法:這種 case,訂單服務直接傳回訂單建立成功。

三、解決 ABA

3.1 什麼是 ABA

如訂單支付後,seller 要發貨,發貨完成後要填個快遞單号。假設 seller 填個 666,剛填完,發現填錯了,趕緊再修改成 888。對訂單服務,這就是 2 個更新訂單的請求。系統異常時 666 請求到了,單号更成 666,接着 888 請求到了,單号又更新成 888,但是 666 更新成功的響應丢了,調用方沒收到成功響應,自動重試,再次發起 666 請求,單号又被更新成 666了,這資料顯然就錯了!

談一談大廠都怎麼防止重複下單?

3.2 解決方案

訂單主表增加 version 列。每次查詢訂單時,版本号要随着訂單資料傳回給頁面。頁面在更新資料的請求中,把這個版本号作為更新請求的參數,帶回給訂單更新接口。

訂單服務在更新資料的時候,需要比較訂單的版本号是否和消息中的一緻:

  • 不一緻:拒絕更新資料
  • 一緻:還需再更新資料的同時,将 version + 1。“比較版本号、更新資料和版本号 + 1”的過程須在同一事務執行
UPDATE orders set tracking_number = 666, version = version + 1 WHERE version = 8;
           

在這條 SQL 的 WHERE 條件中,version 值需要頁面在更新的時候通過請求傳進來。

通過該版本号,就能保證,從我打開這條訂單記錄開始,一直到我更新這條訂單記錄成功,期間沒有其他人修改過該訂單資料。若有,則 DB 中的 version 就會改變,那我的更新操作就會執行失敗。我就隻能重新查詢新版本的訂單資料,再嘗試更新。

有了這個版本号,前文的 ABA 即有兩個 case:

  • 把運單号更新為 666 成功,更新為 888 的請求帶着舊版本号,就更新失敗,頁面提示使用者更新 888 失敗
  • 666 更新成功後,888 帶着新版本号,888 更新成功。這時即使重試的 666 請求再來,因為它和上一條 666 請求帶相同版本号,上一條請求更新成功後,這個版本号已經變了,是以重試請求的更新必然失敗

無論哪種情況,DB 中的資料與頁面上給使用者的回報都是一緻的。這就實作了幂等更新且避免 ABA。

談一談大廠都怎麼防止重複下單?

防止訂單重複支付

訂單支付流程

我們來看看,電商訂單支付的簡要流程:

談一談大廠都怎麼防止重複下單?

從下單/計算開始:

  1. 下單/結算:這一步雖然不是直接的支付起點,但是支付相關的金額等等資訊都來自結算,此時訂單的狀态是未支付
  2. 申請支付:使用者選擇申請支付,用戶端調用支付服務,此時在系統内産生一筆支付流水,這筆流水的狀态是未支付
  3. 發起支付:支付服務調用三方支付,通常這種錢包類的支付,在發起支付這一步,會響應一些支付的連結,用戶端會對連結進行對應的處理。
  4. 錢包支付:使用者進行支付,通常是通過對應的錢包進行的,大家可以回憶一下自己在購物中,支付的過程,不同的端,對錢包支付的處理是不太一樣的:
  5. PC端:PC端,通常是打開收銀台,展示一個二維碼,通過錢包掃碼支付,下面是京東的微信支付掃碼頁
  6. WAP端:手機的網頁站,WAP端的支付一般是直接拉起對應的錢包,如果拉起錢包失敗,就跳轉界面
  7. APP端: 在國内,購物大部分都是在APP端,産品經理會想法設法把使用者帶到APP,為什麼我的示例圖都用京東,不用淘寶呢?因為我拿UC打開淘寶,會直接跳轉APP。
  8. APP端的錢包支付,我們應該都非常熟悉,一般是拉起錢包,支付。
  9. 支付回調:使用者完成支付後,三方支付平台,會回調商戶,通知支付結果。
  10. 同步訂單狀态:支付服務在确認支付完成後,會向訂單服務同步支付的結果,訂單服務變更訂單的狀态,由未支付-》待發貨,用戶端通過輪詢、長連接配接,或者服務端主動推送的方式,在界面上變更訂單狀态。

我們再從支付流水的角度看一下支付狀态的變化:

談一談大廠都怎麼防止重複下單?
  • 從未支付,到有支付結果的終态,中間還有一個中間狀态支付中
  • 使用者通過打開錢包--》完成支付--》支付回調,這段時間的支付流水就處于支付中

為什麼要花這麼多篇幅來講支付的業務流程、互動過程呢?因為我認為,防止訂單的重複支付,不止是技術上的問題,也是業務和産品上的問題。

為什麼訂單會重複支付

未防重導緻的重複支付

我們可以看到PC端支付,是掃描二維碼,這些二維碼,就是對應相應的支付流水,假如使用者重複點選支付,如果不做防重的的話,會生成兩筆支付流水,也就是兩個不同的二維碼,要是使用者分别掃了兩個不同的支付碼,那麼毫無疑問,就會産生重複支付。

掉單導緻的重複支付

“我明明付款了,為什麼我的訂單還沒支付呢?”

談一談大廠都怎麼防止重複下單?

這就是所謂的“掉單”:

  • 外部掉單:三方支付的支付狀态沒有同步或者沒有及時同步到商城,這叫外部掉單
  • 内部掉單:支付服務的狀态沒有同步到訂單,或者用戶端沒有及時擷取到訂單狀态,這叫内部掉單。

使用者一看,自己付了款,結果商城裡訂單還未付款,但是又特别想要,可能就會再下一單,這樣就重複支付了。

多管道導緻的重複支付

我們國内支付的體驗還是非常快捷的,大家可能沒有感覺,如果了解過海外支付的可能了解,很多支付的管道,消耗的時間非常長。

比如使用者保羅選擇了一種支付方式Boleto,結果支付的網點離保羅他們村太遠了,保羅又選擇了Paypal支付,保羅去趕集的時候,又順手去網點把Boleto的這一筆支付了,結果就重複支付了。

這種情況大家可能很少遇到,我們可以用美團下一個單,先打開微信支付,不要支付啊,接着回到美團,打開支付寶,用支付寶支付完成後,用微信接着支付,大家猜猜,兩筆支付是不是都能成功?答案是可以。

談一談大廠都怎麼防止重複下單?

如何防止訂單重複支付

加鎖

不管是3.申請支付、還是5.支付回調,都應該以訂單次元加鎖,防止并發下的重複操作。

加鎖,毫無疑問,也是分布式鎖,通常我們會選擇Redis分布式鎖。

談一談大廠都怎麼防止重複下單?

緩存結果

申請支付成功,支付回調成功,都應該緩存結果。

再申請支付,收到成功回調的時候,都應該先去檢查支付的狀态。

談一談大廠都怎麼防止重複下單?

支付中流水取消

假如說,使用者重複支付了,再次申請支付的時候,如果已經申請支付成功了,那麼這筆支付肯定是要拒絕的。

但是,要是已經存在的這筆流水還在支付中呢?——我們不确定它是成功還是失敗,肯定是不能拒絕支付的,因為可能使用者支付失敗了,但是狀态還沒同步,這樣肯定是不行的。

是以,我們可以取消掉正在支付中的流水,再進行支付。

談一談大廠都怎麼防止重複下單?

已支付流水退款

現在又有新的問題了,假如發起支付的時候,有流水正在支付中,如果第三方支付平台不支援取消支付,或者使用者新的支付是通過不同的管道,我們希望盡可能提高使用者的支付成功率,怎麼辦呢?

我們可以在發起支付的時候,訂單還在支付中的情況下,允許使用者發起多筆支付,在支付回調的時候,檢查使用者是否已經有成功流水,對後來的流水進行退款處理。

談一談大廠都怎麼防止重複下單?

當然,退款是個很危險的操作,畢竟錢退了,可就很難追回來,一定要做好風險的控制。

主動輪詢&重試防止掉單

主動輪詢防止外部掉單

如果因為故障沒有收到回調,或者沒有及時收到回調,就可能會發生所謂的外部掉單。

防止外部掉單的關鍵,就在于,不能傻傻地隻等三方的回調通知,而要主動去查詢,使用者發起支付的3s之後,就可以發起輪詢了,直到拿到支付流水的最終狀态,主動輪詢,一般可以這麼實作:

談一談大廠都怎麼防止重複下單?
  • 定時任務輪詢
  • 使用定時任務,掃描表中支付中的流水,主動查詢支付的狀态,定時任務的實作方式有很多,線程池、排程架構、分布式排程架構等等。
  • 定時任務輪詢的缺點有兩個:
  • 對資料庫有一些壓力,觀察監控,會發現定時任務掃表的時候,有時候會造成資料庫的一些“峰刺”
  • 不便調整頻率,實際上,使用者發起一筆支付之後,一般都會在10s-1min中完成支付,越往後,使用者完成支付,是以輪詢梯度進行,會更合理一些,輪詢的間隔可以設定成類似這種:3s,10s,30s,3min……
  • 延時消息輪詢
  • 另外一種方式就是使用延時消息,使用者發起支付之後,發送一個延時消息,消費到延時消息之後,查詢流水支付狀态,沒有拿到最終狀态,就再發一個延時消息。延時消息的好處是對資料庫的壓力沒有那麼大,輪詢的梯度也可以進行控制,缺點是實作起來複雜一些,而且要維護消息隊列。

同步+異步防止内部掉單

支付服務在收到異步通知回調、或者主動輪詢到流水的最終狀态後,要通知訂單服務支付流水的變化,訂單服務同步更新訂單的狀态,這個過程要盡可能保證通知成功,可以采用同步+異步的方式。

  • 同步調用:支付服務調用訂單服務的通知接口,有可能會因為網絡等等的原因失敗,也可以重試,但是根據經驗,如果網絡出現一些波動,重試很可能也會失敗。
  • 異步通知:支付服務還應該發送一個支付成功的消息,訂單服務可以利用消息隊列的重試機制,來盡可能保證支付狀态的同步。

這裡還有一個問題,用戶端如何同步這個狀态?因為可能服務端更新了訂單狀态,但是用戶端的界面上還是未支付,得使用者主動重新整理一下,才能拿到最新的狀态,這樣明顯是不太合适的。

服務端、用戶端的狀态同步,無非就拉和推:

  • 拉:很簡單,就是用戶端在使用者跳回訂單狀态頁的時候,輪詢一會,如果使用者完成支付,通常很短時間就能擷取到狀态的變更,當然這種方式對用戶端的性能會有一些影響,而且很出現狀态同步“漏網之魚”的情況。
  • 推:推的實作有些麻煩,Web通常是用Websocket,對APP端的推送,一般采用第三方的推送平台。

用戶端支付盡可能不外跳

不管從産品的角度,還是技術的角度,用戶端發起支付這一步,其實應該盡可能地不要外跳,PC端使用支付服務生成的支付碼,而不是跳轉;移動端網頁、APP在應用内展示支付頁,當然這個是由第三方支付平台決定的。

談一談大廠都怎麼防止重複下單?

不知道大家留意到了沒有,現在的支付寶,已經做到了不用拉起錢包,在應用内就可以完成支付,這個對于商家的意義還是比較大的,對使用者體驗、支付成功率,都有正面的作用,相信以國内的内卷程度,其它支付供應商,一定會“跟進”

四、總結

  • 建立訂單服務,可通過預生成訂單号,然後利用 DB 的訂單号唯一限制,避免重複寫入訂單,實作建立訂單服務的幂等性
  • 更新訂單服務,通過一個版本号機制,每次更新資料前校驗版本号,更新資料同時自增版本号,這樣的方式,來解決 ABA 問題,確定更新訂單服務的幂等性

兩種幂等的實作方法,就可以保證,無論請求是不是重複,訂單表中的資料都是正确的。

實作訂單幂等的方法,完全可以套用在其他需要實作幂等的服務中,隻需要這個服務操作的資料儲存在資料庫中,并且有一張帶有主鍵的資料表即可。