画状态图,90% 的人只画"会发生的路"。
而线上事故,几乎全部来自那条"不该发生、却发生了"的路。
上一篇我们用一张时序图揪出了 11 次调用里的 5 次冗余(见《时序图怎么画》)。今天讲它的搭档:UML 状态图(State Diagram,也叫状态机图、状态转移图)。
先记住一个反常识的结论:状态图一半的价值,在那些你不会画出来的箭头上。
一、先分清:流程图画"做",状态图画"是"
很多新手把状态图画成了流程图,这是第一重错。
判断方法只有一个:拿这个词去问——"它现在处于 X 吗?"
"提交订单"是动作,不能问"它现在处于提交订单吗",所以它是事件,不是状态。"待支付"可以问"这笔订单现在处于待支付吗"——它才是状态。
流程图记录的是一次办事的顺序;状态图记录的是一个东西的一生。订单、工单、会员、审批流、甚至一个硬件设备,只要有"生命周期",就该有状态图。
二、三件套和三件进阶
| 元素 | 画法 | 一句话说明 |
|---|---|---|
| 状态 | 圆角矩形 | "它现在是什么",必须能被问出来 |
| 迁移 | 带箭头的线 | 从一个状态到另一个状态的合法通道 |
| 事件 | 箭头上方的动词短语 | 什么触发了这次转移 |
| 守卫条件 | 事件后加 [条件] | 条件不满足,迁移不发生,如 [库存>0] |
| 动作 | 事件后加 /动作 | 迁移发生时顺带执行的事,如 /发短信 |
| 初始/终态 | 实心圆/套圈实心圆 | 出生和死亡,缺一个生命周期就不闭环 |
| 自身迁移 | 箭头指回自己 | 状态不变,但动作发生了,如 待支付/重试 |
最容易混的是守卫和动作:守卫决定"走不走",动作决定"顺带干什么"。 写在一条线上,作用完全不同。
二点五、团队协作的文本记法
图是给人看的,文档和评审里还要有一套文本写法,三个槽位固定:
待支付 --支付成功[金额一致]--> 已支付:事件加守卫待支付 --支付成功/锁定库存--> 已支付:事件加动作已支付 --再次支付[重复回调]--> 已支付:自身迁移
评审时按槽位逐条问:事件是谁发的?守卫漏没漏?动作里有没有塞重活?三个问题问完,一条迁移就算过了。

三、完整算例:一家月订单 38.4 万的电商
沿用 ER 图那篇的算例背景:这家店月订单 38.4 万单。我们给订单建状态机。
3.1 先定状态
| 状态 | 含义 | 是否终态 | 谁能改它 |
|---|---|---|---|
| 待支付 | 下单未付款 | 否 | 支付系统 |
| 已支付 | 付款成功未发货 | 否 | 订单服务 |
| 已发货 | 物流在途 | 否 | 物流系统 |
| 已完成 | 签收 | 否 | 物流回传 |
| 已取消 | 未支付取消 | 否 | 用户/超时任务 |
| 退款中 | 售后受理 | 否 | 售后系统 |
| 已退款 | 钱退回 | 否 | 支付系统 |
| 已关闭 | 终结状态 | 是 | 订单服务 |
注意最后一列:这张表是事故高发区。状态归属没写清,四个系统都能改状态,非法迁移就拦不住。
3.2 合法迁移只有 14 条,非法的有 42 条
8 个状态,理论上的迁移组合是 8×7=56 条。业务上合法的只有 14 条,比如:待支付→已支付(事件:支付成功)、待支付→已取消(事件:用户取消/超时未付)、已支付→退款中(事件:申请售后)。
剩下 42 条全是非法迁移:已取消→已发货、已完成→已支付、已退款→已发货……这些箭头你不会画在图上,但它们必须出现在另一张表里——非法迁移表。状态机代码的职责,就是把这张表变成拦截规则。
3.3 没有这张表,会发生什么:真实数字
这家店的订单,平均每单触发 5 次状态迁移,月迁移量 192 万次。风控统计,其中带非法跳转的请求占 0.06%——每月 1152 次。构成是:
- 重复支付回调 640 次(占 55.6%)——用户重复点付款、回调重试
- 已完成再申请退款 289 次(占 25.1%)
- 已取消的订单被发货 43 次(占 3.7%)
- 其他 180 次(占 15.6%)
最疼的是第三类。43 次"已取消→已发货"里,有 6 次货真的发出去了,拦截率只有 86%。原因查出来很简单:迁移逻辑散落在四个服务里,支付、物流、售后各自只管自己那一步,没有人在总入口看一眼"这笔订单现在到底是什么状态"。
修法就三招:状态迁移收口到订单服务一个入口;非法迁移表变成统一拦截规则;拦截不等于丢弃。

