支付系统核心逻辑 — — 状态机(Java&Golang版本)

一、概念:FSM(有限状态机),模式之间转换

状态机,也叫有限状态机(FSM,Finite State Machine),是一种行为模式,是由一组定义良好的状态、状态之间的转换规则和一个初始状态组成。

  • 根据当前的状态和输入的事件,从一个状态转移到另一个状态。

二、支付核心逻辑

2.1 支付交易三重奏:收单、结算、拒付款

下图中我们可以看到,一共4种状态,每个状态之间的转换都通过指定事件触发。


image.png

2.2 状态机设计原则

无论是设计支付类的系统,还是电商类的系统,在设计状态机时,都建议遵循以下原则

  • 明确性:状态和转换必须清晰定义,避免含糊不清的状态。
  • 完备性:为所有可能的事件-状态组合定义转换逻辑。
  • 可预测性:系统应根据当前状态和给定事件可预测地响应。
  • 最小化:状态数应保持最小,避免不必要的复杂性。

常见误区

  • 过度设计:引入不必要的状态
  • 不完备的处理:没有考虑到状态与事件所有可能的转换关系,导致系统行为不确定
  • 硬编码逻辑:过多硬编码转换逻辑,导致系统不具备可扩展性和灵活性

比如下面的设计:

一眼看过去,好像除了复杂一点,整体还是合理的,比如初始化,受理成功就到ACCEPT,然后到PAYING,如果直接成功就到PAIED,退款成功就到REFUND。


image.png

不合理的地方:

  • 流程复杂:第一眼看过去会发现不那么清晰,流程比较繁琐,比较复杂,有很多状态都可以简化或者舍去。比如ACCEPT没有存在的必要。
  • 职责不明确:支付单只管支付,到PAIED就算支付成功,最终状态不再改变。不应该后面还有REFUND状态。REFUND应该由退款单来负责处理,否则如果客户部分退款,我们就不好处理了。

改进方案:

  • 删除不必要的状态。如:ACCEPT
  • 将一个大型状态机抽取为多份小的状态机。比如把一些退款REFUND、请款等单据单独抽取出来。这个样子,虽然状态机数量多了,但是每个状态机都更加清晰明了。
  • 1、主单


    image.png
  • 2、普通支付单


    image.png
  • 3、预授权单


    image.png
  • 4、请款单


    image.png
  • 5、退款单


    image.png

最佳实践及代码规范

代码层面:

  • 分离状态和处理逻辑:使用状态模式,将每个状态的行为都封装在各自的类中
  • 使用事件驱动模型:通过事件来触发状态转换,而不是直接调用状态方法
  • 确保可追踪性:状态转换应被记录和追踪,以便故障排查和审计

上面几点也就要求我们不应该使用if else或者switch case来写,会让代码看起来复杂。我们应该将每个状态封装为单独的类。

三、 Java版本实现

  • 1、定义状态基类
/**
 * @Author 黄义波
 * @Date 2024/12/9 14:15
 * @Description 状态基类
 */
public interface BaseStatus {
}
  • 2、定义事件基类
/**
 * @Author 黄义波
 * @Date 2024/12/9 14:15
 * @Description 事件基类
 */
public interface BaseEvent {
}
  • 3、定义状态-事件对,指定的状态只能接受指定的事件
/**
 * @Author 黄义波
 * @Date 2024/12/9 14:15
 * @Description  状态事件对,指定的状态只能接受指定的事件
 */
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.hashCode(status, event);
    }
}
  • 4、定义状态机
/**
 * @Author 黄义波
 * @Date 2024/12/9 14:18
 * @Description 状态机
 */
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));
    }
}
  • 5、定义支付状态机。注:支付、退款等不同的业务状态机是独立的。
/**
 * @Author 黄义波
 * @Date 2024/12/9 14:23
 * @Description 支付状态机
 */
@AllArgsConstructor
@Getter
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;

    /**
     * 通过源状态和事件类型获取目标状态
     */
    public static PaymentStatus getTargetStatus(PaymentStatus sourceStatus, PaymentEvent event) {
        return STATE_MACHINE.getTargetStatus(sourceStatus, event);
    }
}
  • 6、定义支付事件。注:支付、退款等不同业务的事件是不一样的。
