做支付和商家入驻的同行大概都有类似经历:第一版只有"没支付""支付了"两个状态,一个 if-else 就够。然后回调重试、定时关单、后台手工操作、退款接口一个个来了。三个月后,状态判断散落在回调处理器、补偿任务、后台接口、退款逻辑四处,每处都写着"if (status == PAID)",却没人说得清 status 共有几种合法值。
我们在多租户小程序 SaaS(Java 21 / Spring Boot / PostgreSQL,27 个业务域)里踩过这类坑之后,把支付单和入驻申请全部重构成了显式状态机。这篇文章讲清楚为什么,以及怎么做。
一、散落的条件判断是怎么演化成事故的
三类典型事故,各对应一种反模式:
重复回调。渠道回调是"至少一次"投递,同一成功回调可能到达两次;两个入口各自判断"未支付则置已支付",并发下双双通过,发货执行两次。
非法迁移。订单超时被关单任务置为"已关闭",两秒后刷卡成功回调姗姗来迟,处理器只判"金额相符",把已关闭单强行改成"已支付"——钱收了,单子却是关闭态,对账对不平。
结果未知。调渠道支付接口超时,代码习惯性 catch 后置"失败"。但超时不等于失败——请求可能已扣款成功,只是响应没回来;用户重新支付,渠道躺着两笔成功交易,次日对账才发现,退款退得手忙脚乱。
一次被拦下的二次退款(复盘)
时间与单号为脱敏示意。某年 11 月 27 日 14:02:09,退款成功回调首次到达,订单从"退款中"迁到"已退款",落库发事件,正常。14:02:11,同一条回调因重试再次到达。旧退款入口只判"金额相符",没有一行代码问"是否退过"——二次退款几乎发出,靠渠道幂等键兜底才没资损,两人翻对账和日志查了大半天。
重构后同场景:回调仍被受理,但它要触发的迁移是"已退款 → 退款中",终态无出边,直接拒绝、留痕,退款请求不会发往渠道:
14:02:11 WARN [state-transition] order=10**4
rejected: REFUNDED -> REFUNDING 不在迁移表(REFUNDED 为终态)
source=退款成功回调(渠道重试第2次) reason=refund_callback
action=忽略本次迁移,留痕上报,不外呼渠道
这行日志就是分界线:if-else 把资金安全外包给渠道幂等键,状态机在自己这侧拦下并留证据链。
共同根源:状态不是被统一管理的值,而是一堆局部判断的副产品。每个入口单独看都对,合在一起就没人能推理;想问"这单能不能退款",得读懂四段代码、三个布尔字段的两两组合——无法靠"更小心"解决。
二、显式状态机的三个组成
我们最终收敛为三件套:
- 状态枚举:所有状态显式列出,禁止用 boolean(
paid、closed组合出隐式状态是最危险的); - 迁移表:一张"当前状态 → 允许到达的状态集合"的静态表,表外迁移一律拒绝,拒绝即报错留痕;
- 唯一写入口:所有状态变更(回调、定时任务、后台操作、退款)收敛到一个方法,在事务内做表校验加并发校验,通过才落库。
第三点最容易被忽略:迁移表再漂亮,各入口各自 UPDATE 就等于没设防。写入口的骨架是"一个事务模板 + 一组按目标状态注册的守卫"——模板管事务与并发裁决,守卫管业务校验,伪代码见第五节。
另一个被低估的收益:迁移表是静态的,可全量审查、穷举测试,把资金流程的正确性从运行时提前到了建表期。
三、以支付为例的迁移图
stateDiagram-v2
[*] --> 待支付 : 创建订单
待支付 --> 支付中 : 发起支付
支付中 --> 成功 : 回调明确成功
支付中 --> 失败 : 回调明确失败
支付中 --> 结果未知 : 超时/网络异常
待支付 --> 已关闭 : 超时关单
支付中 --> 已关闭 : 查单确认未受理
结果未知 --> 成功 : 查单补偿
结果未知 --> 失败 : 查单确认
结果未知 --> 已关闭 : 人工核验
成功 --> 退款中 : 发起退款
退款中 --> 已退款 : 退款成功
退款中 --> 成功 : 退款失败回退
图越画越密,评审和测试里真正当"合同"用的是文字版全图——逐状态盘点出入边,缺一条是实现缺口,多一条是越权路径:
- 待支付(CREATED):入=创建订单;出=→支付中、→已关闭(10 分钟未支付)。
- 支付中(PAYING):入=←待支付;出=→成功、→失败(回调明确)、→结果未知(超时或网络异常,不猜结果)、→已关闭(查单确认渠道未受理)。
- 结果未知(UNKNOWN):入=←支付中,仅此一条;出=→成功、→失败(查单确认)、→已关闭(超窗人工核验)。
- 成功(PAID):入=←支付中、←结果未知、←退款中(退款失败回退);出=→退款中,且是唯一出边——改金额、作废都得先走退款。
- 退款中(REFUNDING):入=←成功;出=→已退款、→成功(退款失败回退)。
- 失败、已退款、已关闭为终态,无出边:入边分别为←支付中或结果未知、←退款中、←待支付/支付中/结果未知。第一节的二次退款就拦在"已退款无出边"上。
这张表本身就是资金安全的边界——表里没有的路,代码就走不通。
四、"结果未知"态的设计价值
多数手写 if-else 的支付代码只有成功/失败两态,这是"结果未知"事故的根源。显式状态机让我们坦然引入第三个值:调第三方超时,不猜结果,置为"结果未知",进恢复路径——
- 查单补偿:定时任务对"结果未知"的单子主动调渠道查单,按真实结果迁到成功或失败;
- 人工核验:超补偿窗口仍未确认的,进人工核验队列,处理人带查单凭据做决定,动作也走同一个写入口。
配合全图,"结果未知"的纪律只有两条:入边唯一;出边必须带凭据(查单结果或人工决定),任何入口都不许"超时即失败"地猜。不确定性被显式建模,而非被错误默认值吞掉。附带的好处:客服和运营看后台时,"结果未知"是有处理流程的状态,而非"订单好像卡住了"——显式建模最终会变成组织里可交接的流程。
五、脱敏伪代码
Java 风格伪代码(脱敏,仅示意结构):
enum PayState { CREATED, PAYING, PAID, FAILED, CLOSED, UNKNOWN, REFUNDING, REFUNDED }
// 迁移表:静态、集中、可审查
static final Map<PayState, Set<PayState>> ALLOWED = Map.of(
CREATED, Set.of(PAYING, CLOSED),
PAYING, Set.of(PAID, FAILED, UNKNOWN, CLOSED),
UNKNOWN, Set.of(PAID, FAILED, CLOSED),
PAID, Set.of(REFUNDING),
REFUNDING, Set.of(PAID, REFUNDED)); // 退款失败回退
// 唯一写入口:回调、定时任务、后台操作、退款全部走这里
@Transactional
public void transition(long orderId, PayState target, String reason) {
var order = orderRepo.find(orderId);
if (!ALLOWED.getOrDefault(order.state, Set.of()).contains(target))
throw new IllegalTransitionException(order.state, target, reason); // 拒绝即留痕
guard(order, target); // 守卫:表管"能不能走",守卫管"该不该走"
// CAS 式更新:UPDATE ... SET state=target WHERE id=? AND state=旧状态
int n = orderRepo.casUpdateState(orderId, order.state, target, reason);
if (n != 1) throw new ConcurrentTransitionException(orderId); // 并发裁决点
events.publish(new PayStateChanged(orderId, order.state, target));
}
守卫按目标状态注册,一个守卫只管一件事(伪代码):
// 进 PAID 前:验回调签名、比金额
void guardToPaid(Order o, Callback c) {
verifySignature(c); // 验签失败直接拒
if (o.amount != c.paidAmount) throw ...; // 金额不符,拒绝进成功态
}
// 进 REFUNDING 前:确认无在途退款,二次退款的第一道闸
void guardToRefunding(Order o, RefundReq r) {
if (refundRepo.existsActiveByOrderId(o.id)) throw new DuplicateRefundException(o.id);
}
迁移表管"能不能走",守卫管"该不该走",两层缺一不可:只查表不守卫,表内但业务不该的动作会畅通无阻;只守卫不查表,路径又散回各入口。
六、踩坑清单
每条按"现象 → 定位 → 修法"展开:
并发双写。现象:回调与查单补偿几乎同时到达,都把"结果未知"迁往"成功",两次 UPDATE 都成功,下游收两份发货事件。定位:同一单两条迁移记录相差 17 毫秒——两边都"先查再改",谁也没锁谁。修法:条件更新当唯一裁决,UPDATE ... SET state=新 WHERE id=? AND state=旧,影响行数不为 1 即冲突、放弃并记日志;重路径可加 SELECT ... FOR UPDATE 行锁减少冲突,裁决仍以影响行数为准。
回调乱序。现象:退款成功回调先于支付成功回调 3 秒到达,按到达顺序处理反复触发告警。定位:对比回调里的渠道事件完成时间戳与库内迁移时间戳,是到达与事件顺序不一致,不是数据错。修法:按事件完成时间戳判新旧,过期事件触发的迁移直接丢弃;拿不准时以查单结果做校正迁移。
穷举迁移矩阵测试。现象:新增"结果未知"态漏改了关单任务,多出一条表外路径,测试环境当天没炸,演练环境隔周才炸。定位:人肉列组合永远列不全。修法:把迁移表当测试输入,两层循环遍历枚举的 N×N 组合生成用例,ALLOWED 内断言成功、其余断言抛 IllegalTransitionException;新增状态矩阵自动扩列,漏一条边当场红。全仓 1241 个测试方法里这套矩阵性价比最高。
终态不可逆。已关闭、已退款永远不给入边,想"复活"就新建关联单据,别改历史。运营曾要求"把误关的单打开",方案是新建一笔引用原单号的支付单,审计链完整可追。
七、三句话总结
- 状态机的价值不在画图,而在把"哪些迁移合法"从散落的 if-else 收敛成一张可审查的静态表;
- 所有状态变更收敛到唯一写入口,在事务内做表校验加 CAS,并发问题才有统一的裁决点;
- 给不确定性留显式状态——"结果未知"配合查单补偿,比任何"超时当失败"的默认值都安全。
运营主体:北京位元跃迁科技有限公司。本文同步发布于本号技术专栏,可搬运至 CSDN/掘金。
