状态机引擎与状态流转

本文讲解状态机引擎的设计与落地,回答状态该定义在代码里还是表里、并发流转如何防冲突、状态爆炸怎么治。覆盖有限状态机的五个要素、表驱动实现、带前置条件的原子更新与乐观锁、幂等键与审计日志、非法流转的三层防御、超时推进、Spring StateMachine 与 Step Functions 的 ASL 实战,并给出测试策略与落地路线。

引言

不是所有流程都值得引入工作流引擎。一个只有「待支付 → 已支付 → 已发货 → 已完成」四个状态的订单系统,用 BPMN 或 Temporal 都是杀鸡用牛刀。但「直接改数据库字段」又会带来另一个问题:非法流转悄悄写进库、并发更新互相覆盖、没人说得清「已取消的订单为什么变成了已发货」。

状态机引擎填补的正是这个空档:它比工作流引擎轻,比裸写状态字段严谨。它的核心承诺是「所有状态变更必须经过显式的转移定义,且并发下不会丢失更新」。

它的复杂度不在实现上(一张转移表加一个校验函数就能跑),而在设计上:状态怎么划分才不爆炸、转移的条件(守卫)放哪里、外部副作用(发消息、调 API)在转移前还是转移后执行、失败时如何回滚状态。这些决策一旦定错,后面很难改。

本文先从「状态机与工作流引擎的边界」讲起,再拆解有限状态机的五个要素与表驱动实现,重点讲持久化与并发控制,然后覆盖审计、非法流转防御、超时推进与状态爆炸的治理,接着给出 Spring StateMachine 与 Step Functions 的实战代码,最后讲测试与落地路线。想先看整体选型框架的读者,可以从 工作流引擎全景与选型 开始。

目录

  1. 状态机与工作流引擎的边界
  2. 有限状态机的五个要素
  3. 状态的定义与命名
  4. 事件与转移的设计
  5. 守卫与动作的职责划分
  6. 表驱动状态机的实现
  7. 状态持久化与并发控制
  8. 乐观锁、版本号与幂等
  9. 状态流转的审计日志
  10. 非法流转的防御
  11. 超时与定时推进
  12. Spring StateMachine 实战
  13. AWS Step Functions 的 ASL
  14. 状态爆炸与状态压缩
  15. 测试策略
  16. 落地路线图
  17. 权衡取舍
  18. 常见坑清单
  19. 小结

1. 状态机与工作流引擎的边界

状态机与工作流引擎都能表达「多步骤流转」,但它们的重心不同:

维度状态机工作流引擎
流程定义状态 + 转移表图形或代码
控制流单线程线性支持并行、子流程、循环
等待靠外部事件推进原生定时器与等待
可视化状态图(简单)流程图(复杂)
人工任务需自建BPMN 原生支持
补偿需自建部分原生支持
适用规模状态 10 个以内步骤数十个
实现成本一天到一周一周到一月

判断标准是「控制流的复杂度」而不是「业务的重要性」。如果一个流程里存在并行分支、子流程嵌套、或者需要跨天等待,那它应该用工作流引擎。如果流转是线性的、状态可枚举、等待靠外部事件(比如支付回调)推进,状态机就够。

一个实用的组合是「状态机做局部、工作流做全局」:工作流引擎负责跨服务的编排与长等待,每个服务内部用一个状态机管理自己的业务实体状态。这样状态机的边界与服务的边界一致,事务边界也清晰。

2. 有限状态机的五个要素

一个状态机由五个要素定义:

  • 状态(State):系统可能处于的稳定情形,比如 PENDING、PAID、SHIPPED。
  • 事件(Event):触发状态变更的外部输入,比如 PAY、SHIP、CANCEL。
  • 转移(Transition):(源状态, 事件) → 目标状态 的映射。
  • 守卫(Guard):转移的前置条件,返回布尔值,决定转移是否允许。
  • 动作(Action):转移时执行的副作用,比如写日志、发消息。

用一张表就能描述全部语义:

源状态      事件      守卫                目标状态    动作
PENDING     PAY       amount > 0          PAID        记流水
PENDING     CANCEL    无                   CANCELED    释放库存
PAID        SHIP      stock 可用           SHIPPED     通知物流
PAID        REFUND    无                   REFUNDED    退款
SHIPPED     CONFIRM   无                   COMPLETED   结算

