在設計交易系統時,穩定性、可擴充套件性、可維護性都是我們需要關注的重點。本文將對如何通過狀態機在交易系統中的應用解決上述問題做出一些探討。
關於馬蜂窩機票訂單交易系統
交易系統往往存在訂單維度多、狀態多、交易鏈路長、流程複雜等特點。以馬蜂窩大交通業務中的機票交易為例,使用者提交的一個訂單除了機票資訊之外可能還包含很多資訊,比如保險或者其他附加產品。其中保險又分為很多型別,如航意險、航延險、組合險等。
從使用者的維度看,一個訂單是由購買的主產品機票和附加產品共同構成,支付的時候是作為一個整體去支付,而如果想要退票、退保也是可以部分操作的;從供應商的維度看,一個訂單中的每個產品背後都有獨立的供應商,機票有機票的供應商,保險有保險的供應商,每個供應商的訂單都需要分開出票、獨立結算。
使用者的購買支付流程、供應商的出票出保流程,構成一個有機的整體穿插在機票交易系統中,密不可分。
狀態機在機票交易系統中的應用與優化
有限狀態機的概念
有限狀態機(以下簡稱狀態機)是一種用於對事物或者物件行為進行建模的工具。
狀態機將複雜的邏輯簡化為有限個穩定狀態,構建在這些狀態之間的轉移和動作等行為的數學模型,在穩定狀態中判斷事件。
對狀態機輸入一個事件,狀態機會根據當前狀態和觸發的事件唯一確定一個狀態遷移。
業務系統的本質就是描述真實的世界,因此幾乎所有的業務系統中都會有狀態機的影子。訂單交易流程更是天然適合狀態機模型的應用。
以使用者支付流程為例,如果不使用狀態機,在接收到支付成功回撥時則需要執行一系列動作:查詢支付流水號、記錄支付時間、修改主訂單狀態為已支付、通知供應商去出票、記錄通知出票時間、修改機票子訂單狀態為出票中…… 邏輯非常繁瑣,而且程式碼耦合嚴重。
為了使交易系統的訂單狀態按照設計流程正確向下流轉,比如當前使用者已支付,不允許再支付;當前訂單已經關單,不能再通知出票等等,我們通過應用狀態機的方式來優化機票交易系統,將所有的狀態、事件、動作都抽離出來,對複雜的狀態遷移邏輯進行統一管理,來取代冗長的 if else 判斷,使機票交易系統中的複雜問題得以解耦,變得直觀、方便操作,使系統更加易於維護和管理。
狀態機設計
在資料庫設計層面,我們將整個訂單整體作為一個主訂單,把供應商的訂單作為子訂單。假設一個使用者同時購買了機票和保險,因為機票、保險對應的是不同的供應商,也就是 1 個主訂單 order 對應 2 個子訂單 sub_order。其中主訂單 order 記錄使用者的資訊(UID、聯絡方式、訂單總價格等),子訂單 sub_order 記錄產品型別、供應商訂單號、結算價格等。
同時,我們把正向出票、逆向退票改簽分開,抽成不同的子系統。這樣每個子系統都是完全獨立的,有利於系統的維護和擴充。
對於機票正向子系統而言,有兩套狀態機:主訂單狀態機負責管理 order 的狀態,包括創單成功、支付成功、交易成功、訂單關閉等;子訂單狀態機負責管理 sub_order 的狀態,維護預訂成功到出票的流程。同樣,對於逆向退票和改簽子系統,也會有各自的狀態機。
框架選型
目前業界常用的狀態機引擎框架主要有 Spring Statemachine、Stateless4j、Squirrel-Foundation 等。經過結合實際業務進行橫向對比後,最終我們決定使用 Squirrel-Foundation,主要是因為:
-
程式碼量適中,擴充套件和維護相對而言比較容易;
-
StateMachine 輕量,例項建立開銷小;
-
切入點豐富,支援狀態進入、狀態完成、異常等節點的監聽,使轉換過程留有足夠的切入點;
-
支援使用註解定義狀態轉移,使用方便;
-
從設計上不支援單例複用,只能隨用隨 New,因此狀態機的本身的生命流管理很清晰,不會因為狀態機單例複用的問題造成麻煩。
MSM 的設計與實現
結合大交通業務邏輯,我們在 Squirrel-Foundation 的基礎之上進行了 Action 概念的抽取和二次封裝,將狀態遷移、非同步訊息糅合到一起,封裝成為 MSM 框架 (MFW State Machine),用來實現業務訂單狀態定義、事件定義和狀態機定義,並用註解的形式來描述狀態遷移。
我們認為一次狀態遷移必然會伴隨著非同步訊息,因此把一個流程中必須要成功的資料庫操作放到一個事務中,把允許失敗重試並且對實時度要求不高的操作放到非同步訊息消費的流程中。
以機票訂單支付成功為例,機票訂單支付成功時,會涉及修改訂單狀態為已支付、更新支付流水號等,這些是在一個事務中;而通知供應商出票,則是放在非同步訊息消費中處理。非同步訊息的實現使用的是 RocketMQ,主要考慮到 RocketMQ 支援二階段提交,訊息可靠性有保證,支援重試,支援多個 Consumer 組。
以下具體說明:
1. 對每個狀態遷移需要執行的動作,都會抽取出一個Action 類,並且繼承 AbstractAction,支援多個不同的狀態遷移執行相同的動作。這裡主要取決於 public List matchConditions() 的實現,因此只需要 matchConditions 返回多個初始狀態-事件的匹配條件鍵值對就可以了。每個 Action 都有一個對應的繼承 MFWContext 類的上下文類,用於在 process saveDB 等方法中的通訊。
2. 註冊所有的 Action,新增每個狀態遷移執行完成或者執行失敗的監聽。
3. 由於依賴 RocketMQ 非同步訊息,所以需要一個 Spring Bean 去繼承 BaseMessageSender,這個類會生成非同步訊息提供者。如果要使用二階段提交,則需要一個類繼承 BaseMsgTransactionListener,這裡可以參考機票的 OrderChangeMessageSender 和 OrderChangeMsgTransactionListener。
4. 最後,實現一個事件觸發器類。在這個類裡面包含一個 Apply 方法,傳入訂單 PO 物件、事件、對應的上下文,每次執行都例項化出一個狀態機例項,並初始化當前狀態,並呼叫 Fire 方法。
5. 例項化一個狀態機物件,設定當前狀態為資料庫對應的狀態,呼叫 Fire 方法之後,最終會執行到 OrderStateMachine 類裡面用註解描述的 callMethod 方法。我們配置的是 callMethod = "action",它就會反射執行當前類的 Action 方法。
Action 方法我們的實現是通過 super.action(from, to, event, context),就會執行 MFWStateMachine 的 Action 方法,先去根據當前狀態和事件獲取對應的Action,這裡使用到了「工廠模式」,然後執行 Process 方法。如果成功,會執行在 MFWStateMachine 類初始化的 TransitionCompleteListener,執行該 Action的 afterProcess 方法來修改資料庫記錄以及傳送訊息;如果失敗,會執行TransitionExceptionListener,執行該 Action 的onException 方法來進行相應處理。
綜上,MSM 可以根據 Action 類的宣告和配置,來動態生成出 Squirrel-Foundation 的狀態機定義,而不需要由使用方再去定義一次,使 MSM 的使用更方便。
趟過的坑
1. 事務不生效
最初我們使用 Spring 註解方式進行事務管理,即在 Action 類的資料庫操作方法上加 @Transactional 註解,卻發現在實踐中不起作用。經過排查後發現, Spring 的事務註解是靠 AOP 切面實現的。在物件內部的方法中呼叫該物件其他使用 AOP 註解的方法,被呼叫方法的 AOP 註解會失效。因為同一個類的內部程式碼呼叫中,不會走代理類。後來我們通過手動開啟事務的方式來解決此問題。
2. 匹配 Action
最初我們匹配 Action 有兩種方式:精準匹配及非精準匹配。精準匹配是指只有當某個狀態遷移的初始狀態和觸發的事件一致時,才能匹配到 Action;非精準匹配是指只要觸發的事件一致,就可以匹配到 Action。後來我們發現非精準匹配在某些情形下會出現問題,於是統一改成了多條件精準匹配。即在執行狀態機觸發時執行的 Action 方法時,去精準匹配 Action,多個狀態遷移執行的方法可以匹配到同一個 Action,這樣能夠複用 Action 程式碼而不會出問題。
3. 非同步訊息一致性
有一些情況是絕不能出現的,比如修改資料庫沒成功即發出了訊息;或是修改資料庫成功了,而傳送訊息失敗;或是在提交資料庫事務之前,訊息已經傳送成功了。解決這個問題我們用到了 RocketMQ 的事務訊息功能,它支援二階段提交,會先傳送一條預處理訊息,然後回撥執行本地事務,最終提交或者回滾,幫助保證修改資料庫的資訊和傳送非同步訊息的一致。
4. 同一條訂單資料併發執行不同事件
在某些情況下,同一條訂單資料可能會在同一時間(毫秒級)同時觸發不同的事件。如機票主訂單在待支付狀態下,可以接收支付中心的回撥,觸發支付成功事件;也可以由使用者點選取消訂單,或者超時未支付定時任務來觸發關單事件。如果不做任何控制的話,一個訂單將可能出現既支付成功又會被取消。
我們用資料庫樂觀鎖來規避這個問題:在執行修改資料庫的事務時,update 訂單的語句帶有原狀態的條件判斷,通過判斷更新行數是否為 1,來決定是否丟擲異常,即生成這樣的 SQL 語句:update order where order_id = ‘1234' and order_status = ‘待支付'。
這樣的話,如果兩個事件同時觸發同時執行,誰先把事務提交成功,誰就能執行成功;事務提交較晚的事件會因為更新行數為 0 而執行失敗,最終回滾事務,就彷彿無事發生過一樣。
使用悲觀鎖也可以解決這個問題,這種方式是誰先爭搶到鎖誰就可以成功執行。但考慮到可能會有指令碼對資料庫批量修改,悲觀鎖存在死鎖的潛在問題,我們最終還是採用了樂觀鎖的方式。
總結
MSM 狀態機的定義和宣告在 Squirrel-Foundation 的基礎之上,抽取出 Action 概念,並對 Action 類配置起始狀態、目標狀態、觸發的事件、上下文定義等,使 MSM 可以根據 Action 類的宣告和配置,來動態生成出 Squirrel-Foundation 的狀態機定義,而不需要使用方再去定義一次,操作更簡單,維護起來也更容易。
通過使用狀態機,機票訂單交易系統的流程複雜性問題迎刃而解,系統在穩定性、可擴充套件性、可維護性等方面也得到了顯著的改善和提升。
狀態機的使用場景不僅僅侷限於訂單交易系統,其他一些涉及到狀態變更的複雜流程的系統也同樣適用。希望通過本文的介紹,能使有狀態機瞭解和使用需求的讀者朋友有所收穫。
本文作者:董天,馬蜂窩大交通研發團隊機票交易系統研發工程師。
(馬蜂窩技術原創內容,轉載務必註明出處儲存文末二維碼圖片,謝謝配合。)