圖解支付系統的交易訂單狀態機設計

大家好,我是隱墨星辰,今天和大家聊聊交易單據狀態機的設計與核心代碼實現。

假如你做的是支付、電商等這類交易系統,但沒有聽過狀態機,或者你聽過但沒有寫過,又或者你是使用 if else 或 switch case 來寫交易訂單的狀態推進,建議花點時間看看,一定會有不一樣的收穫。如果你是產品經理,可以考慮推薦給你們的研發同學,說不定能提升系統的健壯性。

這篇文章主要講清楚:清楚什麼是狀態機,簡潔的狀態機對交易系統的重要性,狀態機設計常見誤區,以及如何設計出簡潔而精妙的狀態機,核心的狀態機代碼實現。

1. 前言

在交易類系統中,比如支付系統或電商系統,交易單據的狀態管理至關重要。一個合理的狀態機設計可以幫助我們清晰地掌握交易單據的狀態變化,提高系統的健壯性和可維護性。本文將一步步介紹狀態機的概念、其在支付系統中的重要性、設計原則、常見誤區、最佳實踐,以及一個實際的 Java 代碼實現。

2. 什麼是狀態機

狀態機,也稱爲有限狀態機(FSM, Finite State Machine),是一種行爲模型,由一組定義良好的狀態、狀態之間的轉換規則和一個初始狀態組成。它根據當前的狀態和輸入的事件,從一個狀態轉移到另一個狀態。

下圖是典型的狀態機設計:

下圖就是在《支付交易的三重奏:收單、結算與拒付在支付系統中的協奏曲》中提到的交易單的狀態機。

從圖中可以看到,一共 4 個狀態,每個狀態之間的轉換由指定的事件觸發。假如當前是 “INIT”,當有“支付中” 的事件發生,就會推進到 “PAYING” 狀態。

3. 狀態機對支付系統的重要性

想像一下,如果沒有狀態機,支付系統如何知道訂單已經支付成功了呢?如果你的訂單已經被一個線程更新爲 “成功”,另一個線程又更新成 “失敗”,你會不會跳起來?

在支付系統中,狀態機管理着每筆交易的生命週期,從初始化到成功或失敗。它確保交易在正確的時間點,以正確的順序流轉到正確的狀態。這不僅提高了交易處理的效率和一致性,還增強了系統的健壯性,使其能夠有效處理異常和錯誤,確保支付流程的順暢。

4. 狀態機設計基本原則

無論是設計支付類的系統,還是電商類的系統,在設計狀態機時,都建議遵循以下原則:

  1. 明確性: 狀態和轉換必須清晰定義,避免含糊不清的狀態。

  2. 完備性: 爲所有可能的事件 - 狀態組合定義轉換邏輯。

  3. 可預測性: 系統應根據當前狀態和給定事件可預測地響應。

  4. 最小化: 狀態數應保持最小,避免不必要的複雜性。

  5. 可擴展性:狀態機應該具有良好的可擴展性,以便在業務需求變化時能夠方便地添加新的狀態和轉換規則

5. 狀態機常見設計誤區

工作多年,見過很多設計得不好的狀態機,導致運維特別麻煩,還容易出故障,總結出來一共有這麼幾條:

  1. 過度設計: 引入不必要的狀態和複雜性,使系統難以理解和維護。

  2. 不完備的處理: 未能處理所有可能的狀態轉換,導致系統行爲不確定。

  3. 硬編碼邏輯: 過多的硬編碼轉換邏輯,使系統不具備靈活性和可擴展性。

舉一個例子感受一下。下面是親眼見過的一個交易單的狀態機設計,而且一眼看過去,好像除了複雜一點,整體還是合理的,比如初始化,受理成功就到 ACCEPT,然後到 PAYING,如果直接成功就到 PAID,退款成功就到 REFUNDED。

這個狀態機有幾個明顯不合理的地方:

  1. 過於複雜。一些不必要的狀態可以去掉,比如 ACCEPT 沒有存在的必要,因爲 INIT 說明已經受理入庫。

  2. 職責不明確。支付單就只管支付,到 PAID 就支付成功,就是終態不再改變。REFUNDED 應該由退款單來負責處理,否則部分退款怎麼辦。