/**
 * @Author 黄义波
 * @Date 2024/12/9 14:24
 * @Description 支付事件
 */
@Getter
@AllArgsConstructor
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;

}
  • 7、在支付单模型中声明状态和根据事件推进状态的方法
/**
 * @Author 黄义波
 * @Date 2024/12/9 14:24
 * @Description 支付单模型
 */
@Data
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, "状态转换失败");
        }
    }
}
  • 8、定义StateMachineException,也可以直接使用RuntimeException
/**
 * @Author 黄义波
 * @Date 2024/12/9 14:26
 * @Description 状态机异常
 */
@Data
public class StateMachineException extends RuntimeException {

    private static final long serialVersionUID = 6610083281801529147L;

    private Integer code;

    private String message;

    private PaymentEvent event;

    private PaymentStatus status;

    public StateMachineException(Integer code,String message) {
        super(message);
        this.code = code;
    }

    public StateMachineException(String message) {
        super(message);
        this.code = ErrorCodeEnum.ERROR.getCode();
    }

    public StateMachineException(PaymentStatus status, PaymentEvent event, String message) {
        this.message = status.getDescription() + event.getDescription() + message;
        this.code = ErrorCodeEnum.ERROR.getCode();
    }
}

在支付业务代码中的使用:只需要paymentModel.transferStatusByEvent(PaymentEvent.valueOf(message.getEvent()))

/**
 * 支付领域域服务
 */
public class PaymentDomainServiceImpl implements PaymentDomainService {

