接手过几个订单系统的同学应该都有同感:状态流转这块代码,刚开始写if-else还挺顺手的,等业务跑起来、需求迭代几个月之后,就会变成一个到处打补丁的“面条锅”。今天我用订单场景做例子,从零手写一个通用状态机,把状态、事件、动作、转移这些概念一次讲透,并且给出能直接落到项目里的代码骨架。这篇文章适合被状态判断折磨过的后端开发,也适合刚接触状态机、想搞懂原理的初学者。
1. 为什么业务代码里需要状态机
1.1 订单状态流转的“脏乱差”现状
先还原一下没有状态机时,订单系统长什么样。典型的订单起码有四个状态:待支付、已支付、已发货、已完成,再加上取消、退款之类的分支,排列组合一下就非常可观。
我见过最典型的问题场景是这样的:只要某个地方需要“支付成功,更新订单状态”,开发同学就复制一段逻辑;取消订单的业务分散在支付接口、超时任务、用户后台三个入口,每个入口都要各自判断当前状态能不能取消,判断条件稍微写差一点,就会出现“已发货的订单被取消了”这种线上事故。
代码写到这个阶段,痛点会非常集中:
- 状态校验逻辑散落在各处,每加一个入口就要重新校一遍,漏一处就是bug。
- 调状态的操作散落在service里,想梳理“哪些操作会导致订单变成已完成”完全靠人肉搜索。
- 非法流转的兜底主要靠开发自觉,靠评审会口头提醒,没有任何强制约束。
- 新人接手代码,只能在几百行if-else里慢慢猜业务规则,上手成本很高。
说白了,问题不在于“需求有多复杂”,而在于状态流转这件事没有一个统一的管理模型。每个业务方各写各的,重复代码和逻辑遗漏几乎必然出现。
1.2 状态机到底解决了什么问题
状态机做的事情看起来很简单:把所有合法的状态流转收拢到一张表里,每次状态变更都必须经过这张表,非法流转直接拒绝,根本不给你执行动作的机会。
还是以订单为例。有了状态机之后,业务方不再需要自己写“if (当前状态 == 待支付) 才能支付”这种代码,而是直接触发一个“支付事件”,状态机自己判断当前状态待支付加上支付事件合不合法。合法就执行对应的动作、把状态推进到目标状态;不合法就抛异常,业务代码在入口处统一拦截。
这种集中管理带来的实际收益有三个:
- 流转规则的可读性大幅提升。一张配置表就能说清楚订单从生到死的所有合法路径,产品经理也能看得懂。
- 重复校验逻辑被收口。取消订单不管从哪个入口进来,都走同一个状态机判定,不会出现入口之间标准不一致。
- 非法路径被系统和测试双层盯死。代码层面的校验是第一层,状态机配置是第二层,逻辑遗漏的概率大大降低。
这里要强调一下,状态机不是炫技,它的本质是“用一张确定性规则表替换散落的if-else分支”,把状态管理的复杂度从“人肉维护”降级为“配置维护”。
2. 状态机的核心概念与设计思路
2.1 四个核心要素:状态、事件、动作、转移
在动手写代码之前,先把概念理清。状态机的四个核心要素分别是状态、事件、动作、转移,理解清楚这四个词,后面所有代码都是围绕它们展开的。
状态(State):业务进行到某个稳定阶段时,对象所处的情况。订单的待支付、已支付、已发货都是状态。它描述的是“现在是什么阶段”。
事件(Event):驱动状态发生变化的消息,比如用户点了“支付”按钮、后台执行了“发货”操作。事件描述的是“发生了什么”。
动作(Action):状态发生变化时需要执行的业务逻辑。比如订单从待支付变成已支付,可能要执行扣减库存、发送通知、写支付流水这一串业务动作。
转移(Transition):把状态、事件、动作串起来的规则。它回答了“在什么状态下、遇到什么事件、做什么动作、最终落到哪个状态”这个问题。
可以拿红绿灯当类比:红灯是状态,倒计时结束是事件,红灯变绿的过程中行人放行、车辆起步是动作,从红灯变成绿灯这个整体描述就是转移。订单业务其实就是把这张表画出来,然后用一个通用引擎去执行。
2.2 从表驱动到通用模型:如何抽象一份“状态机配置”
写状态机的第一步永远是建模,而建模最实用的方式就是先画一张状态流转表。以订单为例,按“源状态 + 事件 -> 目标状态 + 动作”的格式整理,我随手画一份核心的:
| 源状态 | 事件 | 目标状态 | 动作 |
|---|---|---|---|
| 待支付 | 支付成功 | 已支付 | 扣库存、发通知、写流水 |
| 待支付 | 取消 | 已取消 | 释放优惠券、回补库存 |
| 已支付 | 发货 | 已发货 | 生成物流单、推送物流信息 |
| 已支付 | 取消 | 已取消 | 申请退款、回补库存 |
| 已发货 | 确认收货 | 已完成 | 结算确认、发送评价邀请 |
这张表画完之后,状态机的通用模型就呼之欲出了。核心数据结构可以用一个双层Map来表示:
Map<S, Map<E, Transition<S, E>>>- 第一层key是当前状态S。
- 第二层key是触发事件E。
- 最终value是转移规则对象Transition,里面封装了目标状态和要执行的动作。
选择嵌套Map而不是List,主要原因是查找复杂度。状态机每触发一次事件,就要根据“当前状态 + 事件”找到对应转移规则,嵌套Map能做到O(1)级别的查找,而List线性扫描在状态和事件数量多了之后会明显变慢。订单场景虽然状态数不多,但作为通用模型,这个设计必须一开始就做对。
2.3 方案选型:自研轻量级还是引入开源框架
有了模型之后,接下来的问题就是:到底自己写一套,还是引入现成的框架。我给出的建议是,订单这种业务量级,自研一套轻量级状态机的性价比是最高的。
我把两种方案的取舍列一个对比:
| 维度 | 自研轻量级 | 引入开源状态机框架 |
|---|---|---|
| 学习成本 | 代码量小,半天能看完 | 需要学习配置方式、扩展API |
| 灵活性 | 完全按业务定制 | 受框架抽象约束 |
| 功能覆盖 | 只做状态流转核心,够用 | 提供持久化、监听器等高级能力 |
| 依赖复杂度 | 无额外依赖 | 引入大量间接依赖 |
| 排障难度 | 代码在自己手里,好排查 | 出了深层次问题要啃框架源码 |
很多团队一上来就引入开源框架,结果发现光是为了跑通Hello World就要理解一大套概念,最后项目里用得并不深,反而徒增维护成本。我个人的经验是:如果业务的状态数少于几十个、流转逻辑不超过几十条,自研一个几十行的通用状态机完全够用。等哪天真出现状态数量爆炸、流转规则复杂到没法用一张表描述,那再考虑换成框架也不迟。
做这个决策的核心判断标准只有一个:状态流转逻辑的复杂度。不要因为“开源框架功能多”就选它,你的业务可能连百分之十的功能都没用到。
3. 手写一个通用状态机核心实现
3.1 定义核心模型类:状态、事件、转移规则
明确了模型之后,代码就顺理成章了。先定义状态和事件的类型,这里用泛型来处理,这样订单、物流、售后等不同业务都能复用同一套状态机引擎。
// 状态机转移规则:从from状态,经过event事件,执行actions动作,到达to状态 public class Transition<S, E> { private final S from; private final E event; private final S to; private final List<Action<S, E>> actions; public Transition(S from, E event, S to, List<Action<S, E>> actions) { this.from = from; this.event = event; this.to = to; this.actions = actions; } public S getTo() { return to; } public List<Action<S, E>> getActions() { return actions; } }再定义一个动作接口。动作要尽量设计成“可访问上下文”的形式,因为状态转移时,业务动作往往需要拿到订单号、用户ID、金额这些数据。这里我直接用Context把整个状态机的输入包装起来,动作接口内部可以从Context里取所需数据。
public interface Action<S, E> { void execute(S from, S to, E event, StateContext<S, E> context); }Context对象对外看起来是“黑盒”,泛型类型由具体业务自己定义。订单场景里,Context里面放的是订单数据、操作人等业务信息。泛型设计的目的就是让这个状态机不仅可以处理订单,以后接物流、售后、优惠券的时候,只需要替换类型参数即可,无需改动引擎代码。
3.2 核心容器:状态机引擎的骨架代码
接下来是状态机最核心的容器类。它负责维护“当前状态 + 事件 -> 转移规则”的双层映射,并对外提供事件触发入口。
public class StateMachine<S, E> { // 核心存储:源状态 -> (事件 -> 转移规则) private final Map<S, Map<E, Transition<S, E>>> transitionMap = new HashMap<>(); // 注册状态转移规则 public void register(Transition<S, E> transition) { S from = transition.getFromState(); E event = transition.getEvent(); transitionMap.computeIfAbsent(from, k -> new HashMap<>()) .put(event, transition); } // 触发事件,执行状态流转 public S fire(S currentState, E event, StateContext<S, E> context) { Map<E, Transition<S, E>> transitionMapForState = transitionMap.get(currentState); if (transitionMapForState == null) { throw new IllegalStateException("状态不存在: " + currentState); } Transition<S, E> transition = transitionMapForState.get(event); if (transition == null) { throw new IllegalStateException("状态 [" + currentState + "] 不支持事件 [" + event + "]"); } // 执行动作链 for (Action<S, E> action : transition.getActions()) { action.execute(currentState, transition.getTo(), event, context); } // 返回目标状态 return transition.getTo(); } }这段代码是整个状态机引擎的灵魂,但有一个细节需要特别注意:Transition里我并没有存from和event。注册时用from作为第一层key,用event作为第二层key,其实from和event已经被映射表的位置语义隐含了,再存一遍属于冗余。真正需要塞进对象里的,是to和actions。
设计成“位置即语义”的结构还有个好处:后续做校验可以很方便地遍历transitionMap,检查是否出现冲突注册。
3.3 用Builder模式搭配置,让注册过程可读
直接在业务代码里一行一行调用register不是不行,但状态多了之后可读性会变差。我习惯提供一个Builder风格的配置入口,让每一行代码读起来就像在描述那条表驱动记录。
StateMachine<OrderState, OrderEvent> machine = new StateMachine<>(); machine.register(new Transition<>( OrderState.WAIT_PAY, OrderEvent.PAY_SUCCESS, OrderState.PAID, Arrays.asList( new StockAction(), new PayNotifyAction(), new PayFlowAction() ) ));machine.register(new Transition<>( OrderState.WAIT_PAY, OrderEvent.CANCEL, OrderState.CANCELED, Arrays.asList( new CouponReleaseAction(), new StockBackAction() ) ));每一条注册代码就是状态流转表里的一行,配置和表完全一一对应。如果后续想做到“配置化”,把这张表放到数据库或配置中心,那只需要写一个加载器,读配置生成Transition对象再注册到机器里就行,引擎代码一行都不用改。这是“通用状态机”设计里最值钱的地方——业务规则和引擎逻辑彻底解耦。
3.4 把状态机接入订单服务:完整代码演示
核心引擎写完之后,接入业务就非常简单了。以订单服务为例,一个典型的支付成功入口可能是这样的:
@Service public class OrderService { // 状态机按订单类型注入,Spring管理单例即可 private final StateMachine<OrderState, OrderEvent> orderStateMachine; public OrderService(StateMachine<OrderState, OrderEvent> orderStateMachine) { this.orderStateMachine = orderStateMachine; } @Transactional(rollbackFor = Exception.class) public void paySuccess(Long orderId) { Order order = orderMapper.selectById(orderId); // 组装业务上下文 OrderContext context = new OrderContext(order); // 触发状态机流转,非法状态直接抛异常,事务回滚 OrderState newState = orderStateMachine.fire( order.getStatus(), OrderEvent.PAY_SUCCESS, context ); // 状态机返回的新状态更新到数据库 order.setStatus(newState); orderMapper.updateById(order); } }这段代码里面有两个很关键的地方值得展开讲。第一个是状态机的线程安全特性:它启动后只有注册表数据,注册过程结束后不再修改,所以完全可以作为一个Spring单例被多线程共享。第二个是状态机只负责“算出新状态”,具体怎么把新状态写回数据库,是业务自己的事。这样设计的好处是,状态机不需要关心持久化中间状态,逻辑纯粹、也好测试。
4. 订单场景的完整落地:从配置到实战
4.1 订单状态与事件定义
有了通用引擎,接下来就是把它应用到订单业务上。首先定义订单的状态枚举和事件枚举。这两个枚举建议用Java的enum,既能做类型约束,又能天然配合泛型参数使用。
public enum OrderState { WAIT_PAY, // 待支付 PAID, // 已支付 SHIPPED, // 已发货 COMPLETED, // 已完成 CANCELED // 已取消 } public enum OrderEvent { PAY_SUCCESS, // 支付成功 SHIP, // 发货 CONFIRM, // 确认收货 CANCEL // 取消订单 }这里有个很容易踩的坑:很多团队喜欢把状态定义成字符串,比如"1"表示待支付、"2"表示已支付。字符串的可读性极差,而且写错一个字符不会被编译器发现,只能等运行时报错。用枚举之后,状态机的泛型推演、IDE的自动补全、编译期的类型检查全部都能享受到。
4.2 动作实现与扩展点设计
状态机的动作部分是业务逻辑最丰富的区域。以“支付成功”这个事件为例,会依次执行扣减库存、发送通知、写支付流水三个动作。
public class StockAction implements Action<OrderState, OrderEvent> { @Override public void execute(OrderState from, OrderState to, OrderEvent event, StateContext<OrderState, OrderEvent> context) { OrderContext ctx = (OrderContext) context; System.out.println("扣减库存: 订单号=" + ctx.getOrder().getId()); } }public class PayNotifyAction implements Action<OrderState, OrderEvent> { @Override public void execute(OrderState from, OrderState to, OrderEvent event, StateContext<OrderState, OrderEvent> context) { System.out.println("发送支付成功通知"); } }在实际项目里,动作之间如果存在依赖关系,就需要通过“动作链”来编排。这里我建议动作类保持职责单一,不要在一个动作里同时干“扣库存+发通知+写流水”三件事。动作拆得足够细,后续调整顺序、复用、单测都会方便很多。
另外,如果状态机需要支持前后置处理,比如某个动作执行前先做参数校验、执行后统一记录审计日志,可以再扩展两个钩子:
beforeAction:在整条动作链执行前触发。afterAction:在整条动作链执行后触发。
实现的思路是在fire方法里预留两个可覆写的钩子方法,默认空实现,具体业务按需覆盖。这样审计、埋点、监控这类横切逻辑就不用污染每个业务动作。
4.3 幂等与并发:状态机最容易被问住的点
状态机最难的部分往往不是引擎本身,而是把它放到并发环境里还能正确工作。我直接说结论:状态机只解决“流转合法性”的问题,不解决“并发安全性”的问题。这两个问题经常被混为一谈,导致实际落地时出事故。
看一个典型并发场景:用户在前端同时点了多次支付回调,或者超时任务和用户取消操作同时在处理同一笔订单。两个线程分别读取到订单的当前状态都是待支付,都通过了内存里的状态校验,都调用了stateMachine.fire,这时候就会产生同一个订单被处理两次的严重后果。
要解决这个问题,最优雅的做法是把状态校验下沉到数据库层,用带条件的更新语句来保证原子性:
UPDATE orders SET status = #{newStatus} WHERE id = #{orderId} AND status = #{expectStatus}如果更新影响行数为1,说明更新成功;为0,说明中间态已经被其他线程改掉了,当前操作应该被拒绝重试或者丢弃。这个方案在业内通常叫CAS更新,和乐观锁思路是一致的,它能保证“状态变更”这一步在任何并发级别下都不会被超卖或错卖。
在编写时,需要把CAS更新放在业务动作执行之前,还是之后?我的经验是:先CAS更新状态,再执行业务动作。因为状态一旦抢到了,说明当前线程拿到了这份数据的“处理权”,其他线程的状态校验会失败,不会再进来。只要CAS成功,后续执行业务动作就是安全的;如果业务动作失败,整个事务回滚,状态也会跟着回滚。
事务边界也要讲清楚:状态更新、库存扣减、流水写入这三步必须放在同一个数据库事务里。状态机框架本身不管理事务,事务应该由最外层调用方(比如@Transactional注解标记的Service方法)统一控制。任何一步抛出异常,全部回滚,这样就不会出现“库存扣了但状态还是待支付”的数据不一致。
5. 常见问题与排查技巧实录
5.1 状态非法转移被拒后,事务要不要回滚?
这个问题的答案取决于业务的执行顺序。如果状态机的校验发生在业务动作之前,那非法转移被拒时,此前还没有执行任何业务逻辑,自然不需要也不是事务提交的问题,此时事务正常回滚即可。但有一种反模式需要注意:先把业务动作执行了,最后再通过状态机校验。这种顺序一旦动作执行成功、状态校验失败,事务回滚会把动作也回滚掉,表面上看起来没问题,但动作的副作用如果是发短信、调外部接口这类不受本事务控制的操作,就会造成“数据库回滚了,短信已经发出去了”的惨案。
我的建议是:状态机校验永远前置。在校验通过之后,才允许开始执行任何有副作用的业务动作。面对外部调用这种无法回滚的操作,尽量放在所有本地数据库操作完成之后、事务提交的边缘侧执行,也可以引入可靠消息机制来保证一致性。
5.2 并发重复提交导致状态校验失效怎么排查?
这种问题的典型症状是:线上偶尔出现订单被重复发货、重复退款,但代码逻辑看下来明明每个入口都有状态判断。排查路径可以这样走:
- 第一步:确认状态机是否加了并发保护。如果只是在内存里用
if (currentState == ...)再updateById,那肯定有竞态,必须改成CAS方式更新。 - 第二步:检查数据库更新语句有没有
status = #{expectStatus}这个条件。很多代码改成CAS更新之后,写SQL时忘了加这个条件,效果等同于没有保护。 - 第三步:检查动作链上是否有网络IO或者长期等待。事务持有时间越长,并发冲突概率越高,要把耗时操作尽量移出事务。
5.3 扩展点太多,动作链代码变乱怎么办?
状态机用一段时间后,动作清单会越拉越长。我见过一个订单支付成功的事件上挂了十几二十个动作,从核心的库存、流水、通知,到后面的优惠券、积分、推荐奖励,全都往一条动作链上堆。代码看起来还能跑,但出问题时,你很难快速定位是哪个动作抛的异常。
面对这种复杂度,我会做两步整理。第一步是给动作分层:核心动作(库存、状态、流水)和外围动作(通知、积分、奖励)分开,外围动作通过消息队列异步消费,这样既拖垮了主链路,又能保证核心流程的稳定性。第二步是引入事件发布机制:状态机流转完成后发布一个“订单已支付”领域事件,让各个业务方订阅自己关心的部分,各干各的,互不干扰。这其实就是从直链动作向事件驱动架构演进的过程,也是状态机落地成熟的标志之一。
5.4 状态机问题速查表
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 状态不存在异常 | 注册表里漏注册了某个状态 | 检查状态枚举和注册清单的覆盖是否一致 |
| 状态不支持事件异常 | 流转规则缺失,或者业务本就不允许 | 对照状态流转表确认路径是否合法 |
| 重复支付/重复发货 | 并发调用导致内存校验失效 | 数据库CAS更新,或分布式锁 |
| 状态变了但业务动作没执行 | 动作抛异常被吞掉 | 检查动作链异常处理逻辑,避免catch后不抛 |
| 回滚后状态还是不对 | 状态更新和业务动作不在一个事务 | 统一事务边界,状态更新和动作必须同事务 |
写到这里,我再分享一点个人体会。状态机这种设计模式,看起来技术含量不算高,但它的核心价值恰恰在于“把复杂问题变得可列举、可检查”。我见过太多团队在状态机引擎代码上花了很多时间,又是持久化又是可视化,最后死在过度设计上。倒不如先把一张完整的状态流转表画出来,写一个几十行的轻量引擎,让业务跑起来,等真的撑不住了再考虑上更强的能力。状态机最值得投入精力的地方不是引擎本身,而是那张表和那条动作链的边界设计,这两样东西想清楚了,代码怎么写都是顺的。