这张表有两个价值:它是可执行的规范(代码直接由它生成),也是可评审的文档(业务方能读懂)。建议把它作为设计的中心产物,代码与文档都从它派生。

3. 状态的定义与命名

状态定义有三条实用规则。

第一,状态必须是「可观察的稳定情形」。PROCESSING 这种「正在进行中」的中间态通常不该是状态,而应该是「没有状态」(比如数据库里的锁或者任务队列里的记录)。把瞬时中间态建成状态,会引入大量「卡在 PROCESSING」的运维问题。

第二,状态名用名词或过去分词,不用动词。PAID 比 PAYING 好,SHIPPED 比 SHIP 好,因为状态描述的是「已经怎样」,事件才描述「要做什么」。

第三,状态集合要能覆盖「终态」与「异常态」。终态(COMPLETED、CANCELED、REFUNDED)不能再有出边;异常态(FAILED、MANUAL_REVIEW)通常需要人工干预才能离开。

public enum OrderStatus {
    PENDING, PAID, SHIPPED, COMPLETED,   // 正常路径
    CANCELED, REFUNDED,                  // 正常终态
    MANUAL_REVIEW                        // 异常态,需人工
}

枚举与数据库字段要一一对应,且建议在数据库层加 CHECK 约束或枚举类型,避免有人绕过代码直接写库。

4. 事件与转移的设计

事件是「为什么变」,转移是「变成什么」。设计时的关键是「一个事件只表达一个业务意图」,不要把多个意图塞进一个事件。

# 反例:一个事件承担多个语义
ORDER_UPDATE -> 根据 payload 里的字段决定目标状态

# 正例:每个事件语义单一
PAY / SHIP / CANCEL / REFUND / CONFIRM

反例的问题是守卫逻辑会变得复杂且难以测试,而且审计日志里看不出「到底发生了什么」。事件驱动设计里有一条通用原则:事件是已经发生的事实,命名用过去式或名词(PaymentReceived、OrderCanceled),而不是命令式。

转移的设计要注意「不可逆性」。CANCELED 应该是终态,从它出发不应有任何转移。如果业务上允许「取消后恢复」,那应该是「取消 → 重新下单」而不是「CANCELED → PENDING」,因为后者会让状态失去「这个订单曾被取消」的信息。

5. 守卫与动作的职责划分

守卫是纯函数,只做判断,不做副作用。动作可以有副作用,但必须幂等。

public class ShipGuard implements Guard<OrderStatus, OrderEvent> {
    @Override
    public boolean evaluate(StateContext<OrderStatus, OrderEvent> ctx) {
        Order order = ctx.getMessage().getHeaders().get("order", Order.class);
        return stockService.available(order.getSku()) >= order.getQty();
    }
}

守卫里调用远程服务是有风险的:守卫会被反复评估,如果它调用了有副作用的接口就会出问题。推荐的守卫只读本地状态(从数据库或上下文里读出来的快照)。

动作的执行时机有两种选择,各有权衡:

  • 转移前执行动作(pre-action):动作失败则转移不发生,状态保持原样。适合「状态变更依赖于动作成功」的场景。
  • 转移后执行动作(post-action):状态先变更,动作异步执行,失败靠重试或对账。适合「状态变更是本地事实、外部通知可延迟」的场景。

大多数业务应该选后者,因为「状态」描述的是本地事实,外部通知失败不应该让本地状态回退。这也是 Saga 与分布式事务补偿 里「先提交本地事务、再发消息」的思路。

6. 表驱动状态机的实现

最简洁的实现是把转移表放在代码里,用一个校验函数统一入口:

public final class OrderStateMachine {

    private static final Map<TransitionKey, OrderStatus> TRANSITIONS = Map.of(
        new TransitionKey(PENDING, PAY),      PAID,
        new TransitionKey(PENDING, CANCEL),   CANCELED,
        new TransitionKey(PAID, SHIP),        SHIPPED,
        new TransitionKey(PAID, REFUND),      REFUNDED,
        new TransitionKey(SHIPPED, CONFIRM),  COMPLETED
    );

