2.2 状态机:从待支付到已关闭

重做状态机那周,客服系统提了个需求:用户取消订单之后,如果渠道的支付回调晚到,能不能让订单恢复成待支付。产品觉得合理,开发觉得恐怖。这个需求最后被否掉了,理由就是状态机不允许逆向转移。这一节讲我们怎么把订单状态机做成「不可商量」的规则。

场景:状态混乱的四种症状

状态机设计不当,线上的症状是固定的。我们遇到过四种:取消后仍可支付(存在「待支付→已取消→待支付」的暗道);支付成功与超时关单并发执行,两边都成功(状态互相覆盖);状态名称在前后端不一致,前端展示「已完成」,库里其实是「已收货」;支付失败后状态停在待支付,再也不流转。这些症状的共同点是流转缺少约束,好心想加的灵活性最后都成了故障源。

方案:十种状态与合法转移

订单中心定下十种状态,转移只能走下面这张表里的合法路径:

当前状态 允许的下一状态 触发动作
待支付 支付中、已关闭 去支付、超时关单
支付中 已支付、待支付、已关闭 回调成功、回调失败、关单
已支付 待发货 履约系统回调
待发货 已发货 仓库出库
已发货 已收货、售后中 用户确认、申请售后
已收货 售后中、已完成 申请售后、自动完成
售后中 已退款 退款成功
已退款 无 终态
已关闭 已退款 关单后退款
已完成 无 终态

四个终态:已完成、已退款、已关闭(合并了退款之后),其余状态都可以继续流转。所有终态不可逆,这也是为什么前面那个「取消后恢复支付」的需求必须否掉:允许逆向转移意味着所有补偿逻辑都要处理一半状态,复杂度爆炸。

代码:状态机的核心实现

状态转移的核心是一个转移表加一个 update 条件,关键的 SQL 是条件更新,把并发风险压到数据库里:

public boolean transfer(String orderNo, int from, int to) {
    String sql = "UPDATE t_order SET status = ? WHERE order_no = ? AND status = ?";
    return jdbcTemplate.update(sql, to, orderNo, from) == 1;
}

方法返回布尔值,返回 false 说明当前状态已经不是预期的 from 状态,存在并发竞争,调用方决定这是正常的幂等返回还是真实的冲突。所有状态流转都走这个唯一入口,不许在别处写 update status 的 SQL。代码评审只抓这一条就能挡住大部分状态混乱。

踩坑:三种合规但不合理的转移

第一种是为了省事允许的状态「跳跃」,比如从支付中直接跳到已完成。某次渠道的回调接口异常,我们手工修数据跳过了中间三个状态,履约系统监听的是状态流转事件,跳级让它没收到出库信号,订单卡了两天。教训:即使手工修数据也要逐步流转,维护事件链完整。

第二种是状态机里混入业务标记。风控审核中、优惠券锁定中这类需求不该加状态,用扩展字段。状态机的状态是订单生命周期的客观阶段,业务过程用标记位表示。我们曾把风控审核塞进状态机,十个状态变十四个,状态转移表复杂度翻倍,风控策略改一次就动一次状态机。

第三种是终态不彻底。已关闭后还有一个「已关闭_已退款」的二级状态,用扩展字段表示金额,而不是新增状态。状态机是系统最核心的契约,每多一个状态,所有下游系统都要跟着改。

状态机上线之后,配合的还有每天一次的状态校验定时任务,检查是否存在违反转移表的订单状态,违规单号直接报警,做法与 库存一致性对账 的思路相通。

进阶实战:状态机可视化

状态机上线后在团队内部形成了「状态转移图」文化。每次涉及状态变更的需求,先在转移图上画出路径,评审通过才写代码。图很简单,就是圆圈加箭头,但被挂在 wiki 首页之后,产品与客服都开始用它自查需求,开发与需求的沟通成本直接下降。

实践 做法 收益
转移图上墙 wiki 首页维护 需求自查
转移规则单测 遍历非法转移断言 防代码漏洞
状态变更事件 状态机统一发布 下游解耦
非法转移告警 校验任务 线上兜底

