1. 状态机图到底在解决什么问题1.1 从一个真实场景说起去年接手一个订单履约系统的重构代码里散落着几十个if-else判断订单状态待支付、已支付、待发货、已发货、已签收、已取消、退款中、已退款……每个分支里又嵌套着库存回滚、优惠券返还、积分扣减的逻辑。改一个状态流转规则得翻遍五六个文件测试回归跑一整天。后来我们用一张状态机图把整个订单生命周期画出来所有流转规则一目了然代码也重构成状态模式维护成本直接砍半。这就是状态机图的价值——它专门用来描述一个对象在生命周期内响应不同事件时状态如何迁移。UML 里管它叫 State Machine Diagram早期也叫 Statechart Diagram中文习惯叫状态图或状态机图。它属于 UML 动态结构图的一种和活动图、时序图、协作图并列但关注点完全不同活动图看的是流程步骤时序图看的是对象间消息交互而状态机图盯的是单个对象自身的状态变化。1.2 它和活动图、时序图的边界在哪很多人初学 UML 容易把这几张图搞混。我自己的判断标准很简单活动图回答这件事分几步做完适合描述业务流程、算法流程比如用户下单到收货要经过哪些环节。时序图回答这几个对象之间怎么互相调用适合描述接口交互、消息传递顺序。状态机图回答这个对象现在处于什么状态什么事件能让它变到另一个状态适合描述有明确生命周期、状态有限且流转规则复杂的对象。举个具体例子一个电梯控制系统。用活动图描述从1楼到5楼的运行流程没问题用时序图描述乘客按按钮后控制器如何响应也合理但真正能说清楚电梯处于空闲、上行、下行、开门、关门、故障这些状态之间如何切换的只有状态机图。PLCopen 标准里对运动控制的状态定义本质上就是一套严谨的状态机模型工控领域的朋友应该很熟悉。1.3 什么场景下值得画状态机图不是所有对象都值得画状态机图。我的经验是满足以下两三个条件时画它收益最大对象有明确且有限的状态集合比如订单、工单、设备、会话、审批单。状态之间的流转由事件驱动而不是单纯按顺序执行。流转规则复杂到用文字说不清或者团队里不同人对规则理解不一致。系统需要强一致性保证比如支付、库存、权限这类不能出错的领域。反过来如果一个对象只有新建-完成两个状态或者流转完全是线性的那画状态机图就是过度设计写两行注释就够了。我见过有人给一个只有草稿/发布两态的博客文章画状态机图纯属浪费时间。2. 状态机图的核心要素拆解2.1 状态不只是一个小圆角矩形状态State是状态机图的基本单元用圆角矩形表示。但很多人只画一个名字就完事其实状态内部可以拆得很细。一个完整的状态描述通常包含三部分名称状态的标识比如待支付已锁定。入口动作entry进入该状态时自动执行的动作比如进入已锁定时冻结库存。出口动作exit离开该状态时自动执行的动作比如离开已锁定时释放锁。内部活动do处于该状态期间持续进行的活动比如播放中状态持续输出音频。内部转移不改变状态的自转移比如播放中状态下调整音量。用代码类比状态就像一个类entry/exit 是构造函数和析构函数do 是后台线程内部转移是类内部方法调用。这样理解就顺了。状态还分几种特殊类型初学者容易忽略初始状态实心黑圆点一个状态机只能有一个代表起点。终止状态带圈的实心黑圆点可以有多个代表生命周期结束。复合状态状态内部还嵌套子状态机用于分层建模。比如运行中可以细分为正常告警降级。历史状态记录复合状态上次退出时的子状态重新进入时能恢复到那个子状态。这个在复杂系统里非常有用但画起来容易乱建议只在必要时用。2.2 转移事件、守卫、动作三件套转移Transition是状态之间的箭头但箭头上的文字才是精华。一条完整的转移标注格式是事件名 [守卫条件] / 动作三个部分各有分工事件Event触发转移的外部刺激比如用户点击支付超时定时器到期库存服务返回成功。守卫条件Guard布尔表达式只有为真时转移才发生用方括号包裹比如[余额充足]、[库存0]。动作Action转移发生时执行的操作用斜杠开头比如/扣减库存、/发送通知。我踩过的一个坑早期画图时把守卫条件和事件混在一起写导致开发同学理解偏差。比如支付成功到底是事件还是条件正确做法是——事件是收到支付回调守卫是[回调验签通过]动作是/更新订单状态。三者分开写歧义就没了。还有一个容易忽略的点转移可以是无事件的叫完成转移Completion Transition表示源状态的 do 活动完成后自动触发。这种转移不写事件名只写守卫和动作。在工控和协议状态机里很常见。2.3 事件类型别只会写点击事件是状态机的驱动力但很多人只会写用户点击。实际上事件分好几类理解它们对建模质量影响很大事件类型说明示例信号事件异步收到的信号收到MQ消息、硬件中断调用事件同步的方法调用调用 pay() 方法时间事件时间点或时间段触发after(30s)、at(12:00)变化事件条件变为真时触发when(温度80)完成事件内部活动完成do 活动结束时间事件在超时场景里特别有用。比如订单待支付状态可以画一条after(30min) / 取消订单的转移比在代码里写定时任务清晰得多。变化事件则适合监控类系统比如设备运行状态下when(温度阈值) / 进入告警。2.4 动作与活动的区别这两个词经常被混用但在状态机图里有严格区分动作Action原子操作不可中断执行时间可忽略。比如设置标志位发送一条消息。活动Activity可中断的持续过程需要时间完成。比如执行支付上传文件。区分的意义在于动作适合放在转移上活动适合放在状态的 do 里。如果你把一个耗时几秒的支付操作放在转移动作里语义上就不准确——它应该是一个活动或者干脆建模成一个子状态。这个细节在软考 UML 建模题里经常考也是实际建模时容易出错的地方。3. 从零画一张订单状态机图3.1 先定状态清单再画箭头我的习惯是分两步走绝不边画边想。第一步把所有状态列出来用表格管理状态名含义入口动作出口动作待支付订单已创建等待付款锁定库存无已支付付款成功等待发货通知仓储无已发货商品已出库推送物流无已签收用户确认收货结算货款无已取消订单取消释放库存无退款中发起退款申请冻结结算无已退款退款完成返还优惠券无第二步逐条梳理转移规则同样用表格源状态事件守卫条件动作目标状态待支付支付成功验签通过更新状态已支付待支付超时after(30min)取消订单已取消待支付用户取消无取消订单已取消已支付发货库存充足出库已发货已发货确认收货无结算已签收已支付申请退款未发货冻结结算退款中已发货申请退款无冻结结算退款中退款中退款成功无返还权益已退款退款中退款失败无解冻结算已支付表格列完图基本就出来了。这种先表格后图形的方法好处是规则不会漏评审时也方便逐条核对。直接上手画图十有八九会漏掉边界情况。3.2 用 PlantUML 把图落到代码里手绘工具比如 PowerDesigner、StarUML画图直观但版本管理麻烦。我现在更倾向用 PlantUML 写文本图能进 Git改起来也快。上面订单状态机的 PlantUML 代码如下startuml [*] -- 待支付 : 创建订单 / 锁定库存 待支付 -- 已支付 : 支付成功 [验签通过] / 更新状态 待支付 -- 已取消 : 超时 [after(30min)] / 取消订单 待支付 -- 已取消 : 用户取消 / 取消订单 已支付 -- 已发货 : 发货 [库存充足] / 出库 已支付 -- 退款中 : 申请退款 [未发货] / 冻结结算 已发货 -- 已签收 : 确认收货 / 结算 已发货 -- 退款中 : 申请退款 / 冻结结算 退款中 -- 已退款 : 退款成功 / 返还权益 退款中 -- 已支付 : 退款失败 / 解冻结算 已签收 -- [*] 已取消 -- [*] 已退款 -- [*] enduml这段代码渲染出来就是标准的状态机图。几个细节值得说[*]表示初始和终止状态PlantUML 会自动渲染成黑圆点。转移标注格式事件 [守卫] / 动作和 UML 规范一致。时间事件写成[after(30min)]放在守卫位置虽然严格来说 after 是事件不是守卫但 PlantUML 里这样写可读性最好团队也接受。提示PlantUML 对中文支持良好但建议状态名不要带空格和特殊符号否则某些渲染器会出问题。用待支付而不是待 支付。3.3 复合状态怎么拆才不乱订单系统里退款中其实可以再细分申请中、审核中、打款中。如果全平铺图会变得很宽。这时候用复合状态startuml state 退款中 { [*] -- 申请中 申请中 -- 审核中 : 提交审核 审核中 -- 打款中 : 审核通过 审核中 -- 申请中 : 审核驳回 打款中 -- [*] : 打款完成 } enduml复合状态的好处是分层清晰坏处是如果嵌套太深读者要来回跳。我的经验是最多嵌套两层超过两层就该考虑拆成独立的状态机用接口交互。另外复合状态的历史状态要慎用除非确实需要恢复上次进度否则会增加理解成本。4. 状态机图落地时的常见坑4.1 状态爆炸与状态收敛新手最容易犯的错是状态定义太细导致状态数量爆炸。比如把已支付未发货已支付已发货当成两个状态其实是否发货是属性不是状态。判断标准状态是对象生命周期的阶段属性是阶段内的特征。发货与否是已支付状态下的一个属性不该独立成状态。反过来状态也不能太粗。把待支付已支付已发货全合并成处理中那状态机图就失去意义了。收敛的度在于每个状态应该有不同的行为响应。如果两个状态对所有事件的响应完全一样那它们就该合并。4.2 守卫条件与动作的职责边界守卫条件只做判断不做修改动作只做修改不做判断。这个边界一旦模糊图就没法看了。我见过把扣库存并判断是否成功写在守卫里的这明显越界了——扣库存是动作判断结果是守卫应该拆成两条转移待支付 -- 已支付 : 支付成功 [库存充足] / 扣库存 待支付 -- 已取消 : 支付成功 [库存不足] / 退款这样职责清晰开发实现时也容易映射成代码。4.3 并发状态与区域有些对象确实存在并发状态比如一个设备同时有运行状态和通信状态两个维度。UML 用正交区域用虚线分隔表示并发。但我要泼盆冷水并发状态机图极难维护除非业务真的需要否则建议拆成两个独立状态机分别建模。我做过的一个项目里有人画了三个正交区域的状态机结果评审时没人能说清楚所有组合情况最后推倒重来。4.4 状态机图与代码的映射画完图不是终点落地才是。状态机图映射到代码有几种常见模式状态模式每个状态一个类转移就是状态切换。适合状态多、行为差异大的场景。状态表驱动用配置表定义转移规则引擎统一处理。适合规则频繁变动的场景。枚举switch简单场景够用但状态一多就难维护。我个人的选择标准状态少于5个用枚举5到15个用状态表超过15个或者行为差异大用状态模式。订单系统我们用的是状态表驱动转移规则写在 YAML 里改规则不用改代码测试也方便。5. 常见问题速查问题排查思路解决方向图太乱看不清数状态数和转移数超过15个状态考虑分层或拆分评审时规则对不上逐条核对转移表先表格后图形避免遗漏开发说实现不了检查是否有并发区域拆成独立状态机守卫条件写太长检查是否混入动作拆分转移职责分离时间事件不生效确认引擎是否支持用外部定时器触发事件历史状态行为异常检查进入/退出动作明确历史状态的恢复语义注意状态机图评审时一定要拉上开发和测试一起。产品画的图开发看不懂、测试测不全是项目里最常见的返工原因。图不是文档是沟通工具。6. 我个人的几点实操体会画了这么多年状态机图最大的体会是图的价值不在于画得多漂亮而在于逼着团队把规则想清楚。很多需求文档里含糊其辞的订单取消后怎么处理一画状态机图就露馅了——到底哪些状态能取消取消后库存怎么回滚优惠券退不退这些问题不解决代码里就是一堆隐藏 bug。另一个体会是状态机图要和代码保持同步。我们现在的做法是把 PlantUML 源文件放在代码仓库里和状态表配置放一起改代码必须改图CI 里加个检查图不更新就卡住合并。虽然有点强制但确实有效。最后分享一个小技巧画状态机图时先画异常路径再画正常路径。正常流程大家都清楚异常路径才是容易漏的。把超时、失败、取消、回滚这些先画出来正常路径自然就补全了。这个顺序反过来往往会漏掉一堆边界情况。