    public static OrderStatus next(OrderStatus from, OrderEvent event) {
        OrderStatus to = TRANSITIONS.get(new TransitionKey(from, event));
        if (to == null) {
            throw new IllegalTransitionException(from, event);
        }
        return to;
    }
}

TransitionKey 是 (state, event) 的记录类型,用 Map 做 O(1) 查找。这种实现的优点是「转移表就是唯一真相」,任何非法流转都会在入口被拒绝。

如果需要支持守卫,把值换成「目标状态 + 守卫 + 动作」的三元组:

record Transition(OrderStatus target, Predicate<Order> guard, Consumer<Order> action) {}

private static final Map<TransitionKey, Transition> TABLE = Map.of(
    new TransitionKey(PENDING, PAY),
    new Transition(PAID, o -> o.getAmount() > 0, o -> ledger.record(o))
);

当转移表超过 30 条时,建议用外部配置(YAML 或数据库表)驱动,这样新增流转不需要改代码。但要注意配置化会让类型安全与编译期检查失效,需要用启动时的校验(检查所有状态都有出口、所有事件都有定义)来弥补。

7. 状态持久化与并发控制

状态必须持久化,且更新必须是原子的。最朴素也最可靠的做法是把状态放在业务表的一个字段里,用带前置条件校验的更新语句:

UPDATE orders
   SET status = 'PAID', version = version + 1, updated_at = NOW()
 WHERE id = ? AND status = 'PENDING' AND version = ?;

这条语句同时完成了三件事:校验当前状态是 PENDING(防止非法流转)、校验版本号(防止并发覆盖)、原子更新。返回影响行数为 0 就说明并发冲突或状态不符,需要重新读取后决定是重试还是报错。

一个常见错误是把校验与更新分成两条语句:

-- 反例:两条语句之间存在竞态窗口
SELECT status FROM orders WHERE id = ?;
-- 应用层判断 status == 'PENDING'
UPDATE orders SET status = 'PAID' WHERE id = ?;

并发下两个请求会同时通过 SELECT 的检查,然后都执行 UPDATE,后一个覆盖前一个。这就是「丢失更新」,在支付场景里会直接造成资金问题。

8. 乐观锁、版本号与幂等

乐观锁(版本号)适合冲突率低的场景,比如订单状态流转(同一个订单极少被并发操作)。冲突率高的场景(比如库存扣减)应该用悲观锁或原子操作。

@Transactional
public void transition(String orderId, OrderEvent event, long expectedVersion) {
    Order order = orderRepo.findById(orderId).orElseThrow();
    OrderStatus target = OrderStateMachine.next(order.getStatus(), event);
    int updated = orderRepo.compareAndSetStatus(
        orderId, order.getStatus(), target, expectedVersion);
    if (updated == 0) {
        throw new OptimisticLockException("状态已变更,请重试");
    }
    eventPublisher.publish(new OrderStatusChanged(orderId, order.getStatus(), target));
}

版本号不一定要额外维护一个字段:如果状态机是「状态单调推进」的(只会往前进,不会回退),那么用当前状态本身作为「前置条件」就够了,因为状态值就是隐式的版本号。只有当存在「同一状态可以多次进入」的情况(比如重试计数)时,才需要显式的版本字段。

幂等性要单独处理。同一个事件被投递两次(消息重投、用户重复点击),第二次应该被忽略而不是报错。做法是给每次事件处理生成幂等键(orderId + event + 请求ID),用唯一索引去重:

CREATE TABLE state_transition_log (
  id          BIGINT AUTO_INCREMENT PRIMARY KEY,
  biz_id      VARCHAR(64) NOT NULL,
  from_state  VARCHAR(32) NOT NULL,
  to_state    VARCHAR(32) NOT NULL,
  event       VARCHAR(32) NOT NULL,
  idem_key    VARCHAR(128) NOT NULL,
  created_at  TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY uk_idem (idem_key)
);

插入成功说明是首次处理,插入冲突说明是重复,直接返回成功即可。这张表同时充当审计日志,一举两得。更完整的幂等设计见 重试幂等与补偿设计 。