    /**
     * 支付结果通知
     */
    public void notify(PaymentNotifyMessage message) {
        PaymentModel paymentModel = loadPaymentModel(message.getPaymentId());
        try {
            
            // 状态推进
            paymentModel.transferStatusByEvent(PaymentEvent.valueOf(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写状态机,渠道异步通知比同步返回还快,异步通知回来把订单更新为“PAIED”,然后同步返回的代码把单据重新推进到PAYING。

四、Golang版本实现

项目结构:


image.png
  • 1、定义基础状态机:base_state_machine.go
package model

type BaseStatus interface {
}

type BaseEvent interface {
}

type StatusEventPair struct {
    status BaseStatus
    event  BaseEvent
}

func (pair StatusEventPair) equals(other StatusEventPair) bool {
    return pair.status == other.status && pair.event == other.event
}

type StateMachine struct {
    statusEventMap map[StatusEventPair]BaseStatus
}

func (sm *StateMachine) accept(sourceStatus BaseStatus, event BaseEvent, targetStatus BaseStatus) {
    pair := StatusEventPair{status: sourceStatus, event: event}
    sm.statusEventMap[pair] = targetStatus
}

func (sm *StateMachine) getTargetStatus(sourceStatus BaseStatus, event BaseEvent) BaseStatus {
    pair := StatusEventPair{status: sourceStatus, event: event}
    baseStatus := sm.statusEventMap[pair]
    return baseStatus
}
  • 2、定义支付状态机:payment_state_machine.go
package model

type PaymentStatus string

const (
    INIT   PaymentStatus = "INIT"
    PAYING PaymentStatus = "PAYING"
    PAID   PaymentStatus = "PAID"
    FAILED PaymentStatus = "FAILED"
)

type PaymentEvent string

const (
    PAY_CREATE  PaymentEvent = "PAY_CREATE"
    PAY_PROCESS PaymentEvent = "PAY_PROCESS"
    PAY_SUCCESS PaymentEvent = "PAY_SUCCESS"
    PAY_FAIL    PaymentEvent = "PAY_FAIL"
)

var PaymentStateMachine = StateMachine{statusEventMap: map[StatusEventPair]BaseStatus{}}

func init() {
    //支付状态机初始化,包含所有可能的情况
    PaymentStateMachine.accept(nil, PAY_CREATE, INIT)
    PaymentStateMachine.accept(INIT, PAY_PROCESS, PAYING)
    PaymentStateMachine.accept(PAYING, PAY_SUCCESS, PAID)
    PaymentStateMachine.accept(PAYING, PAY_FAIL, FAILED)
}

func GetTargetStatus(sourceStatus PaymentStatus, event PaymentEvent) PaymentStatus {
    status := PaymentStateMachine.getTargetStatus(sourceStatus, event)
    if status != nil {
        return status.(PaymentStatus)
    }
    panic("获取目标状态失败")
}

type PaymentModel struct {
    lastStatus    PaymentStatus
    CurrentStatus PaymentStatus
}

func (pm *PaymentModel) TransferStatusByEvent(event PaymentEvent) {
    targetStatus := GetTargetStatus(pm.CurrentStatus, event)
    if targetStatus != "" {
        pm.lastStatus = pm.CurrentStatus
        pm.CurrentStatus = targetStatus
    } else {
        // 处理异常
        panic("状态转换失败")
    }
}
  • 3、使用及测试:main.go:
package main

import (
    "github.com/kataras/iris/v12"
    "github.com/kataras/iris/v12/context"
    "github.com/ziyifast/log"
    "myTest/demo_home/state_machine_demo/model"
    "time"
)

var (
    testOrder = new(model.PaymentModel)
)

func main() {
    application := iris.New()
    application.Get("/order/create", createOrder)
    application.Get("/order/pay", payOrder)
    application.Get("/order/status", getOrderStatus)
    application.Listen(":8899", nil)
}

func createOrder(context *context.Context) {
    testOrder.CurrentStatus = model.INIT
    context.WriteString("create order succ...")
}

func payOrder(context *context.Context) {
    testOrder.TransferStatusByEvent(model.PAY_PROCESS)
    log.Infof("call third api....")
    //调用第三方支付接口和其他业务处理逻辑
    time.Sleep(time.Second * 15)
    log.Infof("done...")
    testOrder.TransferStatusByEvent(model.PAY_SUCCESS)
}

func getOrderStatus(context *context.Context) {
    context.WriteString(string(testOrder.CurrentStatus))
}

声明:为了快速验证以及让代码更加简洁,没有按照标准的规范来编写controller、service、dao等。

五、测试:

  • 1、启动程序,调用create接口,创建订单

http://localhost:8899/order/create

image.png

  • 2、调用支付接口支付订单

http://localhost:8899/order/pay

我们手动模拟调用第三方支付接口,sleep了几十秒(实际调用肯定比这个快多了),所以不会立即返回结果,我们需要新开一个窗口,直接查询订单状态


image.png
  • 3、立即调用查询接口获取订单状态,查看是否为支付中

http://localhost:8899/order/status

image.png

  • 4、等待支付成功后,调用接口查看订单状态,是否为已支付

等待后台日志打印done之后重新调用查询接口:


image.png

http://localhost:8899/order/status

image.png

六、并发更新问题:多线程修改同一状态机(db版本号)

状态机领域模型同时被两个线程操作怎么避免状态幂等问题
这是一个好问题。在分布式场景下,这种情况太过于常见。同一机器有可能多个线程处理同一笔业务,不同机器也可能处理同一笔业务。

业内通常的做法是设计良好的状态机 + 数据库锁 + 数据版本号解决。

image.png

简要说明:

  • 状态机一定要设计好,只有特定的原始状态 + 特定的事件才可以推进到指定的状态。比如 INIT + 支付成功才能推进到sucess。
  • 更新数据库之前,先使用select for update进行锁行记录,同时在更新时判断版本号是否是之前取出来的版本号,更新成功就结束,更新失败就组成消息发到消息队列,后面再消费。
  • 通过补偿机制兜底,比如查询补单。

通过上述三个步骤,正常情况下,最终的数据状态一定是正确的。除非是某个系统有异常,比如外部渠道开始返回支付成功,然后又返回支付失败,说明依赖的外部系统已经异常,这样只能进人工差错处理流程。

 

参考:
https://blog.csdn.net/weixin_45565886/article/details/137651521

©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 229,327评论 6 537
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 98,996评论 3 423
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 177,316评论 0 382
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 63,406评论 1 316
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 72,128评论 6 410
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 55,524评论 1 324
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 43,576评论 3 444
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 42,759评论 0 289
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 49,310评论 1 335
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 41,065评论 3 356
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 43,249评论 1 371
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 38,821评论 5 362
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 44,479评论 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 34,909评论 0 28
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 36,140评论 1 290
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 51,984评论 3 395
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 48,228评论 2 375

推荐阅读更多精彩内容