☰
通用状态机引擎实战:订单状态流转建模与落地避坑指南
2026/10/14 14:00:53 网站建设 项目流程

第一次意识到状态机不是学院派玩具,是在三年前一个订单项目出线上事故的那晚。运维直接跑了一条更新语句,把一批“已发货”的订单批量改成了“已完成”,结果消费者没收到货,退款入口也全乱了,最后人工对账对了一个通宵。后来我重新设计这套系统时,没有继续在 Service 里堆 if-else,而是实现了一个通用状态机引擎。

这篇文章就拿订单状态流转作为例子,把我做状态机建模的思路、核心代码的写法、订单场景怎么落地,以及真正上线后才会遇到的坑,完整地过一遍。适合正在写订单、工单、审批流、设备管理这类系统的开发者参考,也适合想理解状态机原理但不想一上来就啃框架文档的朋友。

1. 从一次线上事故说起:订单状态到底被谁改乱了?

1.1 那串越拉越长的 if-else

很多订单系统一开始都是这么写的:根据当前状态和传入事件,用 if-else 决定下一步去哪。比如:

public OrderState changeState(Order order, OrderEvent event) { OrderState current = order.getState(); if (current == PENDING_PAYMENT) { if (event == PAY_SUCCESS) { // 扣减库存、写支付流水 return PAID; } else if (event == USER_CANCEL) { // 释放优惠券 return CANCELLED; } } else if (current == PAID) { if (event == SHIP) { if (inventoryService.isDeducted(order.getId())) { return SHIPPED; } } } else if (current == SHIPPED) { if (event == CONFIRM_RECEIPT) { return COMPLETED; } } throw new IllegalStateException("不支持的状态流转: " + current + " -> " + event); }

这种写法的核心问题不是“代码丑”,而是状态转移的规则散落在各个业务方法里。今天加一个“已支付可申请退款”,明天加一个“退款中可拒绝”,后天再加一个“超时自动关闭”,每个新需求都要翻遍所有相关方法,找到一个又一个分支往里塞。等到方法超过几百行,你根本说不清哪些组合是合法的,哪些是历史遗留的 bug。上面那个事故,本质上就是状态可以被绕过、被直接 update,系统没有任何一层在守护流转规则。

1.2 状态机解决的是哪一类问题

状态机做的事情其实很朴素:先把所有合法的状态流转列成一张白名单,然后所有状态变更都走同一个入口。这张白名单规定了当前状态、触发事件、目标状态之间的对应关系,不在名单里的组合,一律拒绝执行。

这正好治住上面两个痛点:一是业务代码不再散落分支,规则集中在一张配置表里;二是状态变更有了“单一入口”,谁想绕过入口直接改库,至少在逻辑层就过不去。即便运维再手滑,更新语句也绕不开状态机的校验逻辑。

需要说明的是,状态机不是银弹。它管的是“这个状态能不能到那个状态”,至于到了之后要扣库存、发短信、写流水,那是转移时执行的“动作”,属于状态机之外的业务设计,后面我会专门讲边界。

1.3 本文的实现目标与前置知识

我这次要做的是一个不绑定任何具体业务的通用状态机引擎,核心只有几个概念:状态(State)、事件(Event)、转移(Transition)、守卫条件(Guard)、动作(Action)。业务侧通过枚举定义自己的状态和事件,再用链式 Builder 配置转移规则,最后调fire(event, currentState, context)完成流转。

前置知识不需要太多,会 Java 枚举、接口、泛型和HashMap就够。如果不用 Java,思想也完全通用:C#、Go、Python 都能按这套结构翻译过去。

2. 四件套拆解:状态、事件、转移、动作各管什么

2.1 状态:先定义一张合法的状态图

拿订单来说,状态不能一开始就拍脑袋列,而是要跟着真实业务流程走。我这里以最常见的电商订单为例,定义这么几个状态:

  • 待支付(PENDING_PAYMENT)
  • 已支付(PAID)
  • 已发货(SHIPPED)
  • 已完成(COMPLETED)
  • 已取消(CANCELLED)
  • 退款中(REFUNDING)
  • 已退款(REFUNDED)

定义状态的时候有个经验:状态要能回答“当前这笔单子处于哪个阶段”,事件要能回答“发生了什么导致阶段变化”。两者不要混在一起,比如“已支付待发货”这种其实是两个状态加一个动作的描述,硬塞进一个状态只会让后续规则越来越难维护。

2.2 事件:哪些外部信号能推动流转

事件是外部传入的信号,不是状态机自己产生的。常见的事件有这么几个:

  • PAY_SUCCESS:支付回调通知支付成功
  • USER_CANCEL:用户主动取消
  • SHIP:商家发货
  • CONFIRM_RECEIPT:用户确认收货,或者系统自动确认收货
  • APPLY_REFUND:用户发起退款申请
  • APPROVE_REFUND/REJECT_REFUND:退款审核通过或拒绝
  • TIMEOUT_CLOSE:超时未支付自动关闭

事件的设计有个容易犯的错:把“两个动作的合并”当事件。比如“支付成功且库存扣减完成”,这听起来很合理,但一旦其中一个失败,整个事件的语义就会很尴尬。正确做法是事件只表达发生了什么事,校验和副作用交给守卫条件和动作去处理。

2.3 转移规则表:把“能做什么”写成白名单

有了状态和事件,接下来就是把合法的组合一行一行写出来。我习惯先做一张转移规则表,给业务评审,评审过了再翻译成代码。

以简化版订单流程为例:

当前状态触发事件目标状态守卫条件
待支付支付成功已支付支付金额与订单金额一致
待支付用户取消已取消订单未支付
待支付超时关闭已取消超过支付时限
已支付发货已发货库存已扣减、物流单已创建
已支付申请退款退款中未发货、未开始配送
已发货确认收货已完成存在签收记录
退款中退款通过已退款审核通过、退款单存在
退款中退款拒绝已支付审核不通过、可重新支付流程

这张表的好处是一眼能看出规则全不全。比如大家会立刻发现“已发货能不能申请退款”没有定义,那就要和业务确认,要么补一行转移,要么明确不支持。这样写代码之前就已经把一半的坑填掉了。

2.4 守卫与动作:动之前校验,动之中记痕

转移规则里有两类附加逻辑。一类是守卫条件(Guard),比如“支付金额与订单金额一致”,守卫返回 true 才能走这条转移,否则拒绝。另一类是动作(Action),比如“扣减库存、写支付流水”,它发生在转移被确定合法之后。

我个人的建议是:守卫里不要做任何“改变外部状态”的事,只读订单、只做判断;真正扣库存、发消息、记日志这些副作用,全部放到动作里。原因很简单:守卫可能被多次调用,也可能在并发场景下被重复执行,如果它本身带副作用,很容易造成重复扣款、重复发消息这类严重问题。

3. 通用状态机引擎:核心数据结构与 API 设计

3.1 为什么用“当前状态 + 事件”做唯一索引

引擎内部最核心的数据结构是一个HashMap,key 是(当前状态, 事件),value 是这条转移规则。这样:

  • 查找复杂度是 O(1),不用遍历规则列表;
  • 天然保证同一个状态下的同一个事件只能有一条转移规则;
  • 语义直观:给我当前状态和事件,我直接查到下一步怎么走。

我把 key 封装成一个StateKey:

public record StateKey<S, E>(S state, E event) { public static <S, E> StateKey<S, E> of(S state, E event) { return new StateKey<>(state, event); } }

用 Java record 就不用写 hashCode 和 equals 了,省事很多。

3.2 引擎主体代码

先定义转移本身的模型:

public class Transition<S, E, C> { private final S source; private final E event; private final S target; private final Guard<C> guard; private final Action<S, E, C> action; public Transition(S source, E event, S target, Guard<C> guard, Action<S, E, C> action) { this.source = source; this.event = event; this.target = target; this.guard = guard; this.action = action; } public S getSource() { return source; } public E getEvent() { return event; } public S getTarget() { return target; } public Guard<C> getGuard() { return guard; } public Action<S, E, C> getAction() { return action; } }

其中Guard和Action是函数式接口:

@FunctionalInterface public interface Guard<C> { boolean evaluate(C context); default String rejectReason() { return "守卫条件未通过"; } } @FunctionalInterface public interface Action<S, E, C> { void execute(TransitionContext<S, E, C> context); }

TransitionContext里带着本次流转涉及的信息:来源状态、目标状态、事件、业务上下文:

public class TransitionContext<S, E, C> { private final S source; private final S target; private final E event; private final C data; public TransitionContext(S source, S target, E event, C data) { this.source = source; this.target = target; this.event = event; this.data = data; } public S getSource() { return source; } public S getTarget() { return target; } public E getEvent() { return event; } public C getData() { return data; } }

然后是状态机容器本身:

public class StateMachine<S, E, C> { private final Map<StateKey<S, E>, Transition<S, E, C>> transitionTable; private StateMachine(Map<StateKey<S, E>, Transition<S, E, C>> transitionTable) { this.transitionTable = transitionTable; } public static <S, E, C> Builder<S, E, C> builder() { return new Builder<>(); } public Result<S, E> fire(E event, S currentState, C context) { Transition<S, E, C> transition = transitionTable.get(StateKey.of(currentState, event)); if (transition == null) { return Result.invalid(currentState, event, "当前状态不支持该事件"); } if (!transition.getGuard().evaluate(context)) { return Result.invalid(currentState, event, transition.getGuard().rejectReason()); } S target = transition.getTarget(); try { transition.getAction().execute(new TransitionContext<>(currentState, target, event, context)); } catch (Exception ex) { return Result.invalid(currentState, event, "动作执行失败: " + ex.getMessage()); } return Result.success(currentState, target, event); } public boolean canFire(E event, S currentState, C context) { Transition<S, E, C> transition = transitionTable.get(StateKey.of(currentState, event)); return transition != null && transition.getGuard().evaluate(context); } public static final class Builder<S, E, C> { private final Map<StateKey<S, E>, Transition<S, E, C>> transitions = new HashMap<>(); public Builder<S, E, C> transition(S source, E event, S target) { return transition(source, event, target, ctx -> true, ctx -> {}); } public Builder<S, E, C> transition(S source, E event, S target, Guard<C> guard, Action<S, E, C> action) { Transition<S, E, C> transition = new Transition<>(source, event, target, guard, action); StateKey<S, E> key = StateKey.of(source, event); if (transitions.put(key, transition) != null) { throw new IllegalArgumentException("重复定义转移: " + source + " + " + event); } return this; } public StateMachine<S, E, C> build() { return new StateMachine<>(Collections.unmodifiableMap(new HashMap<>(transitions))); } } }

几个设计点说一下:

  • fire不直接抛异常,而是返回一个Result对象,把“能不能转、为什么不能转”交给调用方自己决定,这在 Controller 或 Service 层处理起来更舒服。
  • Builder在重复定义同一条转移时会主动报错。这个细节很有用:规则表写错比跑错更好发现,尤其是多人协作时,谁不小心覆盖了别人的规则立刻就能暴露。
  • 返回值对象Result我用最简单的结构:
public class Result<S, E> { private final boolean success; private final S from; private final S to; private final E event; private final String message; private Result(boolean success, S from, S to, E event, String message) { this.success = success; this.from = from; this.to = to; this.event = event; this.message = message; } public static <S, E> Result<S, E> success(S from, S to, E event) { return new Result<>(true, from, to, event, null); } public static <S, E> Result<S, E> invalid(S from, E event, String message) { return new Result<>(false, from, null, event, message); } public boolean isSuccess() { return success; } public S getFrom() { return from; } public S getTo() { return to; } public E getEvent() { return event; } public String getMessage() { return message; } }

3.3 链式 Builder:让规则贴近业务文档

配置一个订单状态机,看起来就像在读需求文档:

public StateMachine<OrderState, OrderEvent, OrderContext> buildOrderMachine(OrderService orderService) { return StateMachine.<OrderState, OrderEvent, OrderContext>builder() .transition(PENDING_PAYMENT, PAY_SUCCESS, PAID, ctx -> ctx.payAmountMatches(), ctx -> orderService.afterPaid(ctx)) .transition(PENDING_PAYMENT, USER_CANCEL, CANCELLED, ctx -> !ctx.isPaid(), ctx -> orderService.afterCancel(ctx)) .transition(PENDING_PAYMENT, TIMEOUT_CLOSE, CANCELLED, ctx -> ctx.isTimeout(), ctx -> orderService.afterTimeoutClose(ctx)) .transition(PAID, SHIP, SHIPPED, ctx -> ctx.isInventoryDeducted(), ctx -> orderService.afterShip(ctx)) .transition(PAID, APPLY_REFUND, REFUNDING, ctx -> !ctx.isShipped(), ctx -> orderService.freezeForRefund(ctx)) .transition(REFUNDING, APPROVE_REFUND, REFUNDED, ctx -> ctx.refundAuditPassed(), ctx -> orderService.executeRefund(ctx)) .transition(REFUNDING, REJECT_REFUND, PAID, ctx -> ctx.refundAuditRejected(), ctx -> orderService.unfreezeForRefund(ctx)) .build(); }

这种写法的好处是,每一条规则都像一个句子:在什么状态下、发生了什么事件、并且满足什么条件,才能到哪个状态,然后执行什么动作。业务人员把规则表贴过来,我们可以一行一行对齐。

3.4 返回 Result 而不是抛异常:把决定权还给调用方

早期版本里我习惯让fire直接抛IllegalStateException,后来发现有几个问题:

  • 调用方被迫写 try-catch,否则非法流转会把整个请求打断;
  • 有些非法流转其实是可以被“宽容”处理的,比如重复的支付回调,直接返回一个“已经是目标状态了”的结果就行;
  • 日志和监控需要看清失败原因,异常堆栈反而不够友好。

所以现在fire返回 Result,只是把判断结果告诉调用方。如果你喜欢强约束风格,也可以把Result再封装一层,统一在入口处处理失败。

4. 订单状态机落地:完整跑通一个订单流转

4.1 定义订单枚举和上下文

先把状态和事件定义成枚举:

public enum OrderState { PENDING_PAYMENT, // 待支付 PAID, // 已支付 SHIPPED, // 已发货 COMPLETED, // 已完成 CANCELLED, // 已取消 REFUNDING, // 退款中 REFUNDED // 已退款 } public enum OrderEvent { PAY_SUCCESS, // 支付成功 USER_CANCEL, // 用户取消 TIMEOUT_CLOSE, // 超时关闭 SHIP, // 发货 CONFIRM_RECEIPT, // 确认收货 APPLY_REFUND, // 申请退款 APPROVE_REFUND, // 退款审核通过 REJECT_REFUND // 退款审核拒绝 }

上下文OrderContext是业务运行时给守卫和动作传参的载体,最简单的做法是用一个类包住订单信息:

public class OrderContext { private final Long orderId; private final BigDecimal payAmount; private final BigDecimal shouldPayAmount; private final boolean paid; private final boolean shipped; private final boolean inventoryDeducted; private final boolean timeout; // 构造函数、getter 省略... }

如果你的订单信息比较复杂,也可以直接把订单实体放进去,但要注意:守卫逻辑不要自己去查库。在一个方法里反复查库会拖慢性能,更关键的是,守卫可能被调用多次,查库时机不同可能拿到不同结果,导致判断不稳定。建议在进入状态机之前把需要判断的字段查好,放进OrderContext。

4.2 把规则表翻译成代码

这一步其实就是把前面第二章的转移规则表,一行一行翻译成 Builder 配置。我建议在类里单独写一个buildOrderMachine()方法,集中管理所有转移规则。以后新增流转,优先改这个方法,而不是去 OrderService 里到处加 if。

对于ORDER_CONFIRMED这种“确认收货”,规则是:

.transition(SHIPPED, CONFIRM_RECEIPT, COMPLETED, ctx -> ctx.hasReceiptRecord(), ctx -> orderService.afterReceived(ctx))

这里守卫判断的是“有没有签收记录”,动作里做的是“发评价邀请、结算佣金”这类副作用。

4.3 服务层唯一变更入口

有了状态机之后,订单状态变更必须收敛到一个入口方法里:

public Result<OrderState, OrderEvent> changeState(Long orderId, OrderEvent event, OrderContext ctx) { Order order = orderMapper.selectById(orderId); OrderContext context = ctx != null ? ctx : buildDefaultContext(order); Result<OrderState, OrderEvent> result = machine.fire(event, order.getState(), context); if (result.isSuccess()) { orderMapper.updateState(orderId, result.getTo(), order.getVersion()); auditService.record(orderId, event, result.getFrom(), result.getTo(), context.getOperator()); } return result; }

这里有一个容易踩的坑:**fire里的 action 已经执行了,但后面的updateState可能失败**。如果 action 是扣库存、写流水这类强副作用操作,就会造成“状态没变,但库存扣了”的不一致。我的处理经验是:

  • 把 action 设计成“发事件”而不是“直接改库”,比如orderService.afterPaid(ctx)内部发一个OrderPaidEvent,由消息队列异步处理;
  • 或者把状态机流转和主要数据变更放进同一个本地事务里,在事务提交后才执行 MQ 消息;
  • 不要指望状态机帮你解决分布式事务,它只负责“决定能不能流转”以及“流转时触发什么信号”。

4.4 非法流转:测试一下那些“不可能”的操作

代码写完了,最应该做的就是拿“不可能组合”去测状态机。比如:

OrderContext ctx = OrderContext.builder() .orderId(1001L) .paid(true) .shipped(true) .build(); Result<OrderState, OrderEvent> result = machine.fire( OrderEvent.SHIP, OrderState.PENDING_PAYMENT, ctx); assertFalse(result.isSuccess()); assertEquals("当前状态不支持该事件", result.getMessage());

再比如“已完成订单再次取消”:

Result<OrderState, OrderEvent> result = machine.fire( OrderEvent.USER_CANCEL, OrderState.COMPLETED, ctx); assertFalse(result.isSuccess());

这些测试写起来成本很低,但价值很大。它们本质上就是在验证状态图里的“不可达路径”,如果业务方告诉你某个组合应该可达,那就说明规则表少了一行,测试往往会先一步发现问题。

5. 真实业务绕不开的四个扩展点

5.1 幂等:重复支付消息怎么办

支付回调不是只回调一次的,渠道方在超时未收到确认时,会重试好几次。如果状态机对PAID + PAY_SUCCESS直接返回“不支持”,虽然逻辑没错,但调用方每次都要为这种“可预期重复”记一条错误日志,很吵。

更优雅的做法是定义一条“自环转移”:同一个状态接收到同一个事件,目标状态还是自己,守卫判断重复消息,动作什么都不做。

.transition(PAID, PAY_SUCCESS, PAID, ctx -> ctx.isAlreadyPaid(), ctx -> { /* 幂等处理,不再扣库存 */ })

这样支付回调重复到达时,状态机返回成功,语义是“已经是支付成功状态,无需再次处理”。调用方和监控都清净了。同样的思路可以套用在“重复发货回调”“重复确认收货”等场景里。

5.2 并发保护:乐观锁兜底

状态机是内存中的判断,挡不住并发下两个请求同时读到同一个状态。比如用户同时点了“取消订单”和支付回调触发“支付成功”,两个请求都读到PENDING_PAYMENT,都通过了状态机校验,最后各自写库,后写的覆盖先写的,就出问题了。

解决办法是在更新语句里加版本号:

int updated = orderMapper.updateStateIfVersion(orderId, result.getTo(), order.getVersion()); if (updated == 0) { throw new OptimisticLockException("订单状态已被其他请求修改,请刷新后重试"); }

对应的 SQL 大致是:

UPDATE t_order SET state = #{newState}, version = version + 1 WHERE id = #{orderId} AND version = #{oldVersion}

状态机解决的是“流程合法性”,乐观锁解决的是“并发下的唯一性”,两者配合才完整。

5.3 审计日志:每次流转都要说得清

状态机收敛了入口之后,审计日志就变得非常好做。每次调用fire成功或失败,都可以记录一行:

字段说明
order_id订单号
from_state流转前状态
event触发事件
to_state流转后状态
operator操作人/系统
context_snapshot关键上下文快照
created_at时间

这张表不仅是排查问题的利器,也是给业务方展示“谁在什么时间对订单做了什么”的凭证。我见过不少项目在状态被改乱之后,连从哪个状态改到哪个状态都查不到,就是因为没有在唯一入口留审计。有了状态机,这个成本低得几乎可以忽略。

5.4 超时自动流转:定时任务与 canFire

“待支付订单超过 30 分钟自动关闭”是状态机非常典型的应用场景。实现方式通常是一个定时任务扫描订单表:

List<Order> timeoutOrders = orderMapper.selectTimeoutOrders(PENDING_PAYMENT, cutoffTime); for (Order order : timeoutOrders) { OrderContext ctx = OrderContext.builder() .orderId(order.getId()) .timeout(true) .operator("system") .build(); if (machine.canFire(OrderEvent.TIMEOUT_CLOSE, order.getState(), ctx)) { Result<OrderState, OrderEvent> result = machine.fire(OrderEvent.TIMEOUT_CLOSE, order.getState(), ctx); if (result.isSuccess()) { orderMapper.updateState(order.getId(), result.getTo(), order.getVersion()); auditService.record(order.getId(), OrderEvent.TIMEOUT_CLOSE, ...); } } }

用canFire先做一次预判,可以避免大量无效调用,也方便做监控:到底有多少订单走到了超时关闭这条转移。

6. 我在落地过程中踩过的坑和取舍

6.1 守卫条件写进动作里,导致状态错乱

这是我犯过最蠢的错误。早期我把“库存是否扣减成功”这个判断直接写进了 action 里,想着反正动作执行完再看结果。结果并发场景下,两个支付回调同时触发,第一个回调扣了库存,第二个回调发现库存不足,在 action 里抛了异常。但这时候状态机已经把PAID返回出去了,订单状态落库成功,库存却只扣了一次,最后对账才发现问题。

所以我现在有一个铁律:守卫只做判断,动作只做执行。凡是“不能走到某个状态”的条件,必须写在Guard里;凡是“到了之后要做什么”的副作用,写在Action里。如果动作本身失败,最好的策略是让状态机返回失败,由调用方决定是重试还是走补偿流程。

6.2 状态机的边界:别把工作流塞进来

状态机的本质是“状态 + 事件 → 新状态”,它不擅长表达“需要多人审批”“需要分支并行”“需要等待多个条件同时满足”这类工作流语义。

比如售后退款,在一笔订单里可能会有“用户申请 → 客服审核 → 仓库确认退货 → 财务打款”等多个环节,这已经不是简单的订单主状态流转了。我现在的做法是:主订单状态机只管“可售/不可售”这类生命周期,具体的售后单、审批单各自维护自己的子状态机,主状态机只监听子流程的结果事件再决定下一步。

状态机管合法流转,工作流管流程编排。把两者混在一起,最后状态枚举会膨胀到几十个,转移规则变成一张谁也看不懂的大表。

6.3 要不要引入现成框架

这个问题我在团队里反复权衡过。成熟的 Java 状态机框架功能确实强大:嵌套状态、历史状态、持久化、监听器、可视化等等都有。但对大多数订单系统来说,需要的只是“一张规则表 + 一个入口校验”而已,引入一套重量级框架反而增加了学习成本和配置复杂度。

我的建议是:

  • 状态数量在十个以内,流转规则不超过二十条,自己写的这种几十行引擎足够;
  • 规则复杂到需要一个可视化界面来维护时,认真评估引入成熟框架是不是更划算;
  • 不管用不用框架,状态转移表的设计永远是最重要的,工具只是表驱动思想的载体。

“通用”不是把什么功能都堆进去,而是让业务方能专注于定义规则,状态机本身保持简单、透明、可测试。

7. 写在最后:先画图,再写代码

这次重构给我最大的教训是:写状态机代码本身不难,难的是把状态和事件理清楚。我现在的流程很固定:先花一两个小时和业务确认所有状态,画出完整的流转清单,让业务方在转移规则表上签字确认,然后才开始写代码。

哪怕规则表只有八行,也值得走这个流程。因为大多数状态错乱事故,都不是代码写错,而是规则本身没定义清楚。代码只是把规则固定下来而已。

如果你现在正打算重构一个状态混乱的订单系统,不妨先试着把所有状态列在一张纸上,然后用事件连起来。连不上的缺口,就是接下来代码里要补的规则;连出环了,就要警惕是不是有死循环风险。等这张图足够清晰,再打开编辑器写代码,你会发现整个过程顺畅得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询