9. 状态流转的审计日志

审计日志要能回答「谁、在什么时间、因为什么、把状态从 A 改成了 B」。上面的 state_transition_log 表再加三个字段就够:

ALTER TABLE state_transition_log
  ADD COLUMN operator_id   VARCHAR(64)  NULL,
  ADD COLUMN operator_type VARCHAR(16)  NOT NULL DEFAULT 'SYSTEM',
  ADD COLUMN reason        VARCHAR(255) NULL;

operator_type 区分「用户操作」与「系统触发」,这对排查问题很重要:一个状态莫名变化时,先看是人工改的还是任务改的。

审计日志的另一个用途是「状态回放」:按时间顺序读出一个实体的所有流转,能重建出它的完整生命周期,也能发现异常模式(比如短时间内反复 PAID → REFUNDED → PAID)。在合规场景里,审计日志往往是监管检查的第一份材料,保留期要与业务约定明确。

日志写入要与状态更新在同一个事务里,否则会出现「状态改了但没日志」或「有日志但状态没改」的不一致。

10. 非法流转的防御

非法流转的防御要分三层:

  • 应用层:状态机入口校验(next() 抛异常)。
  • 数据库层:CHECK 约束或触发器,防止绕过应用直接改库。
  • 监控层:告警非法流转尝试,因为它通常意味着有 bug 或有人在手工操作。
ALTER TABLE orders
  ADD CONSTRAINT chk_order_status
  CHECK (status IN ('PENDING','PAID','SHIPPED','COMPLETED',
                    'CANCELED','REFUNDED','MANUAL_REVIEW'));

数据库层的 CHECK 只能约束「值是合法的状态」,无法约束「流转是合法的」(那需要触发器或存储过程,代价大且难维护)。所以实用做法是:CHECK 管值域,应用层管流转,监控层兜底。

监控的实现很简单:状态机抛 IllegalTransitionException 时打一条带业务 ID 与事件类型的结构化日志,并对它做计数告警。正常运行时这个计数应该是 0,任何非零值都值得查。

11. 超时与定时推进

状态机本身不处理时间,但业务上常需要「超时自动推进」,比如「待支付超过 30 分钟自动取消」。两种实现方式:

  • 定时扫描:一个任务周期性扫描「处于某状态且超过阈值」的记录,触发对应事件。实现简单,但延迟取决于扫描周期,且大表扫描成本高。
  • 延迟队列:进入状态时投递一条延迟消息(比如 RabbitMQ 的延迟队列、Kafka 的时间轮、Redis 的 ZSET),到期后触发事件。延迟精确,但需要处理「状态已经变了,消息才到」的情况。
-- 定时扫描的查询要建索引,否则大表会全扫
SELECT id FROM orders
 WHERE status = 'PENDING'
   AND created_at < NOW() - INTERVAL 30 MINUTE
 LIMIT 1000;

无论哪种方式,触发超时事件时都必须重新校验当前状态(「状态已经变了」是常态),并保证事件幂等。延迟消息到达时,如果订单已经支付,超时取消事件应该被忽略而不是强制取消。

用 Temporal 之类的引擎可以直接用 Workflow.await 加超时,把这件事变成框架能力,参考 Temporal 与持久化执行 。

12. Spring StateMachine 实战

Spring StateMachine 是 JVM 生态里最完整的状态机实现,支持分层、正交、守卫、动作、持久化。

@Configuration
@EnableStateMachineFactory
public class OrderStateMachineConfig
        extends StateMachineConfigurerAdapter<OrderStatus, OrderEvent> {

    @Override
    public void configure(StateMachineStateConfigurer<OrderStatus, OrderEvent> states)
            throws Exception {
        states.withStates()
            .initial(OrderStatus.PENDING)
            .end(OrderStatus.COMPLETED)
            .end(OrderStatus.CANCELED)
            .state(OrderStatus.MANUAL_REVIEW);
    }

    @Override
    public void configure(StateMachineTransitionConfigurer<OrderStatus, OrderEvent> t)
            throws Exception {
        t.withExternal().source(PENDING).target(PAID).event(PAY)
            .guard(amountGuard()).action(recordLedger())
         .and()
         .withExternal().source(PAID).target(SHIPPED).event(SHIP)
            .guard(stockGuard())
         .and()
         .withExternal().source(PAID).target(REFUNDED).event(REFUND);
    }
}

