第三章:状态机核心模型设计

好,咱们进入正题。状态机这东西,说白了就是「当前在哪儿、能去哪儿、怎么去」。很多同学一上来就画状态图,画得挺漂亮,一落地就崩。为什么?因为核心模型没想清楚。

我个人习惯,设计状态机之前,先问自己四个问题:状态是什么?事件从哪来?转移怎么触发?动作谁来执行?把这四个问题搞明白,后面的代码就是体力活了。

核心模型四要素: 状态(State) → 事件(Event) → 转移(Transition) → 动作(Action)

这四个要素,少一个都不行。顺序也不能乱。

3.1 状态(State)设计原则

状态是状态机的「锚点」。我见过最离谱的设计,一个订单系统搞了 47 个状态。你想想看,这谁维护得了?

原则一:状态要正交

两个状态之间不能有重叠。比如「已支付」和「已发货」就是正交的,但「已支付」和「待支付」就不行——这俩是互斥的。我在项目中遇到过有人把「审核中」和「待审核」当成两个状态,结果逻辑里全是 if-else 判断,改一次崩一次。

原则二:状态粒度要适中

太粗了,业务表达不清;太细了,状态图变成蜘蛛网。我的经验是:一个业务实体,状态数控制在 5~15 个之间。超过 20 个,你就该考虑是不是设计有问题了。

状态数量 评价 典型问题
1~3 过粗 业务逻辑全藏在动作里
5~15 适中 推荐范围
20+ 过细 维护成本爆炸

原则三:状态要有明确的进入和退出条件

每个状态,你得清楚「什么情况下能进来」「什么情况下能出去」。我曾经接手过一个老项目,有个状态叫「处理中」,你根本不知道它之前是什么、之后能变成什么——这就是典型的「黑洞状态」。

避坑指南: 我曾经在一个支付系统中,把「支付成功」和「支付失败」设计成了两个独立状态。后来发现,它们其实应该是一个「支付结果」状态的两种取值。改起来那叫一个痛苦。

3.2 事件(Event)设计原则

事件是状态机的「燃料」。没有事件,状态机就是死的。

原则一:事件要可观测

事件必须来自外部输入或内部定时器。你不能凭空捏造一个事件。比如「用户点击支付按钮」是事件,「系统觉得用户想支付」就不是——这玩意儿没法观测。

原则二:事件要幂等

同一个事件重复触发,结果应该一样。嗯,这里要注意:幂等不是指状态不变,而是指最终状态一致。比如「取消订单」事件,第一次触发把状态变成「已取消」,第二次触发应该还是「已取消」,而不是报错。

// 幂等的事件处理
public void handleCancelEvent(Order order) {
    if (order.getState() == OrderState.CANCELLED) {
        return; // 已经取消了,什么都不做
    }
    // 执行取消逻辑
    order.setState(OrderState.CANCELLED);
}

原则三:事件要有明确的语义

别用 event1、event2 这种名字。我见过一个项目,事件名叫「doSomething」——你猜它做什么?猜不出来吧?后来发现它同时做了支付和发货两件事。这种事件,说白了就是定时炸弹。

我的习惯: 事件命名用「动词+名词」结构,比如「submitOrder」「confirmPayment」「cancelBooking」。一眼就能看出它要干什么。

3.3 转移(Transition)设计原则

转移是状态机的「骨架」。它定义了状态之间怎么跳转。

原则一:转移要完整

每个状态,对于每个可能的事件,都要有明确的转移定义。哪怕是不允许的转移,也要显式声明。我见过太多系统,因为漏掉了某个转移,导致状态卡死。

原则二:转移要确定

同一个状态 + 同一个事件,只能有一个转移结果。不能出现「有时候去A,有时候去B」的情况。如果业务上确实需要条件分支,那应该拆成不同的事件。

原则三:转移要有守卫条件

不是所有事件都能触发转移。比如「已取消」的订单,就不能再「确认支付」了。守卫条件就是用来做这个检查的。

// 转移定义示例
Transition transition = Transition.builder()
    .from(OrderState.PENDING_PAYMENT)
    .event(Event.PAY_SUCCESS)
    .to(OrderState.PAID)
    .guard(order -> order.getAmount() > 0)  // 守卫条件
    .action(order -> sendNotification(order)) // 动作
    .build();

避坑指南: 我曾经在一个工作流引擎中,允许了「已拒绝」到「已通过」的转移。结果业务方发现后,直接跳过了审批流程。从那以后,我规定:所有逆向转移必须经过审批事件。

3.4 动作(Action)设计原则

动作是状态机的「肌肉」。它负责执行具体的业务逻辑。

原则一:动作要原子化

一个动作只做一件事。比如「发送邮件」和「更新数据库」要分开。为什么?因为如果发送邮件失败了,你不能让数据库回滚。原子化的动作,方便你单独处理失败场景。

原则二:动作要可重试

动作可能会失败。网络超时、数据库锁、第三方服务挂了——这些都要考虑。我的做法是:每个动作都实现一个重试机制,最多重试 3 次,间隔指数退避。

原则三:动作要可观测

动作执行了没有?成功了没有?花了多长时间?这些都要有日志。我习惯在每个动作的开始和结束都打日志,带上状态机上下文 ID。这样出了问题,直接查日志就能定位。

动作类型 示例 失败处理策略
同步动作 更新数据库 重试 + 最终一致性
异步动作 发送消息 消息队列 + 死信处理
外部调用 调用支付网关 超时 + 熔断 + 补偿

我的经验: 动作执行顺序很重要。我一般把「状态变更」放在最后一步。先执行所有业务动作,都成功了再改状态。这样即使中间某个动作失败,状态也不会被污染。

3.5 核心模型关系图

说了这么多,咱们用一张图把关系理清楚。下面这张图展示了状态机核心模型之间的协作关系:

状态 (State) 当前所处的位置 事件 (Event) 触发转移的输入 转移 (Transition) 状态间的跳转规则 动作 (Action) 执行的业务逻辑 当前状态 触发 执行 更新状态 守卫条件 (Guard) 图例: 状态 事件 转移 动作 守卫 核心流程:状态 + 事件 → 转移(守卫检查)→ 动作执行 → 更新状态

这张图把四要素的关系讲得很清楚。你看,状态和事件是输入,转移是规则引擎,动作是执行器,最后又回到状态更新。形成一个闭环。

总结一下: 状态机设计,说白了就是「用状态锚定业务,用事件驱动流转,用转移定义规则,用动作落地逻辑」。把这四个东西设计好,你的状态机就稳了。

嗯,这一章的内容就到这里。记住:设计状态机不是画图,是建模。模型对了,代码自然就对了。

可可精品资料库