支付系统核心逻辑 — — 状态机
代码地址:https://github.com/ziyifast/ziyifast-code_instruction/tree/main/state_machine_demo
1 概念:FSM(有限状态机),模式之间转换
状态机,也叫有限状态机(FSM,Finite State Machine),是一种行为模式,是由一组定义良好的状态、状态之间的转换规则和一个初始状态组成。
- 根据当前的状态和输入的事件,从一个状态转移到另一个状态。
2 实战:支付核心逻辑
2.1 支付交易三重奏:收单、结算、拒付款
下图中我们可以看到,一共4种状态,每个状态之间的转换都通过指定事件触发。
2.2 状态机设计原则
无论是设计支付类的系统,还是电商类的系统,在设计状态机时,都建议遵循以下原则
- 明确性:状态和转换必须清晰定义,避免含糊不清的状态。
- 完备性:为所有可能的事件-状态组合定义转换逻辑。
- 可预测性:系统应根据当前状态和给定事件可预测地响应。
- 最小化:状态数应保持最小,避免不必要的复杂性。
①明确性:状态与转换必须定义清晰
②完备性:需要考虑所有事件-状态的转换组合
③可预测性:需根据当前状态+给定事件可预测响应
④最小化:状态数要少,避免过于复杂
常见误区
- 过度设计:引入不必要的状态
- 不完备的处理:没有考虑到状态与事件所有可能的转换关系,导致系统行为不确定
- 硬编码逻辑:过多硬编码转换逻辑,导致系统不具备可扩展性和灵活性
比如下面的设计:
一眼看过去,好像除了复杂一点,整体还是合理的,比如初始化,受理成功就到ACCEPT,然后到PAYING,如果直接成功就到PAIED,退款成功就到REFUND。
不合理的地方:
- 流程复杂。第一眼看过去会发现不那么清晰,流程比较繁琐,比较复杂,有很多状态都可以简化或者舍去。比如ACCEPT没有存在的必要。
- 职责不明确。支付单只管支付,到PAIED就算支付成功,最终状态不再改变。不应该后面还有REFUND状态。REFUND应该由退款单来负责处理,否则如果客户部分退款,我们就不好处理了。
改进方案:
- 删除不必要的状态。如:ACCEPT
- 将一个大型状态机抽取为多份小的状态机。比如把一些退款REFUND、请款等单据单独抽取出来。这个样子,虽然状态机数量多了,但是每个状态机都更加清晰明了。
- 主单:
- 普通支付单
- 预授权单
- 请款单
- 退款单
最佳实践及代码规范
代码层面:
- 分离状态和处理逻辑:使用状态模式,将每个状态的行为都封装在各自的类中
- 使用事件驱动模型:通过事件来触发状态转换,而不是直接调用状态方法
- 确保可追踪性:状态转换应被记录和追踪,以便故障排查和审计
上面几点也就要求我们不应该使用if else或者switch case来写,会让代码看起来复杂。我们应该将每个状态封装为单独的类。
2.3 Java版本实现
- 定义状态基类
/**
* 状态基类
*/publicinterfaceBaseStatus{}
- 定义事件基类
/**
* 事件基类
*/publicinterfaceBaseEvent{}
- 定义状态-事件对,指定的状态只能接受指定的事件
/**
* 状态事件对,指定的状态只能接受指定的事件
*/publicclassStatusEventPair<SextendsBaseStatus,EextendsBaseEvent>{/**
* 指定的状态
*/privatefinalS status;/**
* 可接受的事件
*/privatefinalE event;publicStatusEventPair(S status,E event){this.status = status;this.event = event;}@Overridepublicbooleanequals(Object obj){if(obj instanceofStatusEventPair){StatusEventPair<S,E> other =(StatusEventPair<S,E>)obj;returnthis.status.equals(other.status)&&this.event.equals(other.event);}returnfalse;}@OverridepublicinthashCode(){// 这里使用的是google的guava包。com.google.common.base.ObjectsreturnObjects.hashCode(status, event);}}
- 定义状态机
/**
* 状态机
*/publicclassStateMachine<SextendsBaseStatus,EextendsBaseEvent>{privatefinalMap<StatusEventPair<S,E>,S> statusEventMap =newHashMap<>();/**
* 只接受指定的当前状态下,指定的事件触发,可以到达的指定目标状态
*/publicvoidaccept(S sourceStatus,E event,S targetStatus){
statusEventMap.put(newStatusEventPair<>(sourceStatus, event), targetStatus);}/**
* 通过源状态和事件,获取目标状态
*/publicSgetTargetStatus(S sourceStatus,E event){return statusEventMap.get(newStatusEventPair<>(sourceStatus, event));}}
- 定义支付状态机。注:支付、退款等不同的业务状态机是独立的。
/**
* 支付状态机
*/publicenumPaymentStatusimplementsBaseStatus{INIT("INIT","初始化"),PAYING("PAYING","支付中"),PAID("PAID","支付成功"),FAILED("FAILED","支付失败"),;// 支付状态机内容privatestaticfinalStateMachine<PaymentStatus,PaymentEvent>STATE_MACHINE=newStateMachine<>();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);}// 状态privatefinalString status;// 描述privatefinalString description;PaymentStatus(String status,String description){this.status = status;this.description = description;}/**
* 通过源状态和事件类型获取目标状态
*/publicstaticPaymentStatusgetTargetStatus(PaymentStatus sourceStatus,PaymentEvent event){returnSTATE_MACHINE.getTargetStatus(sourceStatus, event);}}
- 定义支付事件。注:支付、退款等不同业务的事件是不一样的。
/**
* 支付事件
*/publicenumPaymentEventimplementsBaseEvent{// 支付创建PAY_CREATE("PAY_CREATE","支付创建"),// 支付中PAY_PROCESS("PAY_PROCESS","支付中"),// 支付成功PAY_SUCCESS("PAY_SUCCESS","支付成功"),// 支付失败PAY_FAIL("PAY_FAIL","支付失败");/**
* 事件
*/privateString event;/**
* 事件描述
*/privateString description;PaymentEvent(String event,String description){this.event = event;this.description = description;}}
- 在支付单模型中声明状态和根据事件推进状态的方法:
/**
* 支付单模型
*/publicclassPaymentModel{/**
* 其它所有字段省略
*/// 上次状态privatePaymentStatus lastStatus;// 当前状态privatePaymentStatus currentStatus;/**
* 根据事件推进状态
*/publicvoidtransferStatusByEvent(PaymentEvent event){// 根据当前状态和事件,去获取目标状态PaymentStatus targetStatus =PaymentStatus.getTargetStatus(currentStatus, event);// 如果目标状态不为空,说明是可以推进的if(targetStatus !=null){
lastStatus = currentStatus;
currentStatus = targetStatus;}else{// 目标状态为空,说明是非法推进,进入异常处理,这里只是抛出去,由调用者去具体处理thrownewStateMachineException(currentStatus, event,"状态转换失败");}}}
代码注释已经写得很清楚,其中StateMachineException是自定义,不想定义的话,直接使用RuntimeException也是可以的。
在支付业务代码中的使用:只需要paymentModel.transferStatusByEvent(PaymentEvent.valueOf(message.getEvent()))
/**
* 支付领域域服务
*/publicclassPaymentDomainServiceImplimplementsPaymentDomainService{/**
* 支付结果通知
*/publicvoidnotify(PaymentNotifyMessage message){PaymentModel paymentModel =loadPaymentModel(message.getPaymentId());try{// 状态推进
paymentModel.transferStatusByEvent(PaymentEvent.valueOf(message.getEvent()));savePaymentModel(paymentModel);// 其它业务处理......}catch(StateMachineException e){// 异常处理......}catch(Exception e){// 异常处理......}}}
上面的代码只需要加完善异常处理,优化一下注释,就可以直接用起来。
上面写法的好处:
- 定义了明确的状态、事件。
- 状态机的推进,只能通过“当前状态、事件、目标状态”来推进,不能通过if else 或case switch来直接写。比如:STATE_MACHINE.accept(INIT, PaymentEvent.PAY_PROCESS, PAYING);
- 避免终态变更。比如线上碰到if else写状态机,渠道异步通知比同步返回还快,异步通知回来把订单更新为“PAIED”,然后同步返回的代码把单据重新推进到PAYING。
2.4 Golang版本实现
项目结构:
①定义基础状态机: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
}
②定义支付状态机:payment_state_machine.go
package model
type PaymentStatus stringconst(
INIT PaymentStatus ="INIT"
PAYING PaymentStatus ="PAYING"
PAID PaymentStatus ="PAID"
FAILED PaymentStatus ="FAILED")type PaymentEvent stringconst(
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{}}funcinit(){//支付状态机初始化,包含所有可能的情况
PaymentStateMachine.accept(nil, PAY_CREATE, INIT)
PaymentStateMachine.accept(INIT, PAY_PROCESS, PAYING)
PaymentStateMachine.accept(PAYING, PAY_SUCCESS, PAID)
PaymentStateMachine.accept(PAYING, PAY_FAIL, FAILED)}funcGetTargetStatus(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("状态转换失败")}}
③使用及测试
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))funcmain(){
application := iris.New()
application.Get("/order/create", createOrder)
application.Get("/order/pay", payOrder)
application.Get("/order/status", getOrderStatus)
application.Listen(":8899",nil)}funccreateOrder(context *context.Context){
testOrder.CurrentStatus = model.INIT
context.WriteString("create order succ...")}funcpayOrder(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)}funcgetOrderStatus(context *context.Context){
context.WriteString(string(testOrder.CurrentStatus))}
声明:为了快速验证以及让代码更加简洁,没有按照标准的规范来编写controller、service、dao等。
测试:
- 启动程序,调用create接口,创建订单
http://localhost:8899/order/create
- 调用支付接口支付订单
http://localhost:8899/order/pay
我们手动模拟调用第三方支付接口,sleep了几十秒(实际调用肯定比这个快多了),所以不会立即返回结果,我们需要新开一个窗口,直接查询订单状态
- 立即调用查询接口获取订单状态,查看是否为支付中
http://localhost:8899/order/status
- 等待支付成功后,调用接口查看订单状态,是否为已支付
等待后台日志打印done之后重新调用查询接口:
http://localhost:8899/order/status
3 并发更新问题:多线程修改同一状态机(db版本号)
“状态机领域模型同时被两个线程操作怎么避免状态幂等问题?”
这是一个好问题。在分布式场景下,这种情况太过于常见。同一机器有可能多个线程处理同一笔业务,不同机器也可能处理同一笔业务。
业内通常的做法是设计良好的状态机 + 数据库锁 + 数据版本号解决。
简要说明:
- 状态机一定要设计好,只有特定的原始状态 + 特定的事件才可以推进到指定的状态。比如 INIT + 支付成功才能推进到sucess。
- 更新数据库之前,先使用select for update进行锁行记录,同时在更新时判断版本号是否是之前取出来的版本号,更新成功就结束,更新失败就组成消息发到消息队列,后面再消费。
- 通过补偿机制兜底,比如查询补单。
通过上述三个步骤,正常情况下,最终的数据状态一定是正确的。除非是某个系统有异常,比如外部渠道开始返回支付成功,然后又返回支付失败,说明依赖的外部系统已经异常,这样只能进人工差错处理流程。
版权归原作者 NPE~ 所有, 如有侵权,请联系我们删除。