使用时通过 StateMachineFactory 创建实例、恢复状态、发送事件:

StateMachine<OrderStatus, OrderEvent> sm = factory.getStateMachine(orderId);
sm.getStateMachineAccessor().doWithAllRegions(a -> a.resetStateMachine(
    new DefaultStateMachineContext<>(order.getStatus(), null, null, null)));
sm.sendEvent(Mono.just(MessageBuilder.withPayload(PAY)
    .setHeader("order", order).build())).subscribe();

要注意 Spring StateMachine 的实例状态在内存里,必须显式持久化(StateMachinePersister)或在每次操作时从数据库恢复。很多团队踩的坑是「重启后状态机回到初始态」,因为忘了持久化。对多数业务来说,直接维护数据库状态字段加表驱动校验,比引入 Spring StateMachine 更简单可控。

13. AWS Step Functions 的 ASL

Step Functions 是云托管的「状态机即服务」,用 Amazon States Language(ASL)声明。它的状态类型比传统状态机丰富:Task(执行工作)、Choice(分支)、Wait(等待)、Parallel(并行)、Map(遍历)、Pass(数据变换)。

{
  "Comment": "订单状态机",
  "StartAt": "Pending",
  "States": {
    "Pending": {
      "Type": "Choice",
      "Choices": [{"Variable": "$.event", "StringEquals": "PAY", "Next": "Paid"}],
      "Default": "Canceled"
    },
    "Paid": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:cn-north-1:123:function:ship",
      "Retry": [{"ErrorEquals": ["States.TaskFailed"], "MaxAttempts": 3}],
      "Catch": [{"ErrorEquals": ["States.ALL"], "Next": "ManualReview"}],
      "Next": "Shipped"
    },
    "Shipped": {"Type": "Wait", "Seconds": 3600, "Next": "Confirm"},
    "Confirm": {"Type": "Succeed"},
    "Canceled": {"Type": "Succeed"},
    "ManualReview": {"Type": "Fail", "Error": "NeedsHuman"}
  }
}

Step Functions 的优势是「零运维 + 内建重试与超时 + 完整执行历史」。它的状态转换是按次数计费的,一个高频短流程(每秒上千次)成本会很高,此时应该用 Express 模式或干脆不用它。

它还有一个约束值得注意:状态之间传递的数据($. 引用)默认限制在 256 KB,大对象要存 S3 传引用。这与工作流引擎里的「变量大小控制」是同一条经验。

14. 状态爆炸与状态压缩

状态爆炸有三个来源,对应三种解法:

来源表现解法
多维度揉在一起PAID_NOT_SHIPPED 这类组合态拆成多个正交字段,各自状态机
中间过程建成状态PROCESSING、RETRYING用任务队列或锁表达中间态
状态带了参数APPROVED_BY_MANAGER_L1参数放字段,状态只表达阶段
-- 反例:状态里编码了参数
status IN ('APPROVED_L1', 'APPROVED_L2', 'APPROVED_L3')

-- 正例:状态表达阶段,参数放字段
status = 'APPROVED', approval_level = 2

经验阈值:状态数超过 20 个就该审视建模了;超过 50 个几乎肯定有可以拆出去的维度。状态爆炸的直接代价是转移表变成二维矩阵(状态数 × 事件数),维护成本指数上升。

15. 测试策略

状态机的测试应该覆盖三类:

@Test
void 所有合法流转都能成功() {
    for (var e : OrderEvent.values()) {
        var to = OrderStateMachine.next(PENDING, e);  // 不抛异常即通过
        assertNotNull(to);
    }
}

@Test
void 非法流转被拒绝() {
    assertThrows(IllegalTransitionException.class,
        () -> OrderStateMachine.next(COMPLETED, PAY));
}

@Test
void 终态没有出边() {
    for (var s : List.of(COMPLETED, CANCELED, REFUNDED)) {
        for (var e : OrderEvent.values()) {
            assertThrows(IllegalTransitionException.class,
                () -> OrderStateMachine.next(s, e));
        }
    }
}

