
1. 从一把 if-else 里逃出来大概两年前我接手过一个内部管理系统的订单模块。代码本身不算复杂但状态相关的逻辑全部揉在一堆布尔标志位和嵌套 if-else 里。那种代码你要说完全跑不了也不至于但每加一个新需求我就要小心翼翼地看一遍“现在是哪些标志位组合下一步该走哪条分支”。有一次线上订单卡在“已支付但未发货”查了半天才发现是某个回调事件把状态又翻回了旧值。这段经历基本就是我开始认真用状态机的原因。State Machines 这个词在教科书里出现得次数不少但真正让我觉得“这不是理论是救命工具”的是把它拆成“状态 事件 转移表”这三个零件以后代码的可读性和可控性一下子变得完全不一样。这篇文章适合两类人。一类是维护过那种“标志位泥潭”的开发者想找一个不引入重量级框架的折中方案另一类是听过状态机但始终觉得它和实际业务隔了一层的人。我会用订单、任务、连接这类常见模型做例子给出可以直接拿去改的代码也会把那些文档里不写的坑一并说清楚。2. 状态机的三个零件状态、事件、转移表2.1 先把术语说人话状态机听起来吓人本质就是一句话系统在任意时刻处于某一个状态来一个事件查一下“当前状态 事件”对应的转移然后决定去哪个新状态。这里最核心的是“当前状态只有一个”。这个约束听起来简单但正是它把无数种标志位组合压缩成了一条确定的路径。比如“已支付”“已发货”“已完成”这些互斥的概念你在传统写法里可能用三个布尔变量来表示结果冒出“既已支付又已发货”的中间态其实不是设计出来的是巧合跑出来的。状态机不给你这个机会。2.2 用订单系统的例子串一遍拿订单来说我一般会先定义状态PENDING待支付、PAID已支付、SHIPPED已发货、COMPLETED已完成、CANCELLED已取消。再定义事件PAY_SUCCESS、SHIP、CONFIRM_RECEIPT、CANCEL。然后画一张二维表行是状态列是事件交叉格写转移结果。当前状态pay_successshipconfirm_receiptcancelPENDINGPAIDILLEGALILLEGALCANCELLEDPAIDIGNOREDSHIPPEDILLEGALCANCELLED退款SHIPPEDILLEGALIGNOREDCOMPLETEDIGNOREDCOMPLETEDIGNOREDIGNOREDIGNOREDIGNOREDCANCELLEDIGNOREDIGNOREDIGNOREDIGNORED这张表的好处是一眼就能看出哪些转移是允许的哪些是非法。比如 PENDING ship 就应该是不允许的你不可能没付款就发货。传统 if-else 里这种非法路径往往要靠程序员自觉去堵在状态机里它是表格的一个空格天然暴露在那里。2.3 转移表怎么设计才不容易漏设计转移表最容易漏的不是“正常路径”而是“用户取消”“超时关闭”“对账失败”这类异常路径。我有个习惯先列出所有状态再列出所有事件然后逐个看“每一个状态遇到每一个事件应该去哪”。可以写一个矩阵或者表格把不可能出现的格子直接标成 ILLEGAL而不是留空。留空的问题在于代码里一旦遇到没有定义的转移你通常只能假装没看到标成 ILLEGAL 之后你就必须在运行时和日志里明确暴露它。很多线上问题就是这种“没定义但实际发生了”的转移造成的。3. 不装框架手写一个最小可用状态机3.1 为什么我建议先手写市面上有现成的状态机库比如 Spring StateMachine、XState功能很全但我个人建议第一版先在业务代码里手写一个几十行的转移引擎。原因很简单库会掩盖概念。当你自己写的时候你被迫想清楚状态怎么存、事件怎么来、转移时动作放哪里。这些思考比任何框架的 API 都重要。而且我的经验是大部分业务场景的状态机不超过十来个状态手写 30 行代码比引入一个几百 KB 的库然后处理它的配置语法和维护成本要划算得多。3.2 核心代码30 行以内的转移引擎一个最小状态机用 Python 写大概是这样的from dataclasses import dataclass from typing import Callable, Dict, Tuple, Optional State str Event str dataclass class Transition: target: State action: Optional[Callable] None class SimpleStateMachine: def __init__(self, initial: State, transitions: Dict[Tuple[State, Event], Transition]): self.state initial self.transitions transitions def send(self, event: Event, *args, **kwargs): key (self.state, event) trans self.transitions.get(key) if trans is None: raise ValueError(fIllegal transition: {self.state} {event}) if trans.action: trans.action(*args, **kwargs) self.state trans.target这段代码的核心就三件事用(state, event)做键查表、有定义就走动作并换状态、没定义就抛异常。没有魔法没有状态栈没有复杂继承但已经能覆盖绝大多数“单层状态机”的业务需求。3.3 接入业务逻辑后长什么样假设是订单支付成功回调状态定义和转移表可以写成class OrderState: PENDING PENDING PAID PAID SHIPPED SHIPPED COMPLETED COMPLETED CANCELLED CANCELLED def on_paid(order_id): order load_order(order_id) order.paid_at now() save_order(order) def on_ship(order_id, express_no): order load_order(order_id) order.express_no express_no save_order(order) transitions { (OrderState.PENDING, pay_success): Transition(OrderState.PAID, on_paid), (OrderState.PAID, ship): Transition(OrderState.SHIPPED, on_ship), (OrderState.SHIPPED, confirm_receipt): Transition(OrderState.COMPLETED, on_complete), (OrderState.PENDING, cancel): Transition(OrderState.CANCELLED, on_cancel), (OrderState.PAID, cancel): Transition(OrderState.CANCELLED, on_refund), } m SimpleStateMachine(OrderState.PENDING, transitions) m.send(pay_success, order_id)这里我把动作放在转移对象里而不是放在状态里是因为同一个事件在不同状态下要做的副作用不同。比如 PENDING 状态下取消订单是直接关闭PAID 状态下取消订单要退款这两个动作挂到同一个cancel事件的不同转移上代码看起来非常直观。3.4 加一点“进入/离开动作”支持手写版本最容易被吐槽的是缺少“进入状态时自动执行动作”。这个确实有用比如每次进入 SHIPPED 都要发消息通知用户。要加也不难在Transition里再加一个可选的enter_action或者在状态机里维护一个state_action映射进入新状态后统一执行。我一般倾向于把“进入动作”和“转移动作”分开转移动作处理事件本身的副作用进入动作处理新状态带来的公共服务。比如发通知、记录审计日志、更新缓存放在进入动作里会避免重复写。4. 状态机真的越多越好吗状态爆炸与分层时机4.1 状态爆炸是真实存在的状态机不是银弹。最典型的问题是“状态爆炸”当你有两个维度互相独立的状态比如“支付状态”和“物流状态”硬塞进一个状态机状态数量会变成笛卡尔积转移表大得没法维护。这时候我的第一反应不是换框架而是拆状态机。支付状态机负责支付域物流状态机负责物流域两个状态机通过事件或消息驱动串联。不要试图用一个总状态机管所有事。4.2 分层状态机和 HSM 什么时候才值得用如果业务确实需要“子状态”概念比如“发货中”下面还有“等待揽收”“运输中”“派送中”可以考虑分层状态机HSM。但说实话我见过的大部分团队在需要 HSM 之前就已经被业务复杂度打败了或者根本没有那么多共享转移。我的判断标准是如果共享转移超过三分之一或者同一个事件在不同子状态下都要走几乎相同的逻辑再考虑引入层级否则用扁平状态机加上复合状态命名比如SHIPPING_WAIT_PICKUP、SHIPPING_IN_TRANSIT也完全能撑住。不要因为“听起来高级”就上分层分层状态机的调试复杂度是上了一个台阶的。4.3 和“不变量”配合的测试思路状态机最大的测试优势在于你不需要覆盖所有可能输入只需要覆盖转移表里的每一条边。每一条(state, event) - new_state就是一个测试用例。我会为每条合法转移写一个用例同时为若干条非法转移写“预期抛错”的用例比如PENDING ship必须报错。这样测试数量是可控的状态数乘以事件数一个订单系统通常也就几十个用例。比起给十几个布尔组合写测试状态机的测试既不重复又容易复盘。我在项目里还会再加一个不变量测试不管怎么发事件最终状态永远落在预定义的状态集合里不会出现“半支付半发货”这种脏状态。5. 我在真实项目里踩过的五个坑5.1 异步事件的到达顺序在线支付回调和前端轮询结果可能不是同一时刻到达的。如果支付已经成功但先收到了用户点“取消”的事件状态机就会在 PAID 状态处理 cancel这时如果不做退款逻辑就会产生“已取消但钱也扣了”的脏订单。踩过这个坑之后我的习惯是事件里带上时间戳在进入转移前先校验事件顺序或者对于有外部回调的场景把“取消”设计成只有 PENDING 和 PAID 能处理并且 PAID 分支走退款。顺序校验错了状态机反而会把脏数据合法化这个比 if-else 更难查因为它看起来每一步都是对的。5.2 非法转移抛异常还是忽略我一开始在send里对非法转移直接抛异常结果线上偶尔出现一个ValueError。后来发现有些事件是外部的、无序的、甚至重复的比如支付回调可能推两遍。在这种情况下纯抛异常会把业务整体打断。现在的做法是分两层如果是外部不可控事件我定义一个IGNORED策略重复事件直接忽略并把日志记下来如果是内部逻辑不该发生的事件才抛异常。怎么区分看业务容忍度。外部回调的重复事件通常跳过即可内部 bug 导致的非法转移必须炸出来。5.3 状态机与数据持久化的对齐状态机跑在内存里很欢但重启之后状态从哪来大部分项目会把状态字段存到数据库但要注意你做转移判断时读到的状态和落库时写下的状态在分布式场景下可能已经被改掉了。所以我在写更新的时候通常用条件更新UPDATE orders SET state :new_state WHERE id :id AND state :old_state如果影响行数为 0说明并发修改了状态这时候要回滚或者重试。这个和状态机本身无关而是“状态字段也是数据”的意识。不加上这个再好的状态机也会在并发下出现脏覆盖。5.4 事件幂等不能靠状态机硬扛有段时间我以为只要状态机定义得好重复事件就进不来。现实是消息队列可能重投HTTP 回调可能重试状态机只是告诉你“当前状态不接受这个事件”但你没法区分“这个事件以前处理过”和“这个事件现在不该发生”。我的做法是在事件处理函数里记录外部事件 ID比如支付回调的 transaction_id处理前先查一下去重表。如果已经处理过直接返回成功。这件事不要交给状态机判断否则状态迁移的语义会被幂等逻辑污染。5.5 调试日志怎么打才有效状态机的问题大多数发生在“事件到达时状态不对”。我现在的日志格式是[state_machine] order_id123 statePENDING eventpay_success - PAID同时把transitions表的内容在启动时打一条摘要这样出问题时我可以对着日志看出当时状态是谁、事件是什么、转移走向哪里而不是去翻一堆业务日志猜。日志里一定要带业务主键不然分布式环境下面根本没法把事件串起来。6. 一个我常用的简化技巧6.1 用枚举而不是字符串虽然前面的例子用了字符串但正式项目里我强烈建议用枚举。字符串会拼错还不好做 IDE 补全。用enum.Enum之后转移表在启动时就能校验一遍有没有引用不存在的状态、有没有漏掉某条路径。这个校验用几十行代码就能写是手写状态机最大的红利。from enum import Enum class OrderState(str, Enum): PENDING PENDING PAID PAID SHIPPED SHIPPED COMPLETED COMPLETED CANCELLED CANCELLED加上str混合之后枚举值可以直接序列化到 JSON数据库里存的还是字符串但代码里的类型提示和自动补全都保住了。6.2 把动作放到配置里如果状态机被多个服务复用可以把转移表变成一份 JSON 或 YAML 配置动作名用字符串注册表映射。这样业务方改流程时只需要改配置不用动代码。不过我不建议一上来就做这个抽象等真正出现两份业务都用到同一套状态的时候再说否则就是过度设计。6.3 一个小工具函数最后给一个我在调试时常用的函数dump_machine把当前状态机所有的转移关系打印成表格用于代码评审和复盘def dump_machine(machine: SimpleStateMachine): for (state, event), trans in sorted(machine.transitions.items()): print(f{state:20s} {event:20s} - {trans.target})你会在评审时发现肉眼读转移表比读一堆 if-else 快得多。这也是我会在各种项目里反复推状态机的原因它不是在约束你而是把复杂逻辑变成一张可以讨论、可以评审、可以测试的表。把这张表打印出来贴在代码评审文档里几乎每次都能揪出一两个大家都没想到的边界转移。