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 的文件,除了状态机类全部打回。强工程纪律换来的是线上零状态错乱。
团队协作:让状态机活在文档里
状态机最大的维护风险是「代码与文档脱节」。文档写着十个状态,代码里偷偷加了两个,直到线上出事才发现。我们的做法有两个。第一是状态定义从代码生成文档:状态枚举上加注释,构建脚本把注释导出成转移图的数据源,文档于是永远是代码的影子。第二是非法转移的校验任务每天跑,线上一旦出现文档之外的流转立即告警。
| 治理项 | 实现 |
|---|---|
| 状态文档自动生成 | 枚举注释导出 |
| 转移单测全覆盖 | 遍历非法路径断言 |
| 线上违规告警 | 每日校验任务 |
| 新状态评审 | 产品加开发双签字 |
客服侧的联动也值得做。客服系统看到状态文本全部来自状态机的映射表,没有自己的翻译层。这样一来产品改文案只改一处,客服与用户看到的话术永远一致。状态机的「单一真源」原则向外延伸,连带着把沟通成本一并降了下来。后来客服系统收到用户问题时,第一反应是查状态机的当前态而不是猜,处理效率明显提升。状态机让「订单在哪里」这个问题第一次有了标准答案。也为后来客服系统的状态展示功能直接提供了这本「字典」,省了一次联调。状态机字典化的另一个好处慢慢也显现出来:所有下游系统订阅同一组状态枚举时,再不会出现「同一状态、各自翻译」的混乱局面。
