有限状态机从原理到实践:状态转移表设计与工程应用指南

📅 发布时间:2026/9/15 21:35:09
有限状态机从原理到实践:状态转移表设计与工程应用指南
写状态机那几年我最大的感受是多数人不是不会写逻辑而是逻辑一旦复杂起来代码就变成了一团乱麻。今天要聊的有限状态机FSMFinite State Machine恰恰是解决这类问题最经典、最实用的一套思维框架。不管你是做嵌入式、写游戏逻辑、搞网络协议解析还是处理订单状态流转FSM都能让原本靠一堆if/else硬撑的设计变得清晰、可控、可测试。这篇文章是系列的开篇我会把FSM最核心的底层逻辑、设计方法、代码实现和踩坑经验一次性讲透。适合对FSM有基本概念但没系统梳理过的人也适合那些已经在项目里用过状态机、但总觉得哪里不太对劲的朋友。1. 先聊为什么需要状态机从一团乱麻到一张状态图先回到一个很常见的场景你现在要写一个用户登录模块用户有未登录、登录中、已登录、登录失败这么几种情况。业务规则大概是用户提交登录请求后进入登录中服务端返回成功则变已登录返回失败就变登录失败登录失败后用户可以重试也可以取消回到未登录。听起来不复杂对吧但如果不用状态机你的代码很容易长成这样boolean isSubmitting false; boolean isLoggedIn false; boolean isFailed false; void onLoginClick() { if (!isSubmitting !isLoggedIn !isFailed) { isSubmitting true; startLoginRequest(); } else if (isSubmitting) { // 按钮置灰什么都不做 } else if (isFailed) { // 重试 isFailed false; isSubmitting true; startLoginRequest(); } } void onLoginSuccess() { if (isSubmitting) { isSubmitting false; isLoggedIn true; } } void onLoginFailed() { if (isSubmitting) { isSubmitting false; isFailed true; } }三个布尔变量组合出八种可能你只用了其中四种剩下的四种是非法状态。可问题恰恰在这里你的代码里压根没有非法状态这个概念。某天产品要求增加一个登录被风控拦截的弹窗你加了一个isBlocked变量八种组合变十六种一半都是垃圾状态。再往后任何一次状态修改都必须小心翼翼地检查所有布尔变量的排列组合漏掉一种就是线上事故。状态机解决问题的思路完全不同它把系统可能处于的每一个情况都定义成状态把所有能触发状态变化的条件定义成事件然后明确地规定——在A状态遇到X事件系统该做什么、下一步跳到哪个状态。整个逻辑变成一张二维表而不是散落在代码各个角落的布尔判断。以登录模块为例用状态图来画就是四个圆圈状态几条带箭头的线转移线上标注触发转移的事件。这张图一旦画出来你就能直接对着图写代码永远不会出现十六种组合里有十二种没人管的情况因为图里根本没有这些转移路径。这就是FSM的第一层价值强制你先把所有可能的情况想清楚再动手写实现。第二层价值在于可测性——状态和事件都是离散的你完全可以枚举出所有状态事件的组合来设计测试用例。第三层价值是代码结构上的每个状态的处理逻辑天然被隔离不会再出现一个上百行的函数同时处理七八种情况。2. 状态机的四样家底状态、事件、动作、转移FSM的定义听起来很学术说来说去就是一个系统在任意时刻处于有限个状态中的一个由输入事件驱动状态切换。但真正理解它要把四个核心概念吃透。2.1 状态State状态是系统在某一时刻的稳定存在形式。关键词是稳定——系统停在某个状态里的时候不会自己乱跳它就在那里等着事件来临。状态本身还可以携带信息这叫扩展状态Extended State。比如网络连接模块里已连接这个状态最好记住连接的对端地址、连接建立时间、已传输字节数。这些信息不属于状态机的控制逻辑但对业务处理至关重要。设计状态时最容易犯的毛病是把状态当动作来定义。比如有人会把正在发请求作为一种状态这就模糊了正在做什么和处于什么阶段的边界。我的建议是状态描述的是系统当前所处的情况而不是系统当前执行的某个动作。一个请求的收发是瞬间行为不是稳定状态而等待响应才是稳定状态。2.2 事件Event事件是触发状态转移的外部或内部信号。外部事件比如用户点击按钮、网络收到数据包、传感器产生中断内部事件比如定时器超时、某个条件满足后代码主动触发的信号。事件与状态的最大区别是事件是瞬时发生的状态是持续存在的。你可以把事件想象成敲门声状态想象成房间里的人所处的境况。敲门声响起事件如果房里的人正在睡觉当前状态他可能翻个身继续睡状态不转移但有内部动作如果他在等外卖他会起身开门状态从等待转移到底接收。同一个事件在不同的状态里引发的响应完全不同——这正是状态机设计的基本逻辑。2.3 动作Action动作是状态转移过程中执行的具体操作。严格来说动作分为两类进入动作Entry Action和退出动作Exit Action。进入动作表示当我进入这个状态时需要做的事只做一次退出动作表示当我离开这个状态时需要做的清理工作也只做一次。这个只做一次的语义非常有用。比如一个播放器状态机进入播放中状态时启动一个节拍器去刷新进度的定时任务退出播放中状态时停掉这个定时任务。如果你没有用状态机的进入/退出动作你就得在每个转移路径上手动调用startTicker()和stopTicker()一旦漏掉一条路径就是一个bug。2.4 转移Transition转移描述的是在什么状态下遇到什么事件做什么事跳到哪个状态。完整的转移定义包含五个要素源状态、触发事件、守卫条件Guard Condition、动作、目标状态。守卫条件很关键它是转移的门卫。同一个重试事件可能在登录失败状态里允许转移回登录中但如果重试次数已经超过三次守卫条件就会拦住这个转移转而触发另一个检查是否锁定的转移。没有守卫条件的转移表本质上只是把if/else换了一种写法并没有解决复杂度问题。理解了这四个概念你已经有了阅读任何状态机代码和画状态图的基础。3. 状态机的两种体形Moore型和Mealy型怎么选FSM在教科书里被分成Moore型和Mealy型两类很多人学的时候觉得抽象实际接触多了会发现这两者的区别其实很实用。Moore型状态机的特点是输出或说行为完全取决于当前状态与触发事件无关。在Moore机里你只要知道系统现在处于什么状态就知道它会表现出什么行为事件只负责让状态发生转移转移本身不影响输出。它的优势是结构简单、行为可预测特别适合做逻辑清晰的业务流程控制。Mealy型状态机的特点是输出同时取决于当前状态和当前输入事件。也就是说同样的状态来不同的外部信号系统可能会产生完全不同的动作而且这些动作是和转移绑定的。比如一个通信协议解析器处于等待帧头状态收到0xAA可能表示帧头正确开始接收数据收到其他字节则直接丢弃。这个丢弃动作只有在等待帧头状态且收到非0xAA字节时才出现它属于Mealy风格的转移动作。实际工程中很少有人严格只用一种基本都是混着来。我的经验是能用Moore表达的业务尽量Moore这会让状态机的可测试性大幅提升对于协议解析这类对输入高度敏感的模块在用Moore保证主流程稳定的前提下把个别输入判断做成Mealy式的转移动作代码会更流畅、更贴近业务直觉。区分这两种模型还有一个实践意义当你review别人的状态机代码时如果发现某个状态的行为会因为事件不同而显著变化那说明它隐含了Mealy式逻辑你在测试时要额外关注事件维度的用例覆盖不能只测状态维度的所有状态。4. 从零写一个订单状态机枚举、转移表与唯一状态入口理论聊完就该上手了。我选一个几乎所有业务系统里都有的场景来演示——订单状态管理。为什么选这个电商、外卖、erp系统里都有而且订单状态的细节坑非常多能自然地把FSM的关键知识点牵出来。4.1 定义状态与事件枚举订单的核心状态我先定义八种待支付、已取消、待发货、已发货、已签收、退款中、已退款、已完成。事件定义六种用户支付、用户取消、商家发货、用户确认收货、用户申请退款、商家同意退款。public enum OrderState { PENDING_PAYMENT, // 待支付 CANCELLED, // 已取消 PENDING_SHIPMENT, // 待发货 SHIPPED, // 已发货 SIGNED, // 已签收 REFUNDING, // 退款中 REFUNDED, // 已退款 COMPLETED // 已完成 } public enum OrderEvent { PAY, // 支付成功 CANCEL, // 用户取消 SHIP, // 商家发货 CONFIRM_RECEIPT, // 确认收货 APPLY_REFUND, // 申请退款 APPROVE_REFUND // 同意退款 }枚举的命名要直白、可读性优先。别用ST1、EVT3这种毫无信息量的名字以后维护的人包括三个月后的你自己会感谢你的。4.2 设计转移表先画表格再写代码写代码之前先做设计。我把允许发生的转移全列出来当前状态触发事件守卫条件目标状态转移动作待支付PAY无待发货发送支付成功通知待支付CANCEL无已取消发送取消通知待发货SHIP无已发货填写物流单号待发货CANCEL无已取消发送取消通知待发货APPLY_REFUND超时不超过约定时间退款中通知商家确认已发货CONFIRM_RECEIPT无已签收结算给商家已发货APPLY_REFUND物流拦截成功退款中发起退款流程已签收APPLY_REFUND签收不超过7天退款中发起售后流程已签收CONFIRM_RECEIPT无已完成订单完结退款中APPROVE_REFUND无已退款原路退款关闭订单已取消无--终态已退款无--终态已完成无--终态注意这张表做了什么非法转移根本不在表里。比如已取消状态不能支付、不能发货已退款状态不能再申请退款。这样设计的直接收益是——所有逻辑合法性在进入状态机那一刻就天然保证了你不用到处写如果订单已取消就不能支付这种零散判断。还有一点很关键每个状态我标了哪些是终态。终态意味着没有任何对外事件能让它继续转移这是状态机语义的一部分也是业务闭环的体现。4.3 用Map维护转移表代码即设计我把转移表直接实现为一个不可变的Map主键是源状态, 事件的组合。这是整个实现里最重要的一行决策MapStateEventPair, Transition transitionTable new HashMap(); record StateEventPair(OrderState source, OrderEvent event) {} record Transition(OrderState target, GuardCondition guard, Action action) {}定义StateEventPair作为复合键比用MapOrderState, MapOrderEvent, Transition更直白。查找语义就是一句根据当前状态和触发事件查到对应的转移规则和状态图的表达方式完全一致。4.4 一个统一的状态入口所有转移必须走大门这是整个实现里我心得最深的一点。状态机一定要提供一个统一的状态变更入口。拿订单来说所有状态变化都只能通过fireEvent(OrderEvent event)这个方法进入不允许业务代码直接setState改状态。public class OrderStateMachine { private OrderState currentState; private final MapStateEventPair, Transition transitions; public synchronized OrderState fireEvent(OrderEvent event) { StateEventPair pair new StateEventPair(currentState, event); Transition transition transitions.get(pair); if (transition null) { throw new IllegalStateException( 非法转移: 当前状态[ currentState ] 不支持事件[ event ] ); } if (transition.guard() ! null !transition.guard().check(this)) { throw new IllegalStateException( 守卫条件未通过: 状态[ currentState ] 事件[ event ] ); } OrderState previous currentState; currentState transition.target(); if (transition.action() ! null) { transition.action().execute(previous, currentState, event); } return currentState; } public OrderState getCurrentState() { return currentState; } }从这个方法能看出来非法事件会直接被拦截抛异常。实际项目里要不要抛异常可以商量——有的场景你希望忽略它比如UI上重复点击导致重复事件有的场景必须暴露问题比如退款流程走到了不该走的分支。我的建议是系统内部的确定性逻辑上抛异常用户操作触发的边界场景上用守卫条件提前拦截。4.5 扩展状态订单实体与状态机怎么配合前面提到过扩展状态的概念在订单场景里最直接的体现是订单状态机不应该脱离订单聚合根存在。订单里除了状态字段还有金额、物流单号、创建时间、支付时间等一堆数据。我通常的做法是让状态机工作在一个订单实体之上守卫条件和转移动作都能读到或修改订单字段。public class Order { private Long orderId; private OrderState state; private BigDecimal amount; private String logisticsNo; private LocalDateTime createdAt; private LocalDateTime paidAt; private LocalDateTime signedAt; }这样的话已发货事件的转移动作里就可以做order.setLogisticsNo(logisticsNo)守卫条件里就可以检查签收未超过7天时读order.getSignedAt()。状态机管理状态流转订单实体管理业务数据各司其职干净利落。4.6 关于状态机的并发问题订单状态机有一个逃不开的坑并发。用户取消订单的同时支付回调也来了这两个事件可能同时触发状态转移。没有并发控制状态机可能从待支付被两条线程分别变为已取消和待发货那就全乱了。处理并发的思路有两个层次。简单做法是在fireEvent方法上加同步锁就是上面代码里synchronized的做法保证同一订单的状态转移串行化。进阶做法是在数据库层做版本号乐观锁更新订单时带上version字段更新成功行数为0说明已有其他请求改过当前请求需要重新处理。我的建议是单体应用里先加同步锁分布式环境里必须在状态表上做乐观锁或CAS更新。同时把查状态执行转移写回状态这个动作用一个独立的service方法包起来避免业务代码里多次调用fireEvent导致中间状态被其他线程读到。5. 状态机框架怎么写才不过度设计从函数对象到Action拆解订单状态机的代码核心逻辑在上面已经完整了接下来自然的问题是这个框架怎么处理动作这部分的复杂度。5.1 动作的粒度一条转移一个类还是一个小函数最朴素的写法是给每个转移配置一个Runnable或Consumer回调Lambda里直接写动作逻辑。好处是简单直观坏处是一旦动作逻辑超过几行Lambda就会膨胀成一段又一段代码平铺在初始化方法里很难复用。我推荐的折中方案是把动作按语义拆成可命名的方法然后通过方法引用绑定到转移表。比如void handlePaid(OrderState source, OrderState target, OrderEvent event) { order.setPaidAt(LocalDateTime.now()); notifyService.sendPaymentSuccessNotification(order); } { transitions.put( new StateEventPair(OrderState.PENDING_PAYMENT, OrderEvent.PAY), new Transition(OrderState.PENDING_SHIPMENT, null, this::handlePaid) ); }这么做有几个好处动作的方法名就是文档、动作逻辑可以被其他转移复用、单元测试可以直接调用方法验证副作用。在Java里我用BiConsumerOrderState, OrderState配合事件参数也可以用自定义的Action接口带上事件信息。总之核心是让转移表保持可读让动作是可命名的逻辑单元不要让几十个Lambda堆积在init方法里。5.2 关于是否引入Spring StateMachine这类框架如果你在网上搜过状态机一定会看到Spring StateMachine、XState、boost::statechart、SMC这些框架。我的建议分场景看。如果项目里就一两个状态机比如只有一个订单状态机手动实现一个几十行的转移表配置反而最清晰——依赖少、流程透明、调试方便根本不需要引入重量级框架。Spring StateMachine功能确实强大支持嵌套状态、历史状态、分布式协同但学习曲线不低配置复杂后很多行为黑盒化出了问题反而难排查。如果系统里状态机很多而且状态之间有层级关系、并行区域那可以考虑使用成熟的框架。只有在你们已经踩过状态机复杂度的坑明确需要高级特性时引入才划算。否则多数项目的状态机问题不是工具不够强而是设计不够清晰。5.3 状态机的分层状态转移逻辑与业务逻辑解耦还有一个重要的设计原则我要提醒状态机本身不该承担超出状态流转职责的业务逻辑。比如支付成功通知这个动作通知发不发送、发什么内容这是业务逻辑不应该全都塞到状态机的转移动作里。我习惯的状态机分三层第一层状态定义、事件定义、转移表、状态入口——这一层是纯状态机不含业务语义。第二层转移动作层——负责调用业务服务、修改订单字段、记录日志但动作里不写复杂的业务规则判断。第三层业务流程层——由外部服务拼接状态机的触发时机比如支付回调服务先校验签名再调用状态机。分层之后状态机保持纯粹容易复用业务规则变化时也只需要改对应层不会动到转移骨架。这个分层思路也是我后面要讲的嵌套状态机和状态机即服务的基础。6. 从表驱动到手写转移两种实现风格各有各的坑前面我演示的是表驱动Table-Driven的实现方式把转移规则集中在一张表里。这是目前我在业务系统里最推荐的写法。但我也见过不少人用手写转移的方式——直接在每个状态的case分支里写下一个状态是什么。这两种方式都必须被理解因为它们在工程里都有适用场景。6.1 表驱动为主什么时候选它表驱动适合业务规则清晰、状态和事件组合有限且确定性强的场景。订单状态机、工单流程状态机、审批流状态机都是典型用例。它的好处非常实在转移规则一目了然新同学读代码就像读需求文档增加一个合法转移只改一处配置可以自动生成文档或状态图后面我讲工具时再说非法转移集中可控坏处是当状态数量膨胀到几十个、事件数量几十个时StateEventPair的数量会爆炸配置文件的体量会很大。这种情况下需要考虑状态压缩或引入高阶状态机结构。6.2 手写转移为主什么时候它更顺手手写转移的风格长这样switch (currentState) { case PENDING_PAYMENT: if (event OrderEvent.PAY) { currentState OrderState.PENDING_SHIPMENT; handlePaid(); } else if (event OrderEvent.CANCEL) { currentState OrderState.CANCELLED; handleCancel(); } break; case PENDING_SHIPMENT: ... break; }这种写法适合状态切换逻辑本身就带复杂的业务分支、守卫条件高度依赖上下文或者转移动作非常个性化的场景。它最大的坑是可维护性差每增加一个事件就要在N个case分支里找有没有需要补充处理的地方很容易漏。而且当两个事件的触发条件互相纠缠时case分支里会迅速长出嵌套的if/else代码又开始乱成一团。所以我的判断很简单能用表驱动的就用表驱动只有状态逻辑确实呈现出每个状态下要根据现场条件做复杂决策时才考虑手写转移并在内部严格使用常量保持逻辑可读。7. 状态机调试最强的三板斧日志、状态图与异常防御状态机是个确定性极强的模型但它跑在真实系统里问题无非出现在三个维度转移路径配错了、事件触达时机不对、并发下状态已变。调这些问题我有三板斧。7.1 结构性日志每次转移都是一条流水线记录状态机是非常适合打日志的模块因为它的信息是高度结构化的。推荐每个fireEvent调用在转移成功和失败两个分支各打一条日志if (transition null) { logger.warn(状态机拒绝转移: orderId{}, currentState{}, event{}, order.getOrderId(), currentState, event); throw new IllegalStateException(...); } OrderState previous currentState; currentState transition.target(); logger.info(状态机转移: orderId{}, from{}, to{}, event{}, costMs{}, order.getOrderId(), previous, currentState, event, costMs);日志里必须带上订单ID或业务主键这是排查问题的关键线索。一旦线上用户反馈订单状态不对你直接按订单ID搜日志就能完整地看到这个订单经历了哪些事件、哪些转移、哪一步开始偏离预期。7.2 自动生成状态图配置不能只躺在代码里转移表已经有了结构化的数据基础完全可以把它渲染成图。我常用的方案是PlantUML——状态机的转移表转成PlantUML语法一行状态、一行[*] -- 状态、一行状态 -- 状态 : 事件(守卫条件)渲染出来就是一张清晰的状态图。很多团队的状态机文档和代码脱节文档画的是旧版本代码已经改了好几轮。其实只要你用表驱动文档根本不用手维护写一个小工具把转移表dump成PlantUML或Graphviz格式每次构建时自动生成图片附件状态文档就永远和代码同步。这样review代码时人脑看图和看代码双重校验比单看代码靠谱得多。7.3 测试用例合法转移全过一遍非法转移必须全被拦状态机的最大优势就是可穷举性。我为状态机写测试通常分三类合法性测试遍历转移表中每一条合法路径逐条触发事件断言状态按预期变更动作被正确调用。非法性测试对每个状态把所有不该发生的事件都尝试触发断言状态机抛出异常或忽略事件取决于你的策略。守卫条件测试针对每个带守卫条件的转移构造守卫通过和不通过两种场景确认转移被正确放行或拦截。第三类测试最容易被忽略。记得前面订单状态表里已签收状态只有签收不超过7天才允许申请退款吗这个守卫条件如果没有测试覆盖某天你把时间计算逻辑改了个时区偏差点售后流程就悄悄坏掉了。状态机代码往往在项目里属于基础但隐蔽的部分不会经常改但一旦改错影响巨大所以测试必须跟到位。写完这些测试再回头看整个状态机的价值循环定义状态和事件 → 画转移表 → 表驱动实现 → 自动生成文档 → 穷举测试。这个循环一旦建立起来状态机的可维护性就远远甩开那些散落的if/else了。8. 常见坑位与选型心得那几个我交过学费的地方到这里有限状态机的基础知识已经覆盖得差不多了。最后必须分享几个我在真实项目里踩过的坑这些不是理论问题是实操中一定会遇到的。第一个坑反向转移要不要被允许。订单从已发货变成退款中通常是被允许的但退款中再回到已发货呢从技术上讲表驱动实现反向转移毫无难度难的是业务语义上它必须被禁止。做转移表设计时一定要把禁止的转移也明确讨论一遍写进评审文档里。只列合法转移不列非法转移的状态机设计等于只写了一半需求。第二个坑事件丢失比事件多余更可怕。有的业务事件是异步到达的比如支付回调。假设订单当前处于待支付你发了两个支付回调第一个成功转移到了待发货第二个回调再触发时就会变成非法转移。这个问题的根因是事件来源方和状态机之间缺少幂等机制。解决办法通常是在事件入口做去重或者在状态机里允许同状态重复事件的转移目标就是自身动作为空而不是让第二笔回调报错。第三个坑状态机的线程模型要提前定。我见过一个生产者消费者系统多个线程各自建了状态机实例处理的却是同一份业务对象结果状态被不同线程反复覆盖最后数据全乱。一个业务实体的状态转移必须收敛到唯一的执行路径上要么加锁要么单线程消费要么数据库乐观锁。不提前设计线程模型状态机写得再漂亮也白搭。第四个坑状态字段的持久化与版本冲突。状态机跑在内存里只是它的一部分真正持久化到数据库后你还会遇到读取状态是待支付执行支付逻辑时数据库里变成了已取消这类并发冲突。乐观锁是组合拳里必不可少的一招。我之前在订单服务里加过一版update order set state ?, version ? where order_id ? and version ?行数为0就抛冲突异常由上层重试这招有效避免了绝大多数并发转移问题。选型上的心得我再多说一句不是所有有多种状态的东西都值得用状态机。如果状态转换很线性、基本不回头、事件种类单一硬套状态机反而增加代码量。状态机最适合的场景是状态多、事件多、交叉转移多的地方。判断标准很简单——如果你的需求文档里出现了一张像样的状态图那就该用如果状态之间只是顺序流转一个枚举字段足够了。另外预告一下系列后面要聊的内容嵌套状态机HSM解决复杂流程的状态复用、并发区域Orthogonal Region处理多个维度独立变化的状态、以及状态模式State Pattern和FSM之间的关系。下一次我打算结合一个实际的通信协议解析器把Mealy型的流式状态机讲透那玩意和业务状态机玩起来手感完全不一样。如果你正准备重构手里那一堆if/else或者在设计一个新系统的状态流转逻辑建议先把这篇的转移表画出来再动手写代码。画图花半小时省下来的维护时间可能是几个星期。