我們需要的改造方案:

  1. 精簡掉不必要的狀態,比如 ACCEPT。

  2. 把一些退款、請款等單據單獨抽出去,通過獨立的退款單、請款單來獨立管理,這樣單據類型雖然多了,但是架構更加清晰合理。

主單:

普通支付單:

預授權單:

請款單:

退款單:

6. 狀態機設計的最佳實踐

在代碼實現層面,需要做到以下幾點:

  1. 分離狀態和處理邏輯:使用狀態模式,將每個狀態的行爲封裝在各自的類中。

  2. 使用事件驅動模型:通過事件來觸發狀態轉換,而不是直接調用狀態方法。

  3. 確保可追蹤性:狀態轉換應該能被記錄和追蹤,以便於故障排查和審計。

具體的實現參考後面的 “JAVA 版本狀態機核心代碼實現”。

7. 常見代碼實現誤區

經常看到工作幾年的同學實現狀態機時,仍然使用 if else 或 switch case 來寫。這不是最優的方案,會讓實現變得複雜,且容易出現問題。

甚至還見過直接在訂單的領域模型裏面使用 String 來定義狀態,而不是把狀態模式封裝單獨的類。

還有就是直接調用領域模型更新狀態,而不是通過事件來驅動。

錯誤的代碼示例:

通過 if else 來實現:

