一、为什么需要状态机
想象你在写一个网络请求模块。请求有几种状态:空闲、加载中、成功、失败。你用布尔变量来标记:isLoading、isSuccess、isError。一开始只有三四个标志,逻辑还算清晰。但很快你会发现,这些布尔变量之间存在隐含的约束——isLoading 和 isSuccess 不可能同时为真,加载中不应该再次触发请求,成功之后不能再变成失败。
这些约束散落在各个 if-else 分支里,没有集中管理。一旦遗漏某个检查,就会出现「加载中还能点击」「成功后还能报错」之类的 bug。更糟糕的是,当你需要加一个「重试」状态时,所有涉及状态判断的地方都要改,漏改一个就是线上事故。
用布尔标志写出来的代码大概长这样:
// 用布尔标志管理请求状态——典型的反面模式let isLoading = false;let isSuccess = false;let isError = false;
function fetch() { if (isLoading) return; // 防止重复请求 if (isSuccess) return; // 成功了还请求? isLoading = true; isError = false; // ...发起请求}
function onSuccess() { isLoading = false; isSuccess = true; // 忘了重置 isError?bug 就来了}
function onError() { isLoading = false; isError = true; isSuccess = false; // 又要记得重置 isSuccess}
function retry() { if (!isError) return; // 只有出错才能重试 isError = false; fetch(); // 但 fetch 里又检查 isSuccess...}这段代码的问题一目了然:每个函数都要手动维护多个布尔变量的联动关系,onSuccess 里忘了重置 isError,retry 调用 fetch 时 isSuccess 可能还是 true 导致请求被跳过。三个布尔变量有 8 种组合,但合法的只有 4 种。剩下的 4 种是不可能状态——它们不该出现,但代码没有任何手段阻止它们出现。
更具体的场景:一个订单系统。订单有「待支付」「已支付」「已发货」「已完成」「已取消」五种状态。如果用布尔标志,你需要 isPaid、isShipped、isCompleted、isCancelled 四个变量,组合出 16 种可能。其中「已支付且已取消」「已发货且未支付」这类不可能状态占了大多数。每写一个业务判断,你都在跟这些幽灵状态搏斗。
问题的根源在于:布尔标志的组合空间远大于合法状态的数量。3 个布尔变量有 8 种组合,但你只需要 4 种合法状态;4 个布尔变量有 16 种组合,但可能只有 5 种合法状态。那些多余的组合是不可能状态——它们不该出现,但代码没有阻止它们出现。状态机从根本上解决这个问题:把合法状态和状态之间的转移规则显式定义出来,让不可能的状态不可表达。
二、现实类比
自动售货机。它有明确的状态:空闲、已投币、正在出货。不投币就不能出货,正在出货时不能再投币。每个按钮只在特定状态下有效——你不会在出货时还能按退币键。售货机不需要一堆 if-else 来判断「当前能不能做这个操作」,它只需要一张状态转移表。
再想想十字路口的红绿灯。它永远在红、黄、绿之间循环,不可能出现「红绿灯同时亮」的情况,因为硬件电路本身就是一张硬编码的转移表。现实世界里的嵌入式设备几乎都用状态机来建模控制逻辑——电梯、洗衣机、微波炉,哪个不是按状态和转移来运转的?
三、核心思想
状态机把实体的生命周期建模为一组有限状态和显式转移。任何时刻,实体恰好处于一个状态。转移由事件触发,只有定义过的转移才能发生。
上面这张状态图只画了合法的转移路径。注意 success 和 error 之间没有箭头——这不是遗漏,而是设计:这两个状态之间不存在合法转移,状态机从结构上杜绝了这种可能。
威力所在:不存在的转移不可能发生。你无法从 success 跳到 error,因为没有定义这样的转移。这比在代码里到处加 if (!isSuccess) 判断要可靠得多——你不可能忘记加检查,因为根本不需要检查。
3.1 转移表
核心数据结构是一张转移表——以 (当前状态, 事件) 为键,下一状态 为值的映射。拿上面的请求状态机举例,转移表如下:
| 当前状态 | 事件 | 下一状态 |
|---|---|---|
| idle | FETCH | loading |
| loading | RESOLVE | success |
| loading | REJECT | error |
| error | RETRY | loading |
| success | RESET | idle |
查表操作是 O(1),当前状态用单个变量存储也是 O(1)。比起散落在各处的 if-else,转移表把所有规则集中在一个地方,一目了然。新增状态或修改转移逻辑只需要改这张表,不用满代码找条件分支。
3.2 守卫条件与动作
实际的转移往往不只是「收到事件就切换状态」这么简单。你经常需要:
- 守卫条件(Guard):即使事件出现了,也要满足额外条件才能转移。比如「从已投币到出货」需要「商品有库存」这个前提。守卫条件不满足时,事件被忽略或排队。
- 进入动作(Entry Action):进入某个状态时自动执行的副作用,比如进入
loading状态时发起网络请求。 - 退出动作(Exit Action):离开某个状态时的清理操作,比如离开
loading状态时取消请求。
加入守卫和动作后,一条完整的转移规则可以表示为:当处于状态 A,收到事件 E,且守卫条件 G 为真时,执行退出动作、转移到状态 B、执行进入动作。这些机制让状态机从纯数学模型变成可落地的工程工具。
| 属性 | 值 |
|---|---|
| 状态转移 | O(1)——状态 x 事件表查找 |
| 当前状态 | O(1)——单个变量 |
| 有效事件 | 每状态可枚举——支持穷举检查 |
| 空间 | O(状态数 x 事件数) 用于转移表 |
四、变体与对比
| 模式 | 与状态机的关系 |
|---|---|
| Actor 模型 | Actor 通常在内部用状态机管理行为,每个 Actor 有自己的状态和消息处理逻辑,收到消息后根据当前状态决定如何响应 |
| 熔断器 | 熔断器本身就是状态机:关闭(正常放行)→ 打开(直接拒绝)→ 半开(试探性放行),三个状态之间的转移由失败率和超时触发 |
| 访问者 | 访问者可以根据状态机当前状态进行不同分发,两者结合可以实现类型安全的状态行为映射 |
| 标签联合体 | 标签联合体编码合法状态(让不可能状态不可构造),状态机编码合法转移(让不可能转移不可触发),互补关系 |
4.1 Mealy 机与 Moore 机
状态机有两个经典变体,区别在于输出是否依赖当前输入:
- Mealy 机:输出取决于当前状态和当前输入。转移发生时产生输出,状态本身不携带输出信息。上面代码里的实现就是 Mealy 风格——
Send(event)返回的结果取决于事件和当前状态的组合。 - Moore 机:输出只取决于当前状态,与输入无关。每个状态关联一个固定的输出,进入该状态就产生该输出。Moore 机通常需要更多状态来表达同样的行为,但行为更容易预测——只看状态就知道输出是什么。
工程实践中,Mealy 机更常见,因为它状态数更少、表达更紧凑。但 Moore 机在需要「状态即输出」的场景下更直观,比如 UI 渲染——当前状态直接决定渲染什么组件,不需要额外的事件判断。
状态机 vs 布尔标志的对比是核心论点:11 个布尔变量有 2048 种组合,TCP 只需要 11 种合法状态。布尔方法允许 2037 种无效组合,每个 if-else 都必须防范它们;状态机通过构造使不可能状态不可表达。
五、多语言实现
5.1 Go 实现
// StateMachine 用转移表管理状态生命周期type StateMachine struct { current string transitions map[string]map[string]string // state -> event -> next}
func New(initial string) *StateMachine { return &StateMachine{ current: initial, transitions: make(map[string]map[string]string), }}
// AddTransition 注册一条合法的状态转移func (sm *StateMachine) AddTransition(from, event, to string) { if sm.transitions[from] == nil { sm.transitions[from] = make(map[string]string) } sm.transitions[from][event] = to}
// Send 触发事件,若转移合法则更新状态func (sm *StateMachine) Send(event string) string { if next, ok := sm.transitions[sm.current][event]; ok { sm.current = next } return sm.current}
// Can 检查当前状态下该事件是否有效func (sm *StateMachine) Can(event string) bool { _, ok := sm.transitions[sm.current][event] return ok}
func (sm *StateMachine) State() string { return sm.current }Go 的实现用嵌套 map 存储转移表。外层 map 的键是当前状态,内层 map 的键是事件,值是目标状态。Send 方法做一次 map 查找决定是否转移,这种设计有几个考量:第一,未定义的事件被静默忽略——状态机保持稳定而不是报错,这是状态机语义的惯例,不是疏忽;第二,用 string 而非枚举类型,是为了保持通用性,生产代码可以用 type State string 定义类型别名来获得类型安全;第三,AddTransition 是运行时构建转移表,适合配置驱动的场景,如果状态和转移在编译期就确定,可以用 const 和 init() 函数来固定。
5.2 TypeScript 实现
type StateConfig<S extends string, E extends string> = { [state in S]: { on: Partial<Record<E, S>> };};
class StateMachine<S extends string, E extends string> { private current: S;
constructor( private config: StateConfig<S, E>, initial: S, ) { this.current = initial; }
get state(): S { return this.current; }
// 触发事件,查转移表决定下一状态 send(event: E): S { const next = this.config[this.current].on[event]; if (next !== undefined) { this.current = next; } return this.current; }
// 当前状态下该事件是否有效 can(event: E): boolean { return this.config[this.current].on[event] !== undefined; }}TypeScript 的实现把转移表直接编码为配置对象,和 Go 版的嵌套 map 思路相同,但利用了类型系统的能力。StateConfig<S, E> 用映射类型确保每个状态都必须声明 on 字段,Partial<Record<E, S>> 表示并非每个事件在每个状态下都有定义——这正是「不存在的转移不可能发生」在类型层面的体现。实际使用时,如果你传了一个在当前状态下没有定义的事件,send 会静默返回当前状态,和 Go 版行为一致。如果想更严格,可以改成 send 在无效事件时抛异常,这取决于业务需要。
六、生产验证
- XState —— StateMachine.ts#L58-L120 JavaScript/TypeScript 工业级状态机库。它不仅实现了基本的状态转移,还支持层级状态(嵌套状态机)、并行状态(正交区域)、延迟转移和守卫条件。Netflix 用它管理复杂的多步注册流程,Microsoft 在 VS Code 的某些扩展中用它编排交互逻辑,AWS 在 Step Functions 中用状态图语言(本质是状态机)定义工作流。
- Linux 内核 —— tcp_input.c#L4865-L4920 TCP 连接状态机。TCP 协议定义了 11 种状态(从
CLOSED到ESTABLISHED到TIME_WAIT),Linux 内核用switch (sk->sk_state)实现状态分发,每个 case 处理该状态下合法的入站报文。这个状态机运行在全球每台联网设备的内核里,是状态机模式最广泛的生产部署之一。TCP 状态机的设计精妙之处在于:TIME_WAIT状态持续 2MSL 后自动转移到CLOSED,这是一种带超时的自转移,保证连接资源最终被回收。 - Kubernetes —— Pod 生命周期状态转移,从
Pending到Running到Succeeded/Failed,通过状态机保证不会出现非法中间状态。Kubernetes 的 Pod 状态虽然看起来简单,但背后涉及容器运行时、调度器、kubelet 三方协作。状态机确保了比如一个Failed的 Pod 不会突然变成Running——即使 kubelet 误报了状态,API Server 也会根据转移规则拒绝非法变更。
七、小结
何时使用:
- 协议实现——TCP、HTTP、WebSocket 等有明确状态转移规范的场景。TCP 状态机是教科书级案例,11 个状态、数十条转移规则,用布尔标志根本无法管理。
- UI 流程管理——多步表单、认证流程、模态框,需要约束用户操作顺序。XState 在前端社区被广泛采用正是因为这类需求。
- 游戏逻辑——角色状态(待机、行走、攻击、死亡),每种状态下可执行的动作不同。死亡状态不接受任何输入,这靠状态机天然保证。
- 工作流引擎——审批链、部署流水线,状态的推进必须遵循规则。Kubernetes 的 Pod 生命周期就是典型的工作流状态机。
何时不用:
- 简单布尔切换——
true/false不需要状态机,一个变量够了。一个开关灯的按钮用状态机是过度设计。 - 无界状态——连续状态空间(位置、分数)用普通变量。角色的坐标是连续值,不可能为每个坐标建一个状态。
- 无非法转移——如果任何状态可以转到任何其他状态,约束没有意义。一个简单的缓存标记(命中/未命中),两个状态之间可以随意切换,状态机不会带来收益。
- 组合爆炸——5 个独立开关等于 32 种状态;应该将正交关注点分别建模为并行状态机。XState 的并行状态(orthogonal states)就是为解决这个问题设计的。
八、参考资料
- XState 官方文档 - JavaScript 状态机与状态图库
- RFC 793 - TCP 协议规范 - TCP 状态机的形式化定义
- Kubernetes Pod 生命周期 - Pod 状态转移图
- Statecharts: A Visual Formalism for Complex Systems - Harel 提出 Statechart 的原始论文,解决了 FSM 的状态爆炸问题
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