状态变更事件总线是另一个关键设计。履约系统、报表系统、通知系统都监听状态机的转移事件,而不是轮询订单表。一次产品要求「已发货时给用户推模板消息」,实现就是在状态机里加一个事件监听器,业务系统完全无感。事件命名规范是「订单从 A 到 B」,调用方按状态而不是按业务动作订阅,新业务的接入成本几乎为零。

非法转移的单测值得多说一句。我们把整张转移表导成一份静态数据,单测遍历所有状态对,凡是不在表里的转移路径,直接差状态机接口断言返回 false。这个测试挡住了一次意外的代码错误:有人把「已完成 → 待发货」的判断条件写反了 if 分支,单测当红。

速查清单

状态机设计的四条原则:

原则 说明
转移表唯一入口 update status 只许一处
条件更新防并发 where status = from 强制
终态绝对不可逆 逆向需求一票否决
业务过程用标记位 不污染状态枚举

小结

状态机的价值不在图好看,在于它把「可以做什么」变成代码层面的强约束:一个转移入口、一张转移表、条件更新保证并发安全、终态绝对不可逆。记住:所有可以被取消的需求,最后都会以 bug 的形式回来。下一篇讲并发防护第一层,分布式锁。

常见误解:状态机的三个陷阱

第一个陷阱是「用布尔字段代替状态」。isPaid、isShipped、isClosed 一堆布尔位,组合空间爆炸,非法组合防不胜防:一个订单既已关闭又已支付。状态机用单字段枚举从根本上杜绝了非法组合,这是设计原则而不是风格偏好。从布尔字段迁到状态枚举是一次较大的重构,我们花了三天梳理所有读写点,上线后状态相关的 bug 归零。

陷阱 症状 修复
布尔位代状态 非法组合 单字段枚举
状态存内存 重启不一致 落库为唯一真源
转移逻辑散落 无从校验 统一入口

第二个陷阱是「状态计算放在内存里」。早期有些代码在下单时计算订单「应该是什么状态」,而不是去库里读,重启或并发后两边对不上。纪律是状态永远以数据库为唯一真源,内存计算只作展示缓存。这个原则也支撑了多实例部署:任何实例处理任何请求,读到的状态都一致。

第三个陷阱是「转移逻辑散落在各处」。控制器里一段、服务里一段、SQL 里还有一段,谁也不知道哪条路径是完整的。统一入口说是容易,执行靠代码扫描器兜底:我们写了个脚本扫描所有包含 update t_order 的文件,除了状态机类全部打回。强工程纪律换来的是线上零状态错乱。

团队协作:让状态机活在文档里

状态机最大的维护风险是「代码与文档脱节」。文档写着十个状态,代码里偷偷加了两个,直到线上出事才发现。我们的做法有两个。第一是状态定义从代码生成文档:状态枚举上加注释,构建脚本把注释导出成转移图的数据源,文档于是永远是代码的影子。第二是非法转移的校验任务每天跑,线上一旦出现文档之外的流转立即告警。

治理项 实现
状态文档自动生成 枚举注释导出
转移单测全覆盖 遍历非法路径断言
线上违规告警 每日校验任务
新状态评审 产品加开发双签字

客服侧的联动也值得做。客服系统看到状态文本全部来自状态机的映射表,没有自己的翻译层。这样一来产品改文案只改一处,客服与用户看到的话术永远一致。状态机的「单一真源」原则向外延伸,连带着把沟通成本一并降了下来。后来客服系统收到用户问题时,第一反应是查状态机的当前态而不是猜,处理效率明显提升。状态机让「订单在哪里」这个问题第一次有了标准答案。也为后来客服系统的状态展示功能直接提供了这本「字典」,省了一次联调。状态机字典化的另一个好处慢慢也显现出来:所有下游系统订阅同一组状态枚举时,再不会出现「同一状态、各自翻译」的混乱局面。

延伸阅读