if (status.equals("PAYING") {
    status = "SUCCESS";
} else if (...) {
    ...
}

直接設置狀態,出現不可預測性:

class OrderDomainService {
    public void notify(PaymentNotifyMessage message) {
        PaymentModel paymentModel = loadPaymentModel(message.getPaymentId());
        // 直接設置狀態
        paymentModel.setStatus(PaymentStatus.valueOf(message.status);
        // 其它業務處理
        ... ...
    }
}

使用複雜的 switch,不好理解:

public void transition(Event event) {
    switch (currentState) {
        case INIT:
            if (event == Event.PAYING) {
                currentState = State.PAYING;
            } else if (event == Event.SUCESS) {
                currentState = State.SUCESS;
            } else if (event == Event.FAIL) {
                currentState = State.FAIL;
            }
            break;
            // Add other case statements for different states and events
        case PROCESSING:
            if (event == OrderEvent.PAY_SUCCESS) {
                ... ...
            }
            ... ...
    }
}

8. Spring Statemachine 簡要介紹

Spring Statemachine 項目是一個非常成熟的狀態機實現項目,核心思路如下:

  1. State ,狀態。一個狀態機至少要包含兩個或以上的狀態。狀態與狀態之間可以轉換。

  2. Event ,事件。事件就是執行狀態轉換的觸發條件。

  3. Action ,動作。事件發生以後要執行動作。

  4. Transition ,變換。也就是從一個狀態變化爲另一個狀態。

網上的介紹和應用示例有很多,可直接參考網上優秀文章,這裏不重複。

9. 用 JAVA 實現一個簡潔的交易狀態機

Spring Statemachine 使用起來稍顯複雜,這裏重點介紹如何使用 Java 代碼來實現一個簡潔的狀態機,部分思路參考 Spring Statemachine。

我們將採用枚舉來定義狀態和事件,以及一個狀態機類來管理狀態轉換。下面是詳細步驟:

  1. 定義狀態基類。
/**
 * 狀態基類
 */
public interface BaseStatus {
}
  1. 定義事件基類。
/**
 * 事件基類
 */
public interface BaseEvent {
}
  1. 定義 “狀態 - 事件對”,指定的狀態只能接受指定的事件。
/**
 * 狀態事件對,指定的狀態只能接受指定的事件
 */
public class StatusEventPair<S extends BaseStatus, E extends BaseEvent> {
    /**
     * 指定的狀態
     */
    private final S status;
    /**
     * 可接受的事件
     */
    private final E event;
    public StatusEventPair(S status, E event) {
        this.status = status;
        this.event = event;
    }
    @Override
    public boolean equals(Object obj) {
        if (obj instanceof StatusEventPair) {
            StatusEventPair<S, E> other = (StatusEventPair<S, E>)obj;
            return this.status.equals(other.status) && this.event.equals(other.event);
        }
        return false;
    }
    @Override
    public int hashCode() {
        // 這裏使用的是google的guava包。com.google.common.base.Objects
        return Objects.hash(status, event);
    }
}
  1. 定義狀態機。
/**
 * 狀態機
 */
public class StateMachine<S extends BaseStatus, E extends BaseEvent> {
    private final Map<StatusEventPair<S, E>, S> statusEventMap = new HashMap<>();
    /**
     * 只接受指定的當前狀態下,指定的事件觸發,可以到達的指定目標狀態
     */
    public void accept(S sourceStatus, E event, S targetStatus) {
        statusEventMap.put(new StatusEventPair<>(sourceStatus, event), targetStatus);
    }
    /**
     * 通過源狀態和事件,獲取目標狀態
     */
    public S getTargetStatus(S sourceStatus, E event) {
        return statusEventMap.get(new StatusEventPair<>(sourceStatus, event));
    }
}
  1. 實現交易訂單(比如支付)的狀態機。注:支付、退款等不同的業務狀態機是獨立的
/**
 * 支付狀態機
 */
public enum PaymentStatus implements BaseStatus {
    INIT("INIT", "初始化"),
    PAYING("PAYING", "支付中"),
    PAID("PAID", "支付成功"),
    FAILED("FAILED", "支付失敗"),
    ;
    // 支付狀態機內容
    private static final StateMachine<PaymentStatus, PaymentEvent> STATE_MACHINE = new StateMachine<>();
    static {
        // 初始狀態
        STATE_MACHINE.accept(null, PaymentEvent.PAY_CREATE, INIT);
        // 支付中
        STATE_MACHINE.accept(INIT, PaymentEvent.PAY_PROCESS, PAYING);
        // 支付成功
        STATE_MACHINE.accept(PAYING, PaymentEvent.PAY_SUCCESS, PAID);
        // 支付失敗
        STATE_MACHINE.accept(PAYING, PaymentEvent.PAY_FAIL, FAILED);
    }
    // 狀態
    private final String status;
    // 描述
    private final String description;
    PaymentStatus(String status, String description) {
        this.status = status;
        this.description = description;
    }
    /**
     * 通過源狀態和事件類型獲取目標狀態
     */
    public static PaymentStatus getTargetStatus(PaymentStatus sourceStatus, PaymentEvent event) {
        return STATE_MACHINE.getTargetStatus(sourceStatus, event);
    }
}
  1. 定義支付事件。注:支付、退款等不同業務的事件是不一樣的
/**
 * 支付事件
 */
public enum PaymentEvent implements BaseEvent {
    // 支付創建
    PAY_CREATE("PAY_CREATE", "支付創建"),
    // 支付中
    PAY_PROCESS("PAY_PROCESS", "支付中"),
    // 支付成功
    PAY_SUCCESS("PAY_SUCCESS", "支付成功"),
    // 支付失敗
    PAY_FAIL("PAY_FAIL", "支付失敗");
    /**
     * 事件
     */
    private String event;
    /**
     * 事件描述
     */
    private String description;
    PaymentEvent(String event, String description) {
        this.event = event;
        this.description = description;
    }
}

以上狀態機定義完成。下面是如何使用的代碼:

  1. 在支付單模型中聲明狀態和根據事件推進狀態的方法:
/**
 * 支付單模型
 */
public class PaymentModel {
    /**
     * 其它所有字段省略
     */
    // 上次狀態
    private PaymentStatus lastStatus;
    // 當前狀態
    private PaymentStatus currentStatus;
    /**
     * 根據事件推進狀態
     */
    public void transferStatusByEvent(PaymentEvent event) {
        // 根據當前狀態和事件,去獲取目標狀態
        PaymentStatus targetStatus = PaymentStatus.getTargetStatus(currentStatus, event);
        // 如果目標狀態不爲空,說明是可以推進的
        if (targetStatus != null) {
            lastStatus = currentStatus;
            currentStatus = targetStatus;
        } else {
            // 目標狀態爲空,說明是非法推進,進入異常處理,這裏只是拋出去,由調用者去具體處理
            throw new StateMachineException(currentStatus, event, "狀態轉換失敗");
        }
    }
}

代碼註釋已經寫得很清楚,其中 StateMachineException 是自定義,不想定義的話,直接使用 RuntimeException 也是可以的。

  1. 在支付業務代碼中的使用:只需要 paymentModel.transferStatusByEvent(message.getEvent())。
/**
 * 支付領域域服務
 */
public class PaymentDomainServiceImpl implements PaymentDomainService {
    /**
     * 支付結果通知
     */
    public void notify(PaymentNotifyMessage message) {
        PaymentModel paymentModel = loadPaymentModel(message.getPaymentId());
        try {
          // 狀態推進
          paymentModel.transferStatusByEvent(message.getEvent());
          savePaymentModel(paymentModel);
          // 其它業務處理
          ... ...
        } catch (StateMachineException e) {
            // 異常處理
            ... ...
        } catch (Exception e) {
            // 異常處理
            ... ...
        }
    }
}

上面的代碼只需要完善異常處理,優化一下注釋,就可以直接用起來。

好處:

  1. 定義了明確的狀態、事件、以及通過什麼事件可以推動哪些狀態轉換。

  2. 狀態機的推進,只能通過 “當前狀態、事件、目標狀態” 來推進,不能通過 if else 或 case switch 來直接寫。比如:STATE_MACHINE.accept(INIT, PaymentEvent.PAY_PROCESS, PAYING);

  3. 避免終態變更。比如線上碰到 if else 寫狀態機,渠道異步通知比同步返回還快,異步通知回來把訂單更新爲 “PAID”,然後同步返回的代碼把單據重新推進到 PAYING。

相對於 Spring Statemachine 而言,有兩個優勢:

  1. 更爲簡潔,所有狀態機相關的代碼也就 100 行左右。

  2. 使用起來更清晰明確。

10. 併發更新問題

有位讀者提到:“狀態機領域模型同時被兩個線程操作怎麼避免狀態冪等問題?”

這是一個好問題。在分佈式場景下,這種情況太過於常見。同一機器有可能多個線程處理同一筆業務,不同機器也可能處理同一筆業務。

業內通常的做法是設計良好的狀態機 + 數據庫鎖 + 數據版本號解決。

簡要說明:

  1. 狀態機一定要設計好,只有特定的原始狀態 + 特定的事件纔可以推進到指定的狀態。比如 INIT + 支付成功才能推進到 sucess。

  2. 更新數據庫之前,先使用 select for update 進行鎖行記錄,同時在更新時判斷版本號是否是之前取出來的版本號,更新成功就結束,更新失敗就組成消息發到消息隊列,後面再消費。(這裏爲什麼要有 select for update,又有版本號判斷?因爲先使用普通的 select 數據進行業務處理,在 update 時需要判斷版本號,避免被其它線程已經更新。同時因爲更新多張表,所以需要 select for update 做爲一個完整的事務)

  3. 通過補償機制兜底,比如查詢補單。

  4. 通過上述三個步驟,正常情況下,最終的數據狀態一定是正確的。除非是某個系統有異常,比如外部渠道開始返回支付成功,然後又返回支付失敗,說明依賴的外部系統已經異常,這樣只能進人工差錯處理流程。

11. 結束語

狀態機在交易系統中扮演着不可或缺的角色。通過合理的狀態機設計,我們可以清晰地管理交易單據的狀態變化,提高系統的健壯性和可維護性。本文介紹了狀態機設計原則、常見誤區和最佳實踐,並展示了一個簡潔的 JAVA 版本狀態機的核心代碼實現。希望本文能爲大家在實際項目中設計和實現交易單據狀態機提供一些有益的參考。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/jl4eoUfmbbhf9OkDXz_BsA