第三类测试最有价值,它把「终态不可离开」这条不变量变成可执行的断言。类似的可以断言「每个非终态至少有一个出边」(避免死状态)、「每个状态都从初始状态可达」(避免孤儿状态)。

除了单元测试,还应该有一个「状态图一致性测试」:从代码里的转移表生成状态图,与设计文档里的图做对比。在 CI 里跑这个对比,能防止「代码改了但文档没改」的漂移。

16. 落地路线图

  • 第 1 天:把业务实体的所有状态与事件列成一张表,与业务方逐条确认,特别确认「哪些状态是终态」。
  • 第 2 天:实现表驱动的状态机(转移表 + next() 函数),加上非法流转的单元测试。
  • 第 3 天:把状态更新改成带前置条件的原子更新语句,加上版本号或状态前置校验。
  • 第 4 天:加上流转审计表与幂等键唯一索引。
  • 第 5 天:接入监控,对非法流转与冲突失败做计数告警。

这套流程的核心产物是那张转移表。它应该放在代码里(可执行)与文档里(可评审)各一份,并在 CI 里校验两者一致。做到这一点,状态机的长期维护成本会显著低于裸写状态字段。

17. 权衡取舍

选择收益代价
表驱动状态机转移表是唯一真相,易测试表达力有限,不支持并行与子流程
引入 Spring StateMachine分层、正交、持久化开箱可用学习成本高,内存态易失,配置繁琐
状态存业务表字段与业务数据同事务,简单表变大,状态查询要加索引
独立状态表状态与业务解耦,可多实体复用需要额外事务保证一致性
乐观锁无锁开销,冲突率低时高效冲突率高时重试放大
前置状态校验(不用版本号)无需额外字段只适用于状态单调推进的场景
用 Step Functions零运维、执行历史完整按转换计费,厂商锁定
用延迟队列做超时触发精确需处理「状态已变」的过期消息

18. 常见坑清单

  1. 先 SELECT 判断状态再 UPDATE,并发下丢失更新,应该用带前置条件的原子更新。
  2. 把瞬时中间态(PROCESSING)建成正式状态,大量实例卡在该状态且无人清理。
  3. 状态里编码参数(APPROVED_L2),状态数膨胀成笛卡尔积。
  4. 终态没有出边保护,出现「已完成后又被退款」这类逻辑矛盾。
  5. 状态变更与审计日志不在同一事务,出现状态与日志不一致。
  6. 事件处理没有幂等键,重复投递导致状态被推进两次(比如重复发货)。
  7. 守卫里调用有副作用的远程接口,守卫被反复评估时产生重复操作。
  8. 用 Spring StateMachine 但忘了持久化,应用重启后状态回到初始态。
  9. 超时扫描没有索引,大表全表扫描,把数据库拖慢。
  10. 延迟队列的过期消息不校验当前状态,强制推进已变化的订单。
  11. 前后端状态定义不同步,前端显示可操作但后端拒绝,体验割裂。
  12. 直接改数据库状态字段做「修复」,绕过状态机,破坏审计链路。

19. 小结

状态机引擎的价值是「用一张可评审的转移表,换取状态流转的严谨性」。它的成本远低于完整的工作流引擎,但能挡住最危险的一类 bug:并发下的状态覆盖与非法流转。判断该不该用它,看「流转是否是线性的、状态是否可枚举、等待是否靠外部事件」。

实现上记住三条底线:状态更新必须是带前置条件的原子操作;每次流转必须有审计记录;事件处理必须有幂等键。这三条做到了,剩下的复杂度就只是「状态怎么划分」这个设计问题,而它可以通过「拆维度、去掉中间态、参数不入状态」三个手法控制住。

如果流程开始出现并行分支、子流程嵌套、跨天等待,那就到了该换工作流引擎的临界点,可以读 BPMN 2.0 与 Camunda 实战 或 Temporal 与持久化执行 ;如果流程里有大量人工审批,则应该看 人工任务与审批流表单 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「工作流引擎」更多文章

  1. 工作流成本优化
  2. 执行器与资源隔离
  3. 调度、回填与补数