3.4 代码怎么落地:三种写法,只推荐最后一种
第一种,if-else 堆叠。每个接口里各写一堆判断。两个月后没人说得清全公司有几处判断逻辑,43 次"已取消被发货"就是这么来的。
第二种,状态模式。一个状态一个类,类里声明自己允许的迁移。比 if-else 清晰,但每加一个状态要动好几个类,几个人维护的系统里,最后总有人图快绕过它直改数据库。
第三种,表驱动。把 14 条合法迁移写成一张配置表,所有状态变更走同一个入口函数:查表,在表内放行;不在表内,一律拦下进兜底态。这张表,就是前面那张非法迁移表的反面。
判断标准很粗暴:谁能在十分钟内向产品经理解释清楚拦截规则,谁就是对的。
四、兜底态:拦下来的东西去哪了
拦下来的 1152 次请求,不能直接返回失败——里面有约 8% 其实是用户有理的(比如网络重试导致的重复回调,钱确实扣了)。
所以状态机里要有最后一个成员:兜底态(异常挂起)。所有被拦截的迁移,一律转入这个状态,进人工核查队列。这家店的数据:平均处理时长 11 分钟,误拦截自动放行,真非法的进工单。
一句话:没有兜底态的拦截,是把问题从系统里扔到用户脸上。
兜底态自己的进和出,也要画在图上:任何状态 --[拦截]--> 异常挂起;异常挂起 --[人工核实通过]--> 原目标状态。注意这条迁移的守卫必须落到具体角色头上——一个没有主人的兜底态,就是给运维埋的雷。
五、画状态图的 5 个坑
- 把流程当状态画。"提交订单"是事件不是状态,用"它现在处于X吗"一测就现形。
- 没有终态。生命周期不闭环,僵尸数据越来越多,报表永远对不上。
- 守卫条件堆成"或"地狱。一条迁移上挂五个 [条件1或条件2或条件3],说明状态本身没切干净,该拆状态了。
- 并发状态硬塞一张图。订单状态和履约状态是两条并行生命周期,硬画在一张图里必然爆炸。用组合状态拆开,或者干脆画两张。
- 画完不生成非法迁移表。只交 14 条合法路径,等于只交了半张图。审图的人第一个该问的,就是那 42 条。
六、画完自检的 9 条
- 每个状态都能通过"它现在处于X吗"的测试吗?
- 有初始态和终态吗?生命周期闭环吗?
- 每条迁移都标了事件吗?还是只画了光秃秃的箭头?
- 守卫 [ ] 和动作 / 分开了吗?
- 非法迁移表列出来了吗?数量对得上吗(n×(n-1) 减合法条数)?
- 有兜底态吗?被拦截的请求去哪了,写得出来吗?
- 每个状态的修改权归属写了吗?
- 状态值和数据库字段一一对应吗?有没有"代码里的状态"和"表里的状态"两张皮?
- 和时序图对得上吗?时序图上每个消息,在状态图上都能找到对应迁移吗?

七、和类图、ER 图、时序图怎么选
这个系列到今天凑齐了四张图,选择口诀一句话:
- 看静态结构(有什么)→ 类图(见 Day45《UML 类图》)
- 看数据关系(存什么)→ ER 图(见 Day50《ER 图完全指南》)
- 看一次交互(怎么协作)→ 时序图(见 Day51《时序图完全指南》)
- 看一个东西的一生(从生到死)→ 状态图(本文)
一个订单系统,四张图各管一段:类图定边界,ER 图定存储,时序图定流程,状态图定生死。缺哪张,哪段就是黑的。
下一篇我们讲流程图之外最常被画错的一张:泳道图——为什么你的泳道图没人看,因为泳道划的是部门,不是角色。
关键词:状态图、UML状态图、状态机、状态转移图、订单状态机、guard守卫、兜底